mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
47ebfa8152
and in a way that allows us to send that message to multiple recipients in a single smtp transaction.
405 lines
12 KiB
Go
405 lines
12 KiB
Go
package dmarcdb
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
|
|
if len(qml) != 1 {
|
|
return fmt.Errorf("queued %d messages, expected 1", len(qml))
|
|
}
|
|
qm := qml[0]
|
|
|
|
// 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)
|
|
}
|