update
This commit is contained in:
+131
-3
@@ -4,7 +4,9 @@
|
||||
package queue
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -12,9 +14,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gomail/internal/dane"
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/dkim"
|
||||
"gomail/internal/mailstore"
|
||||
"gomail/internal/mtasts"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -49,7 +53,7 @@ func NewWorker(database *db.DB, store *mailstore.Store) *Worker {
|
||||
return &Worker{
|
||||
database: database,
|
||||
store: store,
|
||||
deliverer: &MXDeliverer{Hostname: "gomail"},
|
||||
deliverer: &MXDeliverer{Hostname: "gomail", Database: database},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -251,6 +255,7 @@ func isPermanentError(err error) bool {
|
||||
// uniformly regardless of which Deliverer implementation is in use.
|
||||
type MXDeliverer struct {
|
||||
Hostname string // EHLO identity
|
||||
Database *db.DB // caches MTA-STS policies (RFC 8461 requires honoring max_age); nil just disables caching, not the feature — policy is re-fetched every delivery instead
|
||||
}
|
||||
|
||||
func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
|
||||
@@ -271,7 +276,119 @@ func (d *MXDeliverer) Deliver(from, to string, raw []byte) error {
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// tlsPolicy is what resolveTLSPolicy decides for one delivery attempt.
|
||||
// mandatory distinguishes "TLS must succeed or this attempt fails" (DANE,
|
||||
// MTA-STS enforce) from today's original opportunistic behavior (try,
|
||||
// log and continue in plaintext on failure).
|
||||
type tlsPolicy struct {
|
||||
config *tls.Config
|
||||
mandatory bool
|
||||
source string // for logging: "dane" | "mta-sts:enforce" | "opportunistic"
|
||||
}
|
||||
|
||||
// resolveTLSPolicy decides what TLS behavior this delivery attempt must
|
||||
// follow, most-specific first: DANE (per-host) over MTA-STS (per-domain)
|
||||
// over plain opportunistic STARTTLS — today's original, unchanged default.
|
||||
// A non-nil error means delivery to this host must not proceed at all
|
||||
// (e.g. an MTA-STS enforce policy that doesn't list this host as valid —
|
||||
// returned as a 550 so it bounces via the existing permanent-failure path
|
||||
// instead of retrying forever against a host the domain's own policy
|
||||
// disowns). See internal/dane and internal/mtasts package docs for the
|
||||
// respective security models and caveats (DANE's DNSSEC dependency, in
|
||||
// particular).
|
||||
func (d *MXDeliverer) resolveTLSPolicy(ctx context.Context, host, domain string) (tlsPolicy, error) {
|
||||
opportunistic := tlsPolicy{
|
||||
config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12},
|
||||
mandatory: false,
|
||||
source: "opportunistic",
|
||||
}
|
||||
|
||||
if records, err := dane.Lookup(ctx, host, 25); err != nil {
|
||||
slog.Warn("DANE lookup failed, falling back to MTA-STS/opportunistic TLS", "host", host, "err", err)
|
||||
} else if len(records) > 0 {
|
||||
return tlsPolicy{
|
||||
config: &tls.Config{
|
||||
ServerName: host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
// Not disabling verification — replacing it. VerifyPeerCertificate
|
||||
// is DANE's own check (RFC 6698 usage 2/3), see internal/dane.
|
||||
InsecureSkipVerify: true,
|
||||
VerifyPeerCertificate: dane.VerifyPeerCertificate(records),
|
||||
},
|
||||
mandatory: true,
|
||||
source: "dane",
|
||||
}, nil
|
||||
}
|
||||
|
||||
policy, err := d.mtaSTSPolicyFor(ctx, domain)
|
||||
if err != nil {
|
||||
slog.Warn("MTA-STS policy lookup failed, falling back to opportunistic TLS", "domain", domain, "err", err)
|
||||
return opportunistic, nil
|
||||
}
|
||||
if policy == nil || policy.Mode == "none" {
|
||||
return opportunistic, nil
|
||||
}
|
||||
|
||||
hostMatches := false
|
||||
for _, pattern := range policy.MXPatterns {
|
||||
if mtasts.Matches(pattern, host) {
|
||||
hostMatches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch policy.Mode {
|
||||
case "enforce":
|
||||
if !hostMatches {
|
||||
return tlsPolicy{}, fmt.Errorf("550 5.7.5 host %s is not listed in %s's MTA-STS policy (enforce mode)", host, domain)
|
||||
}
|
||||
return tlsPolicy{config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}, mandatory: true, source: "mta-sts:enforce"}, nil
|
||||
case "testing":
|
||||
if !hostMatches {
|
||||
slog.Warn("MTA-STS testing mode: host is not in the domain's policy (would fail under enforce)", "host", host, "domain", domain)
|
||||
}
|
||||
return opportunistic, nil
|
||||
default:
|
||||
return opportunistic, nil
|
||||
}
|
||||
}
|
||||
|
||||
// mtaSTSPolicyFor returns domain's MTA-STS policy, using the DB cache when
|
||||
// present and unexpired (RFC 8461 requires honoring the policy's own
|
||||
// max_age) and fetching fresh otherwise. Returns (nil, nil) if the domain
|
||||
// has no MTA-STS policy at all.
|
||||
func (d *MXDeliverer) mtaSTSPolicyFor(ctx context.Context, domain string) (*mtasts.Policy, error) {
|
||||
if d.Database != nil {
|
||||
if cached, err := d.Database.GetMTASTSPolicy(domain); err == nil && time.Now().UTC().Before(cached.ExpiresAt) {
|
||||
var patterns []string
|
||||
if err := json.Unmarshal([]byte(cached.MXPatterns), &patterns); err == nil {
|
||||
return &mtasts.Policy{ID: cached.PolicyID, Mode: cached.Mode, MXPatterns: patterns, MaxAge: time.Duration(cached.MaxAge) * time.Second}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
policy, err := mtasts.Discover(ctx, domain)
|
||||
if err != nil || policy == nil {
|
||||
return policy, err
|
||||
}
|
||||
|
||||
if d.Database != nil {
|
||||
patternsJSON, _ := json.Marshal(policy.MXPatterns)
|
||||
now := time.Now().UTC()
|
||||
if err := d.Database.UpsertMTASTSPolicy(&db.MTASTSPolicy{
|
||||
Domain: domain, PolicyID: policy.ID, Mode: policy.Mode,
|
||||
MXPatterns: string(patternsJSON), MaxAge: int(policy.MaxAge.Seconds()),
|
||||
FetchedAt: now, ExpiresAt: now.Add(policy.MaxAge),
|
||||
}); err != nil {
|
||||
slog.Warn("failed to cache MTA-STS policy", "domain", domain, "err", err)
|
||||
}
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
|
||||
domain := domainOf(to)
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host+":25", deliveryTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("421 4.4.1 connect to %s failed: %w", host, err)
|
||||
@@ -289,11 +406,22 @@ func (d *MXDeliverer) deliverToHost(host, from, to string, raw []byte) error {
|
||||
return fmt.Errorf("EHLO to %s failed: %w", host, err)
|
||||
}
|
||||
|
||||
policyCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
policy, err := d.resolveTLSPolicy(policyCtx, host, domain)
|
||||
cancel()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsConf := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
if err := client.StartTLS(tlsConf); err != nil {
|
||||
if err := client.StartTLS(policy.config); err != nil {
|
||||
if policy.mandatory {
|
||||
return fmt.Errorf("450 4.7.5 mandatory TLS (%s) failed for %s: %w", policy.source, host, err)
|
||||
}
|
||||
slog.Warn("STARTTLS failed, continuing without encryption", "host", host, "err", err)
|
||||
}
|
||||
} else if policy.mandatory {
|
||||
return fmt.Errorf("450 4.7.5 mandatory TLS (%s) required for %s but server does not offer STARTTLS", policy.source, host)
|
||||
}
|
||||
|
||||
if err := client.Mail(from); err != nil {
|
||||
|
||||
Reference in New Issue
Block a user