summaryrefslogtreecommitdiff
path: root/internal/resolver
diff options
context:
space:
mode:
Diffstat (limited to 'internal/resolver')
-rw-r--r--internal/resolver/named.root92
-rw-r--r--internal/resolver/resolver.go192
-rw-r--r--internal/resolver/resolver_test.go149
-rw-r--r--internal/resolver/root.go55
4 files changed, 488 insertions, 0 deletions
diff --git a/internal/resolver/named.root b/internal/resolver/named.root
new file mode 100644
index 0000000..0a04ecf
--- /dev/null
+++ b/internal/resolver/named.root
@@ -0,0 +1,92 @@
+; This file holds the information on root name servers needed to
+; initialize cache of Internet domain name servers
+; (e.g. reference this file in the "cache . <file>"
+; configuration file of BIND domain name servers).
+;
+; This file is made available by InterNIC
+; under anonymous FTP as
+; file /domain/named.cache
+; on server FTP.INTERNIC.NET
+; -OR- RS.INTERNIC.NET
+;
+; last update: May 21, 2026
+; related version of root zone: 2026052101
+;
+; FORMERLY NS.INTERNIC.NET
+;
+. 3600000 NS A.ROOT-SERVERS.NET.
+A.ROOT-SERVERS.NET. 3600000 A 198.41.0.4
+A.ROOT-SERVERS.NET. 3600000 AAAA 2001:503:ba3e::2:30
+;
+; FORMERLY NS1.ISI.EDU
+;
+. 3600000 NS B.ROOT-SERVERS.NET.
+B.ROOT-SERVERS.NET. 3600000 A 170.247.170.2
+B.ROOT-SERVERS.NET. 3600000 AAAA 2801:1b8:10::b
+;
+; FORMERLY C.PSI.NET
+;
+. 3600000 NS C.ROOT-SERVERS.NET.
+C.ROOT-SERVERS.NET. 3600000 A 192.33.4.12
+C.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:2::c
+;
+; FORMERLY TERP.UMD.EDU
+;
+. 3600000 NS D.ROOT-SERVERS.NET.
+D.ROOT-SERVERS.NET. 3600000 A 199.7.91.13
+D.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:2d::d
+;
+; FORMERLY NS.NASA.GOV
+;
+. 3600000 NS E.ROOT-SERVERS.NET.
+E.ROOT-SERVERS.NET. 3600000 A 192.203.230.10
+E.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:a8::e
+;
+; FORMERLY NS.ISC.ORG
+;
+. 3600000 NS F.ROOT-SERVERS.NET.
+F.ROOT-SERVERS.NET. 3600000 A 192.5.5.241
+F.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:2f::f
+;
+; FORMERLY NS.NIC.DDN.MIL
+;
+. 3600000 NS G.ROOT-SERVERS.NET.
+G.ROOT-SERVERS.NET. 3600000 A 192.112.36.4
+G.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:12::d0d
+;
+; FORMERLY AOS.ARL.ARMY.MIL
+;
+. 3600000 NS H.ROOT-SERVERS.NET.
+H.ROOT-SERVERS.NET. 3600000 A 198.97.190.53
+H.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:1::53
+;
+; FORMERLY NIC.NORDU.NET
+;
+. 3600000 NS I.ROOT-SERVERS.NET.
+I.ROOT-SERVERS.NET. 3600000 A 192.36.148.17
+I.ROOT-SERVERS.NET. 3600000 AAAA 2001:7fe::53
+;
+; OPERATED BY VERISIGN, INC.
+;
+. 3600000 NS J.ROOT-SERVERS.NET.
+J.ROOT-SERVERS.NET. 3600000 A 192.58.128.30
+J.ROOT-SERVERS.NET. 3600000 AAAA 2001:503:c27::2:30
+;
+; OPERATED BY RIPE NCC
+;
+. 3600000 NS K.ROOT-SERVERS.NET.
+K.ROOT-SERVERS.NET. 3600000 A 193.0.14.129
+K.ROOT-SERVERS.NET. 3600000 AAAA 2001:7fd::1
+;
+; OPERATED BY ICANN
+;
+. 3600000 NS L.ROOT-SERVERS.NET.
+L.ROOT-SERVERS.NET. 3600000 A 199.7.83.42
+L.ROOT-SERVERS.NET. 3600000 AAAA 2001:500:9f::42
+;
+; OPERATED BY WIDE
+;
+. 3600000 NS M.ROOT-SERVERS.NET.
+M.ROOT-SERVERS.NET. 3600000 A 202.12.27.33
+M.ROOT-SERVERS.NET. 3600000 AAAA 2001:dc3::35
+; End of file \ No newline at end of file
diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go
new file mode 100644
index 0000000..4ad023a
--- /dev/null
+++ b/internal/resolver/resolver.go
@@ -0,0 +1,192 @@
+package resolver
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+var (
+ ErrMaxDelegations = errors.New("max delegations exceeded")
+ ErrNoServers = errors.New("no nameservers available")
+)
+
+type Resolver struct {
+ roots []string
+ maxDelegations int
+ timeout time.Duration
+ retries int
+}
+
+type Option func(*Resolver)
+
+func New(opts ...Option) *Resolver {
+ r := &Resolver{
+ roots: loadRootServers(),
+ maxDelegations: 30,
+ timeout: 2 * time.Second,
+ retries: 2,
+ }
+ for _, opt := range opts {
+ opt(r)
+ }
+ return r
+}
+
+func WithRootAddresses(addrs []string) Option {
+ return func(r *Resolver) {
+ r.roots = addrs
+ }
+}
+
+func WithTimeout(d time.Duration) Option {
+ return func(r *Resolver) {
+ r.timeout = d
+ }
+}
+
+func (r *Resolver) Lookup(ctx context.Context, qname string, qtype uint16) (*dns.Msg, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return r.resolve(ctx, qname, qtype)
+}
+
+func (r *Resolver) resolve(ctx context.Context, qname string, qtype uint16) (*dns.Msg, error) {
+ servers := make([]string, len(r.roots))
+ copy(servers, r.roots)
+ for depth := 0; depth < r.maxDelegations; depth++ {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ default:
+ }
+ if len(servers) == 0 {
+ return nil, ErrNoServers
+ }
+ reply, err := r.exchangeWithRetries(ctx, servers, qname, qtype)
+ if err != nil {
+ return nil, fmt.Errorf("resolve %s %s: %w",
+ qname, dns.TypeToString[qtype], err)
+ }
+ switch {
+ case reply.Rcode == dns.RcodeSuccess && len(reply.Answer) > 0:
+ return reply, nil
+ case reply.Rcode == dns.RcodeNameError:
+ return reply, nil
+ case reply.Rcode == dns.RcodeSuccess && isReferral(reply):
+ next, err := r.nextServers(ctx, reply)
+ if err != nil {
+ return nil, err
+ }
+ servers = next
+ default:
+ if len(servers) > 1 {
+ servers = servers[1:]
+ } else {
+ return reply, nil
+ }
+ }
+
+ }
+ return nil, ErrMaxDelegations
+}
+
+func isReferral(msg *dns.Msg) bool {
+ return !msg.Authoritative && len(msg.Ns) > 0
+}
+
+func (r *Resolver) nextServers(ctx context.Context, msg *dns.Msg) ([]string, error) {
+ var targets []string
+ glue := make(map[string]string)
+
+ for _, rr := range msg.Ns {
+ ns, ok := rr.(*dns.NS)
+ if !ok {
+ continue
+ }
+ targets = append(targets, ns.Ns)
+ }
+ for _, rr := range msg.Extra {
+ switch v := rr.(type) {
+ case *dns.A:
+ if _, exists := glue[v.Hdr.Name]; !exists {
+ glue[v.Hdr.Name] = v.A.String()
+ }
+ case *dns.AAAA:
+ if _, exists := glue[v.Hdr.Name]; !exists {
+ glue[v.Hdr.Name] = v.AAAA.String()
+ }
+ }
+ }
+
+ var addrs []string
+ var unresolved []string
+ for _, t := range targets {
+ if ip, ok := glue[t]; ok {
+ addrs = append(addrs, ip)
+ } else {
+ unresolved = append(unresolved, t)
+ }
+ }
+
+ if len(addrs) > 0 {
+ return addrs, nil
+ }
+
+ for _, name := range unresolved {
+ reply, err := r.resolve(ctx, name, dns.TypeA)
+ if err != nil {
+ continue
+ }
+ if reply.Rcode != dns.RcodeSuccess {
+ continue
+ }
+ for _, rr := range reply.Answer {
+ if a, ok := rr.(*dns.A); ok {
+ addrs = append(addrs, a.A.String())
+ break
+ }
+ }
+ }
+
+ if len(addrs) == 0 {
+ return nil, ErrNoServers
+ }
+ return addrs, nil
+}
+
+func (r *Resolver) exchangeWithRetries(ctx context.Context, servers []string,
+ qname string, qtype uint16) (*dns.Msg, error) {
+ msg := new(dns.Msg)
+ msg.SetQuestion(qname, qtype)
+ msg.SetEdns0(4096, false)
+ msg.RecursionDesired = false
+
+ client := &dns.Client{
+ Net: "udp",
+ UDPSize: 4096,
+ Timeout: r.timeout,
+ }
+
+ var lastErr error
+ for _, srv := range servers {
+ addr := srv
+ if _, _, err := net.SplitHostPort(srv); err != nil {
+ addr = net.JoinHostPort(srv, "53")
+ }
+ for attempt := 0; attempt < r.retries; attempt++ {
+ reply, _, err := client.ExchangeContext(ctx, msg, addr)
+ if err == nil {
+ return reply, nil
+ }
+ lastErr = err
+ time.Sleep(time.Duration(attempt+1) * 200 * time.Millisecond)
+ }
+ }
+ return nil, lastErr
+}
diff --git a/internal/resolver/resolver_test.go b/internal/resolver/resolver_test.go
new file mode 100644
index 0000000..0bd0402
--- /dev/null
+++ b/internal/resolver/resolver_test.go
@@ -0,0 +1,149 @@
+package resolver
+
+import (
+ "context"
+ "net"
+ "testing"
+ "time"
+
+ "github.com/miekg/dns"
+)
+
+func startTestServer(t *testing.T, addr string, handler dns.Handler) *dns.Server {
+ t.Helper()
+ srv := &dns.Server{
+ Addr: addr,
+ Net: "udp",
+ Handler: handler,
+ UDPSize: 4096,
+ }
+ go func() {
+ if err := srv.ListenAndServe(); err != nil {
+ }
+ }()
+ return srv
+}
+
+func TestLookupDirectAnswer(t *testing.T) {
+ mux := dns.NewServeMux()
+ mux.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
+ resp := new(dns.Msg)
+ resp.SetReply(req)
+ resp.Authoritative = true
+ if req.Question[0].Name == "example.com." &&
+ req.Question[0].Qtype == dns.TypeA {
+ resp.Answer = append(resp.Answer, &dns.A{
+ Hdr: dns.RR_Header{
+ Name: "example.com.",
+ Rrtype: dns.TypeA,
+ Class: dns.ClassINET,
+ Ttl: 60,
+ },
+ A: net.IPv4(127, 0, 0, 1),
+ })
+ } else {
+ resp.Rcode = dns.RcodeNameError
+ }
+ w.WriteMsg(resp)
+ })
+
+ srv := startTestServer(t, "127.0.0.1:15353", mux)
+ defer srv.Shutdown()
+ time.Sleep(50 * time.Millisecond)
+
+ r := New(
+ WithRootAddresses([]string{"127.0.0.1:15353"}),
+ WithTimeout(500*time.Millisecond),
+ )
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ resp, err := r.Lookup(ctx, "example.com.", dns.TypeA)
+ if err != nil {
+ t.Fatalf("Lookup failed: %v", err)
+ }
+ if resp.Rcode != dns.RcodeSuccess {
+ t.Fatalf("expected NOERROR, got %d (%s)",
+ resp.Rcode, dns.RcodeToString[resp.Rcode])
+ }
+ if len(resp.Answer) != 1 {
+ t.Fatalf("expected 1 answer, got %d", len(resp.Answer))
+ }
+ a, ok := resp.Answer[0].(*dns.A)
+ if !ok {
+ t.Fatal("expected A record")
+ }
+ if !a.A.Equal(net.IPv4(127, 0, 0, 1)) {
+ t.Fatalf("expected 127.0.0.1, got %s", a.A)
+ }
+}
+
+func TestLookupNXDOMAIN(t *testing.T) {
+ mux := dns.NewServeMux()
+ mux.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) {
+ resp := new(dns.Msg)
+ resp.SetReply(req)
+ resp.Rcode = dns.RcodeNameError
+ w.WriteMsg(resp)
+ })
+
+ srv := startTestServer(t, "127.0.0.1:15354", mux)
+ defer srv.Shutdown()
+ time.Sleep(50 * time.Millisecond)
+
+ r := New(
+ WithRootAddresses([]string{"127.0.0.1:15354"}),
+ WithTimeout(500*time.Millisecond),
+ )
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ resp, err := r.Lookup(ctx, "nonexistent.xyz.", dns.TypeA)
+ if err != nil {
+ t.Fatalf("Lookup failed: %v", err)
+ }
+ if resp.Rcode != dns.RcodeNameError {
+ t.Fatalf("expected NXDOMAIN, got %d", resp.Rcode)
+ }
+}
+
+func TestNextServersWithGlue(t *testing.T) {
+ msg := new(dns.Msg)
+ msg.Authoritative = false
+ msg.Ns = append(msg.Ns, &dns.NS{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Ttl: 300},
+ Ns: "ns1.example.com.",
+ })
+ msg.Extra = append(msg.Extra, &dns.A{
+ Hdr: dns.RR_Header{Name: "ns1.example.com.", Rrtype: dns.TypeA, Ttl: 300},
+ A: net.ParseIP("192.0.2.1").To4(),
+ })
+ msg.Extra = append(msg.Extra, &dns.AAAA{
+ Hdr: dns.RR_Header{Name: "ns1.example.com.", Rrtype: dns.TypeAAAA, Ttl: 300},
+ AAAA: net.ParseIP("2001:db8::1"),
+ })
+
+ r := &Resolver{}
+ addrs, err := r.nextServers(context.Background(), msg)
+ if err != nil {
+ t.Fatalf("nextServers failed: %v", err)
+ }
+ if len(addrs) != 1 || addrs[0] != "192.0.2.1" {
+ t.Fatalf("expected [192.0.2.1], got %v", addrs)
+ }
+}
+
+func TestNextServersNoGlue(t *testing.T) {
+ msg := new(dns.Msg)
+ msg.Authoritative = false
+ msg.Ns = append(msg.Ns, &dns.NS{
+ Hdr: dns.RR_Header{Name: "example.com.", Rrtype: dns.TypeNS, Ttl: 300},
+ Ns: "ns1.example.com.",
+ })
+
+ r := &Resolver{maxDelegations: 30, timeout: time.Second, retries: 1}
+ _, err := r.nextServers(context.Background(), msg)
+ if err == nil {
+ t.Fatal("expected error when no glue and no roots")
+ }
+}
diff --git a/internal/resolver/root.go b/internal/resolver/root.go
new file mode 100644
index 0000000..9dac31c
--- /dev/null
+++ b/internal/resolver/root.go
@@ -0,0 +1,55 @@
+package resolver
+
+import (
+ _ "embed"
+ "github.com/miekg/dns"
+ "strings"
+)
+
+//go:embed named.root
+var rootHintsData []byte
+
+func loadRootServers() []string {
+ var addrs []string
+ seen := make(map[string]bool)
+
+ zp := dns.NewZoneParser(strings.NewReader(string(rootHintsData)), "", "")
+ for {
+ rr, ok := zp.Next()
+ if !ok {
+ break
+ }
+ a, ok := rr.(*dns.A)
+ if !ok {
+ continue
+ }
+ ip := a.A.String()
+ if !seen[ip] {
+ seen[ip] = true
+ addrs = append(addrs, ip)
+ }
+ }
+
+ if err := zp.Err(); err != nil || len(addrs) == 0 {
+ return hardcodedRoots()
+ }
+ return addrs
+}
+
+func hardcodedRoots() []string {
+ return []string{
+ "198.41.0.4",
+ "170.247.170.2",
+ "192.33.4.12",
+ "199.7.91.13",
+ "192.203.230.10",
+ "192.5.5.241",
+ "192.112.36.4",
+ "198.97.190.53",
+ "192.36.148.17",
+ "192.58.128.30",
+ "193.0.14.129",
+ "199.7.83.42",
+ "202.12.27.33",
+ }
+}