mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 18:24:35 +03:00
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:
@ -27,6 +27,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -212,6 +213,12 @@ func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
|
||||
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
|
||||
// present in the call to Login.
|
||||
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
|
||||
// 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)
|
||||
resolver := dns.StrictResolver{Pkg: "check", Log: log.Logger}
|
||||
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?
|
||||
var dnsbls []dns.Domain
|
||||
if l, ok := mox.Conf.Static.Listeners["public"]; ok {
|
||||
for _, dnsbl := range l.SMTP.DNSBLs {
|
||||
zone, err := dns.ParseDomain(dnsbl)
|
||||
xcheckf(ctx, err, "parse dnsbl zone")
|
||||
dnsbls = append(dnsbls, zone)
|
||||
using = mox.Conf.Static.Listeners["public"].SMTP.DNSBLZones
|
||||
zones := append([]dns.Domain{}, using...)
|
||||
for _, zone := range mox.Conf.MonitorDNSBLs() {
|
||||
if !slices.Contains(zones, zone) {
|
||||
zones = append(zones, zone)
|
||||
monitoring = append(monitoring, zone)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1789,7 +1796,7 @@ func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[
|
||||
}
|
||||
ipstr := ip.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)
|
||||
result := string(status)
|
||||
if err != nil {
|
||||
@ -1801,7 +1808,29 @@ func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[
|
||||
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
|
||||
@ -1894,7 +1923,7 @@ func (Admin) AddressRemove(ctx context.Context, address string) {
|
||||
func (Admin) SetPassword(ctx context.Context, accountName, password string) {
|
||||
log := pkglog.WithContext(ctx)
|
||||
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)
|
||||
xcheckf(ctx, err, "open account")
|
||||
|
@ -714,10 +714,17 @@ var api;
|
||||
async DNSBLStatus() {
|
||||
const fn = "DNSBLStatus";
|
||||
const paramTypes = [];
|
||||
const returnTypes = [["{}", "{}", "string"]];
|
||||
const returnTypes = [["{}", "{}", "string"], ["[]", "Domain"], ["[]", "Domain"]];
|
||||
const 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
|
||||
// configured domain.
|
||||
async DomainRecords(domain) {
|
||||
@ -1624,7 +1631,7 @@ const index = async () => {
|
||||
fieldset.disabled = false;
|
||||
}
|
||||
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.stopPropagation();
|
||||
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()))),
|
||||
// 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 [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)))))));
|
||||
};
|
||||
const dnsbl = async () => {
|
||||
const ipZoneResults = await client.DNSBLStatus();
|
||||
const url = (ip) => {
|
||||
return 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html';
|
||||
};
|
||||
const [ipZoneResults, usingZones, monitorZones] = await client.DNSBLStatus();
|
||||
const url = (ip) => '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 => {
|
||||
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])))));
|
||||
})), !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 [msgs, transports] = await Promise.all([
|
||||
|
@ -333,6 +333,7 @@ const index = async () => {
|
||||
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(
|
||||
@ -361,9 +362,6 @@ const index = async () => {
|
||||
),
|
||||
// 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'))),
|
||||
@ -2219,11 +2217,12 @@ const makeMTASTSTable = (items: api.PolicyRecord[]) => {
|
||||
}
|
||||
|
||||
const dnsbl = async () => {
|
||||
const ipZoneResults = await client.DNSBLStatus()
|
||||
const [ipZoneResults, usingZones, monitorZones] = await client.DNSBLStatus()
|
||||
|
||||
const url = (ip: string) => {
|
||||
return 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'
|
||||
}
|
||||
const url = (ip: string) => 'https://multirbl.valli.org/lookup/' + encodeURIComponent(ip) + '.html'
|
||||
|
||||
let fieldset: HTMLFieldSetElement
|
||||
let monitorTextarea: HTMLTextAreaElement
|
||||
|
||||
dom._kids(page,
|
||||
crumbs(
|
||||
@ -2248,6 +2247,43 @@ const dnsbl = async () => {
|
||||
})
|
||||
),
|
||||
!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')),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -432,15 +432,42 @@
|
||||
"Params": [],
|
||||
"Returns": [
|
||||
{
|
||||
"Name": "r0",
|
||||
"Name": "results",
|
||||
"Typewords": [
|
||||
"{}",
|
||||
"{}",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "using",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"Domain"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "monitoring",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"Domain"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "MonitorDNSBLsSave",
|
||||
"Docs": "",
|
||||
"Params": [
|
||||
{
|
||||
"Name": "text",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
}
|
||||
],
|
||||
"Returns": []
|
||||
},
|
||||
{
|
||||
"Name": "DomainRecords",
|
||||
"Docs": "DomainRecords returns lines describing DNS records that should exist for the\nconfigured domain.",
|
||||
|
@ -1181,12 +1181,20 @@ export class Client {
|
||||
//
|
||||
// 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: ...".
|
||||
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 paramTypes: string[][] = []
|
||||
const returnTypes: string[][] = [["{}","{}","string"]]
|
||||
const returnTypes: string[][] = [["{}","{}","string"],["[]","Domain"],["[]","Domain"]]
|
||||
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
|
||||
|
Reference in New Issue
Block a user