Cooperative email for PDS operators
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

DNS verification gate, SPF/DKIM enforcement, and CriticalPass checks

+399 -2
+5
cmd/relay/main.go
··· 29 29 adminui "atmosphere-mail/internal/admin/ui" 30 30 "atmosphere-mail/internal/atpoauth" 31 31 "atmosphere-mail/internal/config" 32 + "atmosphere-mail/internal/dns" 32 33 "atmosphere-mail/internal/enroll" 33 34 "atmosphere-mail/internal/notify" 34 35 "atmosphere-mail/internal/osprey" ··· 402 403 EdPriv: edKey, 403 404 }, 404 405 DKIMSelector: d.DKIMSelector, 406 + CreatedAt: d.CreatedAt, 405 407 } 406 408 } 407 409 ··· 852 854 TLSConfig: tlsConfig, 853 855 }, memberLookup, sendCheck, onAccept) 854 856 smtpServer.SetMetrics(metrics) 857 + smtpServer.SetDNSGate(relay.NewDNSGate(relay.DNSGateConfig{ 858 + Verifier: dns.NewVerifier(net.DefaultResolver), 859 + })) 855 860 856 861 go func() { 857 862 log.Printf("SMTP server listening on %s", cfg.SMTPAddr)
+6
internal/dns/verifier.go
··· 28 28 return r.MX && r.SPF && r.DKIM && r.DMARC 29 29 } 30 30 31 + // CriticalPass returns true if the hard-requirement checks passed (SPF + DKIM). 32 + // DMARC is recommended but not required for delivery. 33 + func (r *Result) CriticalPass() bool { 34 + return r.SPF && r.DKIM 35 + } 36 + 31 37 // Verifier checks MX, SPF, DKIM, and DMARC for a domain. 32 38 type Verifier struct { 33 39 resolver Resolver
+130
internal/relay/dnsgate.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + package relay 4 + 5 + import ( 6 + "context" 7 + "fmt" 8 + "log" 9 + "strings" 10 + "sync" 11 + "time" 12 + 13 + "atmosphere-mail/internal/dns" 14 + ) 15 + 16 + // DNSGate checks DNS records before allowing SMTP sends. 17 + // Results are cached in memory with a configurable TTL. 18 + type DNSGate struct { 19 + verifier *dns.Verifier 20 + gracePeriod time.Duration 21 + cacheTTL time.Duration 22 + 23 + mu sync.RWMutex 24 + cache map[string]cacheEntry 25 + bypass map[string]bool 26 + } 27 + 28 + type cacheEntry struct { 29 + result dns.Result 30 + checkedAt time.Time 31 + } 32 + 33 + // DNSGateConfig configures the DNS gate. 34 + type DNSGateConfig struct { 35 + Verifier *dns.Verifier 36 + GracePeriod time.Duration // default 72h 37 + CacheTTL time.Duration // default 1h 38 + } 39 + 40 + // NewDNSGate creates a DNS gate with the given configuration. 41 + func NewDNSGate(cfg DNSGateConfig) *DNSGate { 42 + if cfg.GracePeriod == 0 { 43 + cfg.GracePeriod = 72 * time.Hour 44 + } 45 + if cfg.CacheTTL == 0 { 46 + cfg.CacheTTL = 1 * time.Hour 47 + } 48 + return &DNSGate{ 49 + verifier: cfg.Verifier, 50 + gracePeriod: cfg.GracePeriod, 51 + cacheTTL: cfg.CacheTTL, 52 + cache: make(map[string]cacheEntry), 53 + bypass: make(map[string]bool), 54 + } 55 + } 56 + 57 + // Bypass adds a domain to the bypass list (admin override). 58 + func (g *DNSGate) Bypass(domain string) { 59 + g.mu.Lock() 60 + g.bypass[strings.ToLower(domain)] = true 61 + g.mu.Unlock() 62 + } 63 + 64 + // RemoveBypass removes a domain from the bypass list. 65 + func (g *DNSGate) RemoveBypass(domain string) { 66 + g.mu.Lock() 67 + delete(g.bypass, strings.ToLower(domain)) 68 + g.mu.Unlock() 69 + } 70 + 71 + // Check verifies DNS records for the domain. Returns nil if sending is allowed, 72 + // or an error describing why DNS verification failed. 73 + // 74 + // Sending is allowed if: 75 + // - the domain is in the bypass list, OR 76 + // - the domain was enrolled less than gracePeriod ago, OR 77 + // - SPF and DKIM records are present and correct 78 + // 79 + // DMARC failures produce a log warning but do not block sending. 80 + func (g *DNSGate) Check(ctx context.Context, domain string, dkimSelectors []string, enrolledAt time.Time) error { 81 + domainLower := strings.ToLower(domain) 82 + 83 + g.mu.RLock() 84 + bypassed := g.bypass[domainLower] 85 + g.mu.RUnlock() 86 + if bypassed { 87 + return nil 88 + } 89 + 90 + if time.Since(enrolledAt) < g.gracePeriod { 91 + return nil 92 + } 93 + 94 + result := g.lookup(ctx, domainLower, dkimSelectors) 95 + 96 + if !result.DMARC { 97 + log.Printf("dnsgate.warn: domain=%s reason=dmarc_missing", domain) 98 + } 99 + 100 + if result.CriticalPass() { 101 + return nil 102 + } 103 + 104 + var reasons []string 105 + for _, f := range result.Failures { 106 + fl := strings.ToLower(f) 107 + if strings.Contains(fl, "spf") || strings.Contains(fl, "dkim") { 108 + reasons = append(reasons, f) 109 + } 110 + } 111 + return fmt.Errorf("DNS verification failed for %s: %s", domain, strings.Join(reasons, "; ")) 112 + } 113 + 114 + func (g *DNSGate) lookup(ctx context.Context, domain string, dkimSelectors []string) dns.Result { 115 + g.mu.RLock() 116 + entry, ok := g.cache[domain] 117 + g.mu.RUnlock() 118 + 119 + if ok && time.Since(entry.checkedAt) < g.cacheTTL { 120 + return entry.result 121 + } 122 + 123 + result := g.verifier.Verify(ctx, domain, dkimSelectors) 124 + 125 + g.mu.Lock() 126 + g.cache[domain] = cacheEntry{result: result, checkedAt: time.Now()} 127 + g.mu.Unlock() 128 + 129 + return result 130 + }
+233
internal/relay/dnsgate_test.go
··· 1 + // SPDX-License-Identifier: AGPL-3.0-or-later 2 + 3 + package relay 4 + 5 + import ( 6 + "context" 7 + "net" 8 + "testing" 9 + "time" 10 + 11 + "atmosphere-mail/internal/dns" 12 + ) 13 + 14 + type mockDNSResolver struct { 15 + mx []*net.MX 16 + txt map[string][]string 17 + } 18 + 19 + func (m *mockDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { 20 + if m.mx == nil { 21 + return nil, &net.DNSError{Err: "no MX", Name: name, IsNotFound: true} 22 + } 23 + return m.mx, nil 24 + } 25 + 26 + func (m *mockDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { 27 + if records, ok := m.txt[name]; ok { 28 + return records, nil 29 + } 30 + return nil, &net.DNSError{Err: "no TXT", Name: name, IsNotFound: true} 31 + } 32 + 33 + func goodDNSResolver(domain, selector string) *mockDNSResolver { 34 + return &mockDNSResolver{ 35 + mx: []*net.MX{{Host: "mail." + domain, Pref: 10}}, 36 + txt: map[string][]string{ 37 + domain: {"v=spf1 include:_spf.atmos.email ~all"}, 38 + selector + "._domainkey." + domain: {"v=DKIM1; k=rsa; p=MIIBIjANBg..."}, 39 + "_dmarc." + domain: {"v=DMARC1; p=reject"}, 40 + }, 41 + } 42 + } 43 + 44 + func TestDNSGate_AllPass(t *testing.T) { 45 + r := goodDNSResolver("example.com", "default") 46 + gate := NewDNSGate(DNSGateConfig{ 47 + Verifier: dns.NewVerifier(r), 48 + }) 49 + 50 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 51 + if err != nil { 52 + t.Fatalf("expected pass, got: %v", err) 53 + } 54 + } 55 + 56 + func TestDNSGate_BlockMissingSPF(t *testing.T) { 57 + r := goodDNSResolver("example.com", "default") 58 + delete(r.txt, "example.com") 59 + 60 + gate := NewDNSGate(DNSGateConfig{ 61 + Verifier: dns.NewVerifier(r), 62 + }) 63 + 64 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 65 + if err == nil { 66 + t.Fatal("expected block for missing SPF") 67 + } 68 + if !contains(err.Error(), "SPF") { 69 + t.Errorf("error should mention SPF: %v", err) 70 + } 71 + } 72 + 73 + func TestDNSGate_BlockMissingDKIM(t *testing.T) { 74 + r := goodDNSResolver("example.com", "default") 75 + delete(r.txt, "default._domainkey.example.com") 76 + 77 + gate := NewDNSGate(DNSGateConfig{ 78 + Verifier: dns.NewVerifier(r), 79 + }) 80 + 81 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 82 + if err == nil { 83 + t.Fatal("expected block for missing DKIM") 84 + } 85 + if !contains(err.Error(), "DKIM") { 86 + t.Errorf("error should mention DKIM: %v", err) 87 + } 88 + } 89 + 90 + func TestDNSGate_DMARCWarnOnly(t *testing.T) { 91 + r := goodDNSResolver("example.com", "default") 92 + delete(r.txt, "_dmarc.example.com") 93 + 94 + gate := NewDNSGate(DNSGateConfig{ 95 + Verifier: dns.NewVerifier(r), 96 + }) 97 + 98 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 99 + if err != nil { 100 + t.Fatalf("DMARC failure should warn only, not block: %v", err) 101 + } 102 + } 103 + 104 + func TestDNSGate_GracePeriod(t *testing.T) { 105 + r := goodDNSResolver("example.com", "default") 106 + delete(r.txt, "example.com") 107 + delete(r.txt, "default._domainkey.example.com") 108 + 109 + gate := NewDNSGate(DNSGateConfig{ 110 + Verifier: dns.NewVerifier(r), 111 + GracePeriod: 72 * time.Hour, 112 + }) 113 + 114 + // Enrolled 1 hour ago — within grace period, should pass despite bad DNS 115 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-1*time.Hour)) 116 + if err != nil { 117 + t.Fatalf("should pass within grace period: %v", err) 118 + } 119 + 120 + // Enrolled 100 hours ago — outside grace period, should block 121 + err = gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 122 + if err == nil { 123 + t.Fatal("should block outside grace period with bad DNS") 124 + } 125 + } 126 + 127 + func TestDNSGate_Bypass(t *testing.T) { 128 + r := goodDNSResolver("example.com", "default") 129 + delete(r.txt, "example.com") 130 + delete(r.txt, "default._domainkey.example.com") 131 + 132 + gate := NewDNSGate(DNSGateConfig{ 133 + Verifier: dns.NewVerifier(r), 134 + }) 135 + 136 + // Without bypass, should block 137 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 138 + if err == nil { 139 + t.Fatal("should block without bypass") 140 + } 141 + 142 + // Add bypass 143 + gate.Bypass("example.com") 144 + 145 + err = gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 146 + if err != nil { 147 + t.Fatalf("should pass with bypass: %v", err) 148 + } 149 + 150 + // Remove bypass 151 + gate.RemoveBypass("example.com") 152 + 153 + err = gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 154 + if err == nil { 155 + t.Fatal("should block after bypass removed") 156 + } 157 + } 158 + 159 + func TestDNSGate_CacheHit(t *testing.T) { 160 + callCount := 0 161 + r := &mockDNSResolver{ 162 + mx: []*net.MX{{Host: "mail.example.com", Pref: 10}}, 163 + txt: map[string][]string{ 164 + "example.com": {"v=spf1 ~all"}, 165 + "default._domainkey.example.com": {"v=DKIM1; k=rsa; p=key"}, 166 + "_dmarc.example.com": {"v=DMARC1; p=reject"}, 167 + }, 168 + } 169 + 170 + countingResolver := &countingDNSResolver{inner: r, count: &callCount} 171 + 172 + gate := NewDNSGate(DNSGateConfig{ 173 + Verifier: dns.NewVerifier(countingResolver), 174 + CacheTTL: 1 * time.Hour, 175 + }) 176 + 177 + enrolled := time.Now().Add(-100 * time.Hour) 178 + 179 + // First call — should hit DNS 180 + gate.Check(context.Background(), "example.com", []string{"default"}, enrolled) 181 + firstCount := callCount 182 + 183 + // Second call — should hit cache 184 + gate.Check(context.Background(), "example.com", []string{"default"}, enrolled) 185 + 186 + if callCount != firstCount { 187 + t.Errorf("expected cache hit on second call, but DNS was queried again (calls: %d → %d)", firstCount, callCount) 188 + } 189 + } 190 + 191 + func TestDNSGate_BypassCaseInsensitive(t *testing.T) { 192 + r := goodDNSResolver("Example.COM", "default") 193 + delete(r.txt, "Example.COM") 194 + 195 + gate := NewDNSGate(DNSGateConfig{ 196 + Verifier: dns.NewVerifier(r), 197 + }) 198 + 199 + gate.Bypass("EXAMPLE.com") 200 + 201 + err := gate.Check(context.Background(), "example.com", []string{"default"}, time.Now().Add(-100*time.Hour)) 202 + if err != nil { 203 + t.Fatalf("bypass should be case-insensitive: %v", err) 204 + } 205 + } 206 + 207 + type countingDNSResolver struct { 208 + inner dns.Resolver 209 + count *int 210 + } 211 + 212 + func (c *countingDNSResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { 213 + *c.count++ 214 + return c.inner.LookupMX(ctx, name) 215 + } 216 + 217 + func (c *countingDNSResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { 218 + *c.count++ 219 + return c.inner.LookupTXT(ctx, name) 220 + } 221 + 222 + func contains(s, substr string) bool { 223 + return len(s) >= len(substr) && containsStr(s, substr) 224 + } 225 + 226 + func containsStr(s, substr string) bool { 227 + for i := 0; i <= len(s)-len(substr); i++ { 228 + if s[i:i+len(substr)] == substr { 229 + return true 230 + } 231 + } 232 + return false 233 + }
+25 -2
internal/relay/smtp.go
··· 54 54 APIKeyHash []byte 55 55 DKIMKeys *DKIMKeys 56 56 DKIMSelector string 57 + CreatedAt time.Time 57 58 } 58 59 59 60 // AuthMember is the resolved member for a specific domain in this SMTP session. ··· 84 85 sendCheck SendCheckFunc 85 86 onAccept OnAcceptFunc 86 87 domain string 87 - metrics *Metrics // optional — nil-safe 88 + metrics *Metrics // optional — nil-safe 89 + dnsGate *DNSGate // optional — nil-safe 88 90 } 89 91 90 92 // SetMetrics attaches Prometheus metrics to the SMTP server. Nil-safe. 91 93 func (s *SMTPServer) SetMetrics(m *Metrics) { 92 94 s.metrics = m 95 + } 96 + 97 + // SetDNSGate attaches a DNS verification gate to the SMTP server. Nil-safe. 98 + func (s *SMTPServer) SetDNSGate(g *DNSGate) { 99 + s.dnsGate = g 93 100 } 94 101 95 102 // NewSMTPServer creates a new SMTP submission server. ··· 225 232 } 226 233 } 227 234 228 - if mwd.Status == relaystore.StatusSuspended { 235 + if s.server.dnsGate != nil { 236 + var selectors []string 237 + if matched.DKIMSelector != "" { 238 + selectors = append(selectors, matched.DKIMSelector) 239 + } 240 + if err := s.server.dnsGate.Check(context.Background(), matched.Domain, selectors, matched.CreatedAt); err != nil { 241 + log.Printf("smtp.auth: did=%q domain=%s ip=%q success=false failure_reason=dns_verification error=%v", username, matched.Domain, s.conn.Hostname(), err) 242 + authFail() 243 + return &smtp.SMTPError{ 244 + Code: 451, 245 + EnhancedCode: smtp.EnhancedCode{4, 7, 0}, 246 + Message: "DNS verification failed — configure SPF and DKIM records for " + matched.Domain + " and retry", 247 + } 248 + } 249 + } 250 + 251 + if mwd.Status == relaystore.StatusSuspended { 229 252 log.Printf("smtp.auth: did=%q ip=%q success=false failure_reason=suspended", username, s.conn.Hostname()) 230 253 authFail() 231 254 return &smtp.SMTPError{