implement only monitoring dns blocklists, without using them for incoming deliveries

so you can still know when someone has put you on their blocklist (which may
affect delivery), without using them.

also query dnsbls for our ips more often when we do more outgoing connections
for delivery: once every 100 messages, but at least 5 mins and at most 3 hours
since the previous check.
This commit is contained in:
Mechiel Lukkien 2024-03-05 16:30:38 +01:00
parent e0c36edb8f
commit 15e450df61
No known key found for this signature in database
13 changed files with 305 additions and 83 deletions

View file

@ -111,8 +111,10 @@ type Dynamic struct {
WebDomainRedirects map[string]string `sconf:"optional" sconf-doc:"Redirect all requests from domain (key) to domain (value). Always redirects to HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect."` WebDomainRedirects map[string]string `sconf:"optional" sconf-doc:"Redirect all requests from domain (key) to domain (value). Always redirects to HTTPS. For plain HTTP redirects, use a WebHandler with a WebRedirect."`
WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers, e.g. for account, admin, autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."` WebHandlers []WebHandler `sconf:"optional" sconf-doc:"Handle webserver requests by serving static files, redirecting or reverse-proxying HTTP(s). The first matching WebHandler will handle the request. Built-in handlers, e.g. for account, admin, autoconfig and mta-sts always run first. If no handler matches, the response status code is file not found (404). If functionality you need is missng, simply forward the requests to an application that can provide the needed functionality."`
Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, domain routes and finally these global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, domain routes and finally these global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."`
MonitorDNSBLs []string `sconf:"optional" sconf-doc:"DNS blocklists to periodically check with if IPs we send from are present, without using them for checking incoming deliveries.. Also see DNSBLs in SMTP listeners in mox.conf, which specifies DNSBLs to use both for incoming deliveries and for checking our IPs against. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net."`
WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-" json:"-"` WebDNSDomainRedirects map[dns.Domain]dns.Domain `sconf:"-" json:"-"`
MonitorDNSBLZones []dns.Domain `sconf:"-"`
} }
type ACME struct { type ACME struct {
@ -150,7 +152,7 @@ type Listener struct {
// Reoriginated messages (such as messages sent to mailing list subscribers) should // Reoriginated messages (such as messages sent to mailing list subscribers) should
// keep REQUIRETLS. ../rfc/8689:412 // keep REQUIRETLS. ../rfc/8689:412
DNSBLs []string `sconf:"optional" sconf-doc:"Addresses of DNS block lists for incoming messages. Block lists are only consulted for connections/messages without enough reputation to make an accept/reject decision. This prevents sending IPs of all communications to the block list provider. If any of the listed DNSBLs contains a requested IP address, the message is rejected as spam. The DNSBLs are checked for healthiness before use, at most once per 4 hours. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information and terms of use."` DNSBLs []string `sconf:"optional" sconf-doc:"Addresses of DNS block lists for incoming messages. Block lists are only consulted for connections/messages without enough reputation to make an accept/reject decision. This prevents sending IPs of all communications to the block list provider. If any of the listed DNSBLs contains a requested IP address, the message is rejected as spam. The DNSBLs are checked for healthiness before use, at most once per 4 hours. IPs we can send from are periodically checked for being in the configured DNSBLs. See MonitorDNSBLs in domains.conf to only monitor IPs we send from, without using those DNSBLs for incoming messages. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information and terms of use."`
FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."` FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."`

View file

@ -239,9 +239,12 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# accept/reject decision. This prevents sending IPs of all communications to the # accept/reject decision. This prevents sending IPs of all communications to the
# block list provider. If any of the listed DNSBLs contains a requested IP # block list provider. If any of the listed DNSBLs contains a requested IP
# address, the message is rejected as spam. The DNSBLs are checked for healthiness # address, the message is rejected as spam. The DNSBLs are checked for healthiness
# before use, at most once per 4 hours. Example DNSBLs: sbl.spamhaus.org, # before use, at most once per 4 hours. IPs we can send from are periodically
# bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ # checked for being in the configured DNSBLs. See MonitorDNSBLs in domains.conf to
# for more information and terms of use. (optional) # only monitor IPs we send from, without using those DNSBLs for incoming messages.
# Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See
# https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information
# and terms of use. (optional)
DNSBLs: DNSBLs:
- -
@ -1198,6 +1201,14 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
MinimumAttempts: 0 MinimumAttempts: 0
Transport: Transport:
# DNS blocklists to periodically check with if IPs we send from are present,
# without using them for checking incoming deliveries.. Also see DNSBLs in SMTP
# listeners in mox.conf, which specifies DNSBLs to use both for incoming
# deliveries and for checking our IPs against. Example DNSBLs: sbl.spamhaus.org,
# bl.spamcop.net. (optional)
MonitorDNSBLs:
-
# Examples # Examples
Mox includes configuration files to illustrate common setups. You can see these Mox includes configuration files to illustrate common setups. You can see these

View file

@ -1069,6 +1069,35 @@ func AccountLimitsSave(ctx context.Context, account string, maxOutgoingMessagesP
return nil return nil
} }
func MonitorDNSBLsSave(ctx context.Context, zones []dns.Domain) (rerr error) {
log := pkglog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("saving monitor dnsbl zones", rerr)
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.MonitorDNSBLs = make([]string, len(zones))
nc.MonitorDNSBLZones = nil
for i, z := range zones {
nc.MonitorDNSBLs[i] = z.Name()
}
if err := writeDynamic(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("monitor dnsbl zones saved")
return nil
}
type TLSMode uint8 type TLSMode uint8
const ( const (

View file

@ -24,6 +24,7 @@ import (
"os/user" "os/user"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -242,6 +243,13 @@ func (c *Config) Routes(accountName string, domain dns.Domain) (accountRoutes, d
return return
} }
func (c *Config) MonitorDNSBLs() (zones []dns.Domain) {
c.withDynamicLock(func() {
zones = c.Dynamic.MonitorDNSBLZones
})
return
}
func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) { func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
for _, l := range c.Static.Listeners { for _, l := range c.Static.Listeners {
if l.TLS == nil || l.TLS.ACME == "" { if l.TLS == nil || l.TLS.ACME == "" {
@ -1609,6 +1617,19 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
} }
} }
for _, s := range c.MonitorDNSBLs {
d, err := dns.ParseDomain(s)
if err != nil {
addErrorf("invalid monitor dnsbl zone %s: %v", s, err)
continue
}
if slices.Contains(c.MonitorDNSBLZones, d) {
addErrorf("duplicate zone %s in monitor dnsbl zones", d)
continue
}
c.MonitorDNSBLZones = append(c.MonitorDNSBLZones, d)
}
return return
} }

View file

@ -10,6 +10,7 @@ import (
"net" "net"
"os" "os"
"strings" "strings"
"sync/atomic"
"time" "time"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@ -30,6 +31,10 @@ import (
"github.com/mjl-/mox/tlsrpt" "github.com/mjl-/mox/tlsrpt"
) )
// Increased each time an outgoing connection is made for direct delivery. Used by
// dnsbl monitoring to pace querying.
var connectionCounter atomic.Int64
var ( var (
metricDestinations = promauto.NewCounter( metricDestinations = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
@ -88,6 +93,10 @@ var (
) )
) )
func ConnectionCounter() int64 {
return connectionCounter.Load()
}
// todo: rename function, perhaps put some of the params in a delivery struct so we don't pass all the params all the time? // todo: rename function, perhaps put some of the params in a delivery struct so we don't pass all the params all the time?
func fail(ctx context.Context, qlog mlog.Log, m Msg, backoff time.Duration, permanent bool, remoteMTA dsn.NameIP, secodeOpt, errmsg, firstLine string, moreLines []string) { func fail(ctx context.Context, qlog mlog.Log, m Msg, backoff time.Duration, permanent bool, remoteMTA dsn.NameIP, secodeOpt, errmsg, firstLine string, moreLines []string) {
// todo future: when we implement relaying, we should be able to send DSNs to non-local users. and possibly specify a null mailfrom. ../rfc/5321:1503 // todo future: when we implement relaying, we should be able to send DSNs to non-local users. and possibly specify a null mailfrom. ../rfc/5321:1503
@ -534,6 +543,7 @@ func deliverHost(log mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
if m.DialedIPs == nil { if m.DialedIPs == nil {
m.DialedIPs = map[string][]net.IP{} m.DialedIPs = map[string][]net.IP{}
} }
connectionCounter.Add(1)
conn, remoteIP, err = smtpclient.Dial(ctx, log.Logger, dialer, host, ips, 25, m.DialedIPs, mox.Conf.Static.SpecifiedSMTPListenIPs) conn, remoteIP, err = smtpclient.Dial(ctx, log.Logger, dialer, host, ips, 25, m.DialedIPs, mox.Conf.Static.SpecifiedSMTPListenIPs)
} }
cancel() cancel()

View file

@ -708,6 +708,11 @@ and check the admin page for the needed DNS records.`)
public.SMTP.DNSBLs = append(public.SMTP.DNSBLs, zone.Name()) public.SMTP.DNSBLs = append(public.SMTP.DNSBLs, zone.Name())
} }
// Monitor DNSBLs by default, without using them for incoming deliveries.
for _, zone := range zones {
dc.MonitorDNSBLs = append(dc.MonitorDNSBLs, zone.Name())
}
internal := config.Listener{ internal := config.Listener{
IPs: privateListenerIPs, IPs: privateListenerIPs,
Hostname: "localhost", Hostname: "localhost",

View file

@ -14,8 +14,8 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"sync"
"syscall" "syscall"
"time" "time"
@ -29,10 +29,22 @@ import (
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
"github.com/mjl-/mox/updates" "github.com/mjl-/mox/updates"
) )
var metricDNSBL = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "mox_dnsbl_ips_success",
Help: "DNSBL lookups to configured DNSBLs of our IPs.",
},
[]string{
"zone",
"ip",
},
)
func monitorDNSBL(log mlog.Log) { func monitorDNSBL(log mlog.Log) {
defer func() { defer func() {
// On error, don't bring down the entire server. // On error, don't bring down the entire server.
@ -44,48 +56,71 @@ func monitorDNSBL(log mlog.Log) {
} }
}() }()
l, ok := mox.Conf.Static.Listeners["public"] publicListener := mox.Conf.Static.Listeners["public"]
if !ok {
log.Info("no listener named public, not monitoring our ips at dnsbls")
return
}
var zones []dns.Domain
for _, zone := range l.SMTP.DNSBLs {
d, err := dns.ParseDomain(zone)
if err != nil {
log.Fatalx("parsing dnsbls zone", err, slog.Any("zone", zone))
}
zones = append(zones, d)
}
if len(zones) == 0 {
return
}
// We keep track of the previous metric values, so we can delete those we no longer
// monitor.
type key struct { type key struct {
zone dns.Domain zone dns.Domain
ip string ip string
} }
metrics := map[key]prometheus.GaugeFunc{} prevResults := map[key]struct{}{}
var statusMutex sync.Mutex
statuses := map[key]bool{} // Last time we checked, and how many outgoing delivery connections were made at that time.
var last time.Time
var lastConns int64
resolver := dns.StrictResolver{Pkg: "dnsblmonitor"} resolver := dns.StrictResolver{Pkg: "dnsblmonitor"}
var sleep time.Duration // No sleep on first iteration. var sleep time.Duration // No sleep on first iteration.
for { for {
time.Sleep(sleep) time.Sleep(sleep)
sleep = 3 * time.Hour // We check more often when we send more. Every 100 messages, and between 5 mins
// and 3 hours.
conns := queue.ConnectionCounter()
if sleep > 0 && conns < lastConns+100 && time.Since(last) < 3*time.Hour {
continue
}
sleep = 5 * time.Minute
lastConns = conns
last = time.Now()
// Gather zones.
zones := append([]dns.Domain{}, publicListener.SMTP.DNSBLZones...)
for _, zone := range mox.Conf.MonitorDNSBLs() {
if !slices.Contains(zones, zone) {
zones = append(zones, zone)
}
}
// And gather IPs.
ips, err := mox.IPs(mox.Context, false) ips, err := mox.IPs(mox.Context, false)
if err != nil { if err != nil {
log.Errorx("listing ips for dnsbl monitor", err) log.Errorx("listing ips for dnsbl monitor", err)
// Mark checks as broken.
for k := range prevResults {
metricDNSBL.WithLabelValues(k.zone.Name(), k.ip).Set(-1)
}
continue continue
} }
var publicIPs []net.IP
var publicIPstrs []string
for _, ip := range ips { for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() { if ip.IsLoopback() || ip.IsPrivate() {
continue continue
} }
publicIPs = append(publicIPs, ip)
publicIPstrs = append(publicIPstrs, ip.String())
}
// Remove labels that no longer exist from metric.
for k := range prevResults {
if !slices.Contains(zones, k.zone) || !slices.Contains(publicIPstrs, k.ip) {
metricDNSBL.DeleteLabelValues(k.zone.Name(), k.ip)
delete(prevResults, k)
}
}
// Do DNSBL checks and update metric.
for _, ip := range publicIPs {
for _, zone := range zones { for _, zone := range zones {
status, expl, err := dnsbl.Lookup(mox.Context, log.Logger, resolver, zone, ip) status, expl, err := dnsbl.Lookup(mox.Context, log.Logger, resolver, zone, ip)
if err != nil { if err != nil {
@ -95,32 +130,14 @@ func monitorDNSBL(log mlog.Log) {
slog.String("expl", expl), slog.String("expl", expl),
slog.Any("status", status)) slog.Any("status", status))
} }
var v float64
if status == dnsbl.StatusPass {
v = 1
}
metricDNSBL.WithLabelValues(zone.Name(), ip.String()).Set(v)
k := key{zone, ip.String()} k := key{zone, ip.String()}
prevResults[k] = struct{}{}
statusMutex.Lock()
statuses[k] = status == dnsbl.StatusPass
statusMutex.Unlock()
if _, ok := metrics[k]; !ok {
metrics[k] = promauto.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "mox_dnsbl_ips_success",
Help: "DNSBL lookups to configured DNSBLs of our IPs.",
ConstLabels: prometheus.Labels{
"zone": zone.LogString(),
"ip": k.ip,
},
},
func() float64 {
statusMutex.Lock()
defer statusMutex.Unlock()
if statuses[k] {
return 1
}
return 0
},
)
}
time.Sleep(time.Second) time.Sleep(time.Second)
} }
} }

