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