feat(netxlite): implements NS queries (#734)

This diff has been extracted from eb0bf38957.

See https://github.com/ooni/probe/issues/2096.

While there, skip the broken tests caused by issue
https://github.com/ooni/probe/issues/2098.
This commit is contained in:
Simone Basso 2022-05-16 10:46:53 +02:00 committed by GitHub
parent c1b06a2d09
commit ce052b665e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 857 additions and 73 deletions

View File

@ -11,6 +11,7 @@ func init() {
} }
func TestCheck(t *testing.T) { func TestCheck(t *testing.T) {
t.Skip("see https://github.com/ooni/probe/issues/2098")
*mode = "check" *mode = "check"
main() main()
} }

View File

@ -67,6 +67,11 @@ func (c *Client) LookupHTTPS(ctx context.Context, domain string) (*model.HTTPSSv
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
// LookupNS implements model.Resolver.LookupNS.
func (c *Client) LookupNS(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("not implemented")
}
// Network implements Resolver.Network // Network implements Resolver.Network
func (c *Client) Network() string { func (c *Client) Network() string {
return c.dnsClient.Network() return c.dnsClient.Network()

View File

@ -56,6 +56,10 @@ func (c FakeResolver) LookupHTTPS(ctx context.Context, domain string) (*model.HT
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (c FakeResolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("not implemented")
}
var _ model.Resolver = FakeResolver{} var _ model.Resolver = FakeResolver{}
type FakeTransport struct { type FakeTransport struct {

View File

@ -56,6 +56,10 @@ func (c FakeResolver) LookupHTTPS(ctx context.Context, domain string) (*model.HT
return nil, errors.New("not implemented") return nil, errors.New("not implemented")
} }
func (c FakeResolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("not implemented")
}
var _ model.Resolver = FakeResolver{} var _ model.Resolver = FakeResolver{}
type FakeTransport struct { type FakeTransport struct {

View File

@ -29,6 +29,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"math/rand" "math/rand"
"net"
"net/url" "net/url"
"sync" "sync"
"time" "time"
@ -110,9 +111,16 @@ func (r *Resolver) Stats() string {
return fmt.Sprintf("sessionresolver: %s", string(data)) return fmt.Sprintf("sessionresolver: %s", string(data))
} }
var errNotImplemented = errors.New("not implemented")
// LookupHTTPS implements Resolver.LookupHTTPS. // LookupHTTPS implements Resolver.LookupHTTPS.
func (r *Resolver) LookupHTTPS(ctx context.Context, domain string) (*model.HTTPSSvc, error) { func (r *Resolver) LookupHTTPS(ctx context.Context, domain string) (*model.HTTPSSvc, error) {
return nil, errors.New("not implemented") return nil, errNotImplemented
}
// LookupNS implements Resolver.LookupNS.
func (r *Resolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errNotImplemented
} }
// ErrLookupHost indicates that LookupHost failed. // ErrLookupHost indicates that LookupHost failed.

View File

@ -343,3 +343,27 @@ func TestShouldSkipWithProxyWorks(t *testing.T) {
} }
} }
} }
func TestUnimplementedFunctions(t *testing.T) {
t.Run("LookupHTTPS", func(t *testing.T) {
r := &Resolver{}
https, err := r.LookupHTTPS(context.Background(), "dns.google")
if !errors.Is(err, errNotImplemented) {
t.Fatal("unexpected error", err)
}
if https != nil {
t.Fatal("expected nil result")
}
})
t.Run("LookupNS", func(t *testing.T) {
r := &Resolver{}
ns, err := r.LookupNS(context.Background(), "dns.google")
if !errors.Is(err, errNotImplemented) {
t.Fatal("unexpected error", err)
}
if len(ns) > 0 {
t.Fatal("expected empty result")
}
})
}

View File

@ -25,7 +25,7 @@ func (r *CacheResolver) LookupHost(
if err != nil { if err != nil {
return nil, err return nil, err
} }
if r.ReadOnly == false { if !r.ReadOnly {
r.Set(hostname, entry) r.Set(hostname, entry)
} }
return entry, nil return entry, nil

View File

@ -6,14 +6,11 @@ import (
"testing" "testing"
"github.com/ooni/probe-cli/v3/internal/engine/netx/resolver" "github.com/ooni/probe-cli/v3/internal/engine/netx/resolver"
"github.com/ooni/probe-cli/v3/internal/model"
) )
func TestCacheFailure(t *testing.T) { func TestCacheFailure(t *testing.T) {
expected := errors.New("mocked error") expected := errors.New("mocked error")
var r model.Resolver = resolver.FakeResolver{ r := resolver.NewFakeResolverWithExplicitError(expected)
Err: expected,
}
cache := &resolver.CacheResolver{Resolver: r} cache := &resolver.CacheResolver{Resolver: r}
addrs, err := cache.LookupHost(context.Background(), "www.google.com") addrs, err := cache.LookupHost(context.Background(), "www.google.com")
if !errors.Is(err, expected) { if !errors.Is(err, expected) {
@ -28,9 +25,8 @@ func TestCacheFailure(t *testing.T) {
} }
func TestCacheHitSuccess(t *testing.T) { func TestCacheHitSuccess(t *testing.T) {
var r model.Resolver = resolver.FakeResolver{ expected := errors.New("mocked error")
Err: errors.New("mocked error"), r := resolver.NewFakeResolverWithExplicitError(expected)
}
cache := &resolver.CacheResolver{Resolver: r} cache := &resolver.CacheResolver{Resolver: r}
cache.Set("dns.google.com", []string{"8.8.8.8"}) cache.Set("dns.google.com", []string{"8.8.8.8"})
addrs, err := cache.LookupHost(context.Background(), "dns.google.com") addrs, err := cache.LookupHost(context.Background(), "dns.google.com")
@ -43,9 +39,7 @@ func TestCacheHitSuccess(t *testing.T) {
} }
func TestCacheMissSuccess(t *testing.T) { func TestCacheMissSuccess(t *testing.T) {
var r model.Resolver = resolver.FakeResolver{ r := resolver.NewFakeResolverWithResult([]string{"8.8.8.8"})
Result: []string{"8.8.8.8"},
}
cache := &resolver.CacheResolver{Resolver: r} cache := &resolver.CacheResolver{Resolver: r}
addrs, err := cache.LookupHost(context.Background(), "dns.google.com") addrs, err := cache.LookupHost(context.Background(), "dns.google.com")
if err != nil { if err != nil {
@ -60,9 +54,7 @@ func TestCacheMissSuccess(t *testing.T) {
} }
func TestCacheReadonlySuccess(t *testing.T) { func TestCacheReadonlySuccess(t *testing.T) {
var r model.Resolver = resolver.FakeResolver{ r := resolver.NewFakeResolverWithResult([]string{"8.8.8.8"})
Result: []string{"8.8.8.8"},
}
cache := &resolver.CacheResolver{Resolver: r, ReadOnly: true} cache := &resolver.CacheResolver{Resolver: r, ReadOnly: true}
addrs, err := cache.LookupHost(context.Background(), "dns.google.com") addrs, err := cache.LookupHost(context.Background(), "dns.google.com")
if err != nil { if err != nil {

View File

@ -7,8 +7,10 @@ import (
"net" "net"
"time" "time"
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model"
"github.com/ooni/probe-cli/v3/internal/model/mocks"
"github.com/ooni/probe-cli/v3/internal/netxlite"
"github.com/ooni/probe-cli/v3/internal/runtimex"
) )
type FakeDialer struct { type FakeDialer struct {
@ -108,48 +110,53 @@ func (fe FakeEncoder) Encode(domain string, qtype uint16, padding bool) ([]byte,
return fe.Data, fe.Err return fe.Data, fe.Err
} }
type FakeResolver struct { func NewFakeResolverThatFails() model.Resolver {
NumFailures *atomicx.Int64 return NewFakeResolverWithExplicitError(netxlite.ErrOODNSNoSuchHost)
Err error
Result []string
} }
func NewFakeResolverThatFails() FakeResolver { func NewFakeResolverWithExplicitError(err error) model.Resolver {
return FakeResolver{NumFailures: &atomicx.Int64{}, Err: errNotFound} runtimex.PanicIfNil(err, "passed nil error")
} return &mocks.Resolver{
MockLookupHost: func(ctx context.Context, domain string) ([]string, error) {
func NewFakeResolverWithResult(r []string) FakeResolver { return nil, err
return FakeResolver{NumFailures: &atomicx.Int64{}, Result: r} },
} MockNetwork: func() string {
return "fake"
var errNotFound = &net.DNSError{ },
Err: "no such host", MockAddress: func() string {
} return ""
},
func (c FakeResolver) LookupHost(ctx context.Context, hostname string) ([]string, error) { MockCloseIdleConnections: func() {
time.Sleep(10 * time.Microsecond) // nothing
if c.Err != nil { },
if c.NumFailures != nil { MockLookupHTTPS: func(ctx context.Context, domain string) (*model.HTTPSSvc, error) {
c.NumFailures.Add(1) return nil, errors.New("not implemented")
} },
return nil, c.Err MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("not implemented")
},
} }
return c.Result, nil
} }
func (c FakeResolver) Network() string { func NewFakeResolverWithResult(r []string) model.Resolver {
return "fake" return &mocks.Resolver{
MockLookupHost: func(ctx context.Context, domain string) ([]string, error) {
return r, nil
},
MockNetwork: func() string {
return "fake"
},
MockAddress: func() string {
return ""
},
MockCloseIdleConnections: func() {
// nothing
},
MockLookupHTTPS: func(ctx context.Context, domain string) (*model.HTTPSSvc, error) {
return nil, errors.New("not implemented")
},
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("not implemented")
},
}
} }
func (c FakeResolver) Address() string {
return ""
}
func (c FakeResolver) CloseIdleConnections() {}
func (c FakeResolver) LookupHTTPS(
ctx context.Context, domain string) (*model.HTTPSSvc, error) {
return nil, errors.New("not implemented")
}
var _ model.Resolver = FakeResolver{}

View File

@ -16,10 +16,8 @@ func TestSaverResolverFailure(t *testing.T) {
expected := errors.New("no such host") expected := errors.New("no such host")
saver := &trace.Saver{} saver := &trace.Saver{}
reso := resolver.SaverResolver{ reso := resolver.SaverResolver{
Resolver: resolver.FakeResolver{ Resolver: resolver.NewFakeResolverWithExplicitError(expected),
Err: expected, Saver: saver,
},
Saver: saver,
} }
addrs, err := reso.LookupHost(context.Background(), "www.google.com") addrs, err := reso.LookupHost(context.Background(), "www.google.com")
if !errors.Is(err, expected) { if !errors.Is(err, expected) {
@ -65,10 +63,8 @@ func TestSaverResolverSuccess(t *testing.T) {
expected := []string{"8.8.8.8", "8.8.4.4"} expected := []string{"8.8.8.8", "8.8.4.4"}
saver := &trace.Saver{} saver := &trace.Saver{}
reso := resolver.SaverResolver{ reso := resolver.SaverResolver{
Resolver: resolver.FakeResolver{ Resolver: resolver.NewFakeResolverWithResult(expected),
Result: expected, Saver: saver,
},
Saver: saver,
} }
addrs, err := reso.LookupHost(context.Background(), "www.google.com") addrs, err := reso.LookupHost(context.Background(), "www.google.com")
if err != nil { if err != nil {

View File

@ -14,6 +14,7 @@ import (
) )
func TestCheckReportIDWorkingAsIntended(t *testing.T) { func TestCheckReportIDWorkingAsIntended(t *testing.T) {
t.Skip("see https://github.com/ooni/probe/issues/2098")
client := probeservices.Client{ client := probeservices.Client{
APIClientTemplate: httpx.APIClientTemplate{ APIClientTemplate: httpx.APIClientTemplate{
BaseURL: "https://ams-pg.ooni.org/", BaseURL: "https://ams-pg.ooni.org/",

View File

@ -1,6 +1,8 @@
package mocks package mocks
import ( import (
"net"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model"
) )
@ -8,10 +10,9 @@ import (
// DNSDecoder allows mocking dnsx.DNSDecoder. // DNSDecoder allows mocking dnsx.DNSDecoder.
type DNSDecoder struct { type DNSDecoder struct {
MockDecodeLookupHost func(qtype uint16, reply []byte, queryID uint16) ([]string, error) MockDecodeLookupHost func(qtype uint16, reply []byte, queryID uint16) ([]string, error)
MockDecodeHTTPS func(reply []byte, queryID uint16) (*model.HTTPSSvc, error)
MockDecodeHTTPS func(reply []byte, queryID uint16) (*model.HTTPSSvc, error) MockDecodeNS func(reply []byte, queryID uint16) ([]*net.NS, error)
MockDecodeReply func(reply []byte) (*dns.Msg, error)
MockDecodeReply func(reply []byte) (*dns.Msg, error)
} }
// DecodeLookupHost calls MockDecodeLookupHost. // DecodeLookupHost calls MockDecodeLookupHost.
@ -24,6 +25,11 @@ func (e *DNSDecoder) DecodeHTTPS(reply []byte, queryID uint16) (*model.HTTPSSvc,
return e.MockDecodeHTTPS(reply, queryID) return e.MockDecodeHTTPS(reply, queryID)
} }
// DecodeNS calls MockDecodeNS.
func (e *DNSDecoder) DecodeNS(reply []byte, queryID uint16) ([]*net.NS, error) {
return e.MockDecodeNS(reply, queryID)
}
// DecodeReply calls MockDecodeReply. // DecodeReply calls MockDecodeReply.
func (e *DNSDecoder) DecodeReply(reply []byte) (*dns.Msg, error) { func (e *DNSDecoder) DecodeReply(reply []byte) (*dns.Msg, error) {
return e.MockDecodeReply(reply) return e.MockDecodeReply(reply)

View File

@ -2,6 +2,7 @@ package mocks
import ( import (
"errors" "errors"
"net"
"testing" "testing"
"github.com/miekg/dns" "github.com/miekg/dns"
@ -41,6 +42,22 @@ func TestDNSDecoder(t *testing.T) {
} }
}) })
t.Run("DecodeNS", func(t *testing.T) {
expected := errors.New("mocked error")
e := &DNSDecoder{
MockDecodeNS: func(reply []byte, queryID uint16) ([]*net.NS, error) {
return nil, expected
},
}
out, err := e.DecodeNS(make([]byte, 17), dns.Id())
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("unexpected out")
}
})
t.Run("DecodeReply", func(t *testing.T) { t.Run("DecodeReply", func(t *testing.T) {
expected := errors.New("mocked error") expected := errors.New("mocked error")
e := &DNSDecoder{ e := &DNSDecoder{

View File

@ -2,6 +2,7 @@ package mocks
import ( import (
"context" "context"
"net"
"github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model"
) )
@ -13,6 +14,7 @@ type Resolver struct {
MockAddress func() string MockAddress func() string
MockCloseIdleConnections func() MockCloseIdleConnections func()
MockLookupHTTPS func(ctx context.Context, domain string) (*model.HTTPSSvc, error) MockLookupHTTPS func(ctx context.Context, domain string) (*model.HTTPSSvc, error)
MockLookupNS func(ctx context.Context, domain string) ([]*net.NS, error)
} }
// LookupHost calls MockLookupHost. // LookupHost calls MockLookupHost.
@ -39,3 +41,8 @@ func (r *Resolver) CloseIdleConnections() {
func (r *Resolver) LookupHTTPS(ctx context.Context, domain string) (*model.HTTPSSvc, error) { func (r *Resolver) LookupHTTPS(ctx context.Context, domain string) (*model.HTTPSSvc, error) {
return r.MockLookupHTTPS(ctx, domain) return r.MockLookupHTTPS(ctx, domain)
} }
// LookupNS calls MockLookupNS.
func (r *Resolver) LookupNS(ctx context.Context, domain string) ([]*net.NS, error) {
return r.MockLookupNS(ctx, domain)
}

View File

@ -3,6 +3,7 @@ package mocks
import ( import (
"context" "context"
"errors" "errors"
"net"
"testing" "testing"
"github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model"
@ -77,4 +78,21 @@ func TestResolver(t *testing.T) {
t.Fatal("expected nil addr") t.Fatal("expected nil addr")
} }
}) })
t.Run("LookupNS", func(t *testing.T) {
expected := errors.New("mocked error")
r := &Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, expected
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "dns.google")
if !errors.Is(err, expected) {
t.Fatal("unexpected error", err)
}
if ns != nil {
t.Fatal("expected nil addr")
}
})
} }

View File

@ -51,6 +51,9 @@ type DNSDecoder interface {
// an error, though, when there are no IPv4/IPv6 hints in the reply. // an error, though, when there are no IPv4/IPv6 hints in the reply.
DecodeHTTPS(data []byte, queryID uint16) (*HTTPSSvc, error) DecodeHTTPS(data []byte, queryID uint16) (*HTTPSSvc, error)
// DecodeNS is like DecodeHTTPS but for NS queries.
DecodeNS(data []byte, queryID uint16) ([]*net.NS, error)
// DecodeReply decodes a DNS reply message. // DecodeReply decodes a DNS reply message.
// //
// Arguments: // Arguments:
@ -194,6 +197,9 @@ type Resolver interface {
// LookupHTTPS issues an HTTPS query for a domain. // LookupHTTPS issues an HTTPS query for a domain.
LookupHTTPS( LookupHTTPS(
ctx context.Context, domain string) (*HTTPSSvc, error) ctx context.Context, domain string) (*HTTPSSvc, error)
// LookupNS issues a NS query for a domain.
LookupNS(ctx context.Context, domain string) ([]*net.NS, error)
} }
// TLSDialer is a Dialer dialing TLS connections. // TLSDialer is a Dialer dialing TLS connections.

View File

@ -6,8 +6,6 @@ package netxlite
// This file helps us to decide if an IPAddr is a bogon. // This file helps us to decide if an IPAddr is a bogon.
// //
// TODO(bassosimone): code in engine/netx should use this file.
import ( import (
"net" "net"

View File

@ -6,6 +6,7 @@ package netxlite
import ( import (
"errors" "errors"
"net"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/model" "github.com/ooni/probe-cli/v3/internal/model"
@ -111,4 +112,22 @@ func (d *DNSDecoderMiekg) DecodeLookupHost(qtype uint16, data []byte, queryID ui
return addrs, nil return addrs, nil
} }
func (d *DNSDecoderMiekg) DecodeNS(data []byte, queryID uint16) ([]*net.NS, error) {
reply, err := d.parseReply(data, queryID)
if err != nil {
return nil, err
}
out := []*net.NS{}
for _, answer := range reply.Answer {
switch avalue := answer.(type) {
case *dns.NS:
out = append(out, &net.NS{Host: avalue.Ns})
}
}
if len(out) < 1 {
return nil, ErrOODNSNoAnswer
}
return out, nil
}
var _ model.DNSDecoder = &DNSDecoderMiekg{} var _ model.DNSDecoder = &DNSDecoderMiekg{}

View File

@ -192,8 +192,8 @@ func TestDNSDecoder(t *testing.T) {
queryID = 17 queryID = 17
unrelatedID = 14 unrelatedID = 14
) )
reply := dnsGenHTTPSReplySuccess(dnsGenQuery(dns.TypeA, queryID), nil, nil, nil) reply := dnsGenHTTPSReplySuccess(dnsGenQuery(dns.TypeHTTPS, queryID), nil, nil, nil)
data, err := d.DecodeLookupHost(dns.TypeA, reply, unrelatedID) data, err := d.DecodeHTTPS(reply, unrelatedID)
if !errors.Is(err, ErrDNSReplyWithWrongQueryID) { if !errors.Is(err, ErrDNSReplyWithWrongQueryID) {
t.Fatal("unexpected error", err) t.Fatal("unexpected error", err)
} }
@ -239,6 +239,64 @@ func TestDNSDecoder(t *testing.T) {
} }
}) })
}) })
t.Run("DecodeNS", func(t *testing.T) {
t.Run("with nil data", func(t *testing.T) {
d := &DNSDecoderMiekg{}
reply, err := d.DecodeNS(nil, 0)
if err == nil || err.Error() != "dns: overflow unpacking uint16" {
t.Fatal("not the error we expected", err)
}
if reply != nil {
t.Fatal("expected nil reply")
}
})
t.Run("wrong query ID", func(t *testing.T) {
d := &DNSDecoderMiekg{}
const (
queryID = 17
unrelatedID = 14
)
reply := dnsGenNSReplySuccess(dnsGenQuery(dns.TypeNS, queryID))
data, err := d.DecodeNS(reply, unrelatedID)
if !errors.Is(err, ErrDNSReplyWithWrongQueryID) {
t.Fatal("unexpected error", err)
}
if data != nil {
t.Fatal("expected nil data here")
}
})
t.Run("with empty answer", func(t *testing.T) {
queryID := dns.Id()
data := dnsGenNSReplySuccess(dnsGenQuery(dns.TypeNS, queryID))
d := &DNSDecoderMiekg{}
reply, err := d.DecodeNS(data, queryID)
if !errors.Is(err, ErrOODNSNoAnswer) {
t.Fatal("unexpected err", err)
}
if reply != nil {
t.Fatal("expected nil reply")
}
})
t.Run("with full answer", func(t *testing.T) {
queryID := dns.Id()
data := dnsGenNSReplySuccess(dnsGenQuery(dns.TypeNS, queryID), "ns1.zdns.google.")
d := &DNSDecoderMiekg{}
reply, err := d.DecodeNS(data, queryID)
if err != nil {
t.Fatal(err)
}
if len(reply) != 1 {
t.Fatal("unexpected reply length")
}
if reply[0].Host != "ns1.zdns.google." {
t.Fatal("unexpected reply host")
}
})
})
} }
// dnsGenQuery generates a query suitable to be used with testing. // dnsGenQuery generates a query suitable to be used with testing.
@ -281,6 +339,10 @@ func dnsGenLookupHostReplySuccess(rawQuery []byte, ips ...string) []byte {
runtimex.PanicOnError(err, "query.Unpack failed") runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question") runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question")
question := query.Question[0] question := query.Question[0]
runtimex.PanicIfFalse(
question.Qtype == dns.TypeA || question.Qtype == dns.TypeAAAA,
"invalid query type (expected A or AAAA)",
)
reply := new(dns.Msg) reply := new(dns.Msg)
reply.Compress = true reply.Compress = true
reply.MsgHdr.RecursionAvailable = true reply.MsgHdr.RecursionAvailable = true
@ -326,6 +388,9 @@ func dnsGenHTTPSReplySuccess(rawQuery []byte, alpns, ipv4s, ipv6s []string) []by
query := new(dns.Msg) query := new(dns.Msg)
err := query.Unpack(rawQuery) err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed") runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "expected just a single question")
question := query.Question[0]
runtimex.PanicIfFalse(question.Qtype == dns.TypeHTTPS, "expected HTTPS query")
reply := new(dns.Msg) reply := new(dns.Msg)
reply.Compress = true reply.Compress = true
reply.MsgHdr.RecursionAvailable = true reply.MsgHdr.RecursionAvailable = true
@ -364,3 +429,31 @@ func dnsGenHTTPSReplySuccess(rawQuery []byte, alpns, ipv4s, ipv6s []string) []by
runtimex.PanicOnError(err, "reply.Pack failed") runtimex.PanicOnError(err, "reply.Pack failed")
return data return data
} }
// dnsGenNSReplySuccess generates a successful NS reply using the given names.
func dnsGenNSReplySuccess(rawQuery []byte, names ...string) []byte {
query := new(dns.Msg)
err := query.Unpack(rawQuery)
runtimex.PanicOnError(err, "query.Unpack failed")
runtimex.PanicIfFalse(len(query.Question) == 1, "more than one question")
question := query.Question[0]
runtimex.PanicIfFalse(question.Qtype == dns.TypeNS, "expected NS query")
reply := new(dns.Msg)
reply.Compress = true
reply.MsgHdr.RecursionAvailable = true
reply.SetReply(query)
for _, name := range names {
reply.Answer = append(reply.Answer, &dns.NS{
Hdr: dns.RR_Header{
Name: dns.Fqdn("x.org"),
Rrtype: question.Qtype,
Class: dns.ClassINET,
Ttl: 0,
},
Ns: name,
})
}
data, err := reply.Pack()
runtimex.PanicOnError(err, "reply.Pack failed")
return data
}

View File

@ -6,6 +6,7 @@ package netxlite
import ( import (
"context" "context"
"net"
"github.com/miekg/dns" "github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/atomicx" "github.com/ooni/probe-cli/v3/internal/atomicx"
@ -129,3 +130,18 @@ func (r *ParallelResolver) lookupHost(ctx context.Context, hostname string,
err: err, err: err,
} }
} }
// LookupNS implements Resolver.LookupNS.
func (r *ParallelResolver) LookupNS(
ctx context.Context, hostname string) ([]*net.NS, error) {
querydata, queryID, err := r.Encoder.Encode(
hostname, dns.TypeNS, r.Txp.RequiresPadding())
if err != nil {
return nil, err
}
replydata, err := r.Txp.RoundTrip(ctx, querydata)
if err != nil {
return nil, err
}
return r.Decoder.DecodeNS(replydata, queryID)
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"errors" "errors"
"net"
"testing" "testing"
"github.com/miekg/dns" "github.com/miekg/dns"
@ -266,4 +267,94 @@ func TestParallelResolver(t *testing.T) {
} }
}) })
}) })
t.Run("LookupNS", func(t *testing.T) {
t.Run("for encoding error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &ParallelResolver{
Encoder: &mocks.DNSEncoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, uint16, error) {
return nil, 0, expected
},
},
Decoder: nil,
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.DNSTransport{
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if ns != nil {
t.Fatal("unexpected result")
}
})
t.Run("for round-trip error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &ParallelResolver{
Encoder: &mocks.DNSEncoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, uint16, error) {
return make([]byte, 64), 0, nil
},
},
Decoder: nil,
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.DNSTransport{
MockRoundTrip: func(ctx context.Context, query []byte) (reply []byte, err error) {
return nil, expected
},
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if ns != nil {
t.Fatal("unexpected result")
}
})
t.Run("for decode error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &ParallelResolver{
Encoder: &mocks.DNSEncoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, uint16, error) {
return make([]byte, 64), 0, nil
},
},
Decoder: &mocks.DNSDecoder{
MockDecodeNS: func(reply []byte, queryID uint16) ([]*net.NS, error) {
return nil, expected
},
},
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.DNSTransport{
MockRoundTrip: func(ctx context.Context, query []byte) (reply []byte, err error) {
return make([]byte, 128), nil
},
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
https, err := r.LookupNS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if https != nil {
t.Fatal("unexpected result")
}
})
})
} }

