diff options
| author | radhitya <alif@radhitya.org> | 2026-06-13 16:09:53 +0700 |
|---|---|---|
| committer | radhitya <alif@radhitya.org> | 2026-06-13 16:09:53 +0700 |
| commit | 3e44adc94f32bfe500730fcbf1c02cedf65b0a30 (patch) | |
| tree | 66932e0f386ba1277506e9d1fb18eaaad70bfef3 | |
| parent | d802d4a685016be8b79c89b4f21099b9a1569532 (diff) | |
root hints, glue record, delegation loop, iterative, ns fallback, timeout, glue record
| -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 | ||||
| -rw-r--r-- | internal/server/doh.go | 2 | ||||
| -rw-r--r-- | internal/server/handler.go | 37 | ||||
| -rw-r--r-- | internal/server/server.go | 17 | ||||
| -rw-r--r-- | internal/server/server_test.go | 83 | ||||
| -rw-r--r-- | main.go | 5 |
9 files changed, 557 insertions, 75 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", + } +} diff --git a/internal/server/doh.go b/internal/server/doh.go index e9cf466..3f5a538 100644 --- a/internal/server/doh.go +++ b/internal/server/doh.go @@ -52,7 +52,7 @@ func (s *Server) dohHandler(w http.ResponseWriter, r *http.Request) { return } - resp := buildResponse(msg) + resp := s.buildResponse(msg) packed, err := resp.Pack() if err != nil { http.Error(w, "pack response", http.StatusInternalServerError) diff --git a/internal/server/handler.go b/internal/server/handler.go index 2e2f08b..bac1c81 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -1,11 +1,13 @@ package server import ( + "context" "github.com/miekg/dns" "log/slog" + "time" ) -func handleQuery(w dns.ResponseWriter, req *dns.Msg) { +func (s *Server) handleQuery(w dns.ResponseWriter, req *dns.Msg) { if len(req.Question) == 0 { m := new(dns.Msg) m.SetRcode(req, dns.RcodeFormatError) @@ -13,7 +15,7 @@ func handleQuery(w dns.ResponseWriter, req *dns.Msg) { return } - resp := buildResponse(req) + resp := s.buildResponse(req) if err := w.WriteMsg(resp); err != nil { slog.Error("write response failed", @@ -31,7 +33,7 @@ func handleQuery(w dns.ResponseWriter, req *dns.Msg) { ) } -func buildResponse(req *dns.Msg) *dns.Msg { +func (s *Server) buildResponse(req *dns.Msg) *dns.Msg { if len(req.Question) == 0 { m := new(dns.Msg) m.SetRcode(req, dns.RcodeFormatError) @@ -46,20 +48,19 @@ func buildResponse(req *dns.Msg) *dns.Msg { resp.SetEdns0(4096, false) } - if q.Name == "example.com." && q.Qtype == dns.TypeA { - resp.Answer = []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: q.Name, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 60, - }, - A: []byte{127, 0, 0, 1}, - }, - } - } else { - resp.Rcode = dns.RcodeNameError + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + reply, err := s.resolver.Lookup(ctx, q.Name, q.Qtype) + if err != nil { + slog.Error("resolution failed", + "err", err, + "qname", q.Name, + "qtype", dns.TypeToString[q.Qtype], + ) + resp.Rcode = dns.RcodeServerFailure + return resp } - return resp + + return reply } diff --git a/internal/server/server.go b/internal/server/server.go index f40648e..3114073 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -7,20 +7,21 @@ import ( "time" "github.com/miekg/dns" + "sdns/internal/resolver" ) type Server struct { - logger *slog.Logger - udp *dns.Server - tcp *dns.Server - doh *http.Server + logger *slog.Logger + resolver *resolver.Resolver + udp *dns.Server + tcp *dns.Server + doh *http.Server } -func New(udpAddr, tcpAddr, dohAddr string, logger *slog.Logger) (*Server, error) { +func New(udpAddr, tcpAddr, dohAddr string, logger *slog.Logger, r *resolver.Resolver) (*Server, error) { + s := &Server{logger: logger, resolver: r} mux := dns.NewServeMux() - mux.HandleFunc(".", handleQuery) - - s := &Server{logger: logger} + mux.HandleFunc(".", s.handleQuery) if udpAddr != "" { s.udp = &dns.Server{ diff --git a/internal/server/server_test.go b/internal/server/server_test.go index eaf0190..6fc2092 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,12 +1,25 @@ package server import ( + "log/slog" "testing" + "time" "github.com/miekg/dns" + "sdns/internal/resolver" ) +func testServer(t *testing.T) *Server { + t.Helper() + r := resolver.New( + resolver.WithRootAddresses([]string{"127.0.0.1:1"}), + resolver.WithTimeout(50*time.Millisecond), + ) + return &Server{logger: slog.Default(), resolver: r} +} + func TestBuildResponse(t *testing.T) { + s := testServer(t) tests := []struct { name string req *dns.Msg @@ -15,39 +28,6 @@ func TestBuildResponse(t *testing.T) { wantEdns0 bool }{ { - name: "example.com A returns 127.0.0.1", - req: func() *dns.Msg { - m := new(dns.Msg) - m.SetQuestion("example.com.", dns.TypeA) - return m - }(), - wantRcode: dns.RcodeSuccess, - wantAnswers: 1, - wantEdns0: false, - }, - { - name: "google.com A returns NXDOMAIN", - req: func() *dns.Msg { - m := new(dns.Msg) - m.SetQuestion("google.com.", dns.TypeA) - return m - }(), - wantRcode: dns.RcodeNameError, - wantAnswers: 0, - wantEdns0: false, - }, - { - name: "other.com A returns NXDOMAIN", - req: func() *dns.Msg { - m := new(dns.Msg) - m.SetQuestion("other.com.", dns.TypeA) - return m - }(), - wantRcode: dns.RcodeNameError, - wantAnswers: 0, - wantEdns0: false, - }, - { name: "no questions returns FORMERR", req: func() *dns.Msg { return new(dns.Msg) @@ -56,23 +36,11 @@ func TestBuildResponse(t *testing.T) { wantAnswers: 0, wantEdns0: false, }, - { - name: "EDNS0 query preserved with 4096 buffer", - req: func() *dns.Msg { - m := new(dns.Msg) - m.SetQuestion("example.com.", dns.TypeA) - m.SetEdns0(1232, true) - return m - }(), - wantRcode: dns.RcodeSuccess, - wantAnswers: 1, - wantEdns0: true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - resp := buildResponse(tt.req) + resp := s.buildResponse(tt.req) if resp.Rcode != tt.wantRcode { t.Errorf("rcode: got %d, want %d", resp.Rcode, tt.wantRcode) } @@ -90,7 +58,28 @@ func TestBuildResponse(t *testing.T) { } } +func TestBuildResponseWithQuery(t *testing.T) { + s := testServer(t) + // Valid query → must not panic, rcode must be valid + m := new(dns.Msg) + m.SetQuestion("example.com.", dns.TypeA) + resp := s.buildResponse(m) + if resp == nil { + t.Fatal("buildResponse returned nil") + } + if resp.Rcode != dns.RcodeSuccess && resp.Rcode != dns.RcodeServerFailure { + t.Errorf("expected success or server failure, got %d", resp.Rcode) + } +} + func FuzzBuildResponse(f *testing.F) { + s := &Server{logger: slog.Default()} + // For fuzz, use a resolver that won't make real network calls + s.resolver = resolver.New( + resolver.WithRootAddresses([]string{"127.0.0.1:1"}), + resolver.WithTimeout(10*time.Millisecond), + ) + seed := []byte{ 0x00, 0x00, // ID 0x01, 0x00, // flags: RD @@ -111,7 +100,7 @@ func FuzzBuildResponse(f *testing.F) { if err := msg.Unpack(data); err != nil { return } - resp := buildResponse(msg) + resp := s.buildResponse(msg) if resp == nil { t.Fatal("buildResponse returned nil") } @@ -7,6 +7,7 @@ import ( "os/signal" "syscall" + "sdns/internal/resolver" "sdns/internal/server" ) @@ -16,6 +17,8 @@ func main() { })) slog.SetDefault(logger) + r := resolver.New() + udp := os.Getenv("SDNS_LISTEN_UDP") if udp == "" { udp = ":5353" @@ -34,7 +37,7 @@ func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - srv, err := server.New(udp, tcp, doh, logger) + srv, err := server.New(udp, tcp, doh, logger, r) if err != nil { logger.Error("create server failed", "err", err) os.Exit(1) |
