mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 18:24:35 +03:00
mox!
This commit is contained in:
130
dnsbl/dnsbl.go
Normal file
130
dnsbl/dnsbl.go
Normal file
@ -0,0 +1,130 @@
|
||||
// Package dnsbl implements DNS block lists (RFC 5782), for checking incoming messages from sources without reputation.
|
||||
package dnsbl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
)
|
||||
|
||||
var xlog = mlog.New("dnsbl")
|
||||
|
||||
var (
|
||||
metricLookup = promauto.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "mox_dnsbl_lookup_duration_seconds",
|
||||
Help: "DNSBL lookup",
|
||||
Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
|
||||
},
|
||||
[]string{
|
||||
"zone",
|
||||
"status",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
var ErrDNS = errors.New("dnsbl: dns error")
|
||||
|
||||
// Status is the result of a DNSBL lookup.
|
||||
type Status string
|
||||
|
||||
var (
|
||||
StatusTemperr Status = "temperror" // Temporary failure.
|
||||
StatusPass Status = "pass" // Not present in block list.
|
||||
StatusFail Status = "fail" // Present in block list.
|
||||
)
|
||||
|
||||
// Lookup checks if "ip" occurs in the DNS block list "zone" (e.g. dnsbl.example.org).
|
||||
func Lookup(ctx context.Context, resolver dns.Resolver, zone dns.Domain, ip net.IP) (rstatus Status, rexplanation string, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
metricLookup.WithLabelValues(zone.Name(), string(rstatus)).Observe(float64(time.Since(start)) / float64(time.Second))
|
||||
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)))
|
||||
}()
|
||||
|
||||
b := &strings.Builder{}
|
||||
v4 := ip.To4()
|
||||
if v4 != nil {
|
||||
// ../rfc/5782:148
|
||||
s := len(v4) - 1
|
||||
for i := s; i >= 0; i-- {
|
||||
if i < s {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
b.WriteString(strconv.Itoa(int(v4[i])))
|
||||
}
|
||||
} else {
|
||||
// ../rfc/5782:270
|
||||
s := len(ip) - 1
|
||||
const chars = "0123456789abcdef"
|
||||
for i := s; i >= 0; i-- {
|
||||
if i < s {
|
||||
b.WriteByte('.')
|
||||
}
|
||||
v := ip[i]
|
||||
b.WriteByte(chars[v>>0&0xf])
|
||||
b.WriteByte('.')
|
||||
b.WriteByte(chars[v>>4&0xf])
|
||||
}
|
||||
}
|
||||
b.WriteString("." + zone.ASCII + ".")
|
||||
addr := b.String()
|
||||
|
||||
// ../rfc/5782:175
|
||||
_, err := dns.WithPackage(resolver, "dnsbl").LookupIP(ctx, "ip4", addr)
|
||||
if dns.IsNotFound(err) {
|
||||
return StatusPass, "", nil
|
||||
} else if err != nil {
|
||||
return StatusTemperr, "", fmt.Errorf("%w: %s", ErrDNS, err)
|
||||
}
|
||||
|
||||
txts, err := dns.WithPackage(resolver, "dnsbl").LookupTXT(ctx, addr)
|
||||
if dns.IsNotFound(err) {
|
||||
return StatusFail, "", nil
|
||||
} else if err != nil {
|
||||
log.Debugx("looking up txt record from dnsbl", err, mlog.Field("addr", addr))
|
||||
return StatusFail, "", nil
|
||||
}
|
||||
return StatusFail, strings.Join(txts, "; "), nil
|
||||
}
|
||||
|
||||
// CheckHealth checks whether the DNSBL "zone" is operating correctly by
|
||||
// querying for 127.0.0.2 (must be present) and 127.0.0.1 (must not be present).
|
||||
// Users of a DNSBL should periodically check if the DNSBL is still operating
|
||||
// properly.
|
||||
// For temporary errors, ErrDNS is returned.
|
||||
func CheckHealth(ctx context.Context, resolver dns.Resolver, zone dns.Domain) (rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debugx("dnsbl healthcheck result", rerr, mlog.Field("zone", zone), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
// ../rfc/5782:355
|
||||
status1, _, err1 := Lookup(ctx, resolver, zone, net.IPv4(127, 0, 0, 1))
|
||||
status2, _, err2 := Lookup(ctx, resolver, zone, net.IPv4(127, 0, 0, 2))
|
||||
if status1 == StatusPass && status2 == StatusFail {
|
||||
return nil
|
||||
} else if status1 == StatusFail {
|
||||
return fmt.Errorf("dnsbl contains unwanted test address 127.0.0.1")
|
||||
} else if status2 == StatusPass {
|
||||
return fmt.Errorf("dnsbl does not contain required test address 127.0.0.2")
|
||||
}
|
||||
if err1 != nil {
|
||||
return err1
|
||||
} else if err2 != nil {
|
||||
return err2
|
||||
}
|
||||
return ErrDNS
|
||||
}
|
64
dnsbl/dnsbl_test.go
Normal file
64
dnsbl/dnsbl_test.go
Normal file
@ -0,0 +1,64 @@
|
||||
package dnsbl
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
)
|
||||
|
||||
func TestDNSBL(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
resolver := dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"2.0.0.127.example.com.": {"127.0.0.2"}, // required for health
|
||||
"1.0.0.10.example.com.": {"127.0.0.2"},
|
||||
"b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.example.com.": {"127.0.0.2"},
|
||||
},
|
||||
TXT: map[string][]string{
|
||||
"1.0.0.10.example.com.": {"listed!"},
|
||||
"b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.example.com.": {"listed!"},
|
||||
},
|
||||
}
|
||||
|
||||
if status, expl, err := Lookup(ctx, resolver, dns.Domain{ASCII: "example.com"}, net.ParseIP("10.0.0.1")); err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
} else if status != StatusFail {
|
||||
t.Fatalf("lookup, got status %v, expected fail", status)
|
||||
} else if expl != "listed!" {
|
||||
t.Fatalf("lookup, got explanation %q", expl)
|
||||
}
|
||||
|
||||
if status, expl, err := Lookup(ctx, resolver, dns.Domain{ASCII: "example.com"}, net.ParseIP("2001:db8:1:2:3:4:567:89ab")); err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
} else if status != StatusFail {
|
||||
t.Fatalf("lookup, got status %v, expected fail", status)
|
||||
} else if expl != "listed!" {
|
||||
t.Fatalf("lookup, got explanation %q", expl)
|
||||
}
|
||||
|
||||
if status, _, err := Lookup(ctx, resolver, dns.Domain{ASCII: "example.com"}, net.ParseIP("10.0.0.2")); err != nil {
|
||||
t.Fatalf("lookup: %v", err)
|
||||
} else if status != StatusPass {
|
||||
t.Fatalf("lookup, got status %v, expected pass", status)
|
||||
}
|
||||
|
||||
// ../rfc/5782:357
|
||||
if err := CheckHealth(ctx, resolver, dns.Domain{ASCII: "example.com"}); err != nil {
|
||||
t.Fatalf("dnsbl not healthy: %v", err)
|
||||
}
|
||||
if err := CheckHealth(ctx, resolver, dns.Domain{ASCII: "example.org"}); err == nil {
|
||||
t.Fatalf("bad dnsbl is healthy")
|
||||
}
|
||||
|
||||
unhealthyResolver := dns.MockResolver{
|
||||
A: map[string][]string{
|
||||
"1.0.0.127.example.com.": {"127.0.0.2"}, // Should not be present in healthy dnsbl.
|
||||
},
|
||||
}
|
||||
if err := CheckHealth(ctx, unhealthyResolver, dns.Domain{ASCII: "example.com"}); err == nil {
|
||||
t.Fatalf("bad dnsbl is healthy")
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user