mirror of
https://github.com/mjl-/mox.git
synced 2025-01-26 06:45:53 +03:00
implement accepting dmarc & tls reports for other domains
to accept reports for another domain, first add that domain to the config, leaving all options empty except DMARC/TLSRPT in which you configure a Domain. the suggested DNS DMARC/TLSRPT records will show the email address with configured domain. for DMARC, the dnscheck functionality will verify that the destination domain has opted in to receiving reports. there is a new command-line subcommand "mox dmarc checkreportaddrs" that verifies if dmarc reporting destination addresses have opted in to received reports. this also changes the suggested dns records (in quickstart, and through admin pages and cli subcommand) to take into account whether DMARC and TLSRPT is configured, and with which localpart/domain (previously it always printed records as if reporting was enabled for the domain). and when generating the suggested DNS records, the dmarc.Record and tlsrpt.Record code is used, with proper uri-escaping.
This commit is contained in:
parent
9e248860ee
commit
aebfd78a9f
13 changed files with 332 additions and 48 deletions
|
@ -109,16 +109,14 @@ The code is heavily cross-referenced with the RFCs for readability/maintainabili
|
|||
|
||||
## Roadmap
|
||||
|
||||
- Rewrite account and admin javascript to typescript
|
||||
- Prepare data storage for JMAP
|
||||
- IMAP THREAD extension
|
||||
- Prepare data storage for JMAP
|
||||
- DANE and DNSSEC
|
||||
- Sending DMARC and TLS reports (currently only receiving)
|
||||
- Accepting/processing/monitoring DMARC reports for external domains
|
||||
- Calendaring
|
||||
- OAUTH2 support, for single sign on
|
||||
- Add special IMAP mailbox ("Queue?") that contains queued but
|
||||
not-yet-delivered messages
|
||||
- OAUTH2 support, for single sign on
|
||||
- Sieve for filtering (for now see Rulesets in the account config)
|
||||
- Privilege separation, isolating parts of the application to more restricted
|
||||
sandbox (e.g. new unauthenticated connections)
|
||||
|
|
|
@ -41,7 +41,7 @@ type Static struct {
|
|||
NoFixPermissions bool `sconf:"optional" sconf-doc:"If true, do not automatically fix file permissions when starting up. By default, mox will ensure reasonable owner/permissions on the working, data and config directories (and files), and mox binary (if present)."`
|
||||
Hostname string `sconf-doc:"Full hostname of system, e.g. mail.<domain>"`
|
||||
HostnameDomain dns.Domain `sconf:"-" json:"-"` // Parsed form of hostname.
|
||||
CheckUpdates bool `sconf:"optional" sconf-doc:"If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to check for a new release. Each time a new release is found, a changelog is fetched from https://updates.xmox.nl and delivered to the postmaster mailbox."`
|
||||
CheckUpdates bool `sconf:"optional" sconf-doc:"If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to check for a new release. Each time a new release is found, a changelog is fetched from https://updates.xmox.nl/changelog and delivered to the postmaster mailbox."`
|
||||
Pedantic bool `sconf:"optional" sconf-doc:"In pedantic mode protocol violations (that happen in the wild) for SMTP/IMAP/etc result in errors instead of accepting such behaviour."`
|
||||
TLS struct {
|
||||
CA *struct {
|
||||
|
@ -267,10 +267,12 @@ type Domain struct {
|
|||
|
||||
type DMARC struct {
|
||||
Localpart string `sconf-doc:"Address-part before the @ that accepts DMARC reports. Must be non-internationalized. Recommended value: dmarc-reports."`
|
||||
Domain string `sconf:"optional" sconf-doc:"Alternative domain for report recipient address. Can be used to receive reports for other domains. Unicode name."`
|
||||
Account string `sconf-doc:"Account to deliver to."`
|
||||
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. DMARC."`
|
||||
|
||||
ParsedLocalpart smtp.Localpart `sconf:"-"`
|
||||
DNSDomain dns.Domain `sconf:"-"` // Effective domain, always set based on Domain field or Domain where this is configured.
|
||||
}
|
||||
|
||||
type MTASTS struct {
|
||||
|
@ -283,10 +285,12 @@ type MTASTS struct {
|
|||
|
||||
type TLSRPT struct {
|
||||
Localpart string `sconf-doc:"Address-part before the @ that accepts TLSRPT reports. Recommended value: tls-reports."`
|
||||
Domain string `sconf:"optional" sconf-doc:"Alternative domain for report recipient address. Can be used to receive reports for other domains. Unicode name."`
|
||||
Account string `sconf-doc:"Account to deliver to."`
|
||||
Mailbox string `sconf-doc:"Mailbox to deliver to, e.g. TLSRPT."`
|
||||
|
||||
ParsedLocalpart smtp.Localpart `sconf:"-"`
|
||||
DNSDomain dns.Domain `sconf:"-"` // Effective domain, always set based on Domain field or Domain where this is configured.
|
||||
}
|
||||
|
||||
type Selector struct {
|
||||
|
|
|
@ -45,8 +45,8 @@ describe-static" and "mox config describe-domains":
|
|||
|
||||
# If enabled, a single DNS TXT lookup of _updates.xmox.nl is done every 24h to
|
||||
# check for a new release. Each time a new release is found, a changelog is
|
||||
# fetched from https://updates.xmox.nl and delivered to the postmaster mailbox.
|
||||
# (optional)
|
||||
# fetched from https://updates.xmox.nl/changelog and delivered to the postmaster
|
||||
# mailbox. (optional)
|
||||
CheckUpdates: false
|
||||
|
||||
# In pedantic mode protocol violations (that happen in the wild) for SMTP/IMAP/etc
|
||||
|
@ -601,6 +601,10 @@ describe-static" and "mox config describe-domains":
|
|||
# non-internationalized. Recommended value: dmarc-reports.
|
||||
Localpart:
|
||||
|
||||
# Alternative domain for report recipient address. Can be used to receive reports
|
||||
# for other domains. Unicode name. (optional)
|
||||
Domain:
|
||||
|
||||
# Account to deliver to.
|
||||
Account:
|
||||
|
||||
|
@ -640,6 +644,10 @@ describe-static" and "mox config describe-domains":
|
|||
# tls-reports.
|
||||
Localpart:
|
||||
|
||||
# Alternative domain for report recipient address. Can be used to receive reports
|
||||
# for other domains. Unicode name. (optional)
|
||||
Domain:
|
||||
|
||||
# Account to deliver to.
|
||||
Account:
|
||||
|
||||
|
|
|
@ -146,6 +146,63 @@ func lookupRecord(ctx context.Context, resolver dns.Resolver, domain dns.Domain)
|
|||
return StatusNone, record, text, rerr
|
||||
}
|
||||
|
||||
func lookupReportsRecord(ctx context.Context, resolver dns.Resolver, dmarcDomain, extDestDomain dns.Domain) (Status, *Record, string, error) {
|
||||
name := dmarcDomain.ASCII + "._report._dmarc." + extDestDomain.ASCII + "."
|
||||
txts, err := dns.WithPackage(resolver, "dmarc").LookupTXT(ctx, name)
|
||||
if err != nil && !dns.IsNotFound(err) {
|
||||
return StatusTemperror, nil, "", fmt.Errorf("%w: %s", ErrDNS, err)
|
||||
}
|
||||
var record *Record
|
||||
var text string
|
||||
var rerr error = ErrNoRecord
|
||||
for _, txt := range txts {
|
||||
r, isdmarc, err := ParseRecordNoRequired(txt)
|
||||
// Examples in the RFC use "v=DMARC1", even though it isn't a valid DMARC record.
|
||||
// Accept the specific example.
|
||||
// ../rfc/7489-eid5440
|
||||
if !isdmarc && txt == "v=DMARC1" {
|
||||
xr := DefaultRecord
|
||||
r, isdmarc, err = &xr, true, nil
|
||||
}
|
||||
if !isdmarc {
|
||||
// ../rfc/7489:1374
|
||||
continue
|
||||
} else if err != nil {
|
||||
return StatusPermerror, nil, text, fmt.Errorf("%w: %s", ErrSyntax, err)
|
||||
}
|
||||
if record != nil {
|
||||
// ../ ../rfc/7489:1388
|
||||
return StatusNone, nil, "", ErrMultipleRecords
|
||||
}
|
||||
text = txt
|
||||
record = r
|
||||
rerr = nil
|
||||
}
|
||||
return StatusNone, record, text, rerr
|
||||
}
|
||||
|
||||
// LookupExternalReportsAccepted returns whether the extDestDomain has opted in
|
||||
// to receiving dmarc reports for dmarcDomain (where the dmarc record was found),
|
||||
// through a "._report._dmarc." DNS TXT DMARC record.
|
||||
//
|
||||
// Callers should look at status for interpretation, not err, because err will
|
||||
// be set to ErrNoRecord when the DNS TXT record isn't present, which means the
|
||||
// extDestDomain does not opt in (not a failure condition).
|
||||
//
|
||||
// The normally invalid "v=DMARC1" record is accepted since it is used as
|
||||
// example in RFC 7489.
|
||||
func LookupExternalReportsAccepted(ctx context.Context, resolver dns.Resolver, dmarcDomain dns.Domain, extDestDomain dns.Domain) (accepts bool, status Status, record *Record, txt string, rerr error) {
|
||||
log := xlog.WithContext(ctx)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debugx("dmarc externalreports result", rerr, mlog.Field("accepts", accepts), mlog.Field("dmarcdomain", dmarcDomain), mlog.Field("extdestdomain", extDestDomain), mlog.Field("record", record), mlog.Field("duration", time.Since(start)))
|
||||
}()
|
||||
|
||||
status, record, txt, rerr = lookupReportsRecord(ctx, resolver, dmarcDomain, extDestDomain)
|
||||
accepts = rerr == nil
|
||||
return accepts, status, record, txt, rerr
|
||||
}
|
||||
|
||||
// Verify evaluates the DMARC policy for the domain in the From-header of a
|
||||
// message given the DKIM and SPF evaluation results.
|
||||
//
|
||||
|
|
|
@ -50,6 +50,45 @@ func TestLookup(t *testing.T) {
|
|||
test("sub.example.com", StatusNone, "example.com", &r, nil) // Policy published at organizational domain, public suffix.
|
||||
}
|
||||
|
||||
func TestLookupExternalReportsAccepted(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
"example.com._report._dmarc.simple.example.": {"v=DMARC1"},
|
||||
"example.com._report._dmarc.simple2.example.": {"v=DMARC1;"},
|
||||
"example.com._report._dmarc.one.example.": {"v=DMARC1; p=none;", "other"},
|
||||
"example.com._report._dmarc.temperror.example.": {"v=DMARC1; p=none;"},
|
||||
"example.com._report._dmarc.multiple.example.": {"v=DMARC1; p=none;", "v=DMARC1"},
|
||||
"example.com._report._dmarc.malformed.example.": {"v=DMARC1; p=none; bogus;"},
|
||||
},
|
||||
Fail: map[dns.Mockreq]struct{}{
|
||||
{Type: "txt", Name: "example.com._report._dmarc.temperror.example."}: {},
|
||||
},
|
||||
}
|
||||
|
||||
test := func(dom, extdom string, expStatus Status, expAccepts bool, expErr error) {
|
||||
t.Helper()
|
||||
|
||||
accepts, status, _, _, err := LookupExternalReportsAccepted(context.Background(), resolver, dns.Domain{ASCII: dom}, dns.Domain{ASCII: extdom})
|
||||
if (err == nil) != (expErr == nil) || err != nil && !errors.Is(err, expErr) {
|
||||
t.Fatalf("got err %#v, expected %#v", err, expErr)
|
||||
}
|
||||
if status != expStatus || accepts != expAccepts {
|
||||
t.Fatalf("got status %s, accepts %v, expected %v, %v", status, accepts, expStatus, expAccepts)
|
||||
}
|
||||
}
|
||||
|
||||
r := DefaultRecord
|
||||
r.Policy = PolicyNone
|
||||
test("example.com", "simple.example", StatusNone, true, nil)
|
||||
test("example.org", "simple.example", StatusNone, false, ErrNoRecord)
|
||||
test("example.com", "simple2.example", StatusNone, true, nil)
|
||||
test("example.com", "one.example", StatusNone, true, nil)
|
||||
test("example.com", "absent.example", StatusNone, false, ErrNoRecord)
|
||||
test("example.com", "multiple.example", StatusNone, false, ErrMultipleRecords)
|
||||
test("example.com", "malformed.example", StatusPermerror, false, ErrSyntax)
|
||||
test("example.com", "temperror.example", StatusTemperror, false, ErrDNS)
|
||||
}
|
||||
|
||||
func TestVerify(t *testing.T) {
|
||||
resolver := dns.MockResolver{
|
||||
TXT: map[string][]string{
|
||||
|
|
|
@ -20,6 +20,16 @@ func (e parseErr) Error() string {
|
|||
//
|
||||
// DefaultRecord provides default values for tags not present in s.
|
||||
func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
|
||||
return parseRecord(s, true)
|
||||
}
|
||||
|
||||
// ParseRecordNoRequired is like ParseRecord, but don't check for required fields
|
||||
// for regular DMARC records. Useful for checking the _report._dmarc record.
|
||||
func ParseRecordNoRequired(s string) (record *Record, isdmarc bool, rerr error) {
|
||||
return parseRecord(s, false)
|
||||
}
|
||||
|
||||
func parseRecord(s string, checkRequired bool) (record *Record, isdmarc bool, rerr error) {
|
||||
defer func() {
|
||||
x := recover()
|
||||
if x == nil {
|
||||
|
@ -134,7 +144,7 @@ func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
|
|||
// ../rfc/7489:1106 says "p" is required, but ../rfc/7489:1407 implies we must be
|
||||
// able to parse a record without a "p" or with invalid "sp" tag.
|
||||
sp := r.SubdomainPolicy
|
||||
if !seen["p"] || sp != PolicyEmpty && sp != PolicyNone && sp != PolicyQuarantine && sp != PolicyReject {
|
||||
if checkRequired && (!seen["p"] || sp != PolicyEmpty && sp != PolicyNone && sp != PolicyQuarantine && sp != PolicyReject) {
|
||||
if len(r.AggregateReportAddresses) > 0 {
|
||||
r.Policy = PolicyNone
|
||||
r.SubdomainPolicy = PolicyEmpty
|
||||
|
|
12
dmarc/txt.go
12
dmarc/txt.go
|
@ -23,7 +23,7 @@ const (
|
|||
type URI struct {
|
||||
Address string // Should start with "mailto:".
|
||||
MaxSize uint64 // Optional maximum message size, subject to Unit.
|
||||
Unit string // "" (b), "k", "g", "t" (case insensitive), unit size, where k is 2^10 etc.
|
||||
Unit string // "" (b), "k", "m", "g", "t" (case insensitive), unit size, where k is 2^10 etc.
|
||||
}
|
||||
|
||||
// String returns a string representation of the URI for inclusion in a DMARC
|
||||
|
@ -33,7 +33,7 @@ func (u URI) String() string {
|
|||
s = strings.ReplaceAll(s, ",", "%2C")
|
||||
s = strings.ReplaceAll(s, "!", "%21")
|
||||
if u.MaxSize > 0 {
|
||||
s += fmt.Sprintf("%d", u.MaxSize)
|
||||
s += fmt.Sprintf("!%d", u.MaxSize)
|
||||
}
|
||||
s += u.Unit
|
||||
return s
|
||||
|
@ -109,13 +109,13 @@ func (r Record) String() string {
|
|||
s := strings.Join(l, ",")
|
||||
write(true, "ruf", s)
|
||||
}
|
||||
write(r.ADKIM != "", "adkim", string(r.ADKIM))
|
||||
write(r.ASPF != "", "aspf", string(r.ASPF))
|
||||
write(r.ADKIM != "" && r.ADKIM != "r", "adkim", string(r.ADKIM))
|
||||
write(r.ASPF != "" && r.ASPF != "r", "aspf", string(r.ASPF))
|
||||
write(r.AggregateReportingInterval != DefaultRecord.AggregateReportingInterval, "ri", fmt.Sprintf("%d", r.AggregateReportingInterval))
|
||||
if len(r.FailureReportingOptions) > 1 || (len(r.FailureReportingOptions) == 1 && r.FailureReportingOptions[0] != "0") {
|
||||
if len(r.FailureReportingOptions) > 1 || len(r.FailureReportingOptions) == 1 && r.FailureReportingOptions[0] != "0" {
|
||||
write(true, "fo", strings.Join(r.FailureReportingOptions, ":"))
|
||||
}
|
||||
if len(r.ReportingFormat) > 1 || (len(r.ReportingFormat) == 1 && strings.EqualFold(r.ReportingFormat[0], "afrf")) {
|
||||
if len(r.ReportingFormat) > 1 || len(r.ReportingFormat) == 1 && !strings.EqualFold(r.ReportingFormat[0], "afrf") {
|
||||
write(true, "rf", strings.Join(r.FailureReportingOptions, ":"))
|
||||
}
|
||||
write(r.Percentage != 100, "pct", fmt.Sprintf("%d", r.Percentage))
|
||||
|
|
13
doc.go
13
doc.go
|
@ -57,6 +57,7 @@ low-maintenance self-hosted email.
|
|||
mox dmarc lookup domain
|
||||
mox dmarc parsereportmsg message ...
|
||||
mox dmarc verify remoteip mailfromaddress helodomain < message
|
||||
mox dmarc checkreportaddrs domain
|
||||
mox dnsbl check zone ip
|
||||
mox dnsbl checkhealth zone
|
||||
mox mtasts lookup domain
|
||||
|
@ -665,6 +666,18 @@ can be found in message headers.
|
|||
|
||||
usage: mox dmarc verify remoteip mailfromaddress helodomain < message
|
||||
|
||||
# mox dmarc checkreportaddrs
|
||||
|
||||
For each reporting address in the domain's DMARC record, check if it has opted into receiving reports (if needed).
|
||||
|
||||
A DMARC record can request reports about DMARC evaluations to be sent to an
|
||||
email/http address. If the organizational domains of that of the DMARC record
|
||||
and that of the report destination address do not match, the destination
|
||||
address must opt-in to receiving DMARC reports by creating a DMARC record at
|
||||
<dmarcdomain>._report._dmarc.<reportdestdomain>.
|
||||
|
||||
usage: mox dmarc checkreportaddrs domain
|
||||
|
||||
# mox dnsbl check
|
||||
|
||||
Test if IP is in the DNS blocklist of the zone, e.g. bl.spamcop.net.
|
||||
|
|
79
main.go
79
main.go
|
@ -14,6 +14,7 @@ import (
|
|||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
@ -38,6 +39,7 @@ import (
|
|||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/moxvar"
|
||||
"github.com/mjl-/mox/mtasts"
|
||||
"github.com/mjl-/mox/publicsuffix"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/spf"
|
||||
"github.com/mjl-/mox/store"
|
||||
|
@ -120,6 +122,7 @@ var commands = []struct {
|
|||
{"dmarc lookup", cmdDMARCLookup},
|
||||
{"dmarc parsereportmsg", cmdDMARCParsereportmsg},
|
||||
{"dmarc verify", cmdDMARCVerify},
|
||||
{"dmarc checkreportaddrs", cmdDMARCCheckreportaddrs},
|
||||
{"dnsbl check", cmdDNSBLCheck},
|
||||
{"dnsbl checkhealth", cmdDNSBLCheckhealth},
|
||||
{"mtasts lookup", cmdMTASTSLookup},
|
||||
|
@ -1609,6 +1612,82 @@ can be found in message headers.
|
|||
fmt.Printf("dmarc from: %s\ndmarc status: %q\ndmarc reject: %v\ncmarc record: %s\n", dmarcFrom, result.Status, result.Reject, result.Record)
|
||||
}
|
||||
|
||||
func cmdDMARCCheckreportaddrs(c *cmd) {
|
||||
c.params = "domain"
|
||||
c.help = `For each reporting address in the domain's DMARC record, check if it has opted into receiving reports (if needed).
|
||||
|
||||
A DMARC record can request reports about DMARC evaluations to be sent to an
|
||||
email/http address. If the organizational domains of that of the DMARC record
|
||||
and that of the report destination address do not match, the destination
|
||||
address must opt-in to receiving DMARC reports by creating a DMARC record at
|
||||
<dmarcdomain>._report._dmarc.<reportdestdomain>.
|
||||
`
|
||||
args := c.Parse()
|
||||
if len(args) != 1 {
|
||||
c.Usage()
|
||||
}
|
||||
|
||||
dom := xparseDomain(args[0], "domain")
|
||||
_, domain, record, txt, err := dmarc.Lookup(context.Background(), dns.StrictResolver{}, dom)
|
||||
xcheckf(err, "dmarc lookup domain %s", dom)
|
||||
fmt.Printf("dmarc record at domain %s: %q\n", domain, txt)
|
||||
|
||||
check := func(kind, addr string) {
|
||||
printResult := func(format string, args ...any) {
|
||||
fmt.Printf("%s %s: %s\n", kind, addr, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
printResult("parsing uri: %v (skipping)", addr, err)
|
||||
return
|
||||
}
|
||||
var destdom dns.Domain
|
||||
switch u.Scheme {
|
||||
case "mailto":
|
||||
a, err := smtp.ParseAddress(u.Opaque)
|
||||
if err != nil {
|
||||
printResult("parsing destination email address %s: %v (skipping)", u.Opaque, err)
|
||||
return
|
||||
}
|
||||
destdom = a.Domain
|
||||
default:
|
||||
printResult("unrecognized scheme in reporting address %s (skipping)", u.Scheme)
|
||||
return
|
||||
}
|
||||
|
||||
if publicsuffix.Lookup(context.Background(), dom) == publicsuffix.Lookup(context.Background(), destdom) {
|
||||
printResult("pass (same organizational domain)")
|
||||
return
|
||||
}
|
||||
|
||||
accepts, status, _, txt, err := dmarc.LookupExternalReportsAccepted(context.Background(), dns.StrictResolver{}, domain, destdom)
|
||||
var txtstr string
|
||||
txtaddr := fmt.Sprintf("%s._report._dmarc.%s", domain.ASCII, destdom.ASCII)
|
||||
if txt == "" {
|
||||
txtstr = fmt.Sprintf(" (no txt record %s)", txtaddr)
|
||||
} else {
|
||||
txtstr = fmt.Sprintf(" (txt record %s: %q)", txtaddr, txt)
|
||||
}
|
||||
if status != dmarc.StatusNone {
|
||||
printResult("fail: %s%s", err, txtstr)
|
||||
} else if accepts {
|
||||
printResult("pass%s", txtstr)
|
||||
} else if err != nil {
|
||||
printResult("fail: %s%s", err, txtstr)
|
||||
} else {
|
||||
printResult("fail%s", txtstr)
|
||||
}
|
||||
}
|
||||
|
||||
for _, uri := range record.AggregateReportAddresses {
|
||||
check("aggregate reporting", uri.Address)
|
||||
}
|
||||
for _, uri := range record.FailureReportAddresses {
|
||||
check("failure reporting", uri.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func cmdDMARCParsereportmsg(c *cmd) {
|
||||
c.params = "message ..."
|
||||
c.help = `Parse a DMARC report from an email message, and print its extracted details.
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"encoding/pem"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
@ -18,11 +19,13 @@ import (
|
|||
|
||||
"github.com/mjl-/mox/config"
|
||||
"github.com/mjl-/mox/dkim"
|
||||
"github.com/mjl-/mox/dmarc"
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/junk"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/mtasts"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/tlsrpt"
|
||||
)
|
||||
|
||||
// TXTStrings returns a TXT record value as one or more quoted strings, taking the max
|
||||
|
@ -496,6 +499,17 @@ func DomainRecords(domConf config.Domain, domain dns.Domain) ([]string, error) {
|
|||
records = append(records, s)
|
||||
|
||||
}
|
||||
dmarcr := dmarc.DefaultRecord
|
||||
dmarcr.Policy = "reject"
|
||||
if domConf.DMARC != nil {
|
||||
uri := url.URL{
|
||||
Scheme: "mailto",
|
||||
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
|
||||
}
|
||||
dmarcr.AggregateReportAddresses = []dmarc.URI{
|
||||
{Address: uri.String(), MaxSize: 10, Unit: "m"},
|
||||
}
|
||||
}
|
||||
records = append(records,
|
||||
"",
|
||||
|
||||
|
@ -505,10 +519,11 @@ func DomainRecords(domConf config.Domain, domain dns.Domain) ([]string, error) {
|
|||
fmt.Sprintf(`%s. IN TXT "v=spf1 mx ~all"`, d),
|
||||
"",
|
||||
|
||||
"; Emails that fail the DMARC check (without DKIM and without SPF) should be rejected, and request reports.",
|
||||
"; If you email through mailing lists that strip DKIM-Signature headers and don't",
|
||||
"; rewrite the From header, you may want to set the policy to p=none.",
|
||||
fmt.Sprintf(`_dmarc.%s. IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc-reports@%s!10m"`, d, d),
|
||||
"; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
|
||||
"; should be rejected, and request reports. If you email through mailing lists that",
|
||||
"; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
|
||||
"; set the policy to p=none.",
|
||||
fmt.Sprintf(`_dmarc.%s. IN TXT "%s"`, d, dmarcr.String()),
|
||||
"",
|
||||
)
|
||||
|
||||
|
@ -527,11 +542,20 @@ func DomainRecords(domConf config.Domain, domain dns.Domain) ([]string, error) {
|
|||
)
|
||||
}
|
||||
|
||||
records = append(records,
|
||||
"; Request reporting about TLS failures.",
|
||||
fmt.Sprintf(`_smtp._tls.%s. IN TXT "v=TLSRPTv1; rua=mailto:tls-reports@%s"`, d, d),
|
||||
"",
|
||||
if domConf.TLSRPT != nil {
|
||||
uri := url.URL{
|
||||
Scheme: "mailto",
|
||||
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
|
||||
}
|
||||
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]string{{uri.String()}}}
|
||||
records = append(records,
|
||||
"; Request reporting about TLS failures.",
|
||||
fmt.Sprintf(`_smtp._tls.%s. IN TXT "%s"`, d, tlsrptr.String()),
|
||||
"",
|
||||
)
|
||||
}
|
||||
|
||||
records = append(records,
|
||||
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
|
||||
fmt.Sprintf(`autoconfig.%s. IN CNAME %s.`, d, h),
|
||||
fmt.Sprintf(`_autodiscover._tcp.%s. IN SRV 0 1 443 autoconfig.%s.`, d, d),
|
||||
|
|
|
@ -1214,9 +1214,20 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config
|
|||
// ../rfc/8616:234
|
||||
addErrorf("DMARC localpart %q is an internationalized address, only conventional ascii-only address possible for interopability", lp)
|
||||
}
|
||||
addrdom := domain.Domain
|
||||
if dmarc.Domain != "" {
|
||||
addrdom, err = dns.ParseDomain(dmarc.Domain)
|
||||
if err != nil {
|
||||
addErrorf("DMARC domain %q: %s", dmarc.Domain, err)
|
||||
} else if _, ok := c.Domains[addrdom.Name()]; !ok {
|
||||
addErrorf("unknown domain %q for DMARC address in domain %q", dmarc.Domain, d)
|
||||
}
|
||||
}
|
||||
|
||||
domain.DMARC.ParsedLocalpart = lp
|
||||
domain.DMARC.DNSDomain = addrdom
|
||||
c.Domains[d] = domain
|
||||
addrFull := smtp.NewAddress(lp, domain.Domain).String()
|
||||
addrFull := smtp.NewAddress(lp, addrdom).String()
|
||||
dest := config.Destination{
|
||||
Mailbox: dmarc.Mailbox,
|
||||
DMARCReports: true,
|
||||
|
@ -1243,9 +1254,20 @@ func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config
|
|||
// to keep this ascii-only addresses.
|
||||
addErrorf("TLSRPT localpart %q is an internationalized address, only conventional ascii-only address allowed for interopability", lp)
|
||||
}
|
||||
addrdom := domain.Domain
|
||||
if tlsrpt.Domain != "" {
|
||||
addrdom, err = dns.ParseDomain(tlsrpt.Domain)
|
||||
if err != nil {
|
||||
addErrorf("TLSRPT domain %q: %s", tlsrpt.Domain, err)
|
||||
} else if _, ok := c.Domains[addrdom.Name()]; !ok {
|
||||
addErrorf("unknown domain %q for TLSRPT address in domain %q", tlsrpt.Domain, d)
|
||||
}
|
||||
}
|
||||
|
||||
domain.TLSRPT.ParsedLocalpart = lp
|
||||
domain.TLSRPT.DNSDomain = addrdom
|
||||
c.Domains[d] = domain
|
||||
addrFull := smtp.NewAddress(lp, domain.Domain).String()
|
||||
addrFull := smtp.NewAddress(lp, addrdom).String()
|
||||
dest := config.Destination{
|
||||
Mailbox: tlsrpt.Mailbox,
|
||||
TLSReports: true,
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime/debug"
|
||||
|
@ -45,6 +46,7 @@ import (
|
|||
"github.com/mjl-/mox/moxvar"
|
||||
"github.com/mjl-/mox/mtasts"
|
||||
"github.com/mjl-/mox/mtastsdb"
|
||||
"github.com/mjl-/mox/publicsuffix"
|
||||
"github.com/mjl-/mox/queue"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
"github.com/mjl-/mox/spf"
|
||||
|
@ -932,23 +934,43 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer,
|
|||
if record != nil && len(record.AggregateReportAddresses) == 0 {
|
||||
addf(&r.DMARC.Warnings, "It is recommended you specify you would like aggregate reports about delivery success in the DMARC record, see instructions.")
|
||||
}
|
||||
localpart := smtp.Localpart("dmarc-reports")
|
||||
|
||||
dmarcr := dmarc.DefaultRecord
|
||||
dmarcr.Policy = "reject"
|
||||
|
||||
var extInstr string
|
||||
if domConf.DMARC != nil {
|
||||
localpart = domConf.DMARC.ParsedLocalpart
|
||||
// If the domain is in a different Organizational Domain, the receiving domain
|
||||
// needs a special DNS record to opt-in to receiving reports. We check for that
|
||||
// record.
|
||||
// ../rfc/7489:1541
|
||||
orgDom := publicsuffix.Lookup(ctx, domain)
|
||||
destOrgDom := publicsuffix.Lookup(ctx, domConf.DMARC.DNSDomain)
|
||||
if orgDom != destOrgDom {
|
||||
accepts, status, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, resolver, domain, domConf.DMARC.DNSDomain)
|
||||
if status != dmarc.StatusNone {
|
||||
addf(&r.DMARC.Errors, "Checking if external destination accepts reports: %s", err)
|
||||
} else if !accepts {
|
||||
addf(&r.DMARC.Errors, "External destination does not accept reports (%s)", err)
|
||||
}
|
||||
extInstr = fmt.Sprintf("Ensure a DNS TXT record exists in the domain of the destination address to opt-in to receiving reports from this domain:\n\n\t%s._report._dmarc.%s. IN TXT \"v=DMARC1;\"\n\n", domain.ASCII, domConf.DMARC.DNSDomain.ASCII)
|
||||
}
|
||||
|
||||
uri := url.URL{
|
||||
Scheme: "mailto",
|
||||
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
|
||||
}
|
||||
dmarcr.AggregateReportAddresses = []dmarc.URI{
|
||||
{Address: uri.String(), MaxSize: 10, Unit: "m"},
|
||||
}
|
||||
} else {
|
||||
addf(&r.DMARC.Instructions, `Configure a DMARC destination in domain in config file. Localpart could be %q.`, localpart)
|
||||
}
|
||||
dmarcr := dmarc.Record{
|
||||
Version: "DMARC1",
|
||||
Policy: "reject",
|
||||
AggregateReportAddresses: []dmarc.URI{
|
||||
{Address: fmt.Sprintf("mailto:%s!10m", smtp.NewAddress(localpart, domain).Pack(false))},
|
||||
},
|
||||
AggregateReportingInterval: 86400,
|
||||
Percentage: 100,
|
||||
addf(&r.DMARC.Instructions, `Configure a DMARC destination in domain in config file.`)
|
||||
}
|
||||
instr := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_dmarc IN TXT %s\n\nYou can start with testing mode by replacing p=reject with p=none. You can also request for the policy to be applied to a percentage of emails instead of all, by adding pct=X, with X between 0 and 100. Keep in mind that receiving mail servers will apply some anti-spam assessment regardless of the policy and whether it is applied to the message. The ruf= part requests daily aggregate reports to be sent to the specified address, which is automatically configured and reports automatically analyzed.", mox.TXTStrings(dmarcr.String()))
|
||||
addf(&r.DMARC.Instructions, instr)
|
||||
if extInstr != "" {
|
||||
addf(&r.DMARC.Instructions, extInstr)
|
||||
}
|
||||
}()
|
||||
|
||||
// TLSRPT
|
||||
|
@ -966,23 +988,31 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer,
|
|||
r.TLSRPT.Record = &TLSRPTRecord{*record}
|
||||
}
|
||||
|
||||
localpart := smtp.Localpart("tls-reports")
|
||||
instr := fmt.Sprintf(`TLSRPT is an opt-in mechanism to request feedback about TLS connectivity from remote SMTP servers when they connect to us. It allows detecting delivery problems and unwanted downgrades to plaintext SMTP connections. With TLSRPT you configure an email address to which reports should be sent. Remote SMTP servers will send a report once a day with the number of successful connections, and the number of failed connections including details that should help debugging/resolving any issues.`)
|
||||
if domConf.TLSRPT != nil {
|
||||
localpart = domConf.TLSRPT.ParsedLocalpart
|
||||
} else {
|
||||
addf(&r.TLSRPT.Errors, `Configure a TLSRPT destination in domain in config file. Localpart could be %q.`, localpart)
|
||||
}
|
||||
tlsrptr := &tlsrpt.Record{
|
||||
Version: "TLSRPTv1",
|
||||
// todo: should URI-encode the URI, including ',', '!' and ';'.
|
||||
RUAs: [][]string{{fmt.Sprintf("mailto:%s", smtp.NewAddress(localpart, domain).Pack(false))}},
|
||||
}
|
||||
instr := fmt.Sprintf(`TLSRPT is an opt-in mechanism to request feedback about TLS connectivity from remote SMTP servers when they connect to us. It allows detecting delivery problems and unwanted downgrades to plaintext SMTP connections. With TLSRPT you configure an email address to which reports should be sent. Remote SMTP servers will send a report once a day with the number of successful connections, and the number of failed connections including details that should help debugging/resolving any issues.
|
||||
// TLSRPT does not require validation of reporting addresses outside the domain.
|
||||
// ../rfc/8460:1463
|
||||
uri := url.URL{
|
||||
Scheme: "mailto",
|
||||
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
|
||||
}
|
||||
uristr := uri.String()
|
||||
uristr = strings.ReplaceAll(uristr, ",", "%2C")
|
||||
uristr = strings.ReplaceAll(uristr, "!", "%21")
|
||||
uristr = strings.ReplaceAll(uristr, ";", "%3B")
|
||||
tlsrptr := &tlsrpt.Record{
|
||||
Version: "TLSRPTv1",
|
||||
RUAs: [][]string{{uristr}},
|
||||
}
|
||||
instr += fmt.Sprintf(`
|
||||
|
||||
Ensure a DNS TXT record like the following exists:
|
||||
|
||||
_smtp._tls IN TXT %s
|
||||
`, mox.TXTStrings(tlsrptr.String()))
|
||||
} else {
|
||||
addf(&r.TLSRPT.Errors, `Configure a TLSRPT destination in domain in config file.`)
|
||||
}
|
||||
addf(&r.TLSRPT.Instructions, instr)
|
||||
}()
|
||||
|
||||
|
|
|
@ -1446,7 +1446,7 @@
|
|||
},
|
||||
{
|
||||
"Name": "Unit",
|
||||
"Docs": "\"\" (b), \"k\", \"g\", \"t\" (case insensitive), unit size, where k is 2^10 etc.",
|
||||
"Docs": "\"\" (b), \"k\", \"m\", \"g\", \"t\" (case insensitive), unit size, where k is 2^10 etc.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue