1// Package dnsbl implements DNS block lists (RFC 5782), for checking incoming messages from sources without reputation.
2//
3// A DNS block list contains IP addresses that should be blocked. The DNSBL is
4// queried using DNS "A" lookups. The DNSBL starts at a "zone", e.g.
5// "dnsbl.example". To look up whether an IP address is listed, a DNS name is
6// composed: For 10.11.12.13, that name would be "13.12.11.10.dnsbl.example". If
7// the lookup returns "record does not exist", the IP is not listed. If an IP
8// address is returned, the IP is listed. If an IP is listed, an additional TXT
9// lookup is done for more information about the block. IPv6 addresses are also
10// looked up with an DNS "A" lookup of a name similar to an IPv4 address, but with
11// 4-bit hexadecimal dot-separated characters, in reverse.
12//
13// The health of a DNSBL "zone" can be check through a lookup of 127.0.0.1
14// (must not be present) and 127.0.0.2 (must be present).
15package dnsbl
16
17import (
18 "context"
19 "errors"
20 "fmt"
21 "net"
22 "strconv"
23 "strings"
24 "time"
25
26 "golang.org/x/exp/slog"
27
28 "github.com/mjl-/mox/dns"
29 "github.com/mjl-/mox/mlog"
30 "github.com/mjl-/mox/stub"
31)
32
33var (
34 MetricLookup stub.HistogramVec = stub.HistogramVecIgnore{}
35)
36
37var ErrDNS = errors.New("dnsbl: dns error") // Temporary error.
38
39// Status is the result of a DNSBL lookup.
40type Status string
41
42var (
43 StatusTemperr Status = "temperror" // Temporary failure.
44 StatusPass Status = "pass" // Not present in block list.
45 StatusFail Status = "fail" // Present in block list.
46)
47
48// Lookup checks if "ip" occurs in the DNS block list "zone" (e.g. dnsbl.example.org).
49func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, zone dns.Domain, ip net.IP) (rstatus Status, rexplanation string, rerr error) {
50 log := mlog.New("dnsbl", elog)
51 start := time.Now()
52 defer func() {
53 MetricLookup.ObserveLabels(float64(time.Since(start))/float64(time.Second), zone.Name(), string(rstatus))
54 log.Debugx("dnsbl lookup result", rerr,
55 slog.Any("zone", zone),
56 slog.Any("ip", ip),
57 slog.Any("status", rstatus),
58 slog.String("explanation", rexplanation),
59 slog.Duration("duration", time.Since(start)))
60 }()
61
62 b := &strings.Builder{}
63 v4 := ip.To4()
64 if v4 != nil {
65 // ../rfc/5782:148
66 s := len(v4) - 1
67 for i := s; i >= 0; i-- {
68 if i < s {
69 b.WriteByte('.')
70 }
71 b.WriteString(strconv.Itoa(int(v4[i])))
72 }
73 } else {
74 // ../rfc/5782:270
75 s := len(ip) - 1
76 const chars = "0123456789abcdef"
77 for i := s; i >= 0; i-- {
78 if i < s {
79 b.WriteByte('.')
80 }
81 v := ip[i]
82 b.WriteByte(chars[v>>0&0xf])
83 b.WriteByte('.')
84 b.WriteByte(chars[v>>4&0xf])
85 }
86 }
87 b.WriteString("." + zone.ASCII + ".")
88 addr := b.String()
89
90 // ../rfc/5782:175
91 _, _, err := dns.WithPackage(resolver, "dnsbl").LookupIP(ctx, "ip4", addr)
92 if dns.IsNotFound(err) {
93 return StatusPass, "", nil
94 } else if err != nil {
95 return StatusTemperr, "", fmt.Errorf("%w: %s", ErrDNS, err)
96 }
97
98 txts, _, err := dns.WithPackage(resolver, "dnsbl").LookupTXT(ctx, addr)
99 if dns.IsNotFound(err) {
100 return StatusFail, "", nil
101 } else if err != nil {
102 log.Debugx("looking up txt record from dnsbl", err, slog.String("addr", addr))
103 return StatusFail, "", nil
104 }
105 return StatusFail, strings.Join(txts, "; "), nil
106}
107
108// CheckHealth checks whether the DNSBL "zone" is operating correctly by
109// querying for 127.0.0.2 (must be present) and 127.0.0.1 (must not be present).
110// Users of a DNSBL should periodically check if the DNSBL is still operating
111// properly.
112// For temporary errors, ErrDNS is returned.
113func CheckHealth(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, zone dns.Domain) (rerr error) {
114 log := mlog.New("dnsbl", elog)
115 start := time.Now()
116 defer func() {
117 log.Debugx("dnsbl healthcheck result", rerr, slog.Any("zone", zone), slog.Duration("duration", time.Since(start)))
118 }()
119
120 // ../rfc/5782:355
121 status1, _, err1 := Lookup(ctx, log.Logger, resolver, zone, net.IPv4(127, 0, 0, 1))
122 status2, _, err2 := Lookup(ctx, log.Logger, resolver, zone, net.IPv4(127, 0, 0, 2))
123 if status1 == StatusPass && status2 == StatusFail {
124 return nil
125 } else if status1 == StatusFail {
126 return fmt.Errorf("dnsbl contains unwanted test address 127.0.0.1")
127 } else if status2 == StatusPass {
128 return fmt.Errorf("dnsbl does not contain required test address 127.0.0.2")
129 }
130 if err1 != nil {
131 return err1
132 } else if err2 != nil {
133 return err2
134 }
135 return ErrDNS
136}
137