mox/tlsrptdb/report.go
Mechiel Lukkien ec967ef321
use new sherpadoc rename mechanism to remove some typename stuttering
the stuttering was introduced to make the same type name declared in multiple
packages, and used in the admin sherpa api, unique. with sherpadoc's new
rename, we can make them unique when generating the api definition/docs, and
the Go code can use nicer names.
2024-04-19 10:51:24 +02:00

173 lines
5 KiB
Go

// Package tlsrptdb stores reports from "SMTP TLS Reporting" in its database.
package tlsrptdb
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/tlsrpt"
)
var (
metricSession = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_tlsrptdb_session_total",
Help: "Number of sessions, both success and known result types.",
},
[]string{"type"}, // Known result types, and "success"
)
knownResultTypes = map[tlsrpt.ResultType]struct{}{
tlsrpt.ResultSTARTTLSNotSupported: {},
tlsrpt.ResultCertificateHostMismatch: {},
tlsrpt.ResultCertificateExpired: {},
tlsrpt.ResultTLSAInvalid: {},
tlsrpt.ResultDNSSECInvalid: {},
tlsrpt.ResultDANERequired: {},
tlsrpt.ResultCertificateNotTrusted: {},
tlsrpt.ResultSTSPolicyInvalid: {},
tlsrpt.ResultSTSWebPKIInvalid: {},
tlsrpt.ResultValidationFailure: {},
tlsrpt.ResultSTSPolicyFetch: {},
}
)
// Record is a TLS report as a database record, including information
// about the sender.
type Record struct {
ID int64
Domain string `bstore:"index"` // Policy domain to which the TLS report applies. Unicode.
FromDomain string
MailFrom string
HostReport bool // Report for host TLSRPT record, as opposed to domain TLSRPT record.
Report tlsrpt.Report
}
func reportDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
mutex.Lock()
defer mutex.Unlock()
if ReportDB == nil {
p := mox.DataDirPath("tlsrpt.db")
os.MkdirAll(filepath.Dir(p), 0770)
db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, ReportDBTypes...)
if err != nil {
return nil, err
}
ReportDB = db
}
return ReportDB, nil
}
// AddReport adds a TLS report to the database.
//
// The report should have come in over SMTP, with a DKIM-validated
// verifiedFromDomain. Using HTTPS for reports is not recommended as there is no
// authentication on the reports origin.
//
// Only reports for known domains are added to the database. Unknown domains are
// ignored without causing an error, unless no known domain was found in the report
// at all.
//
// Prometheus metrics are updated only for configured domains.
func AddReport(ctx context.Context, log mlog.Log, verifiedFromDomain dns.Domain, mailFrom string, hostReport bool, r *tlsrpt.Report) error {
db, err := reportDB(ctx)
if err != nil {
return err
}
if len(r.Policies) == 0 {
return fmt.Errorf("no policies in report")
}
var inserted int
return db.Write(ctx, func(tx *bstore.Tx) error {
for _, p := range r.Policies {
pp := p.Policy
d, err := dns.ParseDomain(pp.Domain)
if err != nil {
return fmt.Errorf("invalid domain %v in tls report: %v", d, err)
}
if _, ok := mox.Conf.Domain(d); !ok && d != mox.Conf.Static.HostnameDomain {
log.Info("unknown host/recipient policy domain in tls report, not storing", slog.Any("domain", d), slog.String("mailfrom", mailFrom))
continue
}
metricSession.WithLabelValues("success").Add(float64(p.Summary.TotalSuccessfulSessionCount))
for _, f := range p.FailureDetails {
var result string
if _, ok := knownResultTypes[f.ResultType]; ok {
result = string(f.ResultType)
} else {
result = "other"
}
metricSession.WithLabelValues(result).Add(float64(f.FailedSessionCount))
}
record := Record{0, d.Name(), verifiedFromDomain.Name(), mailFrom, d == mox.Conf.Static.HostnameDomain, *r}
if err := tx.Insert(&record); err != nil {
return fmt.Errorf("inserting report for domain: %w", err)
}
inserted++
}
if inserted == 0 {
return fmt.Errorf("no domains in report recognized")
}
return nil
})
}
// Records returns all TLS reports in the database.
func Records(ctx context.Context) ([]Record, error) {
db, err := reportDB(ctx)
if err != nil {
return nil, err
}
return bstore.QueryDB[Record](ctx, db).List()
}
// RecordID returns the report for the ID.
func RecordID(ctx context.Context, id int64) (Record, error) {
db, err := reportDB(ctx)
if err != nil {
return Record{}, err
}
e := Record{ID: id}
err = db.Get(ctx, &e)
return e, err
}
// RecordsPeriodPolicyDomain returns the reports overlapping start and end, for the
// given policy domain. If policy domain is empty, records for all domains are
// returned.
func RecordsPeriodDomain(ctx context.Context, start, end time.Time, policyDomain dns.Domain) ([]Record, error) {
db, err := reportDB(ctx)
if err != nil {
return nil, err
}
q := bstore.QueryDB[Record](ctx, db)
var zerodom dns.Domain
if policyDomain != zerodom {
q.FilterNonzero(Record{Domain: policyDomain.Name()})
}
q.FilterFn(func(r Record) bool {
dr := r.Report.DateRange
return !dr.Start.Before(start) && dr.Start.Before(end) || dr.End.After(start) && !dr.End.After(end)
})
return q.List()
}