init: zero-dep dns-over-https resolver
This commit is contained in:
commit
7348f9b0dd
18 changed files with 589 additions and 0 deletions
21
.github/workflows/ci.yml
vendored
Normal file
21
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- run: pip install -e .[dev]
|
||||||
|
- run: pytest
|
||||||
|
- run: python -m build
|
||||||
|
- run: python -m twine check dist/*
|
||||||
20
.github/workflows/publish.yml
vendored
Normal file
20
.github/workflows/publish.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
name: publish
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
- run: pip install build
|
||||||
|
- run: python -m build
|
||||||
|
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.claude/
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Savsis
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
43
README.md
Normal file
43
README.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
# pydoh
|
||||||
|
|
||||||
|
DNS-over-HTTPS резолвер для питона. ноль зависимостей — весь HTTPS через стандартный `http.client`+`ssl`. провайдер/DPI видит только твой HTTPS к cloudflare, а не голые DNS-запросы.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
App->>pydoh: resolve("example.com")
|
||||||
|
pydoh->>Cloudflare: DNS-запрос внутри HTTPS POST
|
||||||
|
Cloudflare-->>pydoh: ответ внутри HTTPS
|
||||||
|
pydoh-->>App: ["93.184.216.34"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## установка
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install pydoh
|
||||||
|
```
|
||||||
|
|
||||||
|
## юзать
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pydoh
|
||||||
|
|
||||||
|
ips = pydoh.resolve("example.com")
|
||||||
|
```
|
||||||
|
|
||||||
|
или подменить резолвинг вообще везде в питоне одной строкой — `requests`, `aiohttp`, что угодно на сокетах будет резолвить через DoH:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pydoh
|
||||||
|
pydoh.patch_socket()
|
||||||
|
```
|
||||||
|
|
||||||
|
## фичи
|
||||||
|
|
||||||
|
- zero deps, только stdlib
|
||||||
|
- fallback между cloudflare / google / quad9 если один упал
|
||||||
|
- кэш по TTL из ответа
|
||||||
|
- typed (py.typed), питон 3.10–3.13
|
||||||
|
|
||||||
|
## что не умеет
|
||||||
|
|
||||||
|
не проверяет DNSSEC, не поддерживает TCP-фрагментированные DNS-ответы больше одного UDP-пакета.
|
||||||
33
pyproject.toml
Normal file
33
pyproject.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "pydoh"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "zero-dependency DNS over HTTPS resolver for Python, with an optional socket.getaddrinfo patch"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
authors = [{ name = "Savsis" }]
|
||||||
|
keywords = ["dns", "doh", "dns-over-https", "privacy", "networking"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: OS Independent",
|
||||||
|
"Topic :: Internet :: Name Service (DNS)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/ImSavsis/pydoh"
|
||||||
|
Issues = "https://github.com/ImSavsis/pydoh/issues"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest", "build", "twine"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/pydoh"]
|
||||||
17
src/pydoh/__init__.py
Normal file
17
src/pydoh/__init__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from .resolver import ResolveError, resolve, resolve4, resolve6
|
||||||
|
from .patch import patch_socket, unpatch_socket
|
||||||
|
from .providers import CLOUDFLARE, GOOGLE, QUAD9
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"resolve",
|
||||||
|
"resolve4",
|
||||||
|
"resolve6",
|
||||||
|
"ResolveError",
|
||||||
|
"patch_socket",
|
||||||
|
"unpatch_socket",
|
||||||
|
"CLOUDFLARE",
|
||||||
|
"GOOGLE",
|
||||||
|
"QUAD9",
|
||||||
|
]
|
||||||
28
src/pydoh/cache.py
Normal file
28
src/pydoh/cache.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class TTLCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._store: Dict[Any, Tuple[float, Any]] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
|
||||||
|
def get(self, key: Any) -> Optional[Any]:
|
||||||
|
with self._lock:
|
||||||
|
entry = self._store.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
expires_at, value = entry
|
||||||
|
if time.time() > expires_at:
|
||||||
|
del self._store[key]
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
def set(self, key: Any, value: Any, ttl: float) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._store[key] = (time.time() + ttl, value)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._store.clear()
|
||||||
54
src/pydoh/patch.py
Normal file
54
src/pydoh/patch.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import socket as _socket
|
||||||
|
|
||||||
|
from .resolver import resolve
|
||||||
|
|
||||||
|
_original_getaddrinfo = _socket.getaddrinfo
|
||||||
|
_patched = False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_literal(host: str) -> bool:
|
||||||
|
try:
|
||||||
|
_socket.inet_aton(host)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
_socket.inet_pton(_socket.AF_INET6, host)
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _doh_getaddrinfo(host, port, family=0, type=0, proto=0, flags=0):
|
||||||
|
if not host or _is_ip_literal(host):
|
||||||
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ips = resolve(host)
|
||||||
|
except Exception:
|
||||||
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
||||||
|
|
||||||
|
if not ips:
|
||||||
|
return _original_getaddrinfo(host, port, family, type, proto, flags)
|
||||||
|
|
||||||
|
resolved_port = port if isinstance(port, int) else 0
|
||||||
|
return [
|
||||||
|
(_socket.AF_INET, _socket.SOCK_STREAM, 6, "", (ip, resolved_port))
|
||||||
|
for ip in ips
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def patch_socket() -> None:
|
||||||
|
global _patched
|
||||||
|
if _patched:
|
||||||
|
return
|
||||||
|
_socket.getaddrinfo = _doh_getaddrinfo
|
||||||
|
_patched = True
|
||||||
|
|
||||||
|
|
||||||
|
def unpatch_socket() -> None:
|
||||||
|
global _patched
|
||||||
|
if not _patched:
|
||||||
|
return
|
||||||
|
_socket.getaddrinfo = _original_getaddrinfo
|
||||||
|
_patched = False
|
||||||
14
src/pydoh/providers.py
Normal file
14
src/pydoh/providers.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from typing import NamedTuple, List
|
||||||
|
|
||||||
|
|
||||||
|
class Provider(NamedTuple):
|
||||||
|
name: str
|
||||||
|
host: str
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
CLOUDFLARE = Provider("cloudflare", "cloudflare-dns.com", "/dns-query")
|
||||||
|
GOOGLE = Provider("google", "dns.google", "/dns-query")
|
||||||
|
QUAD9 = Provider("quad9", "dns.quad9.net", "/dns-query")
|
||||||
|
|
||||||
|
DEFAULT_PROVIDERS: List[Provider] = [CLOUDFLARE, GOOGLE, QUAD9]
|
||||||
0
src/pydoh/py.typed
Normal file
0
src/pydoh/py.typed
Normal file
75
src/pydoh/resolver.py
Normal file
75
src/pydoh/resolver.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import http.client
|
||||||
|
import ssl
|
||||||
|
from typing import List, Optional, Sequence
|
||||||
|
|
||||||
|
from .cache import TTLCache
|
||||||
|
from .providers import DEFAULT_PROVIDERS, Provider
|
||||||
|
from .wire import build_query, parse_response
|
||||||
|
|
||||||
|
_cache = TTLCache()
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _doh_request(provider: Provider, query: bytes, timeout: float) -> bytes:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
conn = http.client.HTTPSConnection(provider.host, 443, timeout=timeout, context=ctx)
|
||||||
|
try:
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/dns-message",
|
||||||
|
"Accept": "application/dns-message",
|
||||||
|
}
|
||||||
|
conn.request("POST", provider.path, body=query, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
if resp.status != 200:
|
||||||
|
raise ResolveError(f"{provider.name} returned HTTP {resp.status}")
|
||||||
|
return resp.read()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve(
|
||||||
|
hostname: str,
|
||||||
|
record_type: int = 1,
|
||||||
|
providers: Optional[Sequence[Provider]] = None,
|
||||||
|
timeout: float = 3.0,
|
||||||
|
use_cache: bool = True,
|
||||||
|
) -> List[str]:
|
||||||
|
cache_key = (hostname, record_type)
|
||||||
|
if use_cache:
|
||||||
|
cached = _cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
query, query_id = build_query(hostname, record_type)
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for provider in providers or DEFAULT_PROVIDERS:
|
||||||
|
try:
|
||||||
|
raw = _doh_request(provider, query, timeout)
|
||||||
|
resp_id, answers = parse_response(raw)
|
||||||
|
if resp_id != query_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ips = [ip for ip, _ in answers]
|
||||||
|
if ips:
|
||||||
|
min_ttl = min(ttl for _, ttl in answers)
|
||||||
|
if use_cache:
|
||||||
|
_cache.set(cache_key, ips, min_ttl)
|
||||||
|
return ips
|
||||||
|
return []
|
||||||
|
except Exception as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise ResolveError(f"could not resolve {hostname}") from last_error
|
||||||
|
|
||||||
|
|
||||||
|
def resolve4(hostname: str, **kwargs) -> List[str]:
|
||||||
|
return resolve(hostname, record_type=1, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve6(hostname: str, **kwargs) -> List[str]:
|
||||||
|
return resolve(hostname, record_type=28, **kwargs)
|
||||||
71
src/pydoh/wire.py
Normal file
71
src/pydoh/wire.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import random
|
||||||
|
import struct
|
||||||
|
from typing import List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_name(name: str) -> bytes:
|
||||||
|
parts = name.rstrip(".").split(".")
|
||||||
|
out = bytearray()
|
||||||
|
for part in parts:
|
||||||
|
encoded = part.encode("ascii")
|
||||||
|
out.append(len(encoded))
|
||||||
|
out.extend(encoded)
|
||||||
|
out.append(0)
|
||||||
|
return bytes(out)
|
||||||
|
|
||||||
|
|
||||||
|
def build_query(name: str, record_type: int = 1) -> Tuple[bytes, int]:
|
||||||
|
query_id = random.randint(0, 0xFFFF)
|
||||||
|
header = struct.pack(">HHHHHH", query_id, 0x0100, 1, 0, 0, 0)
|
||||||
|
question = _encode_name(name) + struct.pack(">HH", record_type, 1)
|
||||||
|
return header + question, query_id
|
||||||
|
|
||||||
|
|
||||||
|
def _read_name(data: bytes, offset: int) -> Tuple[str, int]:
|
||||||
|
labels: List[str] = []
|
||||||
|
jumped = False
|
||||||
|
return_offset = offset
|
||||||
|
|
||||||
|
while True:
|
||||||
|
length = data[offset]
|
||||||
|
if length == 0:
|
||||||
|
offset += 1
|
||||||
|
break
|
||||||
|
if (length & 0xC0) == 0xC0:
|
||||||
|
pointer = ((length & 0x3F) << 8) | data[offset + 1]
|
||||||
|
if not jumped:
|
||||||
|
return_offset = offset + 2
|
||||||
|
offset = pointer
|
||||||
|
jumped = True
|
||||||
|
continue
|
||||||
|
offset += 1
|
||||||
|
labels.append(data[offset:offset + length].decode("ascii", errors="replace"))
|
||||||
|
offset += length
|
||||||
|
|
||||||
|
end_offset = return_offset if jumped else offset
|
||||||
|
return ".".join(labels), end_offset
|
||||||
|
|
||||||
|
|
||||||
|
def parse_response(data: bytes) -> Tuple[int, List[Tuple[str, int]]]:
|
||||||
|
query_id, _flags, qdcount, ancount, _nscount, _arcount = struct.unpack(">HHHHHH", data[:12])
|
||||||
|
offset = 12
|
||||||
|
|
||||||
|
for _ in range(qdcount):
|
||||||
|
_, offset = _read_name(data, offset)
|
||||||
|
offset += 4
|
||||||
|
|
||||||
|
answers: List[Tuple[str, int]] = []
|
||||||
|
for _ in range(ancount):
|
||||||
|
_name, offset = _read_name(data, offset)
|
||||||
|
rtype, _rclass, ttl, rdlength = struct.unpack(">HHIH", data[offset:offset + 10])
|
||||||
|
offset += 10
|
||||||
|
rdata = data[offset:offset + rdlength]
|
||||||
|
offset += rdlength
|
||||||
|
|
||||||
|
if rtype == 1 and len(rdata) == 4:
|
||||||
|
answers.append((".".join(str(b) for b in rdata), ttl))
|
||||||
|
elif rtype == 28 and len(rdata) == 16:
|
||||||
|
groups = [rdata[i:i + 2].hex() for i in range(0, 16, 2)]
|
||||||
|
answers.append((":".join(groups), ttl))
|
||||||
|
|
||||||
|
return query_id, answers
|
||||||
29
tests/test_cache.py
Normal file
29
tests/test_cache.py
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import time
|
||||||
|
|
||||||
|
from pydoh.cache import TTLCache
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_hit():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("k", ["1.2.3.4"], ttl=60)
|
||||||
|
assert cache.get("k") == ["1.2.3.4"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_expires():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("k", ["1.2.3.4"], ttl=0.05)
|
||||||
|
assert cache.get("k") == ["1.2.3.4"]
|
||||||
|
time.sleep(0.1)
|
||||||
|
assert cache.get("k") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_clear():
|
||||||
|
cache = TTLCache()
|
||||||
|
cache.set("k", ["1.2.3.4"], ttl=60)
|
||||||
|
cache.clear()
|
||||||
|
assert cache.get("k") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cache_miss():
|
||||||
|
cache = TTLCache()
|
||||||
|
assert cache.get("missing") is None
|
||||||
7
tests/test_live.py
Normal file
7
tests/test_live.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
from pydoh import resolve
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_resolve_example_com():
|
||||||
|
ips = resolve("example.com", use_cache=False)
|
||||||
|
assert len(ips) > 0
|
||||||
|
assert all(part.isdigit() for ip in ips for part in ip.split("."))
|
||||||
28
tests/test_patch.py
Normal file
28
tests/test_patch.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from pydoh.patch import patch_socket, unpatch_socket
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_and_unpatch_restores_original():
|
||||||
|
original = socket.getaddrinfo
|
||||||
|
patch_socket()
|
||||||
|
assert socket.getaddrinfo is not original
|
||||||
|
unpatch_socket()
|
||||||
|
assert socket.getaddrinfo is original
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_is_idempotent():
|
||||||
|
patch_socket()
|
||||||
|
patched = socket.getaddrinfo
|
||||||
|
patch_socket()
|
||||||
|
assert socket.getaddrinfo is patched
|
||||||
|
unpatch_socket()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ip_literal_bypasses_doh():
|
||||||
|
patch_socket()
|
||||||
|
try:
|
||||||
|
result = socket.getaddrinfo("127.0.0.1", 80)
|
||||||
|
assert result
|
||||||
|
finally:
|
||||||
|
unpatch_socket()
|
||||||
73
tests/test_resolver.py
Normal file
73
tests/test_resolver.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import pydoh.resolver as resolver_module
|
||||||
|
from pydoh.resolver import ResolveError, resolve
|
||||||
|
|
||||||
|
|
||||||
|
def _make_response(query_id, ip):
|
||||||
|
header = struct.pack(">HHHHHH", query_id, 0x8180, 1, 1, 0, 0)
|
||||||
|
name = b"\x07example\x03com\x00"
|
||||||
|
question = name + struct.pack(">HH", 1, 1)
|
||||||
|
rdata = bytes(int(part) for part in ip.split("."))
|
||||||
|
answer = b"\xc0\x0c" + struct.pack(">HHIH", 1, 1, 300, 4) + rdata
|
||||||
|
return header + question + answer
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_returns_ip(monkeypatch):
|
||||||
|
def fake_doh_request(provider, query, timeout):
|
||||||
|
query_id = struct.unpack(">H", query[:2])[0]
|
||||||
|
return _make_response(query_id, "93.184.216.34")
|
||||||
|
|
||||||
|
monkeypatch.setattr(resolver_module, "_doh_request", fake_doh_request)
|
||||||
|
resolver_module._cache.clear()
|
||||||
|
|
||||||
|
ips = resolve("example.com", use_cache=False)
|
||||||
|
assert ips == ["93.184.216.34"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_uses_cache(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_doh_request(provider, query, timeout):
|
||||||
|
calls.append(1)
|
||||||
|
query_id = struct.unpack(">H", query[:2])[0]
|
||||||
|
return _make_response(query_id, "1.1.1.1")
|
||||||
|
|
||||||
|
monkeypatch.setattr(resolver_module, "_doh_request", fake_doh_request)
|
||||||
|
resolver_module._cache.clear()
|
||||||
|
|
||||||
|
resolve("example.com", use_cache=True)
|
||||||
|
resolve("example.com", use_cache=True)
|
||||||
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_next_provider(monkeypatch):
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def fake_doh_request(provider, query, timeout):
|
||||||
|
attempts.append(provider.name)
|
||||||
|
if provider.name == "cloudflare":
|
||||||
|
raise RuntimeError("simulated failure")
|
||||||
|
query_id = struct.unpack(">H", query[:2])[0]
|
||||||
|
return _make_response(query_id, "8.8.8.8")
|
||||||
|
|
||||||
|
monkeypatch.setattr(resolver_module, "_doh_request", fake_doh_request)
|
||||||
|
resolver_module._cache.clear()
|
||||||
|
|
||||||
|
ips = resolve("example.com", use_cache=False)
|
||||||
|
assert ips == ["8.8.8.8"]
|
||||||
|
assert attempts[0] == "cloudflare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_raises_when_all_providers_fail(monkeypatch):
|
||||||
|
def fake_doh_request(provider, query, timeout):
|
||||||
|
raise RuntimeError("down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(resolver_module, "_doh_request", fake_doh_request)
|
||||||
|
resolver_module._cache.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolve("example.com", use_cache=False)
|
||||||
|
assert False, "expected ResolveError"
|
||||||
|
except ResolveError:
|
||||||
|
pass
|
||||||
48
tests/test_wire.py
Normal file
48
tests/test_wire.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from pydoh.wire import build_query, parse_response
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_query_shape():
|
||||||
|
query, query_id = build_query("example.com")
|
||||||
|
assert isinstance(query, bytes)
|
||||||
|
assert 0 <= query_id <= 0xFFFF
|
||||||
|
assert query[12:20] == b"\x07example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_response_a_record():
|
||||||
|
query, query_id = build_query("example.com")
|
||||||
|
question = query[12:]
|
||||||
|
|
||||||
|
header = struct.pack(">HHHHHH", query_id, 0x8180, 1, 1, 0, 0)
|
||||||
|
answer = b"\xc0\x0c" + struct.pack(">HHIH", 1, 1, 300, 4) + bytes([93, 184, 216, 34])
|
||||||
|
response = header + question + answer
|
||||||
|
|
||||||
|
resp_id, answers = parse_response(response)
|
||||||
|
assert resp_id == query_id
|
||||||
|
assert answers == [("93.184.216.34", 300)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_response_aaaa_record():
|
||||||
|
query, query_id = build_query("example.com", record_type=28)
|
||||||
|
question = query[12:]
|
||||||
|
|
||||||
|
rdata = bytes.fromhex("20010db8000000000000000000000001")
|
||||||
|
header = struct.pack(">HHHHHH", query_id, 0x8180, 1, 1, 0, 0)
|
||||||
|
answer = b"\xc0\x0c" + struct.pack(">HHIH", 28, 1, 60, 16) + rdata
|
||||||
|
response = header + question + answer
|
||||||
|
|
||||||
|
resp_id, answers = parse_response(response)
|
||||||
|
assert resp_id == query_id
|
||||||
|
assert answers == [("2001:0db8:0000:0000:0000:0000:0000:0001", 60)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_response_no_answers():
|
||||||
|
query, query_id = build_query("nx.example.com")
|
||||||
|
question = query[12:]
|
||||||
|
header = struct.pack(">HHHHHH", query_id, 0x8183, 1, 0, 0, 0)
|
||||||
|
response = header + question
|
||||||
|
|
||||||
|
resp_id, answers = parse_response(response)
|
||||||
|
assert resp_id == query_id
|
||||||
|
assert answers == []
|
||||||
Loading…
Add table
Add a link
Reference in a new issue