1package dmarcdb
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sync"
9 "time"
10
11 "github.com/prometheus/client_golang/prometheus"
12 "github.com/prometheus/client_golang/prometheus/promauto"
13
14 "github.com/mjl-/bstore"
15
16 "github.com/mjl-/mox/dmarcrpt"
17 "github.com/mjl-/mox/dns"
18 "github.com/mjl-/mox/mox-"
19)
20
21var (
22 ReportsDBTypes = []any{DomainFeedback{}} // Types stored in DB.
23 ReportsDB *bstore.DB // Exported for backups.
24 reportsMutex sync.Mutex
25)
26
27var (
28 metricEvaluated = promauto.NewCounterVec(
29 prometheus.CounterOpts{
30 Name: "mox_dmarcdb_policy_evaluated_total",
31 Help: "Number of policy evaluations.",
32 },
33 // We only register validated domains for which we have a config.
34 []string{"domain", "disposition", "dkim", "spf"},
35 )
36 metricDKIM = promauto.NewCounterVec(
37 prometheus.CounterOpts{
38 Name: "mox_dmarcdb_dkim_result_total",
39 Help: "Number of DKIM results.",
40 },
41 []string{"result"},
42 )
43 metricSPF = promauto.NewCounterVec(
44 prometheus.CounterOpts{
45 Name: "mox_dmarcdb_spf_result_total",
46 Help: "Number of SPF results.",
47 },
48 []string{"result"},
49 )
50)
51
52// DomainFeedback is a single report stored in the database.
53type DomainFeedback struct {
54 ID int64
55 // Domain where DMARC DNS record was found, could be organizational domain.
56 Domain string `bstore:"index"`
57 // Domain in From-header.
58 FromDomain string `bstore:"index"`
59 dmarcrpt.Feedback
60}
61
62func reportsDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
63 reportsMutex.Lock()
64 defer reportsMutex.Unlock()
65 if ReportsDB == nil {
66 p := mox.DataDirPath("dmarcrpt.db")
67 os.MkdirAll(filepath.Dir(p), 0770)
68 db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, ReportsDBTypes...)
69 if err != nil {
70 return nil, err
71 }
72 ReportsDB = db
73 }
74 return ReportsDB, nil
75}
76
77// AddReport adds a DMARC aggregate feedback report from an email to the database,
78// and updates prometheus metrics.
79//
80// fromDomain is the domain in the report message From header.
81func AddReport(ctx context.Context, f *dmarcrpt.Feedback, fromDomain dns.Domain) error {
82 db, err := reportsDB(ctx)
83 if err != nil {
84 return err
85 }
86
87 d, err := dns.ParseDomain(f.PolicyPublished.Domain)
88 if err != nil {
89 return fmt.Errorf("parsing domain in report: %v", err)
90 }
91
92 df := DomainFeedback{0, d.Name(), fromDomain.Name(), *f}
93 if err := db.Insert(ctx, &df); err != nil {
94 return err
95 }
96
97 for _, r := range f.Records {
98 for _, dkim := range r.AuthResults.DKIM {
99 count := r.Row.Count
100 if count > 0 {
101 metricDKIM.With(prometheus.Labels{
102 "result": string(dkim.Result),
103 }).Add(float64(count))
104 }
105 }
106
107 for _, spf := range r.AuthResults.SPF {
108 count := r.Row.Count
109 if count > 0 {
110 metricSPF.With(prometheus.Labels{
111 "result": string(spf.Result),
112 }).Add(float64(count))
113 }
114 }
115
116 count := r.Row.Count
117 if count > 0 {
118 pe := r.Row.PolicyEvaluated
119 metricEvaluated.With(prometheus.Labels{
120 "domain": f.PolicyPublished.Domain,
121 "disposition": string(pe.Disposition),
122 "dkim": string(pe.DKIM),
123 "spf": string(pe.SPF),
124 }).Add(float64(count))
125 }
126 }
127 return nil
128}
129
130// Records returns all reports in the database.
131func Records(ctx context.Context) ([]DomainFeedback, error) {
132 db, err := reportsDB(ctx)
133 if err != nil {
134 return nil, err
135 }
136
137 return bstore.QueryDB[DomainFeedback](ctx, db).List()
138}
139
140// RecordID returns the report for the ID.
141func RecordID(ctx context.Context, id int64) (DomainFeedback, error) {
142 db, err := reportsDB(ctx)
143 if err != nil {
144 return DomainFeedback{}, err
145 }
146
147 e := DomainFeedback{ID: id}
148 err = db.Get(ctx, &e)
149 return e, err
150}
151
152// RecordsPeriodDomain returns the reports overlapping start and end, for the given
153// domain. If domain is empty, all records match for domain.
154func RecordsPeriodDomain(ctx context.Context, start, end time.Time, domain string) ([]DomainFeedback, error) {
155 db, err := reportsDB(ctx)
156 if err != nil {
157 return nil, err
158 }
159
160 s := start.Unix()
161 e := end.Unix()
162
163 q := bstore.QueryDB[DomainFeedback](ctx, db)
164 if domain != "" {
165 q.FilterNonzero(DomainFeedback{Domain: domain})
166 }
167 q.FilterFn(func(d DomainFeedback) bool {
168 m := d.Feedback.ReportMetadata.DateRange
169 return m.Begin >= s && m.Begin < e || m.End > s && m.End <= e
170 })
171 return q.List()
172}
173