sitcod3 commited on
Commit
0d4d4aa
·
verified ·
1 Parent(s): d82b7e3

Upload folder using huggingface_hub

Browse files
app/config.py CHANGED
@@ -59,6 +59,10 @@ class Settings(BaseSettings):
59
  # Validation
60
  smtp_timeout: int = 10
61
  dns_timeout: int = 5
 
 
 
 
62
  max_bulk_emails: int = 10000
63
  rate_limit_per_minute: int = 60
64
  bulk_sync_chunk_size: int = 50 # Process this many emails at a time in sync mode
 
59
  # Validation
60
  smtp_timeout: int = 10
61
  dns_timeout: int = 5
62
+ # Public resolvers used instead of the host's own resolver config. A stale
63
+ # gateway list (dead hotspot IPs) makes every lookup burn the full timeout
64
+ # and fail — see dns_check.build_resolver.
65
+ dns_nameservers: list[str] = ["1.1.1.1", "8.8.8.8"]
66
  max_bulk_emails: int = 10000
67
  rate_limit_per_minute: int = 60
68
  bulk_sync_chunk_size: int = 50 # Process this many emails at a time in sync mode
app/validator/dns_check.py CHANGED
@@ -1,19 +1,41 @@
 
 
1
  import dns.resolver
2
  import dns.asyncresolver
3
  import dns.exception
 
4
  from ..models import DNSResult
5
 
6
  DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'dkim', 'k1']
7
 
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  async def check_dns(domain: str, timeout: int = 5) -> DNSResult:
10
  """
11
  Layer 2 & 3: Async DNS + MX Record validation.
12
- Also checks SPF, DMARC, DKIM authentication records.
 
 
 
 
13
  """
14
- resolver = dns.asyncresolver.Resolver()
15
- resolver.timeout = timeout
16
- resolver.lifetime = timeout
17
 
18
  # ---- MX Records --------------------------------------------------
19
  mx_records = []
@@ -97,59 +119,66 @@ async def check_dns(domain: str, timeout: int = 5) -> DNSResult:
97
  error_code="no_mail_records",
98
  )
99
 
100
- # ---- SPF Record ---------------------------------------------------
101
- has_spf = False
102
- spf_record = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  try:
104
  txt_answers = await resolver.resolve(domain, 'TXT')
105
  for record in txt_answers:
106
  txt = b''.join(record.strings).decode('utf-8', errors='replace')
107
  if txt.startswith('v=spf1'):
108
- has_spf = True
109
- spf_record = txt
110
- break
111
  except Exception:
112
  pass
 
113
 
114
- # ---- DMARC Record -------------------------------------------------
115
- has_dmarc = False
116
- dmarc_record = None
117
  try:
118
- dmarc_domain = f"_dmarc.{domain}"
119
- dmarc_answers = await resolver.resolve(dmarc_domain, 'TXT')
120
- for record in dmarc_answers:
121
  txt = b''.join(record.strings).decode('utf-8', errors='replace')
122
  if 'DMARC1' in txt:
123
- has_dmarc = True
124
- dmarc_record = txt
125
- break
126
  except Exception:
127
  pass
 
 
 
 
 
128
 
129
- # ---- DKIM Record --------------------------------------------------
130
- has_dkim = False
131
- for selector in DKIM_SELECTORS:
132
  try:
133
- dkim_domain = f"{selector}._domainkey.{domain}"
134
- dkim_answers = await resolver.resolve(dkim_domain, 'TXT')
135
- for record in dkim_answers:
136
  txt = b''.join(record.strings).decode('utf-8', errors='replace')
137
  if 'DKIM1' in txt or 'k=rsa' in txt or 'p=' in txt:
138
- has_dkim = True
139
- break
140
- if has_dkim:
141
- break
142
  except Exception:
143
- continue
 
144
 
145
- return DNSResult(
146
- domain_exists=domain_exists,
147
- has_mx_records=has_mx,
148
- mx_records=mx_records,
149
- a_fallback=a_fallback,
150
- has_spf=has_spf,
151
- spf_record=spf_record,
152
- has_dmarc=has_dmarc,
153
- dmarc_record=dmarc_record,
154
- has_dkim=has_dkim
155
- )
 