View File

@ -136,6 +136,16 @@ func (r *resolverSystem) LookupHTTPS(
return nil, ErrNoDNSTransport return nil, ErrNoDNSTransport
} }
func (r *resolverSystem) LookupNS(
ctx context.Context, domain string) ([]*net.NS, error) {
// TODO(bassosimone): figure out in which context it makes sense
// to issue this query. How is this implemented under the hood by
// the stdlib? Is it using /etc/resolve.conf on Unix? Until we
// known all these details, let's pretend this functionality does
// not exist in the stdlib and focus on custom resolvers.
return nil, ErrNoDNSTransport
}
// resolverLogger is a resolver that emits events // resolverLogger is a resolver that emits events
type resolverLogger struct { type resolverLogger struct {
Resolver model.Resolver Resolver model.Resolver
@ -188,6 +198,21 @@ func (r *resolverLogger) CloseIdleConnections() {
r.Resolver.CloseIdleConnections() r.Resolver.CloseIdleConnections()
} }
func (r *resolverLogger) LookupNS(
ctx context.Context, domain string) ([]*net.NS, error) {
prefix := fmt.Sprintf("resolve[NS] %s with %s (%s)", domain, r.Network(), r.Address())
r.Logger.Debugf("%s...", prefix)
start := time.Now()
ns, err := r.Resolver.LookupNS(ctx, domain)
elapsed := time.Since(start)
if err != nil {
r.Logger.Debugf("%s... %s in %s", prefix, err, elapsed)
return nil, err
}
r.Logger.Debugf("%s... %+v in %s", prefix, ns, elapsed)
return ns, nil
}
// resolverIDNA supports resolving Internationalized Domain Names. // resolverIDNA supports resolving Internationalized Domain Names.
// //
// See RFC3492 for more information. // See RFC3492 for more information.
@ -226,6 +251,15 @@ func (r *resolverIDNA) CloseIdleConnections() {
r.Resolver.CloseIdleConnections() r.Resolver.CloseIdleConnections()
} }
func (r *resolverIDNA) LookupNS(
ctx context.Context, domain string) ([]*net.NS, error) {
host, err := idna.ToASCII(domain)
if err != nil {
return nil, err
}
return r.Resolver.LookupNS(ctx, host)
}
// resolverShortCircuitIPAddr recognizes when the input hostname is an // resolverShortCircuitIPAddr recognizes when the input hostname is an
// IP address and returns it immediately to the caller. // IP address and returns it immediately to the caller.
type resolverShortCircuitIPAddr struct { type resolverShortCircuitIPAddr struct {
@ -266,6 +300,18 @@ func (r *resolverShortCircuitIPAddr) CloseIdleConnections() {
r.Resolver.CloseIdleConnections() r.Resolver.CloseIdleConnections()
} }
// ErrDNSIPAddress indicates that you passed an IP address to a DNS
// function that only works with domain names.
var ErrDNSIPAddress = errors.New("ooresolver: expected domain, found IP address")
func (r *resolverShortCircuitIPAddr) LookupNS(
ctx context.Context, hostname string) ([]*net.NS, error) {
if net.ParseIP(hostname) != nil {
return nil, ErrDNSIPAddress
}
return r.Resolver.LookupNS(ctx, hostname)
}
// IsIPv6 returns true if the given candidate is a valid IP address // IsIPv6 returns true if the given candidate is a valid IP address
// representation and such representation is IPv6. // representation and such representation is IPv6.
func IsIPv6(candidate string) (bool, error) { func IsIPv6(candidate string) (bool, error) {
@ -313,6 +359,11 @@ func (r *nullResolver) LookupHTTPS(
return nil, ErrNoResolver return nil, ErrNoResolver
} }
func (r *nullResolver) LookupNS(
ctx context.Context, domain string) ([]*net.NS, error) {
return nil, ErrNoResolver
}
// resolverErrWrapper is a Resolver that knows about wrapping errors. // resolverErrWrapper is a Resolver that knows about wrapping errors.
type resolverErrWrapper struct { type resolverErrWrapper struct {
Resolver model.Resolver Resolver model.Resolver
@ -348,3 +399,12 @@ func (r *resolverErrWrapper) Address() string {
func (r *resolverErrWrapper) CloseIdleConnections() { func (r *resolverErrWrapper) CloseIdleConnections() {
r.Resolver.CloseIdleConnections() r.Resolver.CloseIdleConnections()
} }
func (r *resolverErrWrapper) LookupNS(
ctx context.Context, domain string) ([]*net.NS, error) {
out, err := r.Resolver.LookupNS(ctx, domain)
if err != nil {
return nil, newErrWrapper(classifyResolverError, ResolveOperation, err)
}
return out, nil
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"io" "io"
"net"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@ -166,6 +167,17 @@ func TestResolverSystem(t *testing.T) {
t.Fatal("expected nil result") t.Fatal("expected nil result")
} }
}) })
t.Run("LookupNS", func(t *testing.T) {
r := &resolverSystem{}
ns, err := r.LookupNS(context.Background(), "x.org")
if !errors.Is(err, ErrNoDNSTransport) {
t.Fatal("not the error we expected")
}
if ns != nil {
t.Fatal("expected nil result")
}
})
} }
func TestResolverLogger(t *testing.T) { func TestResolverLogger(t *testing.T) {
@ -312,6 +324,94 @@ func TestResolverLogger(t *testing.T) {
}) })
}) })
t.Run("CloseIdleConnections", func(t *testing.T) {
var called bool
child := &mocks.Resolver{
MockCloseIdleConnections: func() {
called = true
},
}
reso := &resolverLogger{
Resolver: child,
Logger: model.DiscardLogger,
}
reso.CloseIdleConnections()
if !called {
t.Fatal("not called")
}
})
t.Run("LookupNS", func(t *testing.T) {
t.Run("with success", func(t *testing.T) {
var count int
lo := &mocks.Logger{
MockDebugf: func(format string, v ...interface{}) {
count++
},
}
expected := []*net.NS{{
Host: "ns1.zdns.google.",
}}
r := &resolverLogger{
Logger: lo,
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return expected, nil
},
MockNetwork: func() string {
return "system"
},
MockAddress: func() string {
return ""
},
},
}
ns, err := r.LookupNS(context.Background(), "dns.google")
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(expected, ns); diff != "" {
t.Fatal(diff)
}
if count != 2 {
t.Fatal("unexpected count")
}
})
t.Run("with failure", func(t *testing.T) {
var count int
lo := &mocks.Logger{
MockDebugf: func(format string, v ...interface{}) {
count++
},
}
expected := errors.New("mocked error")
r := &resolverLogger{
Logger: lo,
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, expected
},
MockNetwork: func() string {
return "system"
},
MockAddress: func() string {
return ""
},
},
}
ns, err := r.LookupNS(context.Background(), "dns.google")
if !errors.Is(err, expected) {
t.Fatal("not the error we expected", err)
}
if ns != nil {
t.Fatal("expected nil addr here")
}
if count != 2 {
t.Fatal("unexpected count")
}
})
})
} }
func TestResolverIDNA(t *testing.T) { func TestResolverIDNA(t *testing.T) {
@ -424,6 +524,63 @@ func TestResolverIDNA(t *testing.T) {
t.Fatal("invalid address") t.Fatal("invalid address")
} }
}) })
t.Run("CloseIdleConnections", func(t *testing.T) {
var called bool
child := &mocks.Resolver{
MockCloseIdleConnections: func() {
called = true
},
}
reso := &resolverIDNA{child}
reso.CloseIdleConnections()
if !called {
t.Fatal("not called")
}
})
t.Run("LookupNS", func(t *testing.T) {
t.Run("with valid IDNA in input", func(t *testing.T) {
expected := []*net.NS{{
Host: "ns1.zdns.google.",
}}
r := &resolverIDNA{
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
if domain != "xn--d1acpjx3f.xn--p1ai" {
return nil, errors.New("passed invalid domain")
}
return expected, nil
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "яндекс.рф")
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(expected, ns); diff != "" {
t.Fatal(diff)
}
})
t.Run("with invalid punycode", func(t *testing.T) {
r := &resolverIDNA{Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("should not happen")
},
}}
// See https://www.farsightsecurity.com/blog/txt-record/punycode-20180711/
ctx := context.Background()
ns, err := r.LookupNS(ctx, "xn--0000h")
if err == nil || !strings.HasPrefix(err.Error(), "idna: invalid label") {
t.Fatal("not the error we expected")
}
if ns != nil {
t.Fatal("expected no response here")
}
})
})
} }
func TestResolverShortCircuitIPAddr(t *testing.T) { func TestResolverShortCircuitIPAddr(t *testing.T) {
@ -520,6 +677,100 @@ func TestResolverShortCircuitIPAddr(t *testing.T) {
} }
}) })
}) })
t.Run("LookupNS", func(t *testing.T) {
t.Run("with IPv4 addr", func(t *testing.T) {
r := &resolverShortCircuitIPAddr{
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("mocked error")
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "8.8.8.8")
if !errors.Is(err, ErrDNSIPAddress) {
t.Fatal("unexpected error", err)
}
if len(ns) > 0 {
t.Fatal("invalid result")
}
})
t.Run("with IPv6 addr", func(t *testing.T) {
r := &resolverShortCircuitIPAddr{
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("mocked error")
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "::1")
if !errors.Is(err, ErrDNSIPAddress) {
t.Fatal("unexpected error", err)
}
if len(ns) > 0 {
t.Fatal("invalid result")
}
})
t.Run("with domain", func(t *testing.T) {
r := &resolverShortCircuitIPAddr{
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, errors.New("mocked error")
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "dns.google")
if err == nil || err.Error() != "mocked error" {
t.Fatal("not the error we expected", err)
}
if len(ns) > 0 {
t.Fatal("invalid result")
}
})
})
t.Run("Network", func(t *testing.T) {
child := &mocks.Resolver{
MockNetwork: func() string {
return "x"
},
}
reso := &resolverShortCircuitIPAddr{child}
if reso.Network() != "x" {
t.Fatal("invalid result")
}
})
t.Run("Address", func(t *testing.T) {
child := &mocks.Resolver{
MockAddress: func() string {
return "x"
},
}
reso := &resolverShortCircuitIPAddr{child}
if reso.Address() != "x" {
t.Fatal("invalid result")
}
})
t.Run("CloseIdleConnections", func(t *testing.T) {
var called bool
child := &mocks.Resolver{
MockCloseIdleConnections: func() {
called = true
},
}
reso := &resolverShortCircuitIPAddr{child}
reso.CloseIdleConnections()
if !called {
t.Fatal("not called")
}
})
} }
func TestIsIPv6(t *testing.T) { func TestIsIPv6(t *testing.T) {
@ -592,6 +843,18 @@ func TestNullResolver(t *testing.T) {
} }
r.CloseIdleConnections() // for coverage r.CloseIdleConnections() // for coverage
}) })
t.Run("LookupNS", func(t *testing.T) {
r := &nullResolver{}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "dns.google")
if !errors.Is(err, ErrNoResolver) {
t.Fatal("unexpected error", err)
}
if len(ns) > 0 {
t.Fatal("unexpected result")
}
})
} }
func TestResolverErrWrapper(t *testing.T) { func TestResolverErrWrapper(t *testing.T) {
@ -719,4 +982,46 @@ func TestResolverErrWrapper(t *testing.T) {
} }
}) })
}) })
t.Run("LookupNS", func(t *testing.T) {
t.Run("on success", func(t *testing.T) {
expected := []*net.NS{{
Host: "antani.local.",
}}
reso := &resolverErrWrapper{
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return expected, nil
},
},
}
ctx := context.Background()
ns, err := reso.LookupNS(ctx, "antani.local")
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(expected, ns); diff != "" {
t.Fatal(diff)
}
})
t.Run("on failure", func(t *testing.T) {
expected := io.EOF
reso := &resolverErrWrapper{
Resolver: &mocks.Resolver{
MockLookupNS: func(ctx context.Context, domain string) ([]*net.NS, error) {
return nil, expected
},
},
}
ctx := context.Background()
ns, err := reso.LookupNS(ctx, "")
if err == nil || err.Error() != FailureEOFError {
t.Fatal("unexpected err", err)
}
if len(ns) > 0 {
t.Fatal("unexpected addrs")
}
})
})
} }

