Skip to content

Commit 12656a0

Browse files
committed
feat: cli dns queries fallback to system dns
Signed-off-by: QuentinN42 <quentin@lieumont.fr>
1 parent 82ed980 commit 12656a0

8 files changed

Lines changed: 418 additions & 30 deletions

File tree

pkg/locations/dns/dns.go

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,46 +3,40 @@ package dns
33

44
import (
55
"strings"
6+
"time"
67

78
"github.com/Escape-Technologies/cli/pkg/log"
8-
9-
"github.com/miekg/dns"
109
)
1110

1211
const (
13-
listenAddr = "127.0.0.1:1053"
12+
listenAddr = "127.0.0.1:1053"
13+
minBackoff = 100 * time.Millisecond
14+
maxBackoff = 5 * time.Second
15+
backoffFactor = 2
1416
)
1517

16-
// Start starts the DNS server
18+
// Start runs the private-location DNS forwarder under a supervised loop: it
19+
// discovers upstream resolvers, serves until the listener exits, then backs off
20+
// and retries. Upstreams are recomputed on each iteration so network changes
21+
// are picked up. This function blocks and is meant to run in its own goroutine.
1722
func Start() {
18-
// Load system resolvers from /etc/resolv.conf
19-
config, err := dns.ClientConfigFromFile("/etc/resolv.conf")
20-
if err != nil {
21-
log.Error("[DNS] Failed to load resolv.conf: %v", err)
22-
return
23-
}
23+
backoff := minBackoff
24+
for {
25+
upstreams := resolveUpstreams()
26+
log.Debug("[DNS] starting TCP DNS server on %s, forwarding to %s", listenAddr, strings.Join(upstreams, ", "))
2427

25-
// TCP-only DNS server
26-
server := &dns.Server{
27-
Addr: listenAddr,
28-
Net: "tcp",
29-
Handler: dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
30-
for _, s := range config.Servers {
31-
resp, err := dns.Exchange(r, s+":"+config.Port)
32-
if err == nil && resp != nil {
33-
_ = w.WriteMsg(resp)
34-
return
35-
}
36-
log.Debug("[DNS] Error forwarding: %v", err)
37-
}
38-
m := new(dns.Msg)
39-
m.SetRcode(r, dns.RcodeServerFailure)
40-
_ = w.WriteMsg(m)
41-
}),
28+
err := serve(listenAddr, newHandler(upstreams))
29+
log.Error("[DNS] server stopped: %v, restarting in %s", err, backoff)
30+
31+
time.Sleep(backoff)
32+
backoff = nextBackoff(backoff)
4233
}
34+
}
4335

