fix: use DNS-over-HTTPS with provider rotation for emailcheck

Tor exit nodes poison plain DNS on port 53 and MITM some HTTPS
connections. Replace raw TCP DNS with DoH (Google, Cloudflare, Quad9)
and retry up to 5 times across providers to find a clean exit node.
MX results are now sorted by priority.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-16 22:40:43 +01:00
parent e8d803abe6
commit 35acc744ac

View File

@@ -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