diff --git a/plugins/emailcheck.py b/plugins/emailcheck.py index ae0c23b..d007470 100644 --- a/plugins/emailcheck.py +++ b/plugins/emailcheck.py @@ -3,67 +3,77 @@ from __future__ import annotations import asyncio +import json import logging import re -import socket +import urllib.request -from derp.dns import QTYPES, build_query, parse_response from derp.http import create_connection +from derp.http import urlopen as _urlopen from derp.plugin import command log = logging.getLogger(__name__) _MAX_BATCH = 5 _SMTP_TIMEOUT = 15 +_DOH_PROVIDERS = [ + "https://dns.google/resolve", + "https://cloudflare-dns.com/dns-query", + "https://dns.quad9.net:5053/dns-query", +] +_DOH_ATTEMPTS = 5 _EMAIL_RE = re.compile(r"^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$") -# Tor DNS resolver for MX lookups -_DNS_ADDR = "10.200.1.13" -_DNS_PORT = 9053 + +def _doh_query(name: str, qtype: str) -> list[dict]: + """Query DNS-over-HTTPS via SOCKS5 proxy with provider rotation. + + Retries across multiple DoH providers to work around unreliable + Tor exit nodes that drop or MITM HTTPS connections. + """ + last_err = None + for attempt in range(_DOH_ATTEMPTS): + provider = _DOH_PROVIDERS[attempt % len(_DOH_PROVIDERS)] + url = f"{provider}?name={name}&type={qtype}" + req = urllib.request.Request(url, headers={ + "Accept": "application/dns-json", + "User-Agent": "derp-bot", + }) + try: + with _urlopen(req, timeout=10) as resp: + data = json.loads(resp.read()) + return data.get("Answer", []) + except Exception as exc: + last_err = exc + log.debug("emailcheck: DoH attempt %d/%d failed (%s): %s", + attempt + 1, _DOH_ATTEMPTS, provider, exc) + raise last_err # type: ignore[misc] def _resolve_mx(domain: str) -> list[str]: - """Resolve MX records for a domain via Tor DNS. Returns list of MX hosts.""" - query = build_query(domain, QTYPES["MX"]) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(5) - try: - sock.sendto(query, (_DNS_ADDR, _DNS_PORT)) - data, _ = sock.recvfrom(4096) - finally: - sock.close() - - rcode, results = parse_response(data) - if rcode != 0 or not results: + """Resolve MX records via DNS-over-HTTPS. Returns list of MX hosts.""" + answers = _doh_query(domain, "MX") + if not answers: return [] - # MX results are "priority host" format - mx_hosts = [] - for r in results: - parts = r.split(None, 1) + # DoH MX data is "priority host." format + mx_entries = [] + for ans in answers: + val = ans.get("data", "") + parts = val.split(None, 1) if len(parts) == 2: - mx_hosts.append(parts[1].rstrip(".")) + mx_entries.append((int(parts[0]), parts[1].rstrip("."))) else: - mx_hosts.append(r.rstrip(".")) - - # Sort by priority (already parsed as "prio host") - return mx_hosts + mx_entries.append((0, val.rstrip("."))) + mx_entries.sort(key=lambda x: x[0]) + return [host for _, host in mx_entries] def _resolve_a(domain: str) -> str | None: - """Resolve A record for a domain as fallback. Returns first IP or None.""" - query = build_query(domain, QTYPES["A"]) - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.settimeout(5) - try: - sock.sendto(query, (_DNS_ADDR, _DNS_PORT)) - data, _ = sock.recvfrom(4096) - finally: - sock.close() - - rcode, results = parse_response(data) - if rcode == 0 and results: - return results[0] + """Resolve A record via DNS-over-HTTPS as fallback.""" + answers = _doh_query(domain, "A") + if answers: + return answers[0].get("data", "") return None