// Package mailcheck checks the reverse DNS of the addresses that send and // receive mail for your public zones (docs/exposure.md#mail): receivers // such as Gmail and Outlook reject or mark mail from an address without a // PTR record, or whose PTR name doesn't resolve back to it. Those records // belong to whoever owns the address (your hosting provider or ISP), not // to Jarl, so the check asks the internet and says who to ask. package mailcheck import ( "context" "errors" "fmt" "net/netip" "slices" "strings" "time" "github.com/miekg/dns" "golang.org/x/net/publicsuffix" "jarl/internal/model" ) // Target is an address mail comes from or goes to, and why it is checked. type Target struct { Zone string `json:"zone"` Address string `json:"address"` Roles []string `json:"roles"` // e.g. "MX mail.adbraun.lol", "SPF ip4" Host string `json:"host,omitempty"` // the MX host name, when it is one } // Result is the check of one address. type Result struct { Target PTR []string `json:"ptr"` // the names its PTR records give // OK: a PTR name resolves back to the address (forward-confirmed). OK bool `json:"ok"` // Level is ok, info or warning; Message says what to do. Level string `json:"level"` Message string `json:"message"` // ReverseZone and ManagedBy say where the PTR record lives. ReverseZone string `json:"reverse_zone,omitempty"` ManagedBy string `json:"managed_by,omitempty"` // LocalZone is a reverse zone for the address in Jarl ("view/zone"), // which the internet doesn't ask unless the address's owner delegated // it to this server. LocalZone string `json:"local_zone,omitempty"` } // Report is a whole check. type Report struct { Checked time.Time `json:"checked"` Results []Result `json:"results"` // Errors are lookups that failed (no internet, say). Errors []string `json:"errors,omitempty"` } // Targets finds the addresses to check in the public zones: the MX hosts // (receiving) and the SPF record's addresses (sending). Names are looked // up later, through the resolver. func Targets(st *model.State, public func(viewID int64) bool) (addrs []Target, names []Target) { for _, z := range st.SortedZones() { if z.Type != model.ZonePrimary || z.Disabled || !public(z.ViewID) { continue } zone := strings.TrimSuffix(z.Name, ".") for _, r := range st.ZoneRecords(z.ID) { if r.Name != z.Name || r.Disabled { continue } switch r.Type { case "MX": f := strings.Fields(r.Data) if len(f) == 2 && f[1] != "." { names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(strings.ToLower(f[1]), "."), Roles: []string{"MX " + strings.TrimSuffix(f[1], ".")}}) } case "TXT": text := strings.ReplaceAll(strings.ReplaceAll(r.Data, `" "`, ""), `"`, "") if !strings.HasPrefix(strings.ToLower(text), "v=spf1") { continue } for _, mech := range strings.Fields(text)[1:] { m := strings.ToLower(strings.TrimLeft(mech, "+?~")) switch { case strings.HasPrefix(m, "ip4:"), strings.HasPrefix(m, "ip6:"): v := m[4:] p, err := netip.ParsePrefix(v) if err != nil { if a, err := netip.ParseAddr(v); err == nil { p = netip.PrefixFrom(a, a.BitLen()) } else { continue } } if p.IsSingleIP() { addrs = append(addrs, Target{Zone: zone, Address: p.Addr().String(), Roles: []string{"SPF " + m[:3]}}) } case m == "a": names = append(names, Target{Zone: zone, Host: zone, Roles: []string{"SPF a"}}) case strings.HasPrefix(m, "a:"): names = append(names, Target{Zone: zone, Host: strings.TrimSuffix(m[2:], "."), Roles: []string{"SPF " + m}}) } } } } } return addrs, names } // Resolver asks recursive resolvers (the public ones of acme.resolvers). 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.RecursionDesired = true m.SetEdns0(1232, false) t := r.Timeout if t == 0 { t = 5 * time.Second } var last error for _, s := range r.Servers { in, _, err := (&dns.Client{Timeout: t}).ExchangeContext(ctx, m, s) if err == nil && in.Truncated { in, _, err = (&dns.Client{Timeout: t, Net: "tcp"}).ExchangeContext(ctx, m, s) } if err == nil && in.Rcode != dns.RcodeServerFailure && in.Rcode != dns.RcodeRefused { return in, nil } if err != nil { last = fmt.Errorf("%s: %w", s, err) } else { last = fmt.Errorf("%s answered %s", s, dns.RcodeToString[in.Rcode]) } } return nil, fmt.Errorf("looking up %s %s: %w", strings.TrimSuffix(name, "."), dns.TypeToString[qtype], last) } // addresses returns a name's A and AAAA addresses (after CNAMEs). func (r Resolver) addresses(ctx context.Context, name string) ([]netip.Addr, bool, error) { var out []netip.Addr alias := false for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} { in, err := r.query(ctx, name, t) if err != nil { return nil, false, err } for _, rr := range in.Answer { switch v := rr.(type) { case *dns.A: a, _ := netip.AddrFromSlice(v.A.To4()) out = append(out, a) case *dns.AAAA: a, _ := netip.AddrFromSlice(v.AAAA) out = append(out, a) case *dns.CNAME: alias = true } } } return out, alias, nil } // Check looks up every target's reverse DNS. func Check(ctx context.Context, st *model.State, public func(int64) bool, res Resolver) *Report { rep := &Report{Checked: time.Now(), Results: []Result{}} addrs, names := Targets(st, public) // Names to addresses, as the internet sees them. for _, n := range names { as, _, err := res.addresses(ctx, n.Host) if err != nil { rep.Errors = append(rep.Errors, err.Error()) continue } if len(as) == 0 { rep.Errors = append(rep.Errors, fmt.Sprintf("%s (%s, %s) has no address as the internet sees it: not published yet, or a typo?", n.Host, n.Zone, strings.Join(n.Roles, ", "))) } for _, a := range as { t := n t.Address = a.String() addrs = append(addrs, t) } } // One result per address, with every reason it is checked. merged := map[string]*Target{} var order []string for _, t := range addrs { k := t.Zone + "|" + t.Address if m := merged[k]; m != nil { for _, role := range t.Roles { if !slices.Contains(m.Roles, role) { m.Roles = append(m.Roles, role) } } if m.Host == "" { m.Host = t.Host } continue } t := t merged[k] = &t order = append(order, k) } for _, k := range order { r := checkOne(ctx, *merged[k], res) r.LocalZone = localReverse(st, r.Address, r.ReverseZone) rep.Results = append(rep.Results, r) } return rep } // localReverse returns a reverse zone in Jarl that covers the address, // unless it is the zone the internet asks (delegated to this server). func localReverse(st *model.State, addr, internetZone string) string { rev, err := dns.ReverseAddr(addr) if err != nil { return "" } rev = strings.ToLower(rev) best := "" var bz *model.Zone for _, z := range st.SortedZones() { n := strings.ToLower(dns.Fqdn(z.Name)) if z.Type == model.ZonePrimary && dns.IsSubDomain(n, rev) && len(n) > len(best) { best, bz = n, z } } if bz == nil || strings.EqualFold(strings.TrimSuffix(best, "."), internetZone) { return "" } if v := st.ViewName(bz); v != "" { return v + "/" + strings.TrimSuffix(bz.Name, ".") } return strings.TrimSuffix(bz.Name, ".") } func checkOne(ctx context.Context, t Target, res Resolver) Result { r := Result{Target: t, PTR: []string{}} a, err := netip.ParseAddr(t.Address) if err != nil { r.Level, r.Message = "warning", err.Error() return r } rev, _ := dns.ReverseAddr(a.String()) in, err := res.query(ctx, rev, dns.TypePTR) if err != nil { r.Level, r.Message = "warning", err.Error() return r } // Who manages the reverse zone: the SOA in the answer. for _, sec := range [][]dns.RR{in.Ns, in.Answer} { for _, rr := range sec { if soa, ok := rr.(*dns.SOA); ok { r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns) } } } if r.ReverseZone == "" { if s, err := res.query(ctx, rev, dns.TypeSOA); err == nil { for _, sec := range [][]dns.RR{s.Answer, s.Ns} { for _, rr := range sec { if soa, ok := rr.(*dns.SOA); ok { r.ReverseZone, r.ManagedBy = strings.TrimSuffix(soa.Hdr.Name, "."), managedBy(soa.Ns) } } } } } for _, rr := range in.Answer { if p, ok := rr.(*dns.PTR); ok { r.PTR = append(r.PTR, strings.TrimSuffix(strings.ToLower(p.Ptr), ".")) } } where := "whoever runs the address's reverse zone (your hosting provider or ISP)" if r.ManagedBy != "" { where = "the reverse zone's owner (" + r.ManagedBy + ": your hosting provider's or ISP's panel)" } if len(r.PTR) == 0 { r.Level = "warning" r.Message = fmt.Sprintf("%s has no PTR record: many receivers reject or mark mail from it. Set its reverse DNS to %s at %s", t.Address, suggestName(t), where) return r } var notes []string for _, name := range r.PTR { as, alias, err := res.addresses(ctx, name) if err != nil { notes = append(notes, err.Error()) continue } if slices.Contains(as, a) { r.OK = true if alias { notes = append(notes, name+" is an alias (CNAME): receivers expect the PTR to name a host with its own A/AAAA record") } if t.Host != "" && name != t.Host && strings.HasPrefix(strings.Join(t.Roles, " "), "MX") { notes = append(notes, fmt.Sprintf("the PTR name is %s, not the MX name %s: fine if the mail server says %s in HELO", name, t.Host, name)) } } } switch { case !r.OK: r.Level = "warning" r.Message = fmt.Sprintf("the PTR record of %s names %s, which doesn't resolve back to %s: receivers check that it does. Point the PTR at a name with an A/AAAA record for %s (set at %s), or add that record", t.Address, strings.Join(r.PTR, ", "), t.Address, t.Address, where) case len(notes) > 0: r.Level, r.Message = "info", strings.Join(notes, "; ") default: r.Level, r.Message = "ok", fmt.Sprintf("%s → %s → %s", t.Address, strings.Join(r.PTR, ", "), t.Address) } return r } func suggestName(t Target) string { if t.Host != "" { return t.Host } return "the host's name (with an A/AAAA record for it)" } // managedBy turns the reverse zone's primary name server into its owner's // domain: ns1.linode.com -> linode.com, dns0.zen.co.uk -> zen.co.uk. func managedBy(ns string) string { ns = strings.TrimSuffix(strings.ToLower(ns), ".") labels := strings.Split(ns, ".") // The registered domain under the ICANN suffix: the list's private // part (members.linode.com) names customers, not the provider. for i := 1; i < len(labels); i++ { if suffix, icann := publicsuffix.PublicSuffix(strings.Join(labels[i:], ".")); icann && suffix == strings.Join(labels[i:], ".") { return strings.Join(labels[i-1:], ".") } } return ns }