View File

@ -23,8 +23,8 @@ import (
// Deprecated: please use ParallelResolver in new code. We cannot // Deprecated: please use ParallelResolver in new code. We cannot
// remove this code as long as we use tracing for measuring. // remove this code as long as we use tracing for measuring.
// //
// QUIRK: unlike the ParallelResolver, this resolver retries each // QUIRK: unlike the ParallelResolver, this resolver's LookupHost retries
// query three times for soft errors. // each query three times for soft errors.
type SerialResolver struct { type SerialResolver struct {
// Encoder is the MANDATORY encoder to use. // Encoder is the MANDATORY encoder to use.
Encoder model.DNSEncoder Encoder model.DNSEncoder
@ -142,3 +142,18 @@ func (r *SerialResolver) lookupHostWithoutRetry(
} }
return r.Decoder.DecodeLookupHost(qtype, replydata, queryID) return r.Decoder.DecodeLookupHost(qtype, replydata, queryID)
} }
// LookupNS implements Resolver.LookupNS.
func (r *SerialResolver) LookupNS(
ctx context.Context, hostname string) ([]*net.NS, error) {
querydata, queryID, err := r.Encoder.Encode(
hostname, dns.TypeNS, r.Txp.RequiresPadding())
if err != nil {
return nil, err
}
replydata, err := r.Txp.RoundTrip(ctx, querydata)
if err != nil {
return nil, err
}
return r.Decoder.DecodeNS(replydata, queryID)
}

