1// Package tlsrptdb stores reports from "SMTP TLS Reporting" in its database.
2package tlsrptdb
3
4import (
5 "context"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "time"
11
12 "github.com/prometheus/client_golang/prometheus"
13 "github.com/prometheus/client_golang/prometheus/promauto"
14
15 "github.com/mjl-/bstore"
16
17 "github.com/mjl-/mox/dns"
18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/mox-"
20 "github.com/mjl-/mox/tlsrpt"
21)
22
23var (
24 metricSession = promauto.NewCounterVec(
25 prometheus.CounterOpts{
26 Name: "mox_tlsrptdb_session_total",
27 Help: "Number of sessions, both success and known result types.",
28 },
29 []string{"type"}, // Known result types, and "success"
30 )
31
32 knownResultTypes = map[tlsrpt.ResultType]struct{}{
33 tlsrpt.ResultSTARTTLSNotSupported: {},
34 tlsrpt.ResultCertificateHostMismatch: {},
35 tlsrpt.ResultCertificateExpired: {},
36 tlsrpt.ResultTLSAInvalid: {},
37 tlsrpt.ResultDNSSECInvalid: {},
38 tlsrpt.ResultDANERequired: {},
39 tlsrpt.ResultCertificateNotTrusted: {},
40 tlsrpt.ResultSTSPolicyInvalid: {},
41 tlsrpt.ResultSTSWebPKIInvalid: {},
42 tlsrpt.ResultValidationFailure: {},
43 tlsrpt.ResultSTSPolicyFetch: {},
44 }
45)
46
47// TLSReportRecord is a TLS report as a database record, including information
48// about the sender.
49//
50// todo: should be named just Record, but it would cause a sherpa type name conflict.
51type TLSReportRecord struct {
52 ID int64 `bstore:"typename Record"`
53 Domain string `bstore:"index"` // Policy domain to which the TLS report applies. Unicode.
54 FromDomain string
55 MailFrom string
56 HostReport bool // Report for host TLSRPT record, as opposed to domain TLSRPT record.
57 Report tlsrpt.Report
58}
59
60func reportDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
61 mutex.Lock()
62 defer mutex.Unlock()
63 if ReportDB == nil {
64 p := mox.DataDirPath("tlsrpt.db")
65 os.MkdirAll(filepath.Dir(p), 0770)
66 db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, ReportDBTypes...)
67 if err != nil {
68 return nil, err
69 }
70 ReportDB = db
71 }
72 return ReportDB, nil
73}
74
75// AddReport adds a TLS report to the database.
76//
77// The report should have come in over SMTP, with a DKIM-validated
78// verifiedFromDomain. Using HTTPS for reports is not recommended as there is no
79// authentication on the reports origin.
80//
81// Only reports for known domains are added to the database. Unknown domains are
82// ignored without causing an error, unless no known domain was found in the report
83// at all.
84//
85// Prometheus metrics are updated only for configured domains.
86func AddReport(ctx context.Context, log mlog.Log, verifiedFromDomain dns.Domain, mailFrom string, hostReport bool, r *tlsrpt.Report) error {
87 db, err := reportDB(ctx)
88 if err != nil {
89 return err
90 }
91
92 if len(r.Policies) == 0 {
93 return fmt.Errorf("no policies in report")
94 }
95
96 var inserted int
97 return db.Write(ctx, func(tx *bstore.Tx) error {
98 for _, p := range r.Policies {
99 pp := p.Policy
100
101 d, err := dns.ParseDomain(pp.Domain)
102 if err != nil {
103 return fmt.Errorf("invalid domain %v in tls report: %v", d, err)
104 }
105
106 if _, ok := mox.Conf.Domain(d); !ok && d != mox.Conf.Static.HostnameDomain {
107 log.Info("unknown host/recipient policy domain in tls report, not storing", slog.Any("domain", d), slog.String("mailfrom", mailFrom))
108 continue
109 }
110
111 metricSession.WithLabelValues("success").Add(float64(p.Summary.TotalSuccessfulSessionCount))
112 for _, f := range p.FailureDetails {
113 var result string
114 if _, ok := knownResultTypes[f.ResultType]; ok {
115 result = string(f.ResultType)
116 } else {
117 result = "other"
118 }
119 metricSession.WithLabelValues(result).Add(float64(f.FailedSessionCount))
120 }
121
122 record := TLSReportRecord{0, d.Name(), verifiedFromDomain.Name(), mailFrom, d == mox.Conf.Static.HostnameDomain, *r}
123 if err := tx.Insert(&record); err != nil {
124 return fmt.Errorf("inserting report for domain: %w", err)
125 }
126 inserted++
127 }
128 if inserted == 0 {
129 return fmt.Errorf("no domains in report recognized")
130 }
131 return nil
132 })
133}
134
135// Records returns all TLS reports in the database.
136func Records(ctx context.Context) ([]TLSReportRecord, error) {
137 db, err := reportDB(ctx)
138 if err != nil {
139 return nil, err
140 }
141 return bstore.QueryDB[TLSReportRecord](ctx, db).List()
142}
143
144// RecordID returns the report for the ID.
145func RecordID(ctx context.Context, id int64) (TLSReportRecord, error) {
146 db, err := reportDB(ctx)
147 if err != nil {
148 return TLSReportRecord{}, err
149 }
150
151 e := TLSReportRecord{ID: id}
152 err = db.Get(ctx, &e)
153 return e, err
154}
155
156// RecordsPeriodPolicyDomain returns the reports overlapping start and end, for the
157// given policy domain. If policy domain is empty, records for all domains are
158// returned.
159func RecordsPeriodDomain(ctx context.Context, start, end time.Time, policyDomain dns.Domain) ([]TLSReportRecord, error) {
160 db, err := reportDB(ctx)
161 if err != nil {
162 return nil, err
163 }
164
165 q := bstore.QueryDB[TLSReportRecord](ctx, db)
166 var zerodom dns.Domain
167 if policyDomain != zerodom {
168 q.FilterNonzero(TLSReportRecord{Domain: policyDomain.Name()})
169 }
170 q.FilterFn(func(r TLSReportRecord) bool {
171 dr := r.Report.DateRange
172 return !dr.Start.Before(start) && dr.Start.Before(end) || dr.End.After(start) && !dr.End.After(end)
173 })
174 return q.List()
175}
176