1
+ import asyncio
2
+
3
  import dns.resolver
4
  import dns.asyncresolver
5
  import dns.exception
6
+ from ..config import settings
7
  from ..models import DNSResult
8
 
9
  DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'dkim', 'k1']
10
 
11
 
12
+ def build_resolver(timeout: float) -> dns.asyncresolver.Resolver:
13
+ """Resolver that never trusts a broken system resolver config.
14
+
15
+ Diagnosed live: the host's resolver list held three stale gateway IPs
16
+ (192.168.3.1 / 192.168.1.1 / 172.20.10.1) from old networks. dnspython tried
17
+ each in turn, each timing out, so EVERY lookup burned the full lifetime and
18
+ returned a timeout — even for domains whose MX answered in 56 ms via a
19
+ public resolver. That produced 42s requests and mass false-invalid verdicts.
20
+ """
21
+ resolver = dns.asyncresolver.Resolver(configure=not settings.dns_nameservers)
22
+ if settings.dns_nameservers:
23
+ resolver.nameservers = list(settings.dns_nameservers)
24
+ resolver.timeout = timeout
25
+ resolver.lifetime = timeout
26
+ return resolver
27
+
28
+
29
  async def check_dns(domain: str, timeout: int = 5) -> DNSResult:
30
  """
31
  Layer 2 & 3: Async DNS + MX Record validation.
32
+ Also checks SPF, DMARC and DKIM authentication records.
33
+
34
+ The authentication lookups are independent of one another and are issued
35
+ concurrently: the DKIM selector list alone is six sequential lookups, which
36
+ multiplied every request's latency when run one after another.
37
  """
38
+ resolver = build_resolver(timeout)
 
 
39
 
40
  # ---- MX Records --------------------------------------------------
41
  mx_records = []
 
119
  error_code="no_mail_records",
120
  )
121
 
