package certs import ( "context" "errors" "fmt" "slices" "strings" "time" "github.com/miekg/dns" ) // resolver asks recursive resolvers (acme.resolvers: public ones by // default), to see a domain as the CA does rather than as this server's // own views answer it. type resolver struct { servers []string timeout time.Duration } func (r resolver) query(ctx context.Context, name string, qtype uint16) (*dns.Msg, error) { if len(r.servers) == 0 { return nil, errors.New("no resolvers configured (acme.resolvers)") } m := new(dns.Msg) m.SetQuestion(dns.Fqdn(name), qtype) m.SetEdns0(1232, false) m.RecursionDesired = true timeout := r.timeout if timeout == 0 { timeout = 5 * time.Second } var errs []error for _, s := range r.servers { c := &dns.Client{Timeout: timeout} in, _, err := c.ExchangeContext(ctx, m, s) if err == nil && in.Truncated { c.Net = "tcp" in, _, err = c.ExchangeContext(ctx, m, s) } if err != nil { errs = append(errs, fmt.Errorf("%s: %w", s, err)) continue } if in.Rcode == dns.RcodeServerFailure || in.Rcode == dns.RcodeRefused { errs = append(errs, fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode])) continue } return in, nil } return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], errors.Join(errs...)) } // zoneOf returns the zone that name is in on the internet (the owner of // the SOA record that answers for it). func (r resolver) zoneOf(ctx context.Context, name string) (string, error) { name = dns.Fqdn(strings.ToLower(name)) for cand := name; ; { in, err := r.query(ctx, cand, dns.TypeSOA) if err != nil { return "", err } for _, sec := range [][]dns.RR{in.Answer, in.Ns} { for _, rr := range sec { if soa, ok := rr.(*dns.SOA); ok && dns.IsSubDomain(soa.Hdr.Name, name) { return strings.ToLower(soa.Hdr.Name), nil } } } i, end := dns.NextLabel(cand, 0) if end { return "", fmt.Errorf("no zone found for %s", strings.TrimSuffix(name, ".")) } cand = cand[i:] } } // nameServers returns the zone's name servers on the internet (lowercase, // without trailing dots, sorted). func (r resolver) nameServers(ctx context.Context, zone string) ([]string, error) { in, err := r.query(ctx, zone, dns.TypeNS) if err != nil { return nil, err } var out []string for _, rr := range in.Answer { if ns, ok := rr.(*dns.NS); ok && strings.EqualFold(ns.Hdr.Name, dns.Fqdn(zone)) { out = append(out, normName(ns.Ns)) } } if len(out) == 0 { return nil, fmt.Errorf("%s has no name servers on the internet (%s)", strings.TrimSuffix(zone, "."), dns.RcodeToString[in.Rcode]) } slices.Sort(out) return slices.Compact(out), nil } // follow returns where name leads after its CNAME records (name itself // when it has none). func (r resolver) follow(ctx context.Context, name string) (string, error) { name = dns.Fqdn(strings.ToLower(name)) for range 10 { in, err := r.query(ctx, name, dns.TypeCNAME) if err != nil { return "", err } next := "" for _, rr := range in.Answer { if c, ok := rr.(*dns.CNAME); ok && strings.EqualFold(c.Hdr.Name, name) { next = strings.ToLower(c.Target) } } if next == "" { return name, nil } name = next } return "", fmt.Errorf("%s: more than 10 CNAME records in a row", name) } func normName(n string) string { return strings.ToLower(strings.TrimSuffix(n, ".")) } // challengeName is the DNS-01 record name for a certificate name (a // wildcard shares its base name's). func challengeName(domain string) string { return "_acme-challenge." + dns.Fqdn(strings.TrimPrefix(strings.ToLower(domain), "*.")) }