diff --git a/net/dns/publicdns/publicdns.go b/net/dns/publicdns/publicdns.go index fa5076831..c67c1145d 100644 --- a/net/dns/publicdns/publicdns.go +++ b/net/dns/publicdns/publicdns.go @@ -7,10 +7,13 @@ package publicdns import ( "bytes" + "encoding/binary" "encoding/hex" "fmt" + "math/big" "net/netip" "sort" + "strconv" "strings" "sync" ) @@ -23,6 +26,11 @@ var dohOfIP = map[netip.Addr]string{} // 8.8.8.8 => "https://..." var dohIPsOfBase = map[string][]netip.Addr{} var populateOnce sync.Once +const ( + nextDNSBase = "https://dns.nextdns.io/" + controlDBase = "https://dns.controld.com/" +) + // DoHEndpointFromIP returns the DNS-over-HTTPS base URL for a given IP // and whether it's DoH-only (not speaking DNS on port 53). // @@ -39,14 +47,21 @@ func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) { if nextDNSv6RangeA.Contains(ip) || nextDNSv6RangeB.Contains(ip) { a := ip.As16() var sb strings.Builder - const base = "https://dns.nextdns.io/" - sb.Grow(len(base) + 12) - sb.WriteString(base) + sb.Grow(len(nextDNSBase) + 12) + sb.WriteString(nextDNSBase) for _, b := range bytes.TrimLeft(a[4:], "\x00") { fmt.Fprintf(&sb, "%02x", b) } return sb.String(), true, true } + + // Control D DoH URLs are of the form "https://dns.controld.com/8yezwenugs" + // where the path component is represented by 8 bytes (7-14) of the IPv6 address in base36 + if controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) { + path := big.NewInt(0).SetBytes(ip.AsSlice()[6:14]).Text(36) + return controlDBase + path, true, true + } + return "", false, false } @@ -80,7 +95,7 @@ func DoHIPsOfBase(dohBase string) []netip.Addr { if s := dohIPsOfBase[dohBase]; len(s) > 0 { return s } - if hexStr, ok := strings.CutPrefix(dohBase, "https://dns.nextdns.io/"); ok { + if hexStr, ok := strings.CutPrefix(dohBase, nextDNSBase); ok { // The path is of the form /[///...] // or /? // but only the is required. Ignore the rest: @@ -106,6 +121,14 @@ func DoHIPsOfBase(dohBase string) []netip.Addr { } } } + if pathStr, ok := strings.CutPrefix(dohBase, controlDBase); ok { + return []netip.Addr{ + controlDv4One, + controlDv4Two, + controlDv6Gen(nextDNSv6RangeA.Addr(), pathStr), + controlDv6Gen(nextDNSv6RangeB.Addr(), pathStr), + } + } return nil } @@ -213,6 +236,36 @@ func populate() { // Wikimedia addDoH(wikimediaDNSv4, "https://wikimedia-dns.org/dns-query") addDoH(wikimediaDNSv6, "https://wikimedia-dns.org/dns-query") + + // Control D + addDoH("76.76.2.0", "https://freedns.controld.com/p0") + addDoH("76.76.10.0", "https://freedns.controld.com/p0") + addDoH("2606:1a40::", "https://freedns.controld.com/p0") + addDoH("2606:1a40:1::", "https://freedns.controld.com/p0") + + // Control D -Malware + addDoH("76.76.2.1", "https://freedns.controld.com/p1") + addDoH("76.76.10.1", "https://freedns.controld.com/p1") + addDoH("2606:1a40::1", "https://freedns.controld.com/p1") + addDoH("2606:1a40:1::1", "https://freedns.controld.com/p1") + + // Control D -Malware + Ads + addDoH("76.76.2.2", "https://freedns.controld.com/p2") + addDoH("76.76.10.2", "https://freedns.controld.com/p2") + addDoH("2606:1a40::2", "https://freedns.controld.com/p2") + addDoH("2606:1a40:1::2", "https://freedns.controld.com/p2") + + // Control D -Malware + Ads + Social + addDoH("76.76.2.3", "https://freedns.controld.com/p3") + addDoH("76.76.10.3", "https://freedns.controld.com/p3") + addDoH("2606:1a40::3", "https://freedns.controld.com/p3") + addDoH("2606:1a40:1::3", "https://freedns.controld.com/p3") + + // Control D -Malware + Ads + Adult + addDoH("76.76.2.4", "https://freedns.controld.com/family") + addDoH("76.76.10.4", "https://freedns.controld.com/family") + addDoH("2606:1a40::4", "https://freedns.controld.com/family") + addDoH("2606:1a40:1::4", "https://freedns.controld.com/family") } var ( @@ -239,6 +292,13 @@ var ( // Wikimedia DNS server IPs (anycast) wikimediaDNSv4Addr = netip.MustParseAddr(wikimediaDNSv4) wikimediaDNSv6Addr = netip.MustParseAddr(wikimediaDNSv6) + + // The Control D IPv6 ranges (primary and secondary). The customer ID is + // encoded in the ipv6 address is used (in base 36 form) as the DoH query + controlDv6RangeA = netip.MustParsePrefix("2606:1a40::/48") + controlDv6RangeB = netip.MustParsePrefix("2606:1a40:1::/48") + controlDv4One = netip.MustParseAddr("76.76.2.22") + controlDv4Two = netip.MustParseAddr("76.76.10.22") ) // nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the @@ -252,10 +312,26 @@ func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr { return netip.AddrFrom16(a) } +// controlDv6Gen generates a Control D IPv6 address from provided ip and id. +// +// The id is taken from the DoH query path component and represents a unique resolver configuration. +// e.g. https://dns.controld.com/hyq3ipr2ct +func controlDv6Gen(ip netip.Addr, id string) netip.Addr { + b := make([]byte, 8) + decoded, _ := strconv.ParseUint(id, 36, 64) + binary.BigEndian.PutUint64(b, decoded) + a := ip.AsSlice() + copy(a[6:14], b) + addr, _ := netip.AddrFromSlice(a) + return addr +} + // IPIsDoHOnlyServer reports whether ip is a DNS server that should only use // DNS-over-HTTPS (not regular port 53 DNS). func IPIsDoHOnlyServer(ip netip.Addr) bool { return nextDNSv6RangeA.Contains(ip) || nextDNSv6RangeB.Contains(ip) || nextDNSv4RangeA.Contains(ip) || nextDNSv4RangeB.Contains(ip) || - ip == wikimediaDNSv4Addr || ip == wikimediaDNSv6Addr + ip == wikimediaDNSv4Addr || ip == wikimediaDNSv6Addr || + controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) || + ip == controlDv4One || ip == controlDv4Two } diff --git a/net/dns/publicdns/publicdns_test.go b/net/dns/publicdns/publicdns_test.go index c4129a120..7c77dea1a 100644 --- a/net/dns/publicdns/publicdns_test.go +++ b/net/dns/publicdns/publicdns_test.go @@ -116,6 +116,24 @@ func TestDoHIPsOfBase(t *testing.T) { "2a07:a8c1::c3:a884", ), }, + { + base: "https://dns.controld.com/hyq3ipr2ct", + want: ips( + "76.76.2.22", + "76.76.10.22", + "2a07:a8c0:0:6:7b5b:5949:35ad:0", + "2a07:a8c1:0:6:7b5b:5949:35ad:0", + ), + }, + { + base: "https://dns.controld.com/112233445566778899aabbcc", + want: ips( + "76.76.2.22", + "76.76.10.22", + "2a07:a8c0:0:ffff:ffff:ffff:ffff:0", + "2a07:a8c1:0:ffff:ffff:ffff:ffff:0", + ), + }, } for _, tt := range tests { got := DoHIPsOfBase(tt.base) diff --git a/net/dns/resolver/forwarder_test.go b/net/dns/resolver/forwarder_test.go index 9ac64a6bb..aef9ce50e 100644 --- a/net/dns/resolver/forwarder_test.go +++ b/net/dns/resolver/forwarder_test.go @@ -101,6 +101,16 @@ func TestResolversWithDelays(t *testing.T) { in: q("https://dns.nextdns.io/c3a884"), want: o("https://dns.nextdns.io/c3a884"), }, + { + name: "controld-ipv6-expand", + in: q("2606:1a40:0:6:7b5b:5949:35ad:0"), + want: o("https://dns.controld.com/hyq3ipr2ct"), + }, + { + name: "controld-doh-input", + in: q("https://dns.controld.com/hyq3ipr2ct"), + want: o("https://dns.controld.com/hyq3ipr2ct"), + }, } for _, tt := range tests {