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)