package ptr import ( "bytes" "context" "errors" "fmt" "io" "math" "net/http" "strings" "time" "github.com/miekg/dns" ) const ( // LookupTimeout limits one address's lookup, every resolver included. LookupTimeout = 2 * time.Second // minTTL and maxTTL limit how long PTR names are kept. minTTL = time.Minute maxTTL = 24 * time.Hour // maxNegativeTTL limits how long an address with no PTR names is // remembered as having none. maxNegativeTTL = time.Hour // dnsMessageType is the content type of RFC 8484 requests and answers. dnsMessageType = "application/dns-message" // maxMessageBytes is the largest possible DNS message. maxMessageBytes = 65535 ) var ( errBadStatus = errors.New("unexpected HTTP status") errBadResponse = errors.New("resolver reported an error") ) // Resolvers returns the URLs of the public DNS-over-HTTPS resolvers, in the // order they are tried. func Resolvers() []string { return []string{ "https://dns.google/dns-query", "https://cloudflare-dns.com/dns-query", "https://unfiltered.adguard-dns.com/dns-query", "https://dns.quad9.net/dns-query", } } // answer is a resolver's answer for one address. type answer struct { // names are the address's PTR names, nil when it has none. names []string // ttl is how long the answer may be kept. Zero means it is not kept. ttl time.Duration } // lookup asks the resolvers in turn for the PTR names of the reverse name // until one answers. It fails when none has answered within LookupTimeout. func (c *Cache) lookup(ctx context.Context, reverseName string) (answer, error) { ctx, cancel := context.WithTimeout(ctx, LookupTimeout) defer cancel() query := new(dns.Msg).SetQuestion(reverseName, dns.TypePTR) query.Id = 0 // RFC 8484 asks for 0, so that equal queries can be cached. wire, err := query.Pack() if err != nil { return answer{}, fmt.Errorf("packing the query: %w", err) } var failures []error for i, resolver := range c.resolvers { // Each resolver gets an equal share of the time left, so that one // that never answers leaves time for the next. deadline, _ := ctx.Deadline() share := time.Until(deadline) / time.Duration(len(c.resolvers)-i) result, err := c.ask(ctx, resolver, wire, share) if err == nil { return result, nil } failures = append(failures, fmt.Errorf("%s: %w", resolver, err)) } return answer{}, errors.Join(failures...) } // ask sends the query to one resolver and waits at most timeout for its answer. func (c *Cache) ask( ctx context.Context, resolver string, query []byte, timeout time.Duration, ) (answer, error) { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() request, err := http.NewRequestWithContext( ctx, http.MethodPost, resolver, bytes.NewReader(query)) if err != nil { return answer{}, err } request.Header.Set("Content-Type", dnsMessageType) request.Header.Set("Accept", dnsMessageType) response, err := c.client.Do(request) if err != nil { return answer{}, err } defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return answer{}, fmt.Errorf("%w: %d", errBadStatus, response.StatusCode) } body, err := io.ReadAll(io.LimitReader(response.Body, maxMessageBytes)) if err != nil { return answer{}, err } var reply dns.Msg err = reply.Unpack(body) if err != nil { return answer{}, err } return answerFrom(&reply) } // answerFrom reads a resolver's reply. A name that does not exist and a name // with no PTR records are answers. Every other response code is a failure. func answerFrom(reply *dns.Msg) (answer, error) { if reply.Rcode != dns.RcodeSuccess && reply.Rcode != dns.RcodeNameError { return answer{}, fmt.Errorf("%w: %s", errBadResponse, dns.RcodeToString[reply.Rcode]) } var names []string seconds := uint32(math.MaxUint32) for _, record := range reply.Answer { // The chain to a PTR record can hold CNAME records, and the answer // is good only as long as every record in it. seconds = min(seconds, record.Header().Ttl) ptr, ok := record.(*dns.PTR) if ok && isHostName(ptr.Ptr) { names = append(names, ptr.Ptr) } } if len(names) == 0 { return answer{ttl: negativeTTL(reply.Ns)}, nil } ttl := time.Duration(seconds) * time.Second return answer{names: names, ttl: min(max(ttl, minTTL), maxTTL)}, nil } // negativeTTL returns how long a reply with no PTR names may be kept: the // smaller of the SOA record's own TTL and its minimum field (RFC 2308), at most // maxNegativeTTL. Without a SOA record it is zero. func negativeTTL(authority []dns.RR) time.Duration { for _, record := range authority { soa, ok := record.(*dns.SOA) if ok { ttl := time.Duration(min(soa.Hdr.Ttl, soa.Minttl)) * time.Second return min(ttl, maxNegativeTTL) } } return 0 } // isHostName reports whether name is a host name. A PTR record can hold any // bytes, and the names go into web pages, so anything else is dropped, as // net.Resolver.LookupAddr drops it. The root name is not a host name. func isHostName(name string) bool { _, ok := dns.IsDomainName(name) return ok && name != "." && !strings.ContainsFunc(name, notInHostName) } // notInHostName reports whether r cannot be part of a host name. func notInHostName(r rune) bool { return !strings.ContainsRune(hostNameCharacters, r) } // hostNameCharacters are the characters of a host name, in presentation format. const hostNameCharacters = "abcdefghijklmnopqrstuvwxyz" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_."