File size: 9,030 Bytes
de745bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import asyncio

import dns.resolver
import dns.asyncresolver
import dns.exception
from ..config import settings
from ..models import DNSResult
from .doh_resolver import resolve_mx_dual_cached
from .i18n import to_punycode

DKIM_SELECTORS = ['default', 'google', 'selector1', 'selector2', 'dkim', 'k1']


def build_resolver(timeout: float) -> dns.asyncresolver.Resolver:
    """Resolver that never trusts a broken system resolver config.

    Diagnosed live: the host's resolver list held three stale gateway IPs
    (192.168.3.1 / 192.168.1.1 / 172.20.10.1) from old networks. dnspython tried
    each in turn, each timing out, so EVERY lookup burned the full lifetime and
    returned a timeout — even for domains whose MX answered in 56 ms via a
    public resolver. That produced 42s requests and mass false-invalid verdicts.
    """
    resolver = dns.asyncresolver.Resolver(configure=not settings.dns_nameservers)
    if settings.dns_nameservers:
        resolver.nameservers = list(settings.dns_nameservers)
    resolver.timeout = timeout
    resolver.lifetime = timeout
    return resolver


async def check_dns(domain: str, timeout: int = 5) -> DNSResult:
    """
    Layer 2 & 3: Async DNS + MX Record validation.
    Also checks SPF, DMARC and DKIM authentication records.

    The MX gate runs first over DoH (free public resolvers, dual-query with a
    consistency check — see doh_resolver.py); the legacy dnspython path is the
    automatic fallback when DoH is disabled or unreachable. SPF/DMARC/DKIM are
    informational TXT lookups and stay on the dnspython path.

    The authentication lookups are independent of one another and are issued
    concurrently: the DKIM selector list alone is six sequential lookups, which
    multiplied every request's latency when run one after another.
    """
    # ---- MX gate over DoH (spec §3.2), legacy path as fallback -------------
    if settings.doh_enabled:
        doh = await resolve_mx_dual_cached(domain, timeout)
        status = doh.get("status")
        if status in {"ok", "nxdomain", "no_mail"}:
            if status == "nxdomain":
                return DNSResult(
                    domain_exists=False,
                    has_mx_records=False,
                    error=doh.get("error") or "Domain does not exist (NXDOMAIN)",
                    error_code="nxdomain",
                )
            if status == "no_mail":
                return DNSResult(
                    domain_exists=True,
                    has_mx_records=False,
                    error=doh.get("error") or "Domain has no MX, A, or AAAA records",
                    error_code="no_mail_records",
                )
            # status == "ok": finish the record with the informational TXT layer.
            resolver = build_resolver(timeout)
            ascii_domain = to_punycode(domain)
            has_spf, spf_record = await _lookup_spf(resolver, ascii_domain)
            (has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather(
                _lookup_dmarc(resolver, ascii_domain),
                _lookup_dkim(resolver, ascii_domain),
            )
            return DNSResult(
                domain_exists=True,
                has_mx_records=bool(doh.get("mx_records")),
                mx_records=doh.get("mx_records") or [],
                a_fallback=bool(doh.get("a_fallback")),
                has_spf=has_spf,
                spf_record=spf_record,
                has_dmarc=has_dmarc,
                dmarc_record=dmarc_record,
                has_dkim=has_dkim,
                dns_inconsistent=doh.get("consistent") is False,
                mx_source="doh:dual" if doh.get("consistent") is not None else "doh:primary",
            )
        # DoH errored → fall through to the legacy resolver path.

    return await _check_dns_legacy(domain, timeout)


async def _check_dns_legacy(domain: str, timeout: int = 5) -> DNSResult:
    """Legacy dnspython path (UDP/53 against configured public resolvers)."""
    resolver = build_resolver(timeout)

    # ---- MX Records --------------------------------------------------
    mx_records = []
    domain_exists = False
    has_mx = False
    a_fallback = False

    try:
        mx_answers = await resolver.resolve(domain, 'MX')
        has_mx = True
        domain_exists = True
        for r in sorted(mx_answers, key=lambda x: x.preference):
            mx_records.append({
                "host": str(r.exchange).rstrip('.'),
                "priority": r.preference
            })
    except dns.resolver.NXDOMAIN:
        return DNSResult(
            domain_exists=False,
            has_mx_records=False,
            error="Domain does not exist (NXDOMAIN)",
            error_code="nxdomain",
        )
    except dns.resolver.NoAnswer:
        # No MX -> try A/AAAA fallback per RFC
        try:
            for record_type in ('A', 'AAAA'):
                try:
                    await resolver.resolve(domain, record_type)
                    domain_exists = True
                    a_fallback = True
                    mx_records.append({"host": domain, "priority": 0})
                    break
                except dns.resolver.NoAnswer:
                    continue
        except dns.resolver.NXDOMAIN:
            return DNSResult(
                domain_exists=False,
                has_mx_records=False,
                error="Domain does not exist (NXDOMAIN)",
                error_code="nxdomain",
            )
        except dns.exception.Timeout:
            return DNSResult(
                domain_exists=False,
                has_mx_records=False,
                error="DNS address lookup timed out",
                error_code="timeout",
            )
        except dns.exception.DNSException as e:
            return DNSResult(
                domain_exists=False,
                has_mx_records=False,
                error=f"DNS address lookup failed: {e.__class__.__name__}",
                error_code="resolver_error",
            )
        if not domain_exists:
            # An MX NODATA response confirms the owner name exists, even when
            # it has no address record to which SMTP can be routed.
            domain_exists = True
    except dns.exception.Timeout:
        return DNSResult(
            domain_exists=False,
            has_mx_records=False,
            error="DNS timeout",
            error_code="timeout",
        )
    except dns.exception.DNSException as e:
        return DNSResult(
            domain_exists=False,
            has_mx_records=False,
            error=f"DNS lookup failed: {e.__class__.__name__}",
            error_code="resolver_error",
        )

    if not has_mx and not a_fallback:
        return DNSResult(
            domain_exists=True,
            has_mx_records=False,
            error="Domain has no MX, A, or AAAA records",
            error_code="no_mail_records",
        )

    # ---- SPF / DMARC / DKIM (independent of each other -> concurrent) ----
    has_spf, spf_record = await _lookup_spf(resolver, domain)
    (has_dmarc, dmarc_record), (has_dkim, _) = await asyncio.gather(
        _lookup_dmarc(resolver, domain),
        _lookup_dkim(resolver, domain),
    )

    return DNSResult(
        domain_exists=domain_exists,
        has_mx_records=has_mx,
        mx_records=mx_records,
        a_fallback=a_fallback,
        has_spf=has_spf,
        spf_record=spf_record,
        has_dmarc=has_dmarc,
        dmarc_record=dmarc_record,
        has_dkim=has_dkim
    )


async def _lookup_spf(resolver, domain: str) -> tuple[bool, str | None]:
    try:
        txt_answers = await resolver.resolve(domain, 'TXT')
        for record in txt_answers:
            txt = b''.join(record.strings).decode('utf-8', errors='replace')
            if txt.startswith('v=spf1'):
                return True, txt
    except Exception:
        pass
    return False, None


async def _lookup_dmarc(resolver, domain: str) -> tuple[bool, str | None]:
    try:
        answers = await resolver.resolve(f"_dmarc.{domain}", 'TXT')
        for record in answers:
            txt = b''.join(record.strings).decode('utf-8', errors='replace')
            if 'DMARC1' in txt:
                return True, txt
    except Exception:
        pass
    return False, None


async def _lookup_dkim(resolver, domain: str) -> tuple[bool, str | None]:
    """Probe the known selectors concurrently, first hit wins."""

    async def probe(selector: str):
        try:
            answers = await resolver.resolve(f"{selector}._domainkey.{domain}", 'TXT')
            for record in answers:
                txt = b''.join(record.strings).decode('utf-8', errors='replace')
                if 'DKIM1' in txt or 'k=rsa' in txt or 'p=' in txt:
                    return txt
        except Exception:
            return None
        return None

    results = await asyncio.gather(*(probe(s) for s in DKIM_SELECTORS))
    for txt in results:
        if txt:
            return True, txt
    return False, None