feat(netxlite): implement LookupHTTPS (#514)

This new API call performs DNS lookups for HTTPS records.

Part of https://github.com/ooni/probe/issues/1733 and diff has been
extracted from https://github.com/ooni/probe-cli/pull/506.
This commit is contained in:
Simone Basso
2021-09-27 23:09:41 +02:00
committed by GitHub
parent c6b69cbee8
commit 8b9fe1a160
13 changed files with 621 additions and 24 deletions
+38
View File
@@ -2,13 +2,20 @@ package dnsx
import (
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/netxlite/dnsx/model"
"github.com/ooni/probe-cli/v3/internal/netxlite/errorsx"
)
// HTTPSSvc is an HTTPSSvc reply.
type HTTPSSvc = model.HTTPSSvc
// The Decoder decodes DNS replies.
type Decoder interface {
// DecodeLookupHost decodes an A or AAAA reply.
DecodeLookupHost(qtype uint16, data []byte) ([]string, error)
// DecodeHTTPS decodes an HTTPS reply.
DecodeHTTPS(data []byte) (*HTTPSSvc, error)
}
// MiekgDecoder uses github.com/miekg/dns to implement the Decoder.
@@ -33,6 +40,37 @@ func (d *MiekgDecoder) parseReply(data []byte) (*dns.Msg, error) {
}
}
func (d *MiekgDecoder) DecodeHTTPS(data []byte) (*HTTPSSvc, error) {
reply, err := d.parseReply(data)
if err != nil {
return nil, err
}
out := &HTTPSSvc{}
for _, answer := range reply.Answer {
switch avalue := answer.(type) {
case *dns.HTTPS:
for _, v := range avalue.Value {
switch extv := v.(type) {
case *dns.SVCBAlpn:
out.ALPN = extv.Alpn
case *dns.SVCBIPv4Hint:
for _, ip := range extv.Hint {
out.IPv4 = append(out.IPv4, ip.String())
}
case *dns.SVCBIPv6Hint:
for _, ip := range extv.Hint {
out.IPv6 = append(out.IPv6, ip.String())
}
}
}
}
}
if len(out.ALPN) <= 0 {
return nil, errorsx.ErrOODNSNoAnswer
}
return out, nil
}
func (d *MiekgDecoder) DecodeLookupHost(qtype uint16, data []byte) ([]string, error) {
reply, err := d.parseReply(data)
if err != nil {
+110
View File
@@ -6,6 +6,7 @@ import (
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/netxlite/errorsx"
)
@@ -198,3 +199,112 @@ func TestParseReply(t *testing.T) {
t.Fatal("expected nil reply")
}
}
func genReplyHTTPS(t *testing.T, alpns, ipv4, ipv6 []string) []byte {
question := dns.Question{
Name: dns.Fqdn("x.org"),
Qtype: dns.TypeHTTPS,
Qclass: dns.ClassINET,
}
query := new(dns.Msg)
query.Id = dns.Id()
query.RecursionDesired = true
query.Question = make([]dns.Question, 1)
query.Question[0] = question
reply := new(dns.Msg)
reply.Compress = true
reply.MsgHdr.RecursionAvailable = true
reply.SetReply(query)
answer := &dns.HTTPS{
SVCB: dns.SVCB{
Hdr: dns.RR_Header{
Name: dns.Fqdn("x.org"),
Rrtype: dns.TypeHTTPS,
Class: dns.ClassINET,
Ttl: 100,
Rdlength: 0,
},
Priority: 5,
Target: dns.Fqdn("x.org"),
Value: []dns.SVCBKeyValue{},
},
}
reply.Answer = append(reply.Answer, answer)
if len(alpns) > 0 {
answer.Value = append(answer.Value, &dns.SVCBAlpn{
Alpn: alpns,
})
answer.Hdr.Rdlength++
}
if len(ipv4) > 0 {
var addrs []net.IP
for _, addr := range ipv4 {
addrs = append(addrs, net.ParseIP(addr))
}
answer.Value = append(answer.Value, &dns.SVCBIPv4Hint{
Hint: addrs,
})
answer.Hdr.Rdlength++
}
if len(ipv6) > 0 {
var addrs []net.IP
for _, addr := range ipv6 {
addrs = append(addrs, net.ParseIP(addr))
}
answer.Value = append(answer.Value, &dns.SVCBIPv6Hint{
Hint: addrs,
})
answer.Hdr.Rdlength++
}
data, err := reply.Pack()
if err != nil {
t.Fatal(err)
}
return data
}
func TestDecodeHTTPS(t *testing.T) {
t.Run("with nil data", func(t *testing.T) {
d := &MiekgDecoder{}
reply, err := d.DecodeHTTPS(nil)
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("with empty answer", func(t *testing.T) {
data := genReplyHTTPS(t, nil, nil, nil)
d := &MiekgDecoder{}
reply, err := d.DecodeHTTPS(data)
if !errors.Is(err, errorsx.ErrOODNSNoAnswer) {
t.Fatal("unexpected err", err)
}
if reply != nil {
t.Fatal("expected nil reply")
}
})
t.Run("with full answer", func(t *testing.T) {
alpn := []string{"h3"}
v4 := []string{"1.1.1.1"}
v6 := []string{"::1"}
data := genReplyHTTPS(t, alpn, v4, v6)
d := &MiekgDecoder{}
reply, err := d.DecodeHTTPS(data)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(alpn, reply.ALPN); diff != "" {
t.Fatal(diff)
}
if diff := cmp.Diff(v4, reply.IPv4); diff != "" {
t.Fatal(diff)
}
if diff := cmp.Diff(v6, reply.IPv6); diff != "" {
t.Fatal(diff)
}
})
}
+16 -4
View File
@@ -1,11 +1,23 @@
package mocks
import "github.com/ooni/probe-cli/v3/internal/netxlite/dnsx/model"
// HTTPSSvc is the result of HTTPS queries.
type HTTPSSvc = model.HTTPSSvc
// Decoder allows mocking dnsx.Decoder.
type Decoder struct {
MockDecode func(qtype uint16, reply []byte) ([]string, error)
MockDecodeLookupHost func(qtype uint16, reply []byte) ([]string, error)
MockDecodeHTTPS func(reply []byte) (*HTTPSSvc, error)
}
// Decode calls MockDecode.
func (e *Decoder) Decode(qtype uint16, reply []byte) ([]string, error) {
return e.MockDecode(qtype, reply)
// DecodeLookupHost calls MockDecodeLookupHost.
func (e *Decoder) DecodeLookupHost(qtype uint16, reply []byte) ([]string, error) {
return e.MockDecodeLookupHost(qtype, reply)
}
// DecodeHTTPS calls MockDecodeHTTPS.
func (e *Decoder) DecodeHTTPS(reply []byte) (*HTTPSSvc, error) {
return e.MockDecodeHTTPS(reply)
}
+19 -3
View File
@@ -8,14 +8,30 @@ import (
)
func TestDecoder(t *testing.T) {
t.Run("Decode", func(t *testing.T) {
t.Run("DecodeLookupHost", func(t *testing.T) {
expected := errors.New("mocked error")
e := &Decoder{
MockDecode: func(qtype uint16, reply []byte) ([]string, error) {
MockDecodeLookupHost: func(qtype uint16, reply []byte) ([]string, error) {
return nil, expected
},
}
out, err := e.Decode(dns.TypeA, make([]byte, 17))
out, err := e.DecodeLookupHost(dns.TypeA, make([]byte, 17))
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if out != nil {
t.Fatal("unexpected out")
}
})
t.Run("DecodeHTTPS", func(t *testing.T) {
expected := errors.New("mocked error")
e := &Decoder{
MockDecodeHTTPS: func(reply []byte) (*HTTPSSvc, error) {
return nil, expected
},
}
out, err := e.DecodeHTTPS(make([]byte, 17))
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
+14
View File
@@ -0,0 +1,14 @@
// Package model contains the dnsx model.
package model
// HTTPSSvc is an HTTPSSvc reply.
type HTTPSSvc struct {
// ALPN contains the ALPNs inside the HTTPS reply
ALPN []string
// IPv4 contains the IPv4 hints.
IPv4 []string
// IPv6 contains the IPv6 hints.
IPv6 []string
}
+15
View File
@@ -61,6 +61,21 @@ func (r *SerialResolver) LookupHost(ctx context.Context, hostname string) ([]str
return addrs, nil
}
// LookupHTTPS issues an HTTPS query without retrying on failure.
func (r *SerialResolver) LookupHTTPS(
ctx context.Context, hostname string) (*HTTPSSvc, error) {
querydata, err := r.Encoder.Encode(
hostname, dns.TypeHTTPS, 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.DecodeHTTPS(replydata)
}
func (r *SerialResolver) lookupHostWithRetry(
ctx context.Context, hostname string, qtype uint16) ([]string, error) {
var errorslist []error
+91
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/miekg/dns"
"github.com/ooni/probe-cli/v3/internal/atomicx"
"github.com/ooni/probe-cli/v3/internal/netxlite/dnsx/mocks"
"github.com/ooni/probe-cli/v3/internal/netxlite/errorsx"
)
@@ -160,3 +161,93 @@ func TestSerialResolverCloseIdleConnections(t *testing.T) {
t.Fatal("not called")
}
}
func TestSerialResolverLookupHTTPS(t *testing.T) {
t.Run("for encoding error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &SerialResolver{
Encoder: &mocks.Encoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, error) {
return nil, expected
},
},
Decoder: nil,
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.RoundTripper{
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
https, err := r.LookupHTTPS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if https != nil {
t.Fatal("unexpected result")
}
})
t.Run("for round-trip error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &SerialResolver{
Encoder: &mocks.Encoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, error) {
return make([]byte, 64), nil
},
},
Decoder: nil,
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.RoundTripper{
MockRoundTrip: func(ctx context.Context, query []byte) (reply []byte, err error) {
return nil, expected
},
MockRequiresPadding: func() bool {
return false
},
},
}
ctx := context.Background()
https, err := r.LookupHTTPS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if https != nil {
t.Fatal("unexpected result")
}
})
t.Run("for decode error", func(t *testing.T) {
expected := errors.New("mocked error")
r := &SerialResolver{
Encoder: &mocks.Encoder{
MockEncode: func(domain string, qtype uint16, padding bool) ([]byte, error) {
return make([]byte, 64), nil
},
},
Decoder: &mocks.Decoder{
MockDecodeHTTPS: func(reply []byte) (*mocks.HTTPSSvc, error) {
return nil, expected
},
},
NumTimeouts: &atomicx.Int64{},
Txp: &mocks.RoundTripper{
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.LookupHTTPS(ctx, "example.com")
if !errors.Is(err, expected) {
t.Fatal("unexpected err", err)
}
if https != nil {
t.Fatal("unexpected result")
}
})
}