jarld checks the reverse DNS of the public zones' MX and SPF addresses as the internet sees it, every 6 hours (Exposure page, jarl exposure mail, jarl_mail_reverse_dns_ok). The Server options page, jarl option generated and GET /options/generated list what Jarl writes into named.conf by itself; stats.rate_limit_log turns off the query-errors logging. Option lists of three or more elements are shown one per line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
11 KiB
Go
340 lines
11 KiB
Go
// 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
|
|
}
|