// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause

// Package publicdns contains mapping and helpers for working with
// public DNS providers.
package publicdns

import (
	"bytes"
	"encoding/hex"
	"fmt"
	"net/netip"
	"slices"
	"sort"
	"strings"
	"sync"

	"tailscale.com/feature/buildfeatures"
	"tailscale.com/util/testenv"
)

// dohOfIP maps from public DNS IPs to their DoH base URL.
//
// This does not include NextDNS which is handled specially.
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).
//
// The ok result is whether the IP is a known DNS server.
func DoHEndpointFromIP(ip netip.Addr) (dohBase string, dohOnly bool, ok bool) {
	populateOnce.Do(populate)
	if b, ok := dohOfIP[ip]; ok {
		return b, false, true
	}

	// NextDNS DoH URLs are of the form "https://dns.nextdns.io/c3a884"
	// where the path component is the lower 12 bytes of the IPv6 address
	// in lowercase hex without any zero padding.
	if nextDNSv6RangeA.Contains(ip) || nextDNSv6RangeB.Contains(ip) {
		a := ip.As16()
		var sb strings.Builder
		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's free anycast resolvers (freedns.controld.com/pN) are the only
	// Control D addresses that serve DoH; they're registered as exact entries in
	// dohOfIP (see populate) and are handled by the lookup above.
	//
	// The ID-encoded addresses in the 2606:1a40::/48 ranges (customer resolver ID
	// base36-encoded into the address) are legacy plaintext-DNS (port 53) endpoints
	// that refuse DoH on :443, so we deliberately don't upgrade them to DoH here:
	// they fall through and are used as ordinary port-53 resolvers. Premium DoH is
	// only reachable via the dns.controld.com/<id> URL path (see DoHIPsOfBase).

	return "", false, false
}

// KnownDoHPrefixes returns the list of DoH base URLs.
//
// It returns a new copy each time, sorted. It's meant for tests.
//
// It does not include providers that have customer-specific DoH URLs like
// NextDNS.
func KnownDoHPrefixes() []string {
	populateOnce.Do(populate)
	ret := make([]string, 0, len(dohIPsOfBase))
	for b := range dohIPsOfBase {
		ret = append(ret, b)
	}
	sort.Strings(ret)
	return ret
}

func isSlashOrQuestionMark(r rune) bool {
	return r == '/' || r == '?'
}

// DoHIPsOfBase returns the IP addresses to use to dial the provided DoH base
// URL.
//
// It is basically the inverse of DoHEndpointFromIP with the exception that for
// NextDNS it returns IPv4 addresses that DoHEndpointFromIP doesn't map back.
func DoHIPsOfBase(dohBase string) []netip.Addr {
	populateOnce.Do(populate)
	if s := dohIPsOfBase[dohBase]; len(s) > 0 {
		return s
	}
	if hexStr, ok := strings.CutPrefix(dohBase, nextDNSBase); ok {
		// The path is of the form /<profile-hex>[/<hostname>/<model>/<device id>...]
		// or /<profile-hex>?<query params>
		// but only the <profile-hex> is required. Ignore the rest:
		if i := strings.IndexFunc(hexStr, isSlashOrQuestionMark); i != -1 {
			hexStr = hexStr[:i]
		}

		// TODO(bradfitz): using the NextDNS anycast addresses works but is not
		// ideal. Some of their regions have better latency via a non-anycast IP
		// which we could get by first resolving A/AAAA "dns.nextdns.io" over
		// DoH using their anycast address. For now we only use the anycast
		// addresses. The IPv4 IPs we use are just the first one in their ranges.
		// For IPv6 we put the profile ID in the lower bytes, but that seems just
		// conventional for them and not required (it'll already be in the DoH path).
		// (Really we shouldn't use either IPv4 or IPv6 anycast for DoH once we
		// resolve "dns.nextdns.io".)
		if b, err := hex.DecodeString(hexStr); err == nil && len(b) <= 12 && len(b) > 0 {
			return []netip.Addr{
				nextDNSv4One,
				nextDNSv4Two,
				nextDNSv6Gen(nextDNSv6RangeA.Addr(), b),
				nextDNSv6Gen(nextDNSv6RangeB.Addr(), b),
			}
		}
	}
	if strings.HasPrefix(dohBase, controlDBase) {
		return []netip.Addr{
			controlDv4One,
			controlDv4Two,
			controlDv6One,
			controlDv6Two,
		}
	}
	return nil
}

// DoHV6 returns the first IPv6 DNS address from a given public DNS provider
// if found, along with a boolean indicating success.
func DoHV6(base string) (ip netip.Addr, ok bool) {
	populateOnce.Do(populate)
	for _, ip := range dohIPsOfBase[base] {
		if ip.Is6() {
			return ip, true
		}
	}
	return ip, false
}

// addDoH parses a given well-formed ip string into a netip.Addr type and
// adds it to both knownDoH and dohIPsOFBase maps.
func addDoH(ipStr, base string) {
	ip := netip.MustParseAddr(ipStr)
	dohOfIP[ip] = base
	dohIPsOfBase[base] = append(dohIPsOfBase[base], ip)
}

const (
	wikimediaDNSv4 = "185.71.138.138"
	wikimediaDNSv6 = "2001:67c:930::1"
)

// populate is called once to initialize the knownDoH and dohIPsOfBase maps.
func populate() {
	if !buildfeatures.HasDNS {
		return
	}
	// Cloudflare
	// https://developers.cloudflare.com/1.1.1.1/ip-addresses/
	addDoH("1.1.1.1", "https://cloudflare-dns.com/dns-query")
	addDoH("1.0.0.1", "https://cloudflare-dns.com/dns-query")
	addDoH("2606:4700:4700::1111", "https://cloudflare-dns.com/dns-query")
	addDoH("2606:4700:4700::1001", "https://cloudflare-dns.com/dns-query")

	// Cloudflare -Malware
	addDoH("1.1.1.2", "https://security.cloudflare-dns.com/dns-query")
	addDoH("1.0.0.2", "https://security.cloudflare-dns.com/dns-query")
	addDoH("2606:4700:4700::1112", "https://security.cloudflare-dns.com/dns-query")
	addDoH("2606:4700:4700::1002", "https://security.cloudflare-dns.com/dns-query")

	// Cloudflare -Malware -Adult
	addDoH("1.1.1.3", "https://family.cloudflare-dns.com/dns-query")
	addDoH("1.0.0.3", "https://family.cloudflare-dns.com/dns-query")
	addDoH("2606:4700:4700::1113", "https://family.cloudflare-dns.com/dns-query")
	addDoH("2606:4700:4700::1003", "https://family.cloudflare-dns.com/dns-query")

	// Google
	addDoH("8.8.8.8", "https://dns.google/dns-query")
	addDoH("8.8.4.4", "https://dns.google/dns-query")
	addDoH("2001:4860:4860::8888", "https://dns.google/dns-query")
	addDoH("2001:4860:4860::8844", "https://dns.google/dns-query")

	// OpenDNS
	// TODO(bradfitz): OpenDNS is unique amongst this current set in that
	// its DoH DNS names resolve to different IPs than its normal DNS
	// IPs. Support that later. For now we assume that they're the same.
	// addDoH("208.67.222.222", "https://doh.opendns.com/dns-query")
	// addDoH("208.67.220.220", "https://doh.opendns.com/dns-query")
	// addDoH("208.67.222.123", "https://doh.familyshield.opendns.com/dns-query")
	// addDoH("208.67.220.123", "https://doh.familyshield.opendns.com/dns-query")

	// Quad9
	// https://www.quad9.net/service/service-addresses-and-features
	addDoH("9.9.9.9", "https://dns.quad9.net/dns-query")
	addDoH("149.112.112.112", "https://dns.quad9.net/dns-query")
	addDoH("2620:fe::fe", "https://dns.quad9.net/dns-query")
	addDoH("2620:fe::9", "https://dns.quad9.net/dns-query")

	// Quad9 +ECS +DNSSEC
	addDoH("9.9.9.11", "https://dns11.quad9.net/dns-query")
	addDoH("149.112.112.11", "https://dns11.quad9.net/dns-query")
	addDoH("2620:fe::11", "https://dns11.quad9.net/dns-query")
	addDoH("2620:fe::fe:11", "https://dns11.quad9.net/dns-query")

	// Quad9 -DNSSEC
	addDoH("9.9.9.10", "https://dns10.quad9.net/dns-query")
	addDoH("149.112.112.10", "https://dns10.quad9.net/dns-query")
	addDoH("2620:fe::10", "https://dns10.quad9.net/dns-query")
	addDoH("2620:fe::fe:10", "https://dns10.quad9.net/dns-query")

	// Mullvad
	// See https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/
	// Mullvad (default)
	addDoH("194.242.2.2", "https://dns.mullvad.net/dns-query")
	addDoH("2a07:e340::2", "https://dns.mullvad.net/dns-query")
	// Mullvad (adblock)
	addDoH("194.242.2.3", "https://adblock.dns.mullvad.net/dns-query")
	addDoH("2a07:e340::3", "https://adblock.dns.mullvad.net/dns-query")
	// Mullvad (base)
	addDoH("194.242.2.4", "https://base.dns.mullvad.net/dns-query")
	addDoH("2a07:e340::4", "https://base.dns.mullvad.net/dns-query")
	// Mullvad (extended)
	addDoH("194.242.2.5", "https://extended.dns.mullvad.net/dns-query")
	addDoH("2a07:e340::5", "https://extended.dns.mullvad.net/dns-query")
	// Mullvad (family)
	addDoH("194.242.2.6", "https://family.dns.mullvad.net/dns-query")
	addDoH("2a07:e340::6", "https://family.dns.mullvad.net/dns-query")
	// Mullvad (all)
	addDoH("194.242.2.9", "https://all.dns.mullvad.net/dns-query")
	addDoH("2a07:e340::9", "https://all.dns.mullvad.net/dns-query")

	// 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")

	// CIRA Canadian Shield: https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/

	// CIRA Canadian Shield Private (DNS resolution only)
	addDoH("149.112.121.10", "https://private.canadianshield.cira.ca/dns-query")
	addDoH("149.112.122.10", "https://private.canadianshield.cira.ca/dns-query")
	addDoH("2620:10a:80bb::10", "https://private.canadianshield.cira.ca/dns-query")
	addDoH("2620:10a:80bc::10", "https://private.canadianshield.cira.ca/dns-query")

	// CIRA Canadian Shield Protected (Malware and phishing protection)
	addDoH("149.112.121.20", "https://protected.canadianshield.cira.ca/dns-query")
	addDoH("149.112.122.20", "https://protected.canadianshield.cira.ca/dns-query")
	addDoH("2620:10a:80bb::20", "https://protected.canadianshield.cira.ca/dns-query")
	addDoH("2620:10a:80bc::20", "https://protected.canadianshield.cira.ca/dns-query")

	// CIRA Canadian Shield Family (Protected + blocking adult content)
	addDoH("149.112.121.30", "https://family.canadianshield.cira.ca/dns-query")
	addDoH("149.112.122.30", "https://family.canadianshield.cira.ca/dns-query")
	addDoH("2620:10a:80bb::30", "https://family.canadianshield.cira.ca/dns-query")
	addDoH("2620:10a:80bc::30", "https://family.canadianshield.cira.ca/dns-query")
}

var (
	// The NextDNS IPv6 ranges (primary and secondary). The customer ID is
	// encoded in the lower bytes and is used (in hex form) as the DoH query
	// path.
	nextDNSv6RangeA = netip.MustParsePrefix("2a07:a8c0::/33")
	nextDNSv6RangeB = netip.MustParsePrefix("2a07:a8c1::/33")

	// The first two IPs in the /24 v4 ranges can be used for DoH to NextDNS.
	//
	// They're Anycast and usually okay, but NextDNS has some locations that
	// don't do BGP and can get results for querying them over DoH to find the
	// IPv4 address of "dns.mynextdns.io" and find an even better result.
	//
	// Note that the Tailscale DNS client does not do any of the "IP address
	// linking" that NextDNS can do with its IPv4 addresses. These addresses
	// are only used for DoH.
	nextDNSv4RangeA = netip.MustParsePrefix("45.90.28.0/24")
	nextDNSv4RangeB = netip.MustParsePrefix("45.90.30.0/24")
	nextDNSv4One    = nextDNSv4RangeA.Addr()
	nextDNSv4Two    = nextDNSv4RangeB.Addr()

	// 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")
	controlDv6One    = netip.MustParseAddr("2606:1a40::22")
	controlDv6Two    = netip.MustParseAddr("2606:1a40:1::22")
)

// nextDNSv6Gen generates a NextDNS IPv6 address from the upper 8 bytes in the
// provided ip and using id as the lowest 0-8 bytes.
func nextDNSv6Gen(ip netip.Addr, id []byte) netip.Addr {
	if len(id) > 12 {
		return netip.Addr{}
	}
	a := ip.As16()
	copy(a[16-len(id):], id)
	return netip.AddrFrom16(a)
}

// 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 ||
		controlDv6RangeA.Contains(ip) || controlDv6RangeB.Contains(ip) ||
		ip == controlDv4One || ip == controlDv4Two
}

var testMu sync.Mutex

// RegisterTestDoHEndpoint registers a test DoH endpoint mapping for use in tests.
// It maps the given IP to the DoH base URL, and the URL back to the IP.
//
// This function panics if called outside of tests, and cannot be called
// concurrently with any usage of this package (i.e. before any DNS forwarders
// are created). It is safe to call concurrently with itself.
//
// It returns a cleanup function that removes the registration.
func RegisterTestDoHEndpoint(ip netip.Addr, dohBase string) func() {
	if !testenv.InTest() {
		panic("RegisterTestDoHEndpoint called outside of tests")
	}
	populateOnce.Do(populate)

	testMu.Lock()
	defer testMu.Unlock()

	dohOfIP[ip] = dohBase
	dohIPsOfBase[dohBase] = append(dohIPsOfBase[dohBase], ip)

	return func() {
		testMu.Lock()
		defer testMu.Unlock()

		delete(dohOfIP, ip)
		dohIPsOfBase[dohBase] = slices.DeleteFunc(dohIPsOfBase[dohBase], func(addr netip.Addr) bool {
			return addr == ip
		})
		if len(dohIPsOfBase[dohBase]) == 0 {
			delete(dohIPsOfBase, dohBase)
		}
	}
}
