File size: 8,373 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
"""DNS/MX worker over DNS-over-HTTPS (DoH) — free public resolvers.

Architecture spec §3.2 / §7.3:

- MX resolution runs on Google Public DNS and Cloudflare DNS over HTTPS.
  Both are free, unlimited and need no authentication; HTTPS/443 egress is
  never blocked (unlike UDP/53 on locked-down PaaS hosts).
- The SAME query goes to BOTH resolvers. Divergent MX sets flag the domain as
  `dns_inconsistent` — a deterministic risk signal (regional DNS hijack /
  misconfiguration), requiring no inference.
- Results are cached at the domain level: 24h TTL for MX answers, 1h for
  negative (null-MX / NXDOMAIN) answers per the spec.
- Punycode normalization happens here (§7.1) so Unicode domains resolve.

The legacy dnspython path in dns_check.py remains the fallback: any DoH
failure (network, JSON, schema) degrades to it transparently. The DoH layer
can also be disabled entirely with DOH_ENABLED=false.
"""
from __future__ import annotations

import asyncio
import json
from typing import Any, Optional

import httpx

from ..config import settings
from .i18n import to_punycode

GOOGLE_DOH = "https://dns.google/resolve"
CLOUDFLARE_DOH = "https://cloudflare-dns.com/dns-query"

MX_TTL_SECONDS = 24 * 3600          # spec: 24h for MX records
NEGATIVE_TTL_SECONDS = 1 * 3600     # spec: 1h for null-MX negatives

_TIMEOUT_LABEL = "timeout"


async def _doh_query(
    client: httpx.AsyncClient,
    endpoint: str,
    name: str,
    rtype: str,
    extra_params: dict | None = None,
    accept: str = "application/dns-json",
) -> dict:
    """One DoH JSON query (RFC 8484-style JSON APIs used by both providers)."""
    params = {"name": name, "type": rtype}
    if extra_params:
        params.update(extra_params)
    resp = await client.get(endpoint, params=params, headers={"accept": accept})
    resp.raise_for_status()
    data = resp.json()
    if not isinstance(data, dict):
        raise ValueError("doh: non-object response")
    return data


def _parse_mx(data: dict) -> list[dict]:
    """Extract MX records (sorted by priority) from a DoH answer."""
    records: list[dict] = []
    for ans in data.get("Answer") or []:
        # RFC 1035 type 15 = MX
        if ans.get("type") != 15:
            continue
        parts = str(ans.get("data", "")).split()
        if len(parts) >= 2:
            try:
                priority = int(parts[0])
            except ValueError:
                priority = 0
            host = " ".join(parts[1:]).rstrip(".").strip().lower()
            if host:
                records.append({"host": host, "priority": priority})
    records.sort(key=lambda r: (r["priority"], r["host"]))
    return records


def _parse_a_like(data: dict, rtypes: set[int]) -> bool:
    """True when the answer holds at least one A (1) / AAAA (28) record."""
    for ans in data.get("Answer") or []:
        if ans.get("type") in rtypes:
            return True
    return False


def _mx_sets_match(a: list[dict], b: list[dict]) -> bool:
    """Set-equality on (priority, host) — order-insensitive, tolerant."""
    norm = lambda rs: {(r["host"], r["priority"]) for r in rs}
    return norm(a) == norm(b)


