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