Use dnspython for domain TXT verification

This commit is contained in:
A R R R Associates
2026-07-09 11:08:58 +05:30
parent 94aa8292e2
commit b7c772f6e1
2 changed files with 35 additions and 52 deletions
+35 -52
View File
@@ -4,7 +4,6 @@ import re
import secrets import secrets
import socket import socket
import ssl import ssl
import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
@@ -598,64 +597,53 @@ def _normalise_txt_value(value: str) -> str:
def _resolve_txt_with_dnspython(txt_name: str) -> tuple[list[str], str | None]: def _resolve_txt_with_dnspython(txt_name: str) -> tuple[list[str], str | None]:
"""Resolve TXT records using the Python DNS resolver.
Production containers should not depend on OS tools such as nslookup/dig.
The package dependency is declared in requirements.txt as dnspython.
"""
try: try:
import dns.exception # type: ignore
import dns.resolver # type: ignore import dns.resolver # type: ignore
except Exception: except Exception as exc:
return [], "dnspython is not installed" return [], f"dnspython package is not installed or not importable: {exc}"
resolver = dns.resolver.Resolver(configure=True)
resolver.lifetime = 12.0
resolver.timeout = 4.0
try: try:
answers = dns.resolver.resolve(txt_name, "TXT") answers = resolver.resolve(txt_name, "TXT")
except dns.resolver.NXDOMAIN:
return [], f"TXT name does not exist: {txt_name}"
except dns.resolver.NoAnswer:
return [], f"No TXT records found for: {txt_name}"
except dns.resolver.NoNameservers as exc:
return [], f"DNS nameserver failed for {txt_name}: {exc}"
except dns.exception.Timeout:
return [], f"DNS lookup timed out for: {txt_name}"
except Exception as exc:
return [], f"DNS lookup failed for {txt_name}: {exc}"
values: list[str] = [] values: list[str] = []
for answer in answers: for answer in answers:
try: try:
chunks = [part.decode("utf-8", errors="ignore") if isinstance(part, bytes) else str(part) for part in answer.strings] # dnspython TXT records may be split into multiple byte chunks.
values.append("".join(chunks)) chunks = [
part.decode("utf-8", errors="ignore") if isinstance(part, bytes) else str(part)
for part in getattr(answer, "strings", [])
]
values.append("".join(chunks) if chunks else answer.to_text().strip())
except Exception: except Exception:
values.append(str(answer).strip()) values.append(str(answer).strip())
return values, None return values, None
except Exception as exc:
return [], str(exc)
def _resolve_txt_with_nslookup(txt_name: str) -> tuple[list[str], str | None]:
try:
completed = subprocess.run(
["nslookup", "-type=TXT", txt_name],
capture_output=True,
text=True,
timeout=12,
check=False,
)
except FileNotFoundError:
return [], "nslookup command is not available on this machine"
except Exception as exc:
return [], str(exc)
output = "\n".join([completed.stdout or "", completed.stderr or ""])
if completed.returncode != 0 and not output.strip():
return [], "DNS lookup failed"
values: list[str] = []
for line in output.splitlines():
line = line.strip()
if not line:
continue
# Windows/Linux nslookup generally prints TXT values inside quotes.
quoted = re.findall(r'"([^"]+)"', line)
if quoted:
values.append("".join(quoted))
continue
if "text =" in line.lower():
values.append(line.split("=", 1)[1].strip())
if not values and output.strip():
# Keep a short diagnostic without storing full command noise.
return [], output.strip().splitlines()[-1][:240]
return values, None
def lookup_dns_txt_values(txt_name: str) -> tuple[list[str], str | None]: def lookup_dns_txt_values(txt_name: str) -> tuple[list[str], str | None]:
"""Return TXT records for a DNS name using dnspython when available, else nslookup. """Return TXT records for a DNS name using dnspython only.
No new dependency is required. On Windows, nslookup is normally available by default. This avoids failures in minimal Docker/Coolify containers where the nslookup
command is not installed. Install dependencies with `pip install -r requirements.txt`.
""" """
txt_name = normalize_domain(txt_name) txt_name = normalize_domain(txt_name)
if not txt_name: if not txt_name:
@@ -664,12 +652,7 @@ def lookup_dns_txt_values(txt_name: str) -> tuple[list[str], str | None]:
values, error = _resolve_txt_with_dnspython(txt_name) values, error = _resolve_txt_with_dnspython(txt_name)
if values: if values:
return [_normalise_txt_value(v) for v in values], None return [_normalise_txt_value(v) for v in values], None
return [], error or "No TXT record found"
ns_values, ns_error = _resolve_txt_with_nslookup(txt_name)
if ns_values:
return [_normalise_txt_value(v) for v in ns_values], None
return [], ns_error or error or "No TXT record found"
def verify_domain_dns_txt(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainDnsVerificationResult: def verify_domain_dns_txt(db: Session, mapping: DomainMapping, *, user_id: int | None) -> DomainDnsVerificationResult:
+1 -1
View File
@@ -12,6 +12,6 @@ alembic==1.14.0
psycopg[binary]==3.2.3 psycopg[binary]==3.2.3
pydantic[email] pydantic[email]
email-validator email-validator
dnspython==2.7.0
openpyxl openpyxl
dnspython
itsdangerous itsdangerous