View file

@ -27,6 +27,7 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime/debug" "runtime/debug"
"slices"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@ -212,6 +213,12 @@ func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
panic(&sherpa.Error{Code: "user:error", Message: errmsg}) panic(&sherpa.Error{Code: "user:error", Message: errmsg})
} }
func xusererrorf(ctx context.Context, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
pkglog.WithContext(ctx).Error(msg)
panic(&sherpa.Error{Code: "user:error", Message: msg})
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be // LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login. // present in the call to Login.
func (w Admin) LoginPrep(ctx context.Context) string { func (w Admin) LoginPrep(ctx context.Context) string {
@ -1765,20 +1772,20 @@ func (Admin) LookupIP(ctx context.Context, ip string) Reverse {
// //
// The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and // The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and
// anything else is an error string, e.g. "fail: ..." or "temperror: ...". // anything else is an error string, e.g. "fail: ..." or "temperror: ...".
func (Admin) DNSBLStatus(ctx context.Context) map[string]map[string]string { func (Admin) DNSBLStatus(ctx context.Context) (results map[string]map[string]string, using, monitoring []dns.Domain) {
log := mlog.New("webadmin", nil).WithContext(ctx) log := mlog.New("webadmin", nil).WithContext(ctx)
resolver := dns.StrictResolver{Pkg: "check", Log: log.Logger} resolver := dns.StrictResolver{Pkg: "check", Log: log.Logger}
return dnsblsStatus(ctx, log, resolver) return dnsblsStatus(ctx, log, resolver)
} }
func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[string]map[string]string { func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) (results map[string]map[string]string, using, monitoring []dns.Domain) {
// todo: check health before using dnsbl? // todo: check health before using dnsbl?
var dnsbls []dns.Domain using = mox.Conf.Static.Listeners["public"].SMTP.DNSBLZones
if l, ok := mox.Conf.Static.Listeners["public"]; ok { zones := append([]dns.Domain{}, using...)
for _, dnsbl := range l.SMTP.DNSBLs { for _, zone := range mox.Conf.MonitorDNSBLs() {
zone, err := dns.ParseDomain(dnsbl) if !slices.Contains(zones, zone) {
xcheckf(ctx, err, "parse dnsbl zone") zones = append(zones, zone)
dnsbls = append(dnsbls, zone) monitoring = append(monitoring, zone)
} }
} }
@ -1789,7 +1796,7 @@ func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[
} }
ipstr := ip.String() ipstr := ip.String()
r[ipstr] = map[string]string{} r[ipstr] = map[string]string{}
for _, zone := range dnsbls { for _, zone := range zones {
status, expl, err := dnsbl.Lookup(ctx, log.Logger, resolver, zone, ip) status, expl, err := dnsbl.Lookup(ctx, log.Logger, resolver, zone, ip)
result := string(status) result := string(status)
if err != nil { if err != nil {
@ -1801,7 +1808,29 @@ func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[
r[ipstr][zone.LogString()] = result r[ipstr][zone.LogString()] = result
} }
} }
return r return r, using, monitoring
}
func (Admin) MonitorDNSBLsSave(ctx context.Context, text string) {
var zones []dns.Domain
publicZones := mox.Conf.Static.Listeners["public"].SMTP.DNSBLZones
for _, line := range strings.Split(text, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
d, err := dns.ParseDomain(line)
xcheckuserf(ctx, err, "parsing dnsbl zone %s", line)
if slices.Contains(zones, d) {
xusererrorf(ctx, "duplicate dnsbl zone %s", line)
}
if slices.Contains(publicZones, d) {
xusererrorf(ctx, "dnsbl zone %s already present in public listener", line)
}
zones = append(zones, d)
}
err := mox.MonitorDNSBLsSave(ctx, zones)
xcheckf(ctx, err, "saving monitoring dnsbl zones")
} }
// DomainRecords returns lines describing DNS records that should exist for the // DomainRecords returns lines describing DNS records that should exist for the
@ -1894,7 +1923,7 @@ func (Admin) AddressRemove(ctx context.Context, address string) {
func (Admin) SetPassword(ctx context.Context, accountName, password string) { func (Admin) SetPassword(ctx context.Context, accountName, password string) {
log := pkglog.WithContext(ctx) log := pkglog.WithContext(ctx)
if len(password) < 8 { if len(password) < 8 {
panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"}) xusererrorf(ctx, "message must be at least 8 characters")
} }
acc, err := store.OpenAccount(log, accountName) acc, err := store.OpenAccount(log, accountName)
xcheckf(ctx, err, "open account") xcheckf(ctx, err, "open account")

View file

@ -714,10 +714,17 @@ var api;
async DNSBLStatus() { async DNSBLStatus() {
const fn = "DNSBLStatus"; const fn = "DNSBLStatus";
const paramTypes = []; const paramTypes = [];
const returnTypes = [["{}", "{}", "string"]]; const returnTypes = [["{}", "{}", "string"], ["[]", "Domain"], ["[]", "Domain"]];
const params = []; const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
} }
async MonitorDNSBLsSave(text) {
const fn = "MonitorDNSBLsSave";
const paramTypes = [["string"]];
const returnTypes = [];
const params = [text];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainRecords returns lines describing DNS records that should exist for the // DomainRecords returns lines describing DNS records that should exist for the
// configured domain. // configured domain.
async DomainRecords(domain) { async DomainRecords(domain) {
@ -1624,7 +1631,7 @@ const index = async () => {
fieldset.disabled = false; fieldset.disabled = false;
} }
window.location.hash = '#domains/' + domain.value; window.location.hash = '#domains/' + domain.value;
}, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Domain', dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Postmaster/reporting account', dom.br(), account = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (optional)', attr.title('Must be set if and only if account does not yet exist. The localpart for the user of this domain. E.g. postmaster.')), dom.br(), localpart = dom.input()), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. You should add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(style({ marginTop: '.5ex' }), dom.form(async function submit(e) { }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Domain', dom.br(), domain = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), 'Postmaster/reporting account', dom.br(), account = dom.input(attr.required(''))), ' ', dom.label(style({ display: 'inline-block' }), dom.span('Localpart (optional)', attr.title('Must be set if and only if account does not yet exist. The localpart for the user of this domain. E.g. postmaster.')), dom.br(), localpart = dom.input()), ' ', dom.submitbutton('Add domain', attr.title('Domain will be added and the config reloaded. You should add the required DNS records after adding the domain.')))), dom.br(), dom.h2('Reports'), dom.div(dom.a('DMARC', attr.href('#dmarc/reports'))), dom.div(dom.a('TLS', attr.href('#tlsrpt/reports'))), dom.br(), dom.h2('Operations'), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(dom.a('DNSBL', attr.href('#dnsbl'))), dom.div(style({ marginTop: '.5ex' }), dom.form(async function submit(e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
try { try {
@ -1642,7 +1649,7 @@ const index = async () => {
} }
}, recvIDFieldset = dom.fieldset(dom.label('Received ID', attr.title('The ID in the Received header that was added during incoming delivery.')), ' ', recvID = dom.input(attr.required('')), ' ', dom.submitbutton('Lookup cid', attr.title('Logging about an incoming message includes an attribute "cid", a counter identifying the transaction related to delivery of the message. The ID in the received header is an encrypted cid, which this form decrypts, after which you can look it up in the logging.')), ' ', cidElem = dom.span()))), }, recvIDFieldset = dom.fieldset(dom.label('Received ID', attr.title('The ID in the Received header that was added during incoming delivery.')), ' ', recvID = dom.input(attr.required('')), ' ', dom.submitbutton('Lookup cid', attr.title('Logging about an incoming message includes an attribute "cid", a counter identifying the transaction related to delivery of the message. The ID in the received header is an encrypted cid, which this form decrypts, after which you can look it up in the logging.')), ' ', cidElem = dom.span()))),
// todo: routing, globally, per domain and per account // todo: routing, globally, per domain and per account
dom.br(), dom.h2('DNS blocklist status'), dom.div(dom.a('DNSBL status', attr.href('#dnsbl'))), dom.br(), dom.h2('Configuration'), dom.div(dom.a('Webserver', attr.href('#webserver'))), dom.div(dom.a('Files', attr.href('#config'))), dom.div(dom.a('Log levels', attr.href('#loglevels'))), footer); dom.br(), dom.h2('Configuration'), dom.div(dom.a('Webserver', attr.href('#webserver'))), dom.div(dom.a('Files', attr.href('#config'))), dom.div(dom.a('Log levels', attr.href('#loglevels'))), footer);
}; };
const config = async () => { const config = async () => {
const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles(); const [staticPath, dynamicPath, staticText, dynamicText] = await client.ConfigFiles();
@ -2653,14 +2660,31 @@ const makeMTASTSTable = (items) => {
].map(v => dom.td(v === null ? [] : (v instanceof HTMLElement ? v : '' + v))))))); ].map(v => dom.td(v === null ? [] : (v instanceof HTMLElement ? v : '' + v)))))));
}; };
const dnsbl = async () => { const dnsbl = async () => {
const ipZoneResults = await client.DNSBLStatus(); const [ipZoneResults, usingZones, monitorZones] = await client.DNSBLStatus();
const url = (ip) => { const url = (ip) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html';
return 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'; let fieldset;
}; let monitorTextarea;
dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs'), dom.p('Follow the external links to a third party DNSBL checker to see if the IP is on one of the many blocklist.'), dom.ul(Object.entries(ipZoneResults).sort().map(ipZones => { dom._kids(page, crumbs(crumblink('Mox Admin', '#'), 'DNS blocklist status for IPs'), dom.p('Follow the external links to a third party DNSBL checker to see if the IP is on one of the many blocklist.'), dom.ul(Object.entries(ipZoneResults).sort().map(ipZones => {
const [ip, zoneResults] = ipZones; const [ip, zoneResults] = ipZones;
return dom.li(link(url(ip), ip), !ipZones.length ? [] : dom.ul(Object.entries(zoneResults).sort().map(zoneResult => dom.li(zoneResult[0] + ': ', zoneResult[1] === 'pass' ? 'pass' : box(red, zoneResult[1]))))); return dom.li(link(url(ip), ip), !ipZones.length ? [] : dom.ul(Object.entries(zoneResults).sort().map(zoneResult => dom.li(zoneResult[0] + ': ', zoneResult[1] === 'pass' ? 'pass' : box(red, zoneResult[1])))));
})), !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : []); })), !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : [], dom.br(), dom.h2('DNSBL zones checked due to being used for incoming deliveries'), (usingZones || []).length === 0 ?
dom.div('None') :
dom.ul((usingZones || []).map(zone => dom.li(domainString(zone)))), dom.br(), dom.h2('DNSBL zones to monitor only'), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
fieldset.disabled = true;
try {
await client.MonitorDNSBLsSave(monitorTextarea.value);
dnsbl(); // Render page again.
}
catch (err) {
console.log({ err });
window.alert('Error: ' + errmsg(err));
}
finally {
fieldset.disabled = false;
}
}, fieldset = dom.fieldset(dom.div('One per line'), dom.div(style({ marginBottom: '.5ex' }), monitorTextarea = dom.textarea(style({ width: '20rem' }), attr.rows('' + Math.max(5, 1 + (monitorZones || []).length)), new String((monitorZones || []).map(zone => domainName(zone)).join('\n'))), dom.div('Examples: sbl.spamhaus.org or bl.spamcop.net')), dom.div(dom.submitbutton('Save')))));
}; };
const queueList = async () => { const queueList = async () => {
const [msgs, transports] = await Promise.all([ const [msgs, transports] = await Promise.all([

View file

@ -333,6 +333,7 @@ const index = async () => {
dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))), dom.div(dom.a('MTA-STS policies', attr.href('#mtasts'))),
dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))), dom.div(dom.a('DMARC evaluations', attr.href('#dmarc/evaluations'))),
dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))), dom.div(dom.a('TLS connection results', attr.href('#tlsrpt/results'))),
dom.div(dom.a('DNSBL', attr.href('#dnsbl'))),
dom.div( dom.div(
style({marginTop: '.5ex'}), style({marginTop: '.5ex'}),
dom.form( dom.form(
@ -361,9 +362,6 @@ const index = async () => {
), ),
// todo: routing, globally, per domain and per account // todo: routing, globally, per domain and per account
dom.br(), dom.br(),
dom.h2('DNS blocklist status'),
dom.div(dom.a('DNSBL status', attr.href('#dnsbl'))),
dom.br(),
dom.h2('Configuration'), dom.h2('Configuration'),
dom.div(dom.a('Webserver', attr.href('#webserver'))), dom.div(dom.a('Webserver', attr.href('#webserver'))),
dom.div(dom.a('Files', attr.href('#config'))), dom.div(dom.a('Files', attr.href('#config'))),
@ -2219,11 +2217,12 @@ const makeMTASTSTable = (items: api.PolicyRecord[]) => {
} }
const dnsbl = async () => { const dnsbl = async () => {
const ipZoneResults = await client.DNSBLStatus() const [ipZoneResults, usingZones, monitorZones] = await client.DNSBLStatus()
const url = (ip: string) => { const url = (ip: string) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'
return 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'
} let fieldset: HTMLFieldSetElement
let monitorTextarea: HTMLTextAreaElement
dom._kids(page, dom._kids(page,
crumbs( crumbs(
@ -2248,6 +2247,43 @@ const dnsbl = async () => {
}) })
), ),
!Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : [], !Object.entries(ipZoneResults).length ? box(red, 'No IPs found.') : [],
dom.br(),
dom.h2('DNSBL zones checked due to being used for incoming deliveries'),
(usingZones || []).length === 0 ?
dom.div('None') :
dom.ul((usingZones || []).map(zone => dom.li(domainString(zone)))),
dom.br(),
dom.h2('DNSBL zones to monitor only'),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
fieldset.disabled = true
try {
await client.MonitorDNSBLsSave(monitorTextarea.value)
dnsbl() // Render page again.
} catch (err) {
console.log({err})
window.alert('Error: ' + errmsg(err))
} finally {
fieldset.disabled = false
}
},
fieldset=dom.fieldset(
dom.div('One per line'),
dom.div(
style({marginBottom: '.5ex'}),
monitorTextarea=dom.textarea(
style({width: '20rem'}),
attr.rows('' + Math.max(5, 1+(monitorZones || []).length)),
new String((monitorZones || []).map(zone => domainName(zone)).join('\n')),
),
dom.div('Examples: sbl.spamhaus.org or bl.spamcop.net'),
),
dom.div(dom.submitbutton('Save')),
),
),
) )
} }

