From 7348f9b0dd70ac355bd6ae91e0430487f7e830a3 Mon Sep 17 00:00:00 2001 From: ImSavsis Date: Sun, 19 Jul 2026 20:36:56 +0500 Subject: [PATCH] init: zero-dep dns-over-https resolver --- .github/workflows/ci.yml | 21 ++++++++++ .github/workflows/publish.yml | 20 ++++++++++ .gitignore | 7 ++++ LICENSE | 21 ++++++++++ README.md | 43 ++++++++++++++++++++ pyproject.toml | 33 +++++++++++++++ src/pydoh/__init__.py | 17 ++++++++ src/pydoh/cache.py | 28 +++++++++++++ src/pydoh/patch.py | 54 +++++++++++++++++++++++++ src/pydoh/providers.py | 14 +++++++ src/pydoh/py.typed | 0 src/pydoh/resolver.py | 75 +++++++++++++++++++++++++++++++++++ src/pydoh/wire.py | 71 +++++++++++++++++++++++++++++++++ tests/test_cache.py | 29 ++++++++++++++ tests/test_live.py | 7 ++++ tests/test_patch.py | 28 +++++++++++++ tests/test_resolver.py | 73 ++++++++++++++++++++++++++++++++++ tests/test_wire.py | 48 ++++++++++++++++++++++ 18 files changed, 589 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/pydoh/__init__.py create mode 100644 src/pydoh/cache.py create mode 100644 src/pydoh/patch.py create mode 100644 src/pydoh/providers.py create mode 100644 src/pydoh/py.typed create mode 100644 src/pydoh/resolver.py create mode 100644 src/pydoh/wire.py create mode 100644 tests/test_cache.py create mode 100644 tests/test_live.py create mode 100644 tests/test_patch.py create mode 100644 tests/test_resolver.py create mode 100644 tests/test_wire.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e8b818c --- /dev/null +++ b/.github/workflows/ci.yml @@ -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/* diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f80c5f6 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..385896e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.pytest_cache/ +dist/ +build/ +*.egg-info/ +.claude/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5313c5d --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ee94d9 --- /dev/null +++ b/README.md @@ -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-пакета. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b04bf13 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/pydoh/__init__.py b/src/pydoh/__init__.py new file mode 100644 index 0000000..ae732cf --- /dev/null +++ b/src/pydoh/__init__.py @@ -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", +] diff --git a/src/pydoh/cache.py b/src/pydoh/cache.py new file mode 100644 index 0000000..3ae5583 --- /dev/null +++ b/src/pydoh/cache.py @@ -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() diff --git a/src/pydoh/patch.py b/src/pydoh/patch.py new file mode 100644 index 0000000..9a99ba1 --- /dev/null +++ b/src/pydoh/patch.py @@ -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 diff --git a/src/pydoh/providers.py b/src/pydoh/providers.py new file mode 100644 index 0000000..e9adc69 --- /dev/null +++ b/src/pydoh/providers.py @@ -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] diff --git a/src/pydoh/py.typed b/src/pydoh/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/pydoh/resolver.py b/src/pydoh/resolver.py new file mode 100644 index 0000000..50cf84d --- /dev/null +++ b/src/pydoh/resolver.py @@ -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) diff --git a/src/pydoh/wire.py b/src/pydoh/wire.py new file mode 100644 index 0000000..db4462e --- /dev/null +++ b/src/pydoh/wire.py @@ -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 diff --git a/tests/test_cache.py b/tests/test_cache.py new file mode 100644 index 0000000..7eae268 --- /dev/null +++ b/tests/test_cache.py @@ -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 diff --git a/tests/test_live.py b/tests/test_live.py new file mode 100644 index 0000000..685a207 --- /dev/null +++ b/tests/test_live.py @@ -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(".")) diff --git a/tests/test_patch.py b/tests/test_patch.py new file mode 100644 index 0000000..09210d0 --- /dev/null +++ b/tests/test_patch.py @@ -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() diff --git a/tests/test_resolver.py b/tests/test_resolver.py new file mode 100644 index 0000000..fd4e44f --- /dev/null +++ b/tests/test_resolver.py @@ -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 diff --git a/tests/test_wire.py b/tests/test_wire.py new file mode 100644 index 0000000..26ce3c0 --- /dev/null +++ b/tests/test_wire.py @@ -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 == []