View File

@ -272,4 +272,94 @@ func TestSerialResolver(t *testing.T) {
} }
}) })
}) })
t.Run("LookupNS", func(t *testing.T) {
t.Run("for encoding error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &SerialResolver{
Encoder: &mocks.DNSEncoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, uint16, error) {
return nil, 0, expected
},
},
Decoder: nil,
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.DNSTransport{
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if ns != nil {
t.Fatal("unexpected result")
}
})
t.Run("for round-trip error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &SerialResolver{
Encoder: &mocks.DNSEncoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, uint16, error) {
return make([]byte, 64), 0, nil
},
},
Decoder: nil,
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.DNSTransport{
MockRoundTrip: func(ctx context.Context, query []byte) (reply []byte, err error) {
return nil, expected
},
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
ns, err := r.LookupNS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if ns != nil {
t.Fatal("unexpected result")
}
})
t.Run("for decode error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &SerialResolver{
Encoder: &mocks.DNSEncoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, uint16, error) {
return make([]byte, 64), 0, nil
},
},
Decoder: &mocks.DNSDecoder{
MockDecodeNS: func(reply []byte, queryID uint16) ([]*net.NS, error) {
return nil, expected
},
},
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.DNSTransport{
MockRoundTrip: func(ctx context.Context, query []byte) (reply []byte, err error) {
return make([]byte, 128), nil
},
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
https, err := r.LookupNS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if https != nil {
t.Fatal("unexpected result")
}
})
})
} }

View File

@ -46,6 +46,7 @@ func TestWithRealServerDoCheckIn(t *testing.T) {
} }
func TestWithRealServerDoCheckReportID(t *testing.T) { func TestWithRealServerDoCheckReportID(t *testing.T) {
t.Skip("see https://github.com/ooni/probe/issues/2098")
if testing.Short() { if testing.Short() {
t.Skip("skip test in short mode") t.Skip("skip test in short mode")
} }