diff --git a/bdns/dns.go b/bdns/dns.go index a78faf98f..1774cf764 100644 --- a/bdns/dns.go +++ b/bdns/dns.go @@ -10,6 +10,7 @@ import ( "math/rand" "net" "strings" + "sync" "time" "github.com/jmhodges/clock" @@ -18,6 +19,14 @@ import ( "golang.org/x/net/context" ) +func parseCidr(network string, comment string) net.IPNet { + _, net, err := net.ParseCIDR(network) + if err != nil { + panic(fmt.Sprintf("error parsing %s (%s): %s", network, comment, err)) + } + return *net +} + var ( // Private CIDRs to ignore privateNetworks = []net.IPNet{ @@ -114,6 +123,21 @@ var ( Mask: []byte{255, 192, 0, 0}, }, } + // Sourced from https://www.iana.org/assignments/iana-ipv6-special-registry/iana-ipv6-special-registry.xhtml + // where Global, Source, or Destination is False + privateV6Networks = []net.IPNet{ + parseCidr("::/128", "RFC 4291: Unspecified Address"), + parseCidr("::1/128", "RFC 4291: Loopback Address"), + parseCidr("::ffff:0:0/96", "RFC 4291: IPv4-mapped Address"), + parseCidr("100::/64", "RFC 6666: Discard Address Block"), + parseCidr("2001::/23", "RFC 2928: IETF Protocol Assignments"), + parseCidr("2001:2::/48", "RFC 5180: Benchmarking"), + parseCidr("2001:db8::/32", "RFC 3849: Documentation"), + parseCidr("2001::/32", "RFC 4380: TEREDO"), + parseCidr("fc00::/7", "RFC 4193: Unique-Local"), + parseCidr("fe80::/10", "RFC 4291: Section 2.5.6 Link-Scoped Unicast"), + parseCidr("ff00::/8", "RFC 4291: Section 2.7"), + } ) // DNSResolver queries for DNS records @@ -130,10 +154,12 @@ type DNSResolverImpl struct { servers []string allowRestrictedAddresses bool maxTries int + LookupIPv6 bool clk clock.Clock stats metrics.Scope txtStats metrics.Scope aStats metrics.Scope + aaaaStats metrics.Scope caaStats metrics.Scope mxStats metrics.Scope } @@ -163,6 +189,7 @@ func NewDNSResolverImpl(readTimeout time.Duration, servers []string, stats metri stats: stats, txtStats: stats.NewScope("TXT"), aStats: stats.NewScope("A"), + aaaaStats: stats.NewScope("AAAA"), caaStats: stats.NewScope("CAA"), mxStats: stats.NewScope("MX"), } @@ -283,29 +310,71 @@ func isPrivateV4(ip net.IP) bool { return false } -// LookupHost sends a DNS query to find all A records associated with the -// provided hostname. This method assumes that the external resolver will chase -// CNAME/DNAME aliases and return relevant A records. It will retry requests in -// the case of temporary network errors. It can return net package, -// context.Canceled, and context.DeadlineExceeded errors. -func (dnsResolver *DNSResolverImpl) LookupHost(ctx context.Context, hostname string) ([]net.IP, error) { - var addrs []net.IP - dnsType := dns.TypeA - r, err := dnsResolver.exchangeOne(ctx, hostname, dnsType, dnsResolver.aStats) - if err != nil { - return addrs, &DNSError{dnsType, hostname, err, -1} +func isPrivateV6(ip net.IP) bool { + for _, net := range privateV6Networks { + if net.Contains(ip) { + return true + } } - if r.Rcode != dns.RcodeSuccess { - return nil, &DNSError{dnsType, hostname, nil, r.Rcode} + return false +} + +func (dnsResolver *DNSResolverImpl) lookupIP(ctx context.Context, hostname string, ipType uint16, stats metrics.Scope) ([]dns.RR, error) { + resp, err := dnsResolver.exchangeOne(ctx, hostname, ipType, stats) + if err != nil { + return nil, &DNSError{ipType, hostname, err, -1} + } + if resp.Rcode != dns.RcodeSuccess { + return nil, &DNSError{ipType, hostname, nil, resp.Rcode} + } + return resp.Answer, nil +} + +// LookupHost sends a DNS query to find all A and AAAA records associated with +// the provided hostname. This method assumes that the external resolver will +// chase CNAME/DNAME aliases and return relevant records. It will retry +// requests in the case of temporary network errors. It can return net package, +// context.Canceled, and context.DeadlineExceeded errors, all wrapped in the +// DNSError type. +func (dnsResolver *DNSResolverImpl) LookupHost(ctx context.Context, hostname string) ([]net.IP, error) { + var recordsA, recordsAAAA []dns.RR + var errA, errAAAA error + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + recordsA, errA = dnsResolver.lookupIP(ctx, hostname, dns.TypeA, dnsResolver.aStats) + }() + if dnsResolver.LookupIPv6 { + wg.Add(1) + go func() { + defer wg.Done() + recordsAAAA, errAAAA = dnsResolver.lookupIP(ctx, hostname, dns.TypeAAAA, dnsResolver.aaaaStats) + }() + } + wg.Wait() + + if errA != nil && (errAAAA != nil || !dnsResolver.LookupIPv6) { + return nil, errA } - for _, answer := range r.Answer { - if answer.Header().Rrtype == dnsType { + var addrs []net.IP + + for _, answer := range recordsA { + if answer.Header().Rrtype == dns.TypeA { if a, ok := answer.(*dns.A); ok && a.A.To4() != nil && (!isPrivateV4(a.A) || dnsResolver.allowRestrictedAddresses) { addrs = append(addrs, a.A) } } } + for _, answer := range recordsAAAA { + if answer.Header().Rrtype == dns.TypeAAAA { + if aaaa, ok := answer.(*dns.AAAA); ok && aaaa.AAAA.To16() != nil && (!isPrivateV6(aaaa.AAAA) || dnsResolver.allowRestrictedAddresses) { + addrs = append(addrs, aaaa.AAAA) + } + } + } return addrs, nil } diff --git a/bdns/dns_test.go b/bdns/dns_test.go index 62ff770ed..1907f9e48 100644 --- a/bdns/dns_test.go +++ b/bdns/dns_test.go @@ -59,6 +59,27 @@ func mockDNSQuery(w dns.ResponseWriter, r *dns.Msg) { record.AAAA = net.ParseIP("::1") appendAnswer(record) } + if q.Name == "dualstack.letsencrypt.org." { + record := new(dns.AAAA) + record.Hdr = dns.RR_Header{Name: "dualstack.letsencrypt.org.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0} + record.AAAA = net.ParseIP("::1") + appendAnswer(record) + } + if q.Name == "v4error.letsencrypt.org." { + record := new(dns.AAAA) + record.Hdr = dns.RR_Header{Name: "v4error.letsencrypt.org.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 0} + record.AAAA = net.ParseIP("::1") + appendAnswer(record) + } + if q.Name == "v6error.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNotImplemented) + } + if q.Name == "nxdomain.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNameError) + } + if q.Name == "dualstackerror.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNotImplemented) + } case dns.TypeA: if q.Name == "cps.letsencrypt.org." { record := new(dns.A) @@ -66,9 +87,27 @@ func mockDNSQuery(w dns.ResponseWriter, r *dns.Msg) { record.A = net.ParseIP("127.0.0.1") appendAnswer(record) } + if q.Name == "dualstack.letsencrypt.org." { + record := new(dns.A) + record.Hdr = dns.RR_Header{Name: "dualstack.letsencrypt.org.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0} + record.A = net.ParseIP("127.0.0.1") + appendAnswer(record) + } + if q.Name == "v6error.letsencrypt.org." { + record := new(dns.A) + record.Hdr = dns.RR_Header{Name: "dualstack.letsencrypt.org.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 0} + record.A = net.ParseIP("127.0.0.1") + appendAnswer(record) + } + if q.Name == "v4error.letsencrypt.org." { + m.SetRcode(r, dns.RcodeNotImplemented) + } if q.Name == "nxdomain.letsencrypt.org." { m.SetRcode(r, dns.RcodeNameError) } + if q.Name == "dualstackerror.letsencrypt.org." { + m.SetRcode(r, dns.RcodeRefused) + } case dns.TypeCNAME: if q.Name == "cname.letsencrypt.org." { record := new(dns.CNAME) @@ -246,6 +285,8 @@ func TestDNSLookupTXT(t *testing.T) { func TestDNSLookupHost(t *testing.T) { obj := NewTestDNSResolverImpl(time.Second*10, []string{dnsLoopbackAddr}, testStats, clock.NewFake(), 1) + obj.LookupIPv6 = true + ip, err := obj.LookupHost(context.Background(), "servfail.com") t.Logf("servfail.com - IP: %s, Err: %s", ip, err) test.AssertError(t, err, "Server failure") @@ -266,11 +307,75 @@ func TestDNSLookupHost(t *testing.T) { test.AssertNotError(t, err, "Not an error to exist") test.Assert(t, len(ip) == 1, "Should have IP") - // No IPv6 + // Single IPv6 address ip, err = obj.LookupHost(context.Background(), "v6.letsencrypt.org") t.Logf("v6.letsencrypt.org - IP: %s, Err: %s", ip, err) test.AssertNotError(t, err, "Not an error to exist") - test.Assert(t, len(ip) == 0, "Should not have IPs") + test.Assert(t, len(ip) == 1, "Should not have IPs") + + // Both IPv6 and IPv4 address + ip, err = obj.LookupHost(context.Background(), "dualstack.letsencrypt.org") + t.Logf("dualstack.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 2, "Should have 2 IPs") + expected := net.ParseIP("127.0.0.1") + test.Assert(t, ip[0].To4().Equal(expected), "wrong ipv4 address") + expected = net.ParseIP("::1") + test.Assert(t, ip[1].To16().Equal(expected), "wrong ipv6 address") + + // IPv6 error, IPv4 success + ip, err = obj.LookupHost(context.Background(), "v6error.letsencrypt.org") + t.Logf("v6error.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should have 1 IP") + expected = net.ParseIP("127.0.0.1") + test.Assert(t, ip[0].To4().Equal(expected), "wrong ipv4 address") + + // IPv6 success, IPv4 error + ip, err = obj.LookupHost(context.Background(), "v4error.letsencrypt.org") + t.Logf("v4error.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Not an error to exist") + test.Assert(t, len(ip) == 1, "Should have 1 IP") + expected = net.ParseIP("::1") + test.Assert(t, ip[0].To16().Equal(expected), "wrong ipv6 address") + + // IPv6 error, IPv4 error + // Should return the IPv4 error (Refused) and not IPv6 error (NotImplemented) + hostname := "dualstackerror.letsencrypt.org" + ip, err = obj.LookupHost(context.Background(), hostname) + t.Logf("%s - IP: %s, Err: %s", hostname, ip, err) + test.AssertError(t, err, "Should be an error") + expectedErr := DNSError{dns.TypeA, hostname, nil, dns.RcodeRefused} + if err, ok := err.(*DNSError); !ok || *err != expectedErr { + t.Errorf("Looking up %s, got %#v, expected %#v", hostname, err, expectedErr) + } + + obj.LookupIPv6 = false + + // Single IPv6 address + ip, err = obj.LookupHost(context.Background(), "v6.letsencrypt.org") + t.Logf("v6.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Should not be error") + test.Assert(t, len(ip) == 0, "Should have no IPs") + + // Both IPv6 and IPv4 address + ip, err = obj.LookupHost(context.Background(), "dualstack.letsencrypt.org") + t.Logf("dualstack.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertNotError(t, err, "Should not be error") + test.Assert(t, len(ip) == 1, "Should have 1 IP") + expected = net.ParseIP("127.0.0.1") + test.Assert(t, ip[0].To4().Equal(expected), "wrong ipv4 address") + + // IPv6 success, IPv4 error + hostname = "v4error.letsencrypt.org" + ip, err = obj.LookupHost(context.Background(), hostname) + t.Logf("v4error.letsencrypt.org - IP: %s, Err: %s", ip, err) + test.AssertError(t, err, "Should be error") + test.Assert(t, len(ip) == 0, "Should have 0 IPs") + expectedErr = DNSError{dns.TypeA, hostname, nil, dns.RcodeNotImplemented} + if err, ok := err.(*DNSError); !ok || *err != expectedErr { + t.Errorf("Looking up %s, got %#v, expected %#v", hostname, err, expectedErr) + } } func TestDNSNXDOMAIN(t *testing.T) { @@ -316,6 +421,37 @@ func TestDNSTXTAuthorities(t *testing.T) { test.AssertEquals(t, auths[0], "letsencrypt.org. 0 IN SOA ns.letsencrypt.org. master.letsencrypt.org. 1 1 1 1 1") } +func TestIsPrivateIP(t *testing.T) { + test.Assert(t, isPrivateV4(net.ParseIP("127.0.0.1")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("192.168.254.254")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("10.255.0.3")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("172.16.255.255")), "should be private") + test.Assert(t, isPrivateV4(net.ParseIP("172.31.255.255")), "should be private") + test.Assert(t, !isPrivateV4(net.ParseIP("128.0.0.1")), "should be private") + test.Assert(t, !isPrivateV4(net.ParseIP("192.169.255.255")), "should not be private") + test.Assert(t, !isPrivateV4(net.ParseIP("9.255.0.255")), "should not be private") + test.Assert(t, !isPrivateV4(net.ParseIP("172.32.255.255")), "should not be private") + + test.Assert(t, isPrivateV6(net.ParseIP("::0")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("::1")), "should be private") + test.Assert(t, !isPrivateV6(net.ParseIP("::2")), "should not be private") + + test.Assert(t, isPrivateV6(net.ParseIP("fe80::1")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("febf::1")), "should be private") + test.Assert(t, !isPrivateV6(net.ParseIP("fec0::1")), "should not be private") + test.Assert(t, !isPrivateV6(net.ParseIP("feff::1")), "should not be private") + + test.Assert(t, isPrivateV6(net.ParseIP("ff00::1")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("ff10::1")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")), "should be private") + + test.Assert(t, !isPrivateV6(net.ParseIP("2002::")), "should not be private") + test.Assert(t, !isPrivateV6(net.ParseIP("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff")), "should not be private") + test.Assert(t, isPrivateV6(net.ParseIP("0100::")), "should be private") + test.Assert(t, isPrivateV6(net.ParseIP("0100::0000:ffff:ffff:ffff:ffff")), "should be private") + test.Assert(t, !isPrivateV6(net.ParseIP("0100::0001:0000:0000:0000:0000")), "should be private") +} + type testExchanger struct { sync.Mutex count int diff --git a/cmd/boulder-va/main.go b/cmd/boulder-va/main.go index 63f6eab96..14165034a 100644 --- a/cmd/boulder-va/main.go +++ b/cmd/boulder-va/main.go @@ -74,9 +74,14 @@ func main() { dnsTries = 1 } if !c.Common.DNSAllowLoopbackAddresses { - vai.DNSResolver = bdns.NewDNSResolverImpl(dnsTimeout, []string{c.Common.DNSResolver}, scoped, clk, dnsTries) + resolver := bdns.NewDNSResolverImpl(dnsTimeout, []string{c.Common.DNSResolver}, scoped, clk, dnsTries) + resolver.LookupIPv6 = c.VA.LookupIPv6 + vai.DNSResolver = resolver + } else { - vai.DNSResolver = bdns.NewTestDNSResolverImpl(dnsTimeout, []string{c.Common.DNSResolver}, scoped, clk, dnsTries) + resolver := bdns.NewTestDNSResolverImpl(dnsTimeout, []string{c.Common.DNSResolver}, scoped, clk, dnsTries) + resolver.LookupIPv6 = c.VA.LookupIPv6 + vai.DNSResolver = resolver } vai.UserAgent = c.VA.UserAgent diff --git a/cmd/config.go b/cmd/config.go index 61267c2ff..9e12eb174 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -87,6 +87,8 @@ type Config struct { MaxConcurrentRPCServerRequests int64 + LookupIPv6 bool + GoogleSafeBrowsing *GoogleSafeBrowsingConfig CAAService *GRPCClientConfig diff --git a/test/boulder-config-next.json b/test/boulder-config-next.json index 93c1fb8ab..d3a33c04e 100644 --- a/test/boulder-config-next.json +++ b/test/boulder-config-next.json @@ -206,6 +206,7 @@ "httpsPort": 5001, "tlsPort": 5001 }, + "lookupIPV6": true, "maxConcurrentRPCServerRequests": 16, "dnsTries": 3, "issuerDomain": "happy-hacker-ca.invalid",