122
+ # ---- SPF / DMARC / DKIM (independent of each other -> concurrent) ----
123
+ has_spf, spf_record = await _lookup_spf(resolver, domain)
124
+ (has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather(
125
+ _lookup_dmarc(resolver, domain),
126
+ _lookup_dkim(resolver, domain),
127
+ )
128
+
129
+ return DNSResult(
130
+ domain_exists=domain_exists,
131
+ has_mx_records=has_mx,
132
+ mx_records=mx_records,
133
+ a_fallback=a_fallback,
134
+ has_spf=has_spf,
135
+ spf_record=spf_record,
136
+ has_dmarc=has_dmarc,
137
+ dmarc_record=dmarc_record,
138
+ has_dkim=has_dkim
139
+ )
140
+
141
+
142
+ async def _lookup_spf(resolver, domain: str) -> tuple[bool, str | None]:
143
  try:
144
  txt_answers = await resolver.resolve(domain, 'TXT')
145
  for record in txt_answers:
146
  txt = b''.join(record.strings).decode('utf-8', errors='replace')
147
  if txt.startswith('v=spf1'):
148
+ return True, txt
 
 
149
  except Exception:
150
  pass
151
+ return False, None
152
 
153
+
154
+ async def _lookup_dmarc(resolver, domain: str) -> tuple[bool, str | None]:
 
155
  try:
156
+ answers = await resolver.resolve(f"_dmarc.{domain}", 'TXT')
157
+ for record in answers:
 
158
  txt = b''.join(record.strings).decode('utf-8', errors='replace')
159
  if 'DMARC1' in txt:
160
+ return True, txt
 
 
161
  except Exception:
162
  pass
163
+ return False, None
164
+
165
+
166
+ async def _lookup_dkim(resolver, domain: str) -> tuple[bool, str | None]:
167
+ """Probe the known selectors concurrently, first hit wins."""
168
 
169
+ async def probe(selector: str):
 
 
170
  try:
171
+ answers = await resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT')
172
+ for record in answers:
 
173
  txt = b''.join(record.strings).decode('utf-8', errors='replace')
174
  if 'DKIM1' in txt or 'k=rsa' in txt or 'p=' in txt:
175
+ return txt
 
 
 
176
  except Exception:
177
+ return None
178
+ return None
179
 
180
+ results = await asyncio.gather(*(probe(s) for s in DKIM_SELECTORS))
181
+ for txt in results:
182
+ if txt:
183
+ return True, txt
184
+ return False, None
 
 
 
 
 
 
tests/test_dns_concurrency.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ import unittest
4
+ from unittest.mock import patch
5
+
6
+ from app.validator import dns_check
7
+
8
+
9
+ class FakeAnswer:
10
+ def __init__(self, text: str):
11
+ self.strings = [text.encode()]
12
+
13
+
14
+ class SlowResolver:
15
+ """Every query takes 0.1s — 8 sequential queries cost ~0.8s."""
16
+
17
+ timeout = 5
18
+ lifetime = 5
19
+
20
+ async def resolve(self, domain, rtype):
21
+ await asyncio.sleep(0.1)
22
+ if rtype == "MX":
23
+ return [type("MX", (), {"exchange": "mx.example.test.", "preference": 10})()]
24
+ if rtype == "TXT" and domain.startswith("_dmarc."):
25
+ return [FakeAnswer("v=DMARC1; p=none")]
26
+ if rtype == "TXT" and domain.startswith("default._domainkey."):
27
+ return [FakeAnswer("v=DKIM1; k=rsa; p=abc")]
28
+ if rtype == "TXT":
29
+ return [FakeAnswer("v=spf1 include:example.test -all")]
30
+ raise dns_check.dns.resolver.NoAnswer()
31
+
32
+
33
+ class DnsAuthConcurrencyTests(unittest.IsolatedAsyncioTestCase):
34
+ """SPF / DMARC / DKIM are independent DNS queries and must not run serially.
35
+
36
+ Measured live: one request took 42.4s while the same address took 2.45s
37
+ once caches were warm — the shape of ~8 sequential queries each paying a
38
+ 5s timeout. The DKIM selector list alone is 6 sequential lookups.
39
+ """
40
+
41
+ async def test_auth_record_lookups_run_concurrently(self):
42
+ with patch.object(dns_check.dns.asyncresolver, "Resolver",
43
+ return_value=SlowResolver()):
44
+ t0 = time.perf_counter()
45
+ result = await dns_check.check_dns("example.test")
46
+ elapsed = time.perf_counter() - t0
47
+
48
+ self.assertTrue(result.has_mx_records)
49
+ self.assertTrue(result.has_spf)
50
+ self.assertTrue(result.has_dmarc)
51
+ self.assertTrue(result.has_dkim)
52
+ # Sequential = 1 (MX) + 1 (SPF) + 1 (DMARC) + 6 (DKIM) = 0.9s.
53
+ # Concurrent should be far below that.
54
+ self.assertLess(
55
+ elapsed, 0.5,
56
+ f"auth lookups ran sequentially ({elapsed:.2f}s for 9 queries)",
57
+ )
58
+
59
+ async def test_still_returns_a_result_when_auth_records_are_absent(self):
60
+ class Bare(SlowResolver):
61
+ async def resolve(self, domain, rtype):
62
+ await asyncio.sleep(0.01)
63
+ if rtype == "MX":
64
+ return [type("MX", (), {"exchange": "mx.example.test.",
65
+ "preference": 10})()]
66
+ raise dns_check.dns.resolver.NoAnswer()
67
+
68
+ with patch.object(dns_check.dns.asyncresolver, "Resolver", return_value=Bare()):
69
+ result = await dns_check.check_dns("example.test")
70
+
71
+ self.assertTrue(result.has_mx_records)
72
+ self.assertFalse(result.has_spf)
73
+ self.assertFalse(result.has_dmarc)
74
+ self.assertFalse(result.has_dkim) # absence is a verdict, not an error
75
+
76
+
77
+ if __name__ == "__main__":
78
+ unittest.main()