1// Package mtastsdb stores MTA-STS policies for later use.
 
3// An MTA-STS policy can specify how long it may be cached. By storing a
 
4// policy, it does not have to be fetched again during email delivery, which
 
5// makes it harder for attackers to intervene.
 
17	"github.com/prometheus/client_golang/prometheus"
 
18	"github.com/prometheus/client_golang/prometheus/promauto"
 
20	"github.com/mjl-/bstore"
 
22	"github.com/mjl-/mox/dns"
 
23	"github.com/mjl-/mox/mlog"
 
24	"github.com/mjl-/mox/mox-"
 
25	"github.com/mjl-/mox/mtasts"
 
28var xlog = mlog.New("mtastsdb")
 
31	metricGet = promauto.NewCounterVec(
 
32		prometheus.CounterOpts{
 
33			Name: "mox_mtastsdb_get_total",
 
34			Help: "Number of Get by result.",
 
40var timeNow = time.Now // Tests override this.
 
42// PolicyRecord is a cached policy or absence of a policy.
 
43type PolicyRecord struct {
 
44	Domain        string    // Domain name, with unicode characters.
 
45	Inserted      time.Time `bstore:"default now"`
 
47	LastUpdate    time.Time // Policies are refreshed on use and periodically.
 
48	LastUse       time.Time `bstore:"index"`
 
50	RecordID      string // As retrieved from DNS.
 
51	mtasts.Policy        // As retrieved from the well-known HTTPS url.
 
55	// No valid non-expired policy in database.
 
56	ErrNotFound = errors.New("mtastsdb: policy not found")
 
58	// Indicates an MTA-STS TXT record was fetched recently, but fetching the policy
 
59	// failed and should not yet be retried.
 
60	ErrBackoff = errors.New("mtastsdb: policy fetch failed recently")
 
63var DBTypes = []any{PolicyRecord{}} // Types stored in DB.
 
64var DB *bstore.DB                   // Exported for backups.
 
67func database(ctx context.Context) (rdb *bstore.DB, rerr error) {
 
71		p := mox.DataDirPath("mtasts.db")
 
72		os.MkdirAll(filepath.Dir(p), 0770)
 
73		db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, DBTypes...)
 
82// Init opens the database and starts a goroutine that refreshes policies in
 
83// the database, and keeps doing so periodically.
 
84func Init(refresher bool) error {
 
85	_, err := database(mox.Shutdown)
 
91		// todo: allow us to shut down cleanly?
 
98// Close closes the database.
 
104		xlog.Check(err, "closing database")
 
109// Lookup looks up a policy for the domain in the database.
 
111// Only non-expired records are returned.
 
112func lookup(ctx context.Context, domain dns.Domain) (*PolicyRecord, error) {
 
113	log := xlog.WithContext(ctx)
 
114	db, err := database(ctx)
 
120		return nil, fmt.Errorf("empty domain")
 
123	q := bstore.QueryDB[PolicyRecord](ctx, db)
 
124	q.FilterNonzero(PolicyRecord{Domain: domain.Name()})
 
125	q.FilterGreater("ValidEnd", now)
 
127	if err == bstore.ErrAbsent {
 
128		return nil, ErrNotFound
 
129	} else if err != nil {
 
134	if err := db.Update(ctx, &pr); err != nil {
 
135		log.Errorx("marking cached mta-sts policy as used in database", err)
 
138		return nil, ErrBackoff
 
143// Upsert adds the policy to the database, overwriting an existing policy for the domain.
 
144// Policy can be nil, indicating a failure to fetch the policy.
 
145func Upsert(ctx context.Context, domain dns.Domain, recordID string, policy *mtasts.Policy) error {
 
146	db, err := database(ctx)
 
151	return db.Write(ctx, func(tx *bstore.Tx) error {
 
152		pr := PolicyRecord{Domain: domain.Name()}
 
154		if err != nil && err != bstore.ErrAbsent {
 
165			p.Mode = mtasts.ModeNone
 
166			p.MaxAgeSeconds = 5 * 60
 
168		backoff := policy == nil
 
169		validEnd := now.Add(time.Duration(p.MaxAgeSeconds) * time.Second)
 
171		if err == bstore.ErrAbsent {
 
172			pr = PolicyRecord{domain.Name(), now, validEnd, now, now, backoff, recordID, p}
 
173			return tx.Insert(&pr)
 
176		pr.ValidEnd = validEnd
 
180		pr.RecordID = recordID
 
182		return tx.Update(&pr)
 
186// PolicyRecords returns all policies in the database, sorted descending by last
 
188func PolicyRecords(ctx context.Context) ([]PolicyRecord, error) {
 
189	db, err := database(ctx)
 
193	return bstore.QueryDB[PolicyRecord](ctx, db).SortDesc("LastUse", "Domain").List()
 
196// Get retrieves an MTA-STS policy for domain and whether it is fresh.
 
198// If an error is returned, it should be considered a transient error, e.g. a
 
199// temporary DNS lookup failure.
 
201// The returned policy can be nil also when there is no error. In this case, the
 
202// domain does not implement MTA-STS.
 
204// If a policy is present in the local database, it is refreshed if needed. If no
 
205// policy is present for the domain, an attempt is made to fetch the policy and
 
206// store it in the local database.
 
208// Some errors are logged but not otherwise returned, e.g. if a new policy is
 
209// supposedly published but could not be retrieved.
 
210func Get(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (policy *mtasts.Policy, fresh bool, err error) {
 
211	log := xlog.WithContext(ctx)
 
214		if err != nil && errors.Is(err, ErrBackoff) {
 
216		} else if err != nil && errors.Is(err, ErrNotFound) {
 
218		} else if err != nil {
 
221		metricGet.WithLabelValues(result).Inc()
 
222		log.Debugx("mtastsdb get result", err, mlog.Field("domain", domain), mlog.Field("fresh", fresh))
 
225	cachedPolicy, err := lookup(ctx, domain)
 
226	if err != nil && errors.Is(err, ErrNotFound) {
 
227		// We don't have a policy for this domain, not even a record that we tried recently
 
228		// and should backoff. So attempt to fetch policy.
 
229		nctx, cancel := context.WithTimeout(ctx, time.Minute)
 
231		record, p, err := mtasts.Get(nctx, resolver, domain)
 
234			case errors.Is(err, mtasts.ErrNoRecord) || errors.Is(err, mtasts.ErrMultipleRecords) || errors.Is(err, mtasts.ErrRecordSyntax) || errors.Is(err, mtasts.ErrNoPolicy) || errors.Is(err, mtasts.ErrPolicyFetch) || errors.Is(err, mtasts.ErrPolicySyntax):
 
236				log.Debugx("interpreting mtasts error to mean remote is not doing mta-sts", err)
 
238				// Interpret as temporary error, e.g. mtasts.ErrDNS, try again later.
 
239				return nil, false, fmt.Errorf("lookup up mta-sts policy: %w", err)
 
242		// Insert policy into database. If we could not fetch the policy itself, we back
 
244		if err == nil || errors.Is(err, mtasts.ErrNoPolicy) || errors.Is(err, mtasts.ErrPolicyFetch) || errors.Is(err, mtasts.ErrPolicySyntax) {
 
249			if err := Upsert(ctx, domain, recordID, p); err != nil {
 
250				log.Errorx("inserting policy into cache, continuing", err)
 
254	} else if err != nil && errors.Is(err, ErrBackoff) {
 
256		// We recently failed to fetch a policy, act as if MTA-STS is not implemented.
 
257		return nil, false, nil
 
258	} else if err != nil {
 
259		return nil, false, fmt.Errorf("looking up mta-sts policy in cache: %w", err)
 
262	// Policy was found in database. Check in DNS it is still fresh.
 
263	policy = &cachedPolicy.Policy
 
264	nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
 
266	record, _, _, err := mtasts.LookupRecord(nctx, resolver, domain)
 
268		if !errors.Is(err, mtasts.ErrNoRecord) {
 
269			// Could be a temporary DNS or configuration error.
 
270			log.Errorx("checking for freshness of cached mta-sts dns txt record for domain, continuing with previously cached policy", err)
 
272		return policy, false, nil
 
273	} else if record.ID == cachedPolicy.RecordID {
 
274		return policy, true, nil
 
277	// New policy should be available.
 
278	nctx, cancel = context.WithTimeout(ctx, 30*time.Second)
 
280	p, _, err := mtasts.FetchPolicy(nctx, domain)
 
282		log.Errorx("fetching updated policy for domain, continuing with previously cached policy", err)
 
283		return policy, false, nil
 
285	if err := Upsert(ctx, domain, record.ID, p); err != nil {
 
286		log.Errorx("inserting refreshed policy into cache, continuing with fresh policy", err)