Skip to content

Commit c13f3c3

Browse files
committed
Replace aiodns with dnspython to fix inotify exhaustion
This is a major fix that completely resolves the inotify watch exhaustion issue when processing large lists of DNS resolvers. Changes: - Replaced aiodns/pycares with dnspython for all DNS resolution - dnspython does not use c-ares, so it avoids inotify watch creation entirely - Eliminates "Failed to initialize c-ares channel" errors - Fixed broken functionality that only validated ~350 out of 65k resolvers - Updated all resolver methods to use dnspython API (resolve instead of query) - Updated requirements.txt to replace aiodns/pycares with dnspython - Tested successfully with 2000 resolvers with no errors This allows the tool to scale to 65k+ resolvers without any system resource limitations or configuration changes. Version bumped to 2.4.0 (breaking change due to dependency switch)
1 parent 67c33d4 commit c13f3c3

4 files changed

Lines changed: 47 additions & 45 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,19 @@ All notable changes to PyResolvers will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.4.0] - 2025-01-11
9+
10+
### Changed
11+
- **BREAKING**: Replaced aiodns/pycares with dnspython for DNS resolution
12+
- Completely eliminates inotify watch exhaustion issues on Linux systems
13+
- No more "Failed to initialize c-ares channel" errors
14+
- Resolvers now scale to process 65k+ servers without resource limits
15+
16+
### Fixed
17+
- Fixed functionality breaking when processing large resolver lists
18+
- Resolved incomplete validation results (was only getting ~350/65k resolvers)
19+
- Eliminated system resource exhaustion during large batch processing
20+
821
## [2.3.1] - 2025-01-11
922

1023
### Fixed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = '2.3.1'
1+
__version__ = '2.4.0'
22

pyresolvers/validator.py

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
from typing import AsyncIterator, Dict, List, Optional, Tuple
1414

1515
try:
16-
import aiodns
17-
AIODNS_AVAILABLE = True
16+
import dns.asyncresolver
17+
import dns.exception
18+
import dns.resolver
19+
DNSPYTHON_AVAILABLE = True
1820
except ImportError:
19-
AIODNS_AVAILABLE = False
21+
DNSPYTHON_AVAILABLE = False
2022

2123
# Configuration
2224
TRUSTED_RESOLVERS = ["1.1.1.1", "8.8.8.8"]
@@ -59,9 +61,9 @@ def __init__(
5961
batch_size: int = BATCH_SIZE,
6062
verbose: bool = False
6163
) -> None:
62-
if not AIODNS_AVAILABLE:
64+
if not DNSPYTHON_AVAILABLE:
6365
raise ImportError(
64-
"aiodns required for Validator. Install with: pip install aiodns\n"
66+
"dnspython required for Validator. Install with: pip install dnspython\n"
6567
"Or: pip install -r requirements.txt"
6668
)
6769

