1package tlsrpt
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "github.com/prometheus/client_golang/prometheus"
10 "github.com/prometheus/client_golang/prometheus/promauto"
11
12 "github.com/mjl-/mox/dns"
13 "github.com/mjl-/mox/mlog"
14)
15
16var xlog = mlog.New("tlsrpt")
17
18var (
19 metricLookup = promauto.NewHistogramVec(
20 prometheus.HistogramOpts{
21 Name: "mox_tlsrpt_lookup_duration_seconds",
22 Help: "TLSRPT lookups with result.",
23 Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20, 30},
24 },
25 []string{"result"},
26 )
27)
28
29var (
30 ErrNoRecord = errors.New("tlsrpt: no tlsrpt dns txt record")
31 ErrMultipleRecords = errors.New("tlsrpt: multiple tlsrpt records") // Must be treated as if domain does not implement TLSRPT.
32 ErrDNS = errors.New("tlsrpt: temporary error")
33 ErrRecordSyntax = errors.New("tlsrpt: record syntax error")
34)
35
36// Lookup looks up a TLSRPT DNS TXT record for domain at "_smtp._tls.<domain>" and
37// parses it.
38func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rrecord *Record, rtxt string, rerr error) {
39 log := xlog.WithContext(ctx)
40 start := time.Now()
41 defer func() {
42 result := "ok"
43 if rerr != nil {
44 if errors.Is(rerr, ErrNoRecord) {
45 result = "notfound"
46 } else if errors.Is(rerr, ErrMultipleRecords) {
47 result = "multiple"
48 } else if errors.Is(rerr, ErrDNS) {
49 result = "temperror"
50 } else if errors.Is(rerr, ErrRecordSyntax) {
51 result = "malformed"
52 } else {
53 result = "error"
54 }
55 }
56 metricLookup.WithLabelValues(result).Observe(float64(time.Since(start)) / float64(time.Second))
57 log.Debugx("tlsrpt lookup result", rerr, mlog.Field("domain", domain), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
58 }()
59
60 name := "_smtp._tls." + domain.ASCII + "."
61 txts, _, err := dns.WithPackage(resolver, "tlsrpt").LookupTXT(ctx, name)
62 if dns.IsNotFound(err) {
63 return nil, "", ErrNoRecord
64 } else if err != nil {
65 return nil, "", fmt.Errorf("%w: %s", ErrDNS, err)
66 }
67
68 var text string
69 var record *Record
70 for _, txt := range txts {
71 r, istlsrpt, err := ParseRecord(txt)
72 if !istlsrpt {
73 // This is a loose but probably reasonable interpretation of ../rfc/8460:375 which
74 // wants us to discard otherwise valid records that start with e.g. "v=TLSRPTv1 ;"
75 // (note the space before the ";") when multiple TXT records were returned.
76 continue
77 }
78 if err != nil {
79 return nil, "", fmt.Errorf("parsing record: %w", err)
80 }
81 if record != nil {
82 return nil, "", ErrMultipleRecords
83 }
84 record = r
85 text = txt
86 }
87 if record == nil {
88 return nil, "", ErrNoRecord
89 }
90 return record, text, nil
91}
92