diff options
Diffstat (limited to 'internal/resolver')
| -rw-r--r-- | internal/resolver/named.root | 92 | ||||
| -rw-r--r-- | internal/resolver/resolver.go | 192 | ||||
| -rw-r--r-- | internal/resolver/resolver_test.go | 149 | ||||
| -rw-r--r-- | internal/resolver/root.go | 55 |
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", + } +} |
