From 77a12e58c5bac381a41f413ce1b7065861e0258f Mon Sep 17 00:00:00 2001 From: alexelisenko <39712468+alexelisenko@users.noreply.github.com> Date: Thu, 22 Jun 2023 20:47:36 -0400 Subject: [PATCH] Add Control D support, along with DoH client metadata Signed-off-by: alexelisenko <39712468+alexelisenko@users.noreply.github.com> --- net/dns/publicdns/publicdns.go | 82 +++++++++++++++++++++++++++-- net/dns/publicdns/publicdns_test.go | 18 +++++++ net/dns/resolver/forwarder.go | 43 +++++++++++++++ net/dns/resolver/forwarder_test.go | 10 ++++ 4 files changed, 148 insertions(+), 5 deletions(-) diff --git a/net/dns/publicdns/publicdns.go b/net/dns/publicdns/publicdns.go index 806dea431..d5c068b4f 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,9 @@ 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/" +const 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 +45,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 +93,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 +119,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 } @@ -185,6 +206,37 @@ func populate() { addDoH("194.242.2.3", "https://adblock.doh.mullvad.net/dns-query") addDoH("193.19.108.3", "https://adblock.doh.mullvad.net/dns-query") addDoH("2a07:e340::3", "https://adblock.doh.mullvad.net/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 ( @@ -207,6 +259,13 @@ var ( nextDNSv4RangeB = netip.MustParsePrefix("45.90.30.0/24") nextDNSv4One = nextDNSv4RangeA.Addr() nextDNSv4Two = nextDNSv4RangeB.Addr() + + // 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 @@ -220,9 +279,22 @@ 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. +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) + nextDNSv4RangeA.Contains(ip) || nextDNSv4RangeB.Contains(ip) || + controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) || + ip.String() == controlDv4One.String() || ip.String() == controlDv4Two.String() } diff --git a/net/dns/publicdns/publicdns_test.go b/net/dns/publicdns/publicdns_test.go index c4129a120..a10660bf2 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", + "2606:1a40:0:6:7b5b:5949:35ad:0", + "2606:1a40:1:6:7b5b:5949:35ad:0", + ), + }, + { + base: "https://dns.controld.com/112233445566778899aabbcc", + want: ips( + "76.76.2.22", + "76.76.10.22", + "2606:1a40:0:ffff:ffff:ffff:ffff:0", + "2606:1a40:1:ffff:ffff:ffff:ffff:0", + ), + }, } for _, tt := range tests { got := DoHIPsOfBase(tt.base) diff --git a/net/dns/resolver/forwarder.go b/net/dns/resolver/forwarder.go index 85670e1d6..7e0845f5e 100644 --- a/net/dns/resolver/forwarder.go +++ b/net/dns/resolver/forwarder.go @@ -15,6 +15,7 @@ import ( "net/http" "net/netip" "net/url" + "os" "sort" "strings" "sync" @@ -24,6 +25,7 @@ import ( "tailscale.com/envknob" "tailscale.com/net/dns/publicdns" "tailscale.com/net/dnscache" + "tailscale.com/net/interfaces" "tailscale.com/net/neterror" "tailscale.com/net/netmon" "tailscale.com/net/netns" @@ -200,6 +202,12 @@ type forwarder struct { // /etc/resolv.conf is missing/corrupt, and the peerapi ExitDNS stub // resolver lookup. cloudHostFallback []resolverAndDelay + + // Control D specific fields. These allow the admin to destinguish between + // different clients using the same DoH resolver URL + dohClientHostname string // Hostname to send in Control D DoH requests + dohClientMac string // MAC address to send in Control D DoH requests + dohClientIP string // IP address to send in Control D DoH requests } func init() { @@ -207,11 +215,32 @@ func init() { } func newForwarder(logf logger.Logf, netMon *netmon.Monitor, linkSel ForwardLinkSelector, dialer *tsdial.Dialer) *forwarder { + hostname, _ := os.Hostname() + tailscaleIP := "" + clientMac := "" + + // TODO: is this the best way to determine the tailscale IP of this node? + if tsIPs, _, err := interfaces.Tailscale(); err == nil && len(tsIPs) > 0 { + tailscaleIP = tsIPs[0].String() + } + + // TODO: is this the best way to determine the MAC address of this node? + if idx, err := interfaces.DefaultRouteInterfaceIndex(); err == nil { + iface, err := net.InterfaceByIndex(idx) + if err == nil { + clientMac = iface.HardwareAddr.String() + } + } + f := &forwarder{ logf: logger.WithPrefix(logf, "forward: "), netMon: netMon, linkSel: linkSel, dialer: dialer, + // Control D specific DoH fields + dohClientHostname: hostname, + dohClientIP: tailscaleIP, + dohClientMac: clientMac, } f.ctx, f.ctxCancel = context.WithCancel(context.Background()) return f @@ -419,6 +448,20 @@ func (f *forwarder) sendDoH(ctx context.Context, urlBase string, c *http.Client, req.Header.Set("Accept", dohType) req.Header.Set("User-Agent", "tailscaled/"+version.Long()) + // If this is a Control D request, attach the hostname, mac address and IP address + // this allows for additional DNS analytics and client specific DNS policies + if strings.HasPrefix(urlBase, publicdns.ControlDBase) { + if f.dohClientHostname != "" { + req.Header.Set("X-Cd-Host", f.dohClientHostname) + } + if f.dohClientMac != "" { + req.Header.Set("X-Cd-Mac", f.dohClientMac) + } + if f.dohClientIP != "" { + req.Header.Set("X-Cd-IP", f.dohClientIP) + } + } + hres, err := c.Do(req) if err != nil { metricDNSFwdDoHErrorTransport.Add(1) diff --git a/net/dns/resolver/forwarder_test.go b/net/dns/resolver/forwarder_test.go index dad165fe7..7e699f0d9 100644 --- a/net/dns/resolver/forwarder_test.go +++ b/net/dns/resolver/forwarder_test.go @@ -87,6 +87,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 {