Fallback to the system's dns resolver by default

This commit is contained in:
Tobias Gesellchen
2026-02-22 13:36:58 +01:00
parent 01d702c745
commit 0f802e65c6
9 changed files with 256 additions and 80 deletions
+35 -35
View File
@@ -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)
}
}
+63 -8
View File
@@ -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")
}
}
+1 -1
View File
@@ -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"`
}
+18 -4
View File
@@ -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)
+83 -24
View File
@@ -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()
+37
View File
@@ -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;
+6 -2
View File
@@ -108,8 +108,12 @@
</label>
<div style="margin-left: 20px; margin-bottom: 5px;">
<label for="dns-upstream">Upstream DNS:</label>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 150px;">
<span style="font-size: 0.8em; color: #666; margin-left: 5px;">(For non-intercepted queries)</span>
<input type="text" id="dns-upstream" placeholder="8.8.8.8" style="width: 200px;">
<span class="info-toggle" onclick="toggleInfo('dns-upstream-info')"></span>
<div id="dns-upstream-info" class="info-details">
Optional: comma-separated list of DNS servers (e.g., <code>1.1.1.1, 8.8.8.8</code>).
If empty, AfterTouch defaults to the system nameservers (from <code>/etc/resolv.conf</code>).
</div>
</div>
<div style="margin-left: 20px;">
<label for="dns-bind">DNS Bind Address:</label>
+7
View File
@@ -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' });