44-
log.Debug("[DNS] Starting TCP DNS server on %s, forwarding to %s", listenAddr, strings.Join(config.Servers, ", "))
45-
if err := server.ListenAndServe(); err != nil {
46-
log.Error("[DNS] Failed to start TCP DNS server: %v", err)
36+
func nextBackoff(current time.Duration) time.Duration {
37+
next := current * backoffFactor
38+
if next > maxBackoff {
39+
return maxBackoff
4740
}
41+
return next
4842
}

pkg/locations/dns/forward.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package dns
2+
3+
import (
4+
"github.com/Escape-Technologies/cli/pkg/log"
5+
6+
"github.com/miekg/dns"
7+
)
8+
9+
// newHandler builds a forwarding handler that relays each query to the given
10+
// upstreams in order, returning the first successful response. A panic in the
11+
// forwarding path is recovered and turned into SERVFAIL so a single malformed
12+
// query cannot take the server down.
13+
func newHandler(upstreams []string) dns.HandlerFunc {
14+
return func(w dns.ResponseWriter, r *dns.Msg) {
15+
defer func() {
16+
if rec := recover(); rec != nil {
17+
log.Error("[DNS] recovered from panic while forwarding: %v", rec)
18+
_ = w.WriteMsg(servfail(r))
19+
}
20+
}()
21+
22+
for _, upstream := range upstreams {
23+
resp, err := dns.Exchange(r, upstream)
24+
if err == nil && resp != nil {
25+
_ = w.WriteMsg(resp)
26+
return
27+
}
28+
log.Debug("[DNS] error forwarding to %s: %v", upstream, err)
29+
}
30+
_ = w.WriteMsg(servfail(r))
31+
}
32+
}
33+
34+
func servfail(r *dns.Msg) *dns.Msg {
35+
m := new(dns.Msg)
36+
m.SetRcode(r, dns.RcodeServerFailure)
37+
return m
38+
}

pkg/locations/dns/forward_test.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package dns
2+
3+
import (
4+
"net"
5+
"testing"
6+
7+
"github.com/miekg/dns"
8+
)
9+
10+
// capture is a minimal dns.ResponseWriter that records the written message.
11+
type capture struct {
12+
msg *dns.Msg
13+
}
14+
15+
func (c *capture) LocalAddr() net.Addr { return &net.TCPAddr{} } // nolint:exhaustruct
16+
func (c *capture) RemoteAddr() net.Addr { return &net.TCPAddr{} } // nolint:exhaustruct
17+
func (c *capture) WriteMsg(m *dns.Msg) error { c.msg = m; return nil }
18+
func (c *capture) Write(b []byte) (int, error) {
19+
return len(b), nil
20+
}
21+
func (c *capture) Close() error { return nil }
22+
func (c *capture) TsigStatus() error { return nil }
23+
func (c *capture) TsigTimersOnly(bool) {}
24+
func (c *capture) Hijack() {}
25+
26+
// startUpstream runs a UDP DNS server answering every A query with answer and
27+
// returns its address. dns.Exchange forwards over UDP, so UDP is enough.
28+
func startUpstream(t *testing.T, answer string) string {
29+
t.Helper()
30+
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) // nolint:exhaustruct
31+
if err != nil {
32+
t.Fatalf("listen udp: %v", err)
33+
}
34+
handler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
35+
m := new(dns.Msg)
36+
m.SetReply(r)
37+
for _, q := range r.Question {
38+
if q.Qtype != dns.TypeA {
39+
continue
40+
}
41+
rr, err := dns.NewRR(q.Name + " 60 IN A " + answer)
42+
if err != nil {
43+
t.Errorf("build rr: %v", err)
44+
continue
45+
}
46+
m.Answer = append(m.Answer, rr)
47+
}
48+
_ = w.WriteMsg(m)
49+
})
50+
server := &dns.Server{PacketConn: conn, Net: "udp", Handler: handler} // nolint:exhaustruct
51+
go func() { _ = server.ActivateAndServe() }()
52+
t.Cleanup(func() { _ = server.Shutdown() })
53+
return conn.LocalAddr().String()
54+
}
55+
56+
// deadAddr returns a UDP address with nothing listening on it.
57+
func deadAddr(t *testing.T) string {
58+
t.Helper()
59+
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)}) // nolint:exhaustruct
60+
if err != nil {
61+
t.Fatalf("listen udp: %v", err)
62+
}
63+
addr := conn.LocalAddr().String()
64+
_ = conn.Close()
65+
return addr
66+
}
67+
68+
func query(name string) *dns.Msg {
69+
m := new(dns.Msg)
70+
m.SetQuestion(dns.Fqdn(name), dns.TypeA)
71+
return m
72+
}
73+
74+
func TestForwardSuccess(t *testing.T) {
75+
upstream := startUpstream(t, "1.2.3.4")
76+
w := &capture{} // nolint:exhaustruct
77+
newHandler([]string{upstream})(w, query("example.com"))
78+
79+
if w.msg == nil {
80+
t.Fatal("no response written")
81+
}
82+
if w.msg.Rcode != dns.RcodeSuccess {
83+
t.Fatalf("rcode = %v, want success", w.msg.Rcode)
84+
}
85+
if len(w.msg.Answer) != 1 {
86+
t.Fatalf("answers = %d, want 1", len(w.msg.Answer))
87+
}
88+
a, ok := w.msg.Answer[0].(*dns.A)
89+
if !ok || a.A.String() != "1.2.3.4" {
90+
t.Fatalf("answer = %v, want 1.2.3.4", w.msg.Answer[0])
91+
}
92+
}
93+
94+
func TestForwardFailover(t *testing.T) {
95+
upstream := startUpstream(t, "5.6.7.8")
96+
w := &capture{} // nolint:exhaustruct
97+
newHandler([]string{deadAddr(t), upstream})(w, query("example.com"))
98+
99+
if w.msg == nil || w.msg.Rcode != dns.RcodeSuccess {
100+
t.Fatalf("expected success via second upstream, got %v", w.msg)
101+
}
102+
a, ok := w.msg.Answer[0].(*dns.A)
103+
if !ok || a.A.String() != "5.6.7.8" {
104+
t.Fatalf("answer = %v, want 5.6.7.8", w.msg.Answer)
105+
}
106+
}
107+
108+
func TestForwardAllDead(t *testing.T) {
109+
w := &capture{} // nolint:exhaustruct
110+
newHandler([]string{deadAddr(t), deadAddr(t)})(w, query("example.com"))
111+
112+
if w.msg == nil {
113+
t.Fatal("no response written")
114+
}
115+
if w.msg.Rcode != dns.RcodeServerFailure {
116+
t.Fatalf("rcode = %v, want SERVFAIL", w.msg.Rcode)
117+
}
118+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//go:build !windows
2+
3+
package dns
4+
5+
import (
6+
"net"
7+
8+
"github.com/miekg/dns"
9+
)
10+
11+
// systemResolvers reads the OS resolver configuration from /etc/resolv.conf.
12+
// Covers Linux and macOS, where the file exists and tracks the active resolver.
13+
func systemResolvers() ([]string, error) {
14+
config, err := dns.ClientConfigFromFile("/etc/resolv.conf")
15+
if err != nil {
16+
return nil, err
17+
}
18+
port := config.Port
19+
if port == "" {
20+
port = defaultPort
21+
}
22+
servers := make([]string, 0, len(config.Servers))
23+
for _, s := range config.Servers {
24+
servers = append(servers, net.JoinHostPort(s, port))
25+
}
26+
return servers, nil
27+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//go:build windows
2+
3+
package dns
4+
5+
import (
6+
"syscall"
7+
"unsafe"
8+
9+
"golang.org/x/sys/windows"
10+
)
11+
12+
// systemResolvers enumerates the DNS servers configured on every up adapter
13+
// via GetAdaptersAddresses. Windows has no /etc/resolv.conf, so this is the
14+
// equivalent discovery path. Returned addresses are bare IPs; upstreams.go
15+
// applies the port and link-local filtering.
16+
func systemResolvers() ([]string, error) {
17+
const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST
18+
19+
var size uint32
20+
// First call sizes the buffer; a nil pointer with ERROR_BUFFER_OVERFLOW is expected.
21+
_ = windows.GetAdaptersAddresses(syscall.AF_UNSPEC, flags, 0, nil, &size)
22+
if size == 0 {
23+
return nil, nil
24+
}
25+
26+
buf := make([]byte, size)
27+
addrs := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0]))
28+
if err := windows.GetAdaptersAddresses(syscall.AF_UNSPEC, flags, 0, addrs, &size); err != nil {
29+
return nil, err
30+
}
31+
32+
var out []string
33+
for a := addrs; a != nil; a = a.Next {
34+
if a.OperStatus != windows.IfOperStatusUp {
35+
continue
36+
}
37+
for d := a.FirstDnsServerAddress; d != nil; d = d.Next {
38+
if ip := d.Address.IP(); ip != nil {
39+
out = append(out, ip.String())
40+
}
41+
}
42+
}
43+
return out, nil
44+
}

pkg/locations/dns/serve.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package dns
2+
3+
import "github.com/miekg/dns"
4+
5+
// serve runs a TCP DNS server bound to addr until it stops. It blocks and
6+
// returns the error that caused ListenAndServe to exit.
7+
func serve(addr string, handler dns.Handler) error {
8+
server := &dns.Server{
9+
Addr: addr,
10+
Net: "tcp",
11+
Handler: handler,
12+
}
13+
return server.ListenAndServe()
14+
}

0 commit comments

Comments
 (0)