Files
jarl/internal/mailcheck/mailcheck.go
T
adamandClaude Opus 5.5 c27a6c10bd Mail reverse DNS check, what Jarl adds to named.conf, lists one per line
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>
2026-10-12 21:02:11 +01:00

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
}