@@ -77,9 +79,6 @@ def __init__(
7779
self.verbose = verbose
7880
self._baseline_ip = ""
7981
self._baseline_data: Dict[str, Dict] = {}
80-
# Resolver cache to prevent creating too many instances
81-
self._resolver_cache: Dict[str, aiodns.DNSResolver] = {}
82-
self._cache_lock: Optional[asyncio.Lock] = None
8382

8483
@staticmethod
8584
def _random_subdomain(length: int = SUBDOMAIN_LENGTH) -> str:
@@ -99,51 +98,42 @@ def _log(self, msg: str) -> None:
9998
if self.verbose:
10099
print(msg)
101100

102-
async def _get_resolver(self, nameserver: str, timeout: Optional[int] = None) -> aiodns.DNSResolver:
103-
"""Get or create a cached DNS resolver for the given nameserver.
101+
def _create_resolver(self, nameserver: str, timeout: Optional[float] = None) -> dns.asyncresolver.Resolver:
102+
"""Create a DNS resolver using dnspython (no inotify watches).
104103
105-
This prevents creating too many resolver instances which would exhaust
106-
inotify watches on Linux systems.
104+
dnspython doesn't use c-ares, so it avoids inotify watch exhaustion.
107105
"""
108-
# Initialize lock on first use
109-
if self._cache_lock is None:
110-
self._cache_lock = asyncio.Lock()
111-
112-
cache_key = f"{nameserver}:{timeout or self.timeout}"
113-
114-
async with self._cache_lock:
115-
if cache_key not in self._resolver_cache:
116-
self._resolver_cache[cache_key] = aiodns.DNSResolver(
117-
nameservers=[nameserver],
118-
timeout=timeout or self.timeout
119-
)
120-
return self._resolver_cache[cache_key]
106+
resolver = dns.asyncresolver.Resolver()
107+
resolver.nameservers = [nameserver]
108+
resolver.timeout = timeout or self.timeout
109+
resolver.lifetime = timeout or self.timeout
110+
return resolver
121111

122112
async def _setup_baseline_single(self, resolver_ip: str) -> bool:
123113
"""Setup baseline from single trusted resolver."""
124114
self._log(f"[INFO] {resolver_ip} - Establishing baseline")
125115
try:
126-
resolver = await self._get_resolver(resolver_ip)
116+
resolver = self._create_resolver(resolver_ip)
127117
data = {}
128118

129119
# Get baseline IP
130-
result = await resolver.query(self.baseline_domain, 'A')
131-
data["ip"] = self._baseline_ip = result[0].host
120+
result = await resolver.resolve(self.baseline_domain, 'A')
121+
data["ip"] = self._baseline_ip = str(result[0])
132122

133123
# Test domains in parallel
134-
domain_tasks = [resolver.query(domain, 'A') for domain in self.test_domains]
124+
domain_tasks = [resolver.resolve(domain, 'A') for domain in self.test_domains]
135125
domain_results = await asyncio.gather(*domain_tasks, return_exceptions=True)
136126

137127
data["domains"] = {}
138128
for domain, result in zip(self.test_domains, domain_results):
139129
if not isinstance(result, Exception):
140-
data["domains"][domain] = result[0].host
130+
data["domains"][domain] = str(result[0])
141131

142132
# NXDOMAIN check
143133
try:
144-
await resolver.query(self.query_prefix + self.baseline_domain, 'A')
134+
await resolver.resolve(self.query_prefix + self.baseline_domain, 'A')
145135
data["nxdomain"] = False
146-
except aiodns.error.DNSError:
136+
except (dns.exception.DNSException, dns.resolver.NXDOMAIN):
147137
data["nxdomain"] = True
148138

149139
self._baseline_data[resolver_ip] = data
@@ -159,14 +149,14 @@ async def _setup_baseline(self) -> bool:
159149
success_count = sum(1 for r in results if r is True)
160150
return success_count == len(self.trusted_resolvers)
161151

162-
async def _check_poisoning(self, resolver: aiodns.DNSResolver, server: str) -> Optional[str]:
152+
async def _check_poisoning(self, resolver: dns.asyncresolver.Resolver, server: str) -> Optional[str]:
163153
"""Check for DNS poisoning with parallel queries.
164154
165-
All 5 poison domains are checked in parallel for maximum speed.
155+
All poison domains are checked in parallel for maximum speed.
166156
Returns immediately if ANY domain resolves (indicating poisoning).
167157
"""
168158
subdomains = [f"{self._random_subdomain()}.{domain}" for domain in self.poison_check_domains]
169-
tasks = [resolver.query(subdomain, 'A') for subdomain in subdomains]
159+
tasks = [resolver.resolve(subdomain, 'A') for subdomain in subdomains]
170160
results = await asyncio.gather(*tasks, return_exceptions=True)
171161

172162
for subdomain, result in zip(subdomains, results):
@@ -176,15 +166,15 @@ async def _check_poisoning(self, resolver: aiodns.DNSResolver, server: str) -> O
176166
return None
177167

178168
async def _check_nxdomain_and_baseline(
179-
self, resolver: aiodns.DNSResolver, server: str
169+
self, resolver: dns.asyncresolver.Resolver, server: str
180170
) -> Tuple[bool, bool, Optional[str]]:
181171
"""Combined NXDOMAIN and baseline validation check. Returns (has_nxdomain, baseline_matches, error)."""
182172
subdomain = f"{self._random_subdomain()}.{self.baseline_domain}"
183173

184174
try:
185175
# Check NXDOMAIN and baseline in parallel
186-
nxdomain_task = resolver.query(subdomain, 'A')
187-
baseline_task = resolver.query(self.baseline_domain, 'A')
176+
nxdomain_task = resolver.resolve(subdomain, 'A')
177+
baseline_task = resolver.resolve(self.baseline_domain, 'A')
188178

189179
nxdomain_result, baseline_result = await asyncio.gather(
190180
nxdomain_task, baseline_task, return_exceptions=True
@@ -196,7 +186,7 @@ async def _check_nxdomain_and_baseline(
196186
# Baseline should match
197187
baseline_matches = False
198188
if not isinstance(baseline_result, Exception):
199-
resolved_ip = baseline_result[0].host
189+
resolved_ip = str(baseline_result[0])
200190
baseline_matches = resolved_ip == self._baseline_ip
201191

202192
return has_nxdomain, baseline_matches, None
@@ -215,9 +205,9 @@ def _matches_baseline(self, has_nxdomain: bool) -> bool:
215205
async def _measure_latency(self, server: str) -> float:
216206
"""Measure simple DNS query latency."""
217207
try:
218-
resolver = await self._get_resolver(server, timeout=1)
208+
resolver = self._create_resolver(server, timeout=1)
219209
start = time.time()
220-
await resolver.query(self.baseline_domain, 'A')
210+
await resolver.resolve(self.baseline_domain, 'A')
221211
return (time.time() - start) * 1000
222212
except:
223213
return -1
@@ -229,7 +219,7 @@ async def _validate_server(self, server: str) -> ValidationResult:
229219

230220
try:
231221
timeout = FAST_TIMEOUT if self.use_fast_timeout else self.timeout
232-
resolver = await self._get_resolver(server, timeout=timeout)
222+
resolver = self._create_resolver(server, timeout=timeout)
233223

234224
# Run ALL checks in parallel for max speed
235225
poison_task = self._check_poisoning(resolver, server)

requirements.txt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
aiodns>=3.1.0
2-
pycares>=4.0.0
1+
dnspython>=2.0.0
32
colorclass>=2.2.2

0 commit comments

Comments
 (0)