View file

@ -432,15 +432,42 @@
"Params": [], "Params": [],
"Returns": [ "Returns": [
{ {
"Name": "r0", "Name": "results",
"Typewords": [ "Typewords": [
"{}", "{}",
"{}", "{}",
"string" "string"
] ]
},
{
"Name": "using",
"Typewords": [
"[]",
"Domain"
]
},
{
"Name": "monitoring",
"Typewords": [
"[]",
"Domain"
]
} }
] ]
}, },
{
"Name": "MonitorDNSBLsSave",
"Docs": "",
"Params": [
{
"Name": "text",
"Typewords": [
"string"
]
}
],
"Returns": []
},
{ {
"Name": "DomainRecords", "Name": "DomainRecords",
"Docs": "DomainRecords returns lines describing DNS records that should exist for the\nconfigured domain.", "Docs": "DomainRecords returns lines describing DNS records that should exist for the\nconfigured domain.",

View file

@ -1181,12 +1181,20 @@ export class Client {
// //
// The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and // The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and
// anything else is an error string, e.g. "fail: ..." or "temperror: ...". // anything else is an error string, e.g. "fail: ..." or "temperror: ...".
async DNSBLStatus(): Promise<{ [key: string]: { [key: string]: string } }> { async DNSBLStatus(): Promise<[{ [key: string]: { [key: string]: string } }, Domain[] | null, Domain[] | null]> {
const fn: string = "DNSBLStatus" const fn: string = "DNSBLStatus"
const paramTypes: string[][] = [] const paramTypes: string[][] = []
const returnTypes: string[][] = [["{}","{}","string"]] const returnTypes: string[][] = [["{}","{}","string"],["[]","Domain"],["[]","Domain"]]
const params: any[] = [] const params: any[] = []
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: { [key: string]: string } } return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [{ [key: string]: { [key: string]: string } }, Domain[] | null, Domain[] | null]
}
async MonitorDNSBLsSave(text: string): Promise<void> {
const fn: string = "MonitorDNSBLsSave"
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [text]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
} }
// DomainRecords returns lines describing DNS records that should exist for the // DomainRecords returns lines describing DNS records that should exist for the

View file

@ -241,9 +241,9 @@ another account can accept messages from the same sender.
### DNSBL ### DNSBL
Mox can be configured to use an IP-based DNS blocklist (DNSBL). These are Mox can be configured to use an IP-based DNS blocklist (DNSBL). In other software, these are
typically employed early in the SMTP session, to see if the remote IP is a typically employed early in the SMTP session, to see if the remote IP is a
known spammer. If so, the delivery attempt is stopped early. Mox doesn't use known spammer. If so, the delivery attempt is stopped immediately. Mox doesn't use
DNSBLs in its default installation. But if it is configured to use a DNSBL, it DNSBLs in its default installation. But if it is configured to use a DNSBL, it
is only invoked when the other reputation-based checks are not conclusive. For is only invoked when the other reputation-based checks are not conclusive. For
these reasons: these reasons:
@ -256,6 +256,9 @@ these reasons:
3. No leaking of IP addresses of mail servers a mox instance is communicating 3. No leaking of IP addresses of mail servers a mox instance is communicating
with to the DNSBL operator. with to the DNSBL operator.
Mox can also monitor DNSBLs for its own IPs only, without using those
blocklists to analyze incoming deliveries. The status is exported in metrics.
### Greylisting ### Greylisting
Greylisting is a commonly implemented mechanism whereby the first delivery Greylisting is a commonly implemented mechanism whereby the first delivery