mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
5b20cba50a
we don't want external software to include internal details like mlog. slog.Logger is/will be the standard. we still have mlog for its helper functions, and its handler that logs in concise logfmt used by mox. packages that are not meant for reuse still pass around mlog.Log for convenience. we use golang.org/x/exp/slog because we also support the previous Go toolchain version. with the next Go release, we'll switch to the builtin slog.
401 lines
12 KiB
Go
401 lines
12 KiB
Go
package dmarcdb
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"golang.org/x/exp/slog"
|
|
|
|
"github.com/mjl-/mox/dmarcrpt"
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/mox-"
|
|
"github.com/mjl-/mox/moxio"
|
|
"github.com/mjl-/mox/queue"
|
|
)
|
|
|
|
func tcheckf(t *testing.T, err error, format string, args ...any) {
|
|
t.Helper()
|
|
if err != nil {
|
|
t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
|
|
}
|
|
}
|
|
|
|
func tcompare(t *testing.T, got, expect any) {
|
|
t.Helper()
|
|
if !reflect.DeepEqual(got, expect) {
|
|
t.Fatalf("got:\n%v\nexpected:\n%v", got, expect)
|
|
}
|
|
}
|
|
|
|
func TestEvaluations(t *testing.T) {
|
|
os.RemoveAll("../testdata/dmarcdb/data")
|
|
mox.Context = ctxbg
|
|
mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
|
|
mox.MustLoadConfig(true, false)
|
|
EvalDB = nil
|
|
|
|
_, err := evalDB(ctxbg)
|
|
tcheckf(t, err, "database")
|
|
defer func() {
|
|
EvalDB.Close()
|
|
EvalDB = nil
|
|
}()
|
|
|
|
parseJSON := func(s string) (e Evaluation) {
|
|
t.Helper()
|
|
err := json.Unmarshal([]byte(s), &e)
|
|
tcheckf(t, err, "unmarshal")
|
|
return
|
|
}
|
|
packJSON := func(e Evaluation) string {
|
|
t.Helper()
|
|
buf, err := json.Marshal(e)
|
|
tcheckf(t, err, "marshal")
|
|
return string(buf)
|
|
}
|
|
|
|
e0 := Evaluation{
|
|
PolicyDomain: "sender1.example",
|
|
Evaluated: time.Now().Round(0),
|
|
IntervalHours: 1,
|
|
PolicyPublished: dmarcrpt.PolicyPublished{
|
|
Domain: "sender1.example",
|
|
ADKIM: dmarcrpt.AlignmentRelaxed,
|
|
ASPF: dmarcrpt.AlignmentRelaxed,
|
|
Policy: dmarcrpt.DispositionReject,
|
|
SubdomainPolicy: dmarcrpt.DispositionReject,
|
|
Percentage: 100,
|
|
},
|
|
SourceIP: "10.1.2.3",
|
|
Disposition: dmarcrpt.DispositionNone,
|
|
AlignedDKIMPass: true,
|
|
AlignedSPFPass: true,
|
|
EnvelopeTo: "mox.example",
|
|
EnvelopeFrom: "sender1.example",
|
|
HeaderFrom: "sender1.example",
|
|
DKIMResults: []dmarcrpt.DKIMAuthResult{
|
|
{
|
|
Domain: "sender1.example",
|
|
Selector: "test",
|
|
Result: dmarcrpt.DKIMPass,
|
|
},
|
|
},
|
|
SPFResults: []dmarcrpt.SPFAuthResult{
|
|
{
|
|
Domain: "sender1.example",
|
|
Scope: dmarcrpt.SPFDomainScopeMailFrom,
|
|
Result: dmarcrpt.SPFPass,
|
|
},
|
|
},
|
|
}
|
|
e1 := e0
|
|
e2 := parseJSON(strings.ReplaceAll(packJSON(e0), "sender1.example", "sender2.example"))
|
|
e3 := parseJSON(strings.ReplaceAll(packJSON(e0), "10.1.2.3", "10.3.2.1"))
|
|
e3.Optional = true
|
|
|
|
for i, e := range []*Evaluation{&e0, &e1, &e2, &e3} {
|
|
e.Evaluated = e.Evaluated.Add(time.Duration(i) * time.Second)
|
|
err = AddEvaluation(ctxbg, 3600, e)
|
|
tcheckf(t, err, "add evaluation")
|
|
}
|
|
|
|
expStats := map[string]EvaluationStat{
|
|
"sender1.example": {
|
|
Domain: dns.Domain{ASCII: "sender1.example"},
|
|
Dispositions: []string{"none"},
|
|
Count: 3,
|
|
SendReport: true,
|
|
},
|
|
"sender2.example": {
|
|
Domain: dns.Domain{ASCII: "sender2.example"},
|
|
Dispositions: []string{"none"},
|
|
Count: 1,
|
|
SendReport: true,
|
|
},
|
|
}
|
|
stats, err := EvaluationStats(ctxbg)
|
|
tcheckf(t, err, "evaluation stats")
|
|
tcompare(t, stats, expStats)
|
|
|
|
// EvaluationsDomain
|
|
evals, err := EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
|
|
tcheckf(t, err, "get evaluations for domain")
|
|
tcompare(t, evals, []Evaluation{e0, e1, e3})
|
|
|
|
evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender2.example"})
|
|
tcheckf(t, err, "get evaluations for domain")
|
|
tcompare(t, evals, []Evaluation{e2})
|
|
|
|
evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "bogus.example"})
|
|
tcheckf(t, err, "get evaluations for domain")
|
|
tcompare(t, evals, []Evaluation{})
|
|
|
|
// RemoveEvaluationsDomain
|
|
err = RemoveEvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
|
|
tcheckf(t, err, "remove evaluations")
|
|
|
|
expStats = map[string]EvaluationStat{
|
|
"sender2.example": {
|
|
Domain: dns.Domain{ASCII: "sender2.example"},
|
|
Dispositions: []string{"none"},
|
|
Count: 1,
|
|
SendReport: true,
|
|
},
|
|
}
|
|
stats, err = EvaluationStats(ctxbg)
|
|
tcheckf(t, err, "evaluation stats")
|
|
tcompare(t, stats, expStats)
|
|
}
|
|
|
|
func TestSendReports(t *testing.T) {
|
|
mlog.SetConfig(map[string]slog.Level{"": slog.LevelDebug})
|
|
|
|
os.RemoveAll("../testdata/dmarcdb/data")
|
|
mox.Context = ctxbg
|
|
mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
|
|
mox.MustLoadConfig(true, false)
|
|
EvalDB = nil
|
|
|
|
db, err := evalDB(ctxbg)
|
|
tcheckf(t, err, "database")
|
|
defer func() {
|
|
EvalDB.Close()
|
|
EvalDB = nil
|
|
}()
|
|
|
|
resolver := dns.MockResolver{
|
|
TXT: map[string][]string{
|
|
"_dmarc.sender.example.": {
|
|
"v=DMARC1; rua=mailto:dmarcrpt@sender.example; ri=3600",
|
|
},
|
|
},
|
|
}
|
|
|
|
end := nextWholeHour(time.Now())
|
|
|
|
eval := Evaluation{
|
|
PolicyDomain: "sender.example",
|
|
Evaluated: end.Add(-time.Hour / 2),
|
|
IntervalHours: 1,
|
|
PolicyPublished: dmarcrpt.PolicyPublished{
|
|
Domain: "sender.example",
|
|
ADKIM: dmarcrpt.AlignmentRelaxed,
|
|
ASPF: dmarcrpt.AlignmentRelaxed,
|
|
Policy: dmarcrpt.DispositionReject,
|
|
SubdomainPolicy: dmarcrpt.DispositionReject,
|
|
Percentage: 100,
|
|
},
|
|
SourceIP: "10.1.2.3",
|
|
Disposition: dmarcrpt.DispositionNone,
|
|
AlignedDKIMPass: true,
|
|
AlignedSPFPass: true,
|
|
EnvelopeTo: "mox.example",
|
|
EnvelopeFrom: "sender.example",
|
|
HeaderFrom: "sender.example",
|
|
DKIMResults: []dmarcrpt.DKIMAuthResult{
|
|
{
|
|
Domain: "sender.example",
|
|
Selector: "test",
|
|
Result: dmarcrpt.DKIMPass,
|
|
},
|
|
},
|
|
SPFResults: []dmarcrpt.SPFAuthResult{
|
|
{
|
|
Domain: "sender.example",
|
|
Scope: dmarcrpt.SPFDomainScopeMailFrom,
|
|
Result: dmarcrpt.SPFPass,
|
|
},
|
|
},
|
|
}
|
|
|
|
expFeedback := &dmarcrpt.Feedback{
|
|
XMLName: xml.Name{Local: "feedback"},
|
|
Version: "1.0",
|
|
ReportMetadata: dmarcrpt.ReportMetadata{
|
|
OrgName: "mail.mox.example",
|
|
Email: "postmaster@mail.mox.example",
|
|
DateRange: dmarcrpt.DateRange{
|
|
Begin: end.Add(-1 * time.Hour).Unix(),
|
|
End: end.Add(-time.Second).Unix(),
|
|
},
|
|
},
|
|
PolicyPublished: dmarcrpt.PolicyPublished{
|
|
Domain: "sender.example",
|
|
ADKIM: dmarcrpt.AlignmentRelaxed,
|
|
ASPF: dmarcrpt.AlignmentRelaxed,
|
|
Policy: dmarcrpt.DispositionReject,
|
|
SubdomainPolicy: dmarcrpt.DispositionReject,
|
|
Percentage: 100,
|
|
},
|
|
Records: []dmarcrpt.ReportRecord{
|
|
{
|
|
Row: dmarcrpt.Row{
|
|
SourceIP: "10.1.2.3",
|
|
Count: 1,
|
|
PolicyEvaluated: dmarcrpt.PolicyEvaluated{
|
|
Disposition: dmarcrpt.DispositionNone,
|
|
DKIM: dmarcrpt.DMARCPass,
|
|
SPF: dmarcrpt.DMARCPass,
|
|
},
|
|
},
|
|
Identifiers: dmarcrpt.Identifiers{
|
|
EnvelopeTo: "mox.example",
|
|
EnvelopeFrom: "sender.example",
|
|
HeaderFrom: "sender.example",
|
|
},
|
|
AuthResults: dmarcrpt.AuthResults{
|
|
DKIM: []dmarcrpt.DKIMAuthResult{
|
|
{
|
|
Domain: "sender.example",
|
|
Selector: "test",
|
|
Result: dmarcrpt.DKIMPass,
|
|
},
|
|
},
|
|
SPF: []dmarcrpt.SPFAuthResult{
|
|
{
|
|
Domain: "sender.example",
|
|
Scope: dmarcrpt.SPFDomainScopeMailFrom,
|
|
Result: dmarcrpt.SPFPass,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Set a timeUntil that we steplock and that causes the actual sleep to return immediately when we want to.
|
|
wait := make(chan struct{})
|
|
step := make(chan time.Duration)
|
|
jitteredTimeUntil = func(_ time.Time) time.Duration {
|
|
wait <- struct{}{}
|
|
return <-step
|
|
}
|
|
|
|
sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) { return true }
|
|
|
|
test := func(evals []Evaluation, expAggrAddrs map[string]struct{}, expErrorAddrs map[string]struct{}, optExpReport *dmarcrpt.Feedback) {
|
|
t.Helper()
|
|
|
|
mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
|
|
|
|
for _, e := range evals {
|
|
err := db.Insert(ctxbg, &e)
|
|
tcheckf(t, err, "inserting evaluation")
|
|
}
|
|
|
|
aggrAddrs := map[string]struct{}{}
|
|
errorAddrs := map[string]struct{}{}
|
|
|
|
queueAdd = func(ctx context.Context, log mlog.Log, qm *queue.Msg, msgFile *os.File) error {
|
|
// Read message file. Also write copy to disk for inspection.
|
|
buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
|
|
tcheckf(t, err, "read report message")
|
|
err = os.WriteFile("../testdata/dmarcdb/data/report.eml", append(append([]byte{}, qm.MsgPrefix...), buf...), 0600)
|
|
tcheckf(t, err, "write report message")
|
|
|
|
var feedback *dmarcrpt.Feedback
|
|
addr := qm.Recipient().String()
|
|
isErrorReport := strings.Contains(string(buf), "DMARC aggregate reporting error report")
|
|
if isErrorReport {
|
|
errorAddrs[addr] = struct{}{}
|
|
} else {
|
|
aggrAddrs[addr] = struct{}{}
|
|
|
|
feedback, err = dmarcrpt.ParseMessageReport(log.Logger, msgFile)
|
|
tcheckf(t, err, "parsing generated report message")
|
|
}
|
|
|
|
if optExpReport != nil {
|
|
// Parse report in message and compare with expected.
|
|
optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID
|
|
tcompare(t, feedback, expFeedback)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
Start(resolver)
|
|
// Run first loop.
|
|
<-wait
|
|
step <- 0
|
|
<-wait
|
|
tcompare(t, aggrAddrs, expAggrAddrs)
|
|
tcompare(t, errorAddrs, expErrorAddrs)
|
|
|
|
// Second loop. Evaluations cleaned, should not result in report messages.
|
|
aggrAddrs = map[string]struct{}{}
|
|
errorAddrs = map[string]struct{}{}
|
|
step <- 0
|
|
<-wait
|
|
tcompare(t, aggrAddrs, map[string]struct{}{})
|
|
tcompare(t, errorAddrs, map[string]struct{}{})
|
|
|
|
// Caus Start to stop.
|
|
mox.ShutdownCancel()
|
|
step <- time.Minute
|
|
}
|
|
|
|
// Typical case, with a single address that receives an aggregate report.
|
|
test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
|
|
|
|
// Only optional evaluations, no report at all.
|
|
evalOpt := eval
|
|
evalOpt.Optional = true
|
|
test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil)
|
|
|
|
// Address is suppressed.
|
|
sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
|
|
err = db.Insert(ctxbg, &sa)
|
|
tcheckf(t, err, "insert suppress address")
|
|
test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
|
|
|
|
// Suppression has expired.
|
|
sa.Until = time.Now().Add(-time.Minute)
|
|
err = db.Update(ctxbg, &sa)
|
|
tcheckf(t, err, "update suppress address")
|
|
test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
|
|
|
|
// Two RUA's, one with a size limit that doesn't pass, and one that does pass.
|
|
resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"}
|
|
test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil)
|
|
|
|
// Redirect to external domain, without permission, no report sent.
|
|
resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:unauthorized@other.example"}
|
|
test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
|
|
|
|
// Redirect to external domain, with basic permission.
|
|
resolver.TXT = map[string][]string{
|
|
"_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
|
|
"sender.example._report._dmarc.other.example.": {"v=DMARC1"},
|
|
}
|
|
test([]Evaluation{eval}, map[string]struct{}{"authorized@other.example": {}}, map[string]struct{}{}, nil)
|
|
|
|
// Redirect to authorized external domain, with 2 allowed replacements and 1 invalid and 1 refusing due to size.
|
|
resolver.TXT = map[string][]string{
|
|
"_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
|
|
"sender.example._report._dmarc.other.example.": {"v=DMARC1; rua=mailto:good1@other.example,mailto:bad1@yetanother.example,mailto:good2@other.example,mailto:badsize@other.example!1"},
|
|
}
|
|
test([]Evaluation{eval}, map[string]struct{}{"good1@other.example": {}, "good2@other.example": {}}, map[string]struct{}{}, nil)
|
|
|
|
// Without RUA, we send no message.
|
|
resolver.TXT = map[string][]string{
|
|
"_dmarc.sender.example.": {"v=DMARC1;"},
|
|
}
|
|
test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
|
|
|
|
// If message size limit is reached, an error repor is sent.
|
|
resolver.TXT = map[string][]string{
|
|
"_dmarc.sender.example.": {"v=DMARC1; rua=mailto:dmarcrpt@sender.example!1"},
|
|
}
|
|
test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{"dmarcrpt@sender.example": {}}, nil)
|
|
}
|