1// Package dnsbl implements DNS block lists (RFC 5782), for checking incoming messages from sources without reputation.
2package dnsbl
3
4import (
5 "context"
6 "errors"
7 "fmt"
8 "net"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/prometheus/client_golang/prometheus"
14 "github.com/prometheus/client_golang/prometheus/promauto"
15
16 "github.com/mjl-/mox/dns"
17 "github.com/mjl-/mox/mlog"
18)
19
20var xlog = mlog.New("dnsbl")
21
22var (
23 metricLookup = promauto.NewHistogramVec(
24 prometheus.HistogramOpts{
25 Name: "mox_dnsbl_lookup_duration_seconds",
26 Help: "DNSBL lookup",
27 Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
28 },
29 []string{
30 "zone",
31 "status",
32 },
33 )
34)
35
36var ErrDNS = errors.New("dnsbl: dns error")
37
38// Status is the result of a DNSBL lookup.
39type Status string
40
41var (
42 StatusTemperr Status = "temperror" // Temporary failure.
43 StatusPass Status = "pass" // Not present in block list.
44 StatusFail Status = "fail" // Present in block list.
45)
46
47// Lookup checks if "ip" occurs in the DNS block list "zone" (e.g. dnsbl.example.org).
48func Lookup(ctx context.Context, resolver dns.Resolver, zone dns.Domain, ip net.IP) (rstatus Status, rexplanation string, rerr error) {
49 log := xlog.WithContext(ctx)
50 start := time.Now()
51 defer func() {
52 metricLookup.WithLabelValues(zone.Name(), string(rstatus)).Observe(float64(time.Since(start)) / float64(time.Second))
53 log.Debugx("dnsbl lookup result", rerr, mlog.Field("zone", zone), mlog.Field("ip", ip), mlog.Field("status", rstatus), mlog.Field("explanation", rexplanation), mlog.Field("duration", time.Since(start)))
54 }()
55
56 b := &strings.Builder{}
57 v4 := ip.To4()
58 if v4 != nil {
59 // ../rfc/5782:148
60 s := len(v4) - 1
61 for i := s; i >= 0; i-- {
62 if i < s {
63 b.WriteByte('.')
64 }
65 b.WriteString(strconv.Itoa(int(v4[i])))
66 }
67 } else {
68 // ../rfc/5782:270
69 s := len(ip) - 1
70 const chars = "0123456789abcdef"
71 for i := s; i >= 0; i-- {
72 if i < s {
73 b.WriteByte('.')
74 }
75 v := ip[i]
76 b.WriteByte(chars[v>>0&0xf])
77 b.WriteByte('.')
78 b.WriteByte(chars[v>>4&0xf])
79 }
80 }
81 b.WriteString("." + zone.ASCII + ".")
82 addr := b.String()
83
84 // ../rfc/5782:175
85 _, err := dns.WithPackage(resolver, "dnsbl").LookupIP(ctx, "ip4", addr)
86 if dns.IsNotFound(err) {
87 return StatusPass, "", nil
88 } else if err != nil {
89 return StatusTemperr, "", fmt.Errorf("%w: %s", ErrDNS, err)
90 }
91
92 txts, err := dns.WithPackage(resolver, "dnsbl").LookupTXT(ctx, addr)
93 if dns.IsNotFound(err) {
94 return StatusFail, "", nil
95 } else if err != nil {
96 log.Debugx("looking up txt record from dnsbl", err, mlog.Field("addr", addr))
97 return StatusFail, "", nil
98 }
99 return StatusFail, strings.Join(txts, "; "), nil
100}
101
102// CheckHealth checks whether the DNSBL "zone" is operating correctly by
103// querying for 127.0.0.2 (must be present) and 127.0.0.1 (must not be present).
104// Users of a DNSBL should periodically check if the DNSBL is still operating
105// properly.
106// For temporary errors, ErrDNS is returned.
107func CheckHealth(ctx context.Context, resolver dns.Resolver, zone dns.Domain) (rerr error) {
108 log := xlog.WithContext(ctx)
109 start := time.Now()
110 defer func() {
111 log.Debugx("dnsbl healthcheck result", rerr, mlog.Field("zone", zone), mlog.Field("duration", time.Since(start)))
112 }()
113
114 // ../rfc/5782:355
115 status1, _, err1 := Lookup(ctx, resolver, zone, net.IPv4(127, 0, 0, 1))
116 status2, _, err2 := Lookup(ctx, resolver, zone, net.IPv4(127, 0, 0, 2))
117 if status1 == StatusPass && status2 == StatusFail {
118 return nil
119 } else if status1 == StatusFail {
120 return fmt.Errorf("dnsbl contains unwanted test address 127.0.0.1")
121 } else if status2 == StatusPass {
122 return fmt.Errorf("dnsbl does not contain required test address 127.0.0.2")
123 }
124 if err1 != nil {
125 return err1
126 } else if err2 != nil {
127 return err2
128 }
129 return ErrDNS
130}
131