async def resolve_mx_dual(
    domain: str, timeout: Optional[float] = None
) -> dict[str, Any]:
    """Query BOTH public resolvers for MX and compare.

    Returns:
        {
          "status": "ok" | "nxdomain" | "no_mail" | "error",
          "mx_records": [...],          # primary (Google) answer
          "consistent": bool | None,    # None when the second resolver failed
          "a_fallback": bool,
          "error": str | None,
        }
    """
    domain = to_punycode((domain or "").strip().lower().rstrip("."))
    timeout = timeout if timeout is not None else settings.doh_timeout

    async def _query_both() -> tuple[dict | None, dict | None, Exception | None]:
        async with httpx.AsyncClient(timeout=timeout) as client:
            google_task = _doh_query(client, GOOGLE_DOH, domain, "MX")
            cf_task = _doh_query(
                client, CLOUDFLARE_DOH, domain, "MX", extra_params={"ct": "application/dns-json"}
            )
            g_res = await asyncio.gather(google_task, cf_task, return_exceptions=True)
        g, c = g_res
        g_err = g if isinstance(g, Exception) else None
        c_err = c if isinstance(c, Exception) else None
        g_data = g if isinstance(g, dict) else None
        c_data = c if isinstance(c, dict) else None
        # If the PRIMARY (Google) failed but Cloudflare answered, swap so the
        # pipeline still gets an authoritative answer from a public resolver.
        if g_data is None and c_data is not None:
            g_data, c_data = c_data, None
        return g_data, c_data, g_err or c_err

    try:
        g_data, c_data, err = await _query_both()
    except Exception as exc:  # httpx transport failure etc.
        return {"status": "error", "mx_records": [], "consistent": None,
                "a_fallback": False, "error": f"doh transport: {exc}"}

    if g_data is None:
        return {"status": "error", "mx_records": [], "consistent": None,
                "a_fallback": False, "error": f"doh unavailable: {err}"}

    g_status = g_data.get("Status", 0)
    if g_status == 3:  # NXDOMAIN
        return {"status": "nxdomain", "mx_records": [], "consistent": None,
                "a_fallback": False, "error": "Domain does not exist (NXDOMAIN)"}
    if g_status not in (0, 3):
        return {"status": "error", "mx_records": [], "consistent": None,
                "a_fallback": False, "error": f"doh resolver status {g_status}"}

    mx = _parse_mx(g_data)
    if mx:
        consistent = (
            _mx_sets_match(mx, _parse_mx(c_data))
            if c_data is not None and c_data.get("Status", 0) == 0
            else None
        )
        return {"status": "ok", "mx_records": mx, "consistent": consistent,
                "a_fallback": False, "error": None}

    # No MX → A/AAAA fallback per RFC 5321 §5.1
    try:
        async with httpx.AsyncClient(timeout=timeout) as client:
            a_task = _doh_query(client, GOOGLE_DOH, domain, "A")
            aaaa_task = _doh_query(client, GOOGLE_DOH, domain, "AAAA")
            a_res = await asyncio.gather(a_task, aaaa_task, return_exceptions=True)
        has_addr = any(
            isinstance(d, dict) and _parse_a_like(d, {1, 28}) for d in a_res
        )
    except Exception:
        has_addr = False

    if has_addr:
        return {"status": "ok", "a_fallback": True,
                "mx_records": [{"host": domain, "priority": 0}],
                "consistent": None, "error": None}
    return {"status": "no_mail", "mx_records": [], "consistent": None,
            "a_fallback": False, "error": "Domain has no MX, A, or AAAA records"}


# ---- domain-level TTL cache (shared-cache backed, memory tier always works) --

async def _cache_get(key: str) -> dict | None:
    try:
        from ..utils.cache import get_cache
        raw = await get_cache(key)
        if not raw:
            return None
        data = json.loads(raw)
        return data if isinstance(data, dict) else None
    except Exception:
        return None  # cache outage → plain re-resolve


async def _cache_set(key: str, payload: dict, ttl: int) -> None:
    try:
        from ..utils.cache import set_cache
        await set_cache(key, json.dumps(payload), ttl)
    except Exception:
        pass  # a cache write failure must never break resolution


def _mx_cache_key(domain: str) -> str:
    return f"doh:mx:{to_punycode(domain.strip().lower())}"


async def resolve_mx_dual_cached(domain: str, timeout: Optional[float] = None) -> dict:
    """`resolve_mx_dual` with a domain-level TTL cache.

    Definitive answers cache for 24h; negative answers (NXDOMAIN / no mail)
    for 1h per the spec. Errors are never cached.
    """
    domain = (domain or "").strip().lower()
    key = _mx_cache_key(domain)
    cached = await _cache_get(key)
    if cached is not None:
        cached = {**cached, "cache_hit": True}
        return cached

    result = await resolve_mx_dual(domain, timeout)
    if result.get("status") == "ok":
        await _cache_set(key, {**result, "cache_hit": False}, MX_TTL_SECONDS)
    elif result.get("status") in {"nxdomain", "no_mail"}:
        await _cache_set(key, {**result, "cache_hit": False}, NEGATIVE_TTL_SECONDS)
    return result