fmtr.tools 1.2.4__py3-none-any.whl → 1.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of fmtr.tools might be problematic. Click here for more details.

fmtr/tools/__init__.py CHANGED
@@ -29,6 +29,7 @@ from fmtr.tools.constants import Constants
29
29
  # Submodules
30
30
  from fmtr.tools.path_tools import Path, PackagePaths, AppPaths
31
31
  from fmtr.tools import ai_tools as ai
32
+ from fmtr.tools import dns_tools as dns
32
33
 
33
34
  import fmtr.tools.setup_tools as setup
34
35
  from fmtr.tools.setup_tools import Setup, SetupPaths, Dependencies, Tools
@@ -172,11 +173,6 @@ try:
172
173
  except ImportError as exception:
173
174
  patterns = MissingExtraMockModule('patterns', exception)
174
175
 
175
- try:
176
- from fmtr.tools import dns_tools as dns
177
- except ImportError as exception:
178
- dns = MissingExtraMockModule('dns', exception)
179
-
180
176
  try:
181
177
  from fmtr.tools import http_tools as http
182
178
  from fmtr.tools.http_tools import Client
@@ -0,0 +1,6 @@
1
+ from fmtr.tools.import_tools import MissingExtraMockModule
2
+
3
+ try:
4
+ from fmtr.tools.dns_tools import server, client, dm
5
+ except ImportError as exception:
6
+ server = client = dm = MissingExtraMockModule('dns', exception)
@@ -0,0 +1,80 @@
1
+ import dns
2
+ from dataclasses import dataclass
3
+ from dns import query
4
+ from functools import cached_property
5
+ from httpx_retries import Retry, RetryTransport
6
+
7
+ from fmtr.tools import http_tools as http
8
+ from fmtr.tools.dns_tools.dm import Exchange, Response, Request
9
+ from fmtr.tools.logging_tools import logger
10
+
11
+ RETRY_STRATEGY = Retry(
12
+ total=2, # initial + 1 retry
13
+ allowed_methods={"GET", "POST"},
14
+ status_forcelist={502, 503, 504},
15
+ retry_on_exceptions=None, # defaults to httpx.TransportError etc.
16
+ backoff_factor=0.25, # short backoff (e.g. 0.25s, 0.5s)
17
+ max_backoff_wait=0.75, # max total delay before giving up
18
+ backoff_jitter=0.1, # small jitter to avoid retry bursts
19
+ respect_retry_after_header=False, # DoH resolvers probably won't set this
20
+ )
21
+
22
+
23
+ class HTTPClientDoH(http.Client):
24
+ """
25
+
26
+ Base HTTP client for DoH-appropriate retry strategy.
27
+
28
+ """
29
+ TRANSPORT = RetryTransport(retry=RETRY_STRATEGY)
30
+
31
+
32
+ class ClientBasePlain:
33
+ def __init__(self, host, port=53):
34
+ self.host = host
35
+ self.port = port
36
+
37
+ def resolve(self, exchange: Exchange):
38
+ with logger.span(f'UDP {self.host}:{self.port}'):
39
+ response = query.udp(q=exchange.request.message, where=self.host, port=self.port)
40
+ exchange.response_upstream = Response.from_message(response)
41
+
42
+
43
+ @dataclass
44
+ class ClientDoH:
45
+ """
46
+
47
+ Base DoH client.
48
+
49
+ """
50
+
51
+ HEADERS = {"Content-Type": "application/dns-message"}
52
+ CLIENT = HTTPClientDoH()
53
+ BOOTSTRAP = ClientBasePlain('8.8.8.8')
54
+
55
+ host: str
56
+ url: str
57
+
58
+
59
+ @cached_property
60
+ def ip(self):
61
+ message = dns.message.make_query(self.host, dns.rdatatype.A, flags=0)
62
+ request = Request.from_message(message)
63
+ exchange = Exchange(request=request, ip=None, port=None)
64
+ self.BOOTSTRAP.resolve(exchange)
65
+ ip = next(iter(exchange.response_upstream.answer.items.keys())).address
66
+ return ip
67
+
68
+ def resolve(self, exchange: Exchange):
69
+ """
70
+
71
+ Resolve via DoH
72
+
73
+ """
74
+ request = exchange.request
75
+ headers = self.HEADERS | dict(Host=self.host)
76
+ url = self.url.format(host=self.ip)
77
+ response_doh = self.CLIENT.post(url, headers=headers, content=request.wire)
78
+ response_doh.raise_for_status()
79
+ response = Response.from_http(response_doh)
80
+ exchange.response_upstream = response
@@ -0,0 +1,110 @@
1
+ import dns
2
+ import httpx
3
+ from dataclasses import dataclass
4
+ from dns.message import Message
5
+ from functools import cached_property
6
+ from typing import Self, Optional
7
+
8
+
9
+ @dataclass
10
+ class BaseDNSData:
11
+ """
12
+
13
+ DNS response object.
14
+
15
+ """
16
+ wire: bytes
17
+
18
+ @cached_property
19
+ def message(self) -> Message:
20
+ return dns.message.from_wire(self.wire)
21
+
22
+ @classmethod
23
+ def from_message(cls, message: Message) -> Self:
24
+ return cls(message.to_wire())
25
+
26
+
27
+ @dataclass
28
+ class Response(BaseDNSData):
29
+ """
30
+
31
+ DNS response object.
32
+
33
+ """
34
+
35
+ http: Optional[httpx.Response] = None
36
+
37
+ @classmethod
38
+ def from_http(cls, response: httpx.Response) -> Self:
39
+ self = cls(response.content, http=response)
40
+ return self
41
+
42
+ @cached_property
43
+ def answer(self):
44
+ return self.message.answer[-1]
45
+
46
+
47
+ @dataclass
48
+ class Request(BaseDNSData):
49
+ """
50
+
51
+ DNS request object.
52
+
53
+ """
54
+ wire: bytes
55
+
56
+ @cached_property
57
+ def question(self):
58
+ return self.message.question[0]
59
+
60
+ @cached_property
61
+ def is_valid(self):
62
+ return len(self.message.question) != 0
63
+
64
+ @cached_property
65
+ def type(self):
66
+ return self.question.rdtype
67
+
68
+ @cached_property
69
+ def type_text(self):
70
+ return dns.rdatatype.to_text(self.type)
71
+
72
+ @cached_property
73
+ def name(self):
74
+ return self.question.name
75
+
76
+ @cached_property
77
+ def name_text(self):
78
+ return self.name.to_text()
79
+
80
+ @cached_property
81
+ def blackhole(self) -> Response:
82
+ blackhole = dns.message.make_response(self.message)
83
+ blackhole.flags |= dns.flags.RA
84
+ blackhole.set_rcode(dns.rcode.NXDOMAIN)
85
+ response = Response.from_message(blackhole)
86
+ return response
87
+
88
+
89
+ @dataclass
90
+ class Exchange:
91
+ """
92
+
93
+ Entire DNS exchange for a DNS Proxy: request -> upstream response -> response
94
+
95
+ """
96
+ ip: str
97
+ port: int
98
+
99
+ request: Request
100
+ response: Optional[Response] = None
101
+ response_upstream: Optional[Response] = None
102
+
103
+ @classmethod
104
+ def from_wire(cls, wire: bytes, ip: str, port: int) -> Self:
105
+ request = Request(wire)
106
+ return cls(request=request, ip=ip, port=port)
107
+
108
+ @cached_property
109
+ def client(self):
110
+ return f'{self.ip}:{self.port}'
@@ -0,0 +1,89 @@
1
+ import socket
2
+ from dataclasses import dataclass
3
+
4
+ from fmtr.tools import logger
5
+ from fmtr.tools.dns_tools.client import ClientDoH
6
+ from fmtr.tools.dns_tools.dm import Exchange
7
+
8
+
9
+ @dataclass
10
+ class ServerBasePlain:
11
+ """
12
+
13
+ Base for starting a plain DNS server
14
+
15
+ """
16
+
17
+ host: str
18
+ port: int
19
+
20
+ def __post_init__(self):
21
+
22
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
23
+
24
+ def resolve(self, exchange: Exchange):
25
+ raise NotImplemented
26
+
27
+ def start(self):
28
+ """
29
+
30
+ Listen and resolve via overridden resolve method.
31
+
32
+ """
33
+ sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
34
+ sock.bind((self.host, self.port))
35
+ print(f"Listening on {self.host}:{self.port}")
36
+ while True:
37
+ data, (ip, port) = sock.recvfrom(512)
38
+ exchange = Exchange.from_wire(data, ip=ip, port=port)
39
+ self.resolve(exchange)
40
+ sock.sendto(exchange.response.wire, (ip, port))
41
+
42
+
43
+ @dataclass
44
+ class ServerBaseDoHProxy(ServerBasePlain):
45
+ """
46
+
47
+ Base for a DNS Proxy server
48
+
49
+ """
50
+
51
+ client: ClientDoH
52
+
53
+ def process_question(self, exchange: Exchange):
54
+ return
55
+
56
+ def process_upstream(self, exchange: Exchange):
57
+ return
58
+
59
+ def resolve(self, exchange: Exchange):
60
+ """
61
+
62
+ Resolve a request, processing each stage, initial question, upstream response etc.
63
+ Subclasses can override the relevant processing methods to implement custom behaviour.
64
+
65
+ """
66
+
67
+ request = exchange.request
68
+
69
+ with logger.span(f'Handling request for {request.name_text} from {exchange.client}...'):
70
+
71
+ if not request.is_valid:
72
+ raise ValueError(f'Only one question per request is supported. Got {len(request.question)} questions.')
73
+
74
+ with logger.span(f'Processing question...'):
75
+ self.process_question(exchange)
76
+ if exchange.response:
77
+ return
78
+
79
+ with logger.span(f'Making upstream request for {request.name_text}...'):
80
+ self.client.resolve(exchange)
81
+
82
+ with logger.span(f'Processing upstream response...'):
83
+ self.process_upstream(exchange)
84
+
85
+ if exchange.response:
86
+ return
87
+
88
+ exchange.response = exchange.response_upstream
89
+ return
fmtr/tools/version CHANGED
@@ -1 +1 @@
1
- 1.2.4
1
+ 1.2.6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmtr.tools
3
- Version: 1.2.4
3
+ Version: 1.2.6
4
4
  Summary: Collection of high-level tools to simplify everyday development tasks, with a focus on AI/ML
5
5
  Home-page: https://github.com/fmtr/tools
6
6
  Author: Frontmatter
@@ -106,6 +106,8 @@ Requires-Dist: pydevd-pycharm; extra == "debug"
106
106
  Provides-Extra: sets
107
107
  Requires-Dist: pydantic-settings; extra == "sets"
108
108
  Requires-Dist: pydantic; extra == "sets"
109
+ Requires-Dist: yamlscript; extra == "sets"
110
+ Requires-Dist: pyyaml; extra == "sets"
109
111
  Provides-Extra: path-app
110
112
  Requires-Dist: appdirs; extra == "path-app"
111
113
  Provides-Extra: path-type
@@ -128,60 +130,60 @@ Requires-Dist: logfire[httpx]; extra == "http"
128
130
  Provides-Extra: setup
129
131
  Requires-Dist: setuptools; extra == "setup"
130
132
  Provides-Extra: all
131
- Requires-Dist: dask[bag]; extra == "all"
132
- Requires-Dist: huggingface_hub; extra == "all"
133
- Requires-Dist: tabulate; extra == "all"
134
- Requires-Dist: sre_yield; extra == "all"
135
- Requires-Dist: semver; extra == "all"
136
- Requires-Dist: distributed; extra == "all"
137
- Requires-Dist: docker; extra == "all"
138
- Requires-Dist: filetype; extra == "all"
139
- Requires-Dist: pydevd-pycharm; extra == "all"
140
133
  Requires-Dist: uvicorn[standard]; extra == "all"
134
+ Requires-Dist: google-auth-httplib2; extra == "all"
135
+ Requires-Dist: html2text; extra == "all"
136
+ Requires-Dist: distributed; extra == "all"
137
+ Requires-Dist: tabulate; extra == "all"
138
+ Requires-Dist: pydantic; extra == "all"
139
+ Requires-Dist: openpyxl; extra == "all"
141
140
  Requires-Dist: httpx; extra == "all"
142
- Requires-Dist: yamlscript; extra == "all"
141
+ Requires-Dist: tokenizers; extra == "all"
142
+ Requires-Dist: fastapi; extra == "all"
143
143
  Requires-Dist: logfire; extra == "all"
144
- Requires-Dist: pydantic-settings; extra == "all"
145
- Requires-Dist: logfire[httpx]; extra == "all"
146
- Requires-Dist: tinynetrc; extra == "all"
147
- Requires-Dist: Unidecode; extra == "all"
148
- Requires-Dist: openai; extra == "all"
149
- Requires-Dist: contexttimer; extra == "all"
150
- Requires-Dist: appdirs; extra == "all"
151
- Requires-Dist: sentence_transformers; extra == "all"
152
- Requires-Dist: pandas; extra == "all"
153
144
  Requires-Dist: ollama; extra == "all"
154
- Requires-Dist: google-api-python-client; extra == "all"
155
- Requires-Dist: google-auth; extra == "all"
145
+ Requires-Dist: flet[all]; extra == "all"
146
+ Requires-Dist: flet-webview; extra == "all"
156
147
  Requires-Dist: flet-video; extra == "all"
157
- Requires-Dist: tokenizers; extra == "all"
158
- Requires-Dist: pyyaml; extra == "all"
159
148
  Requires-Dist: torchvision; extra == "all"
160
- Requires-Dist: json_repair; extra == "all"
161
- Requires-Dist: transformers[sentencepiece]; extra == "all"
149
+ Requires-Dist: httpx_retries; extra == "all"
150
+ Requires-Dist: tinynetrc; extra == "all"
151
+ Requires-Dist: docker; extra == "all"
162
152
  Requires-Dist: setuptools; extra == "all"
163
- Requires-Dist: bokeh; extra == "all"
164
- Requires-Dist: torchaudio; extra == "all"
165
- Requires-Dist: regex; extra == "all"
166
- Requires-Dist: openpyxl; extra == "all"
153
+ Requires-Dist: pytest-cov; extra == "all"
167
154
  Requires-Dist: logfire[fastapi]; extra == "all"
168
- Requires-Dist: faker; extra == "all"
155
+ Requires-Dist: transformers[sentencepiece]; extra == "all"
169
156
  Requires-Dist: google-auth-oauthlib; extra == "all"
170
- Requires-Dist: diskcache; extra == "all"
171
- Requires-Dist: httpx_retries; extra == "all"
172
- Requires-Dist: pydantic; extra == "all"
173
- Requires-Dist: google-auth-httplib2; extra == "all"
174
- Requires-Dist: html2text; extra == "all"
175
- Requires-Dist: flet-webview; extra == "all"
176
- Requires-Dist: flet[all]; extra == "all"
157
+ Requires-Dist: sentence_transformers; extra == "all"
158
+ Requires-Dist: semver; extra == "all"
159
+ Requires-Dist: Unidecode; extra == "all"
160
+ Requires-Dist: faker; extra == "all"
161
+ Requires-Dist: sre_yield; extra == "all"
162
+ Requires-Dist: regex; extra == "all"
163
+ Requires-Dist: openai; extra == "all"
164
+ Requires-Dist: pymupdf; extra == "all"
165
+ Requires-Dist: yamlscript; extra == "all"
166
+ Requires-Dist: bokeh; extra == "all"
167
+ Requires-Dist: contexttimer; extra == "all"
168
+ Requires-Dist: json_repair; extra == "all"
177
169
  Requires-Dist: peft; extra == "all"
170
+ Requires-Dist: google-api-python-client; extra == "all"
178
171
  Requires-Dist: deepmerge; extra == "all"
179
- Requires-Dist: fastapi; extra == "all"
172
+ Requires-Dist: torchaudio; extra == "all"
173
+ Requires-Dist: google-auth; extra == "all"
174
+ Requires-Dist: pyyaml; extra == "all"
180
175
  Requires-Dist: pydantic-ai[logfire,openai]; extra == "all"
181
- Requires-Dist: pymupdf; extra == "all"
182
- Requires-Dist: pymupdf4llm; extra == "all"
176
+ Requires-Dist: appdirs; extra == "all"
177
+ Requires-Dist: huggingface_hub; extra == "all"
178
+ Requires-Dist: pandas; extra == "all"
179
+ Requires-Dist: diskcache; extra == "all"
180
+ Requires-Dist: logfire[httpx]; extra == "all"
181
+ Requires-Dist: filetype; extra == "all"
182
+ Requires-Dist: pydevd-pycharm; extra == "all"
183
+ Requires-Dist: pydantic-settings; extra == "all"
184
+ Requires-Dist: dask[bag]; extra == "all"
183
185
  Requires-Dist: dnspython[doh]; extra == "all"
184
- Requires-Dist: pytest-cov; extra == "all"
186
+ Requires-Dist: pymupdf4llm; extra == "all"
185
187
  Dynamic: author
186
188
  Dynamic: author-email
187
189
  Dynamic: description
@@ -1,4 +1,4 @@
1
- fmtr/tools/__init__.py,sha256=yXBnDJFPfv6qj4_KuLpMXoDZ92aiEzugLs0vcM31D5k,5770
1
+ fmtr/tools/__init__.py,sha256=vCxKR0SymygpO-EtCGPmGZEQv5O1goM7Jr8P4m-eJsQ,5676
2
2
  fmtr/tools/api_tools.py,sha256=w8Zrp_EwN5KlUghwLoTCXo4z1irg5tAsReCqDLjASfE,2133
3
3
  fmtr/tools/async_tools.py,sha256=ewz757WcveQJd-G5SVr2JDOQVbdLGecCgl-tsBGVZz4,284
4
4
  fmtr/tools/augmentation_tools.py,sha256=-6ESbO4CDlKqVOV1J1V6qBeoBMzbFIinkDHRHnCBej0,55
@@ -8,7 +8,6 @@ fmtr/tools/data_modelling_tools.py,sha256=0BFm-F_cYzVTxftWQwORkPd0FM2BTLVh9-s0-r
8
8
  fmtr/tools/dataclass_tools.py,sha256=0Gt6KeLhtPgubo_2tYkIVqB8oQ91Qzag8OAGZDdjvMU,1209
9
9
  fmtr/tools/datatype_tools.py,sha256=3P4AWIFGkJ-UqvXlj0Jc9IvkIIgTOE9jRrOk3NVbpH8,1508
10
10
  fmtr/tools/debugging_tools.py,sha256=_xzqS0V5BpL8d06j-jVQjGgI7T020QsqVXKAKMz7Du8,2082
11
- fmtr/tools/dns_tools.py,sha256=7OoELFd9lZ3ULdJ6h8QwHYFNUYS9atqYrflKEzzFW-g,5461
12
11
  fmtr/tools/docker_tools.py,sha256=rdaZje2xhlmnfQqZnR7IHgRdWncTLjrJcViUTt5oEwk,617
13
12
  fmtr/tools/environment_tools.py,sha256=ZO2e8pTzbQ8jNXnmKpaZF7_3qkVM6U5iqku5YSwOszE,1849
14
13
  fmtr/tools/function_tools.py,sha256=_oW3-HZXMst2pcU-I-U7KTMmzo0g9MIQKmX-c2_NEoE,858
@@ -45,12 +44,16 @@ fmtr/tools/tabular_tools.py,sha256=tpIpZzYku1HcJrHZJL6BC39LmN3WUWVhFbK2N7nDVmE,1
45
44
  fmtr/tools/tokenization_tools.py,sha256=me-IBzSLyNYejLybwjO9CNB6Mj2NYfKPaOVThXyaGNg,4268
46
45
  fmtr/tools/tools.py,sha256=CAsApa1YwVdNE6H66Vjivs_mXYvOas3rh7fPELAnTpk,795
47
46
  fmtr/tools/unicode_tools.py,sha256=yS_9wpu8ogNoiIL7s1G_8bETFFO_YQlo4LNPv1NLDeY,52
48
- fmtr/tools/version,sha256=MPP62sP52LbIqBiKDnsMNqvJuQiGrD-pRlTilhxP92U,5
47
+ fmtr/tools/version,sha256=PP3XZe61jmbow_ljgcG-wbwzB5i9ZhqLLvJeoEEgV60,5
49
48
  fmtr/tools/version_tools.py,sha256=yNs_CGqWpqE4jbK9wsPIi14peJVXYbhIcMqHAFOw3yE,1480
50
49
  fmtr/tools/yaml_tools.py,sha256=9kuYChqJelWQIjGlSnK4iDdOWWH06P0gp9jIcRrC3UI,1903
51
50
  fmtr/tools/ai_tools/__init__.py,sha256=JZrLuOFNV1A3wvJgonxOgz_4WS-7MfCuowGWA5uYCjs,372
52
51
  fmtr/tools/ai_tools/agentic_tools.py,sha256=acSEPFS-aguDXanWGs3fAAlRyJSYPZW7L-Kb2qDLm-I,4300
53
52
  fmtr/tools/ai_tools/inference_tools.py,sha256=2UP2gXEyOJUjyyV6zmFIYmIxUsh1rXkRH0IbFvr2bRs,11908
53
+ fmtr/tools/dns_tools/__init__.py,sha256=PwHxnpiy6_isQfUmz_5V1hL0dcPaA6ItqvoGWW8MOfk,222
54
+ fmtr/tools/dns_tools/client.py,sha256=omHdk9bA_8u2-VMQhh0c9r9e-oG4mZ0MWA9lfbtSEIc,2371
55
+ fmtr/tools/dns_tools/dm.py,sha256=mvXacq6QJ86G0S0tkzJFhU7bOaSJytvsMNlxs5X9hfE,2236
56
+ fmtr/tools/dns_tools/server.py,sha256=hSZhK4EZD6Ox4uRI3ldbnyyZf6DYgMUcTfffbrZN5pk,2329
54
57
  fmtr/tools/entrypoints/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
55
58
  fmtr/tools/entrypoints/cache_hfh.py,sha256=fQNs4J9twQuZH_Yj98-oOvEX7-LrSUP3kO8nzw2HrHs,60
56
59
  fmtr/tools/entrypoints/ep_test.py,sha256=B8HfWISfSgw_xVX475CbJGh_QnpOe9MH65H8qGjTWbY,46
@@ -70,9 +73,9 @@ fmtr/tools/tests/test_environment.py,sha256=iHaiMQfECYZPkPKwfuIZV9uHuWe3aE-p_dN_
70
73
  fmtr/tools/tests/test_json.py,sha256=IeSP4ziPvRcmS8kq7k9tHonC9rN5YYq9GSNT2ul6Msk,287
71
74
  fmtr/tools/tests/test_path.py,sha256=AkZQa6_8BQ-VaCyL_J-iKmdf2ZaM-xFYR37Kun3k4_g,2188
72
75
  fmtr/tools/tests/test_yaml.py,sha256=jc0TwwKu9eC0LvFGNMERdgBue591xwLxYXFbtsRwXVM,287
73
- fmtr_tools-1.2.4.dist-info/licenses/LICENSE,sha256=FW9aa6vVN5IjRQWLT43hs4_koYSmpcbIovlKeAJ0_cI,10757
74
- fmtr_tools-1.2.4.dist-info/METADATA,sha256=Xm5-3QRQLHzVWpsgq34hCEPasBOx1F-BfMfWqxApHIg,15943
75
- fmtr_tools-1.2.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
- fmtr_tools-1.2.4.dist-info/entry_points.txt,sha256=fSQrDGNctdQXbUxpMWYVfVQ0mhZMDyaEDG3D3a0zOSc,278
77
- fmtr_tools-1.2.4.dist-info/top_level.txt,sha256=LXem9xCgNOD72tE2gRKESdiQTL902mfFkwWb6-dlwEE,5
78
- fmtr_tools-1.2.4.dist-info/RECORD,,
76
+ fmtr_tools-1.2.6.dist-info/licenses/LICENSE,sha256=FW9aa6vVN5IjRQWLT43hs4_koYSmpcbIovlKeAJ0_cI,10757
77
+ fmtr_tools-1.2.6.dist-info/METADATA,sha256=NE-9BBFqSBwTTLeaO5Fz2VpZO1KmolLzAoEmTl_Z5xA,16025
78
+ fmtr_tools-1.2.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
79
+ fmtr_tools-1.2.6.dist-info/entry_points.txt,sha256=fSQrDGNctdQXbUxpMWYVfVQ0mhZMDyaEDG3D3a0zOSc,278
80
+ fmtr_tools-1.2.6.dist-info/top_level.txt,sha256=LXem9xCgNOD72tE2gRKESdiQTL902mfFkwWb6-dlwEE,5
81
+ fmtr_tools-1.2.6.dist-info/RECORD,,
fmtr/tools/dns_tools.py DELETED
@@ -1,221 +0,0 @@
1
- import dns
2
- import httpx
3
- import socket
4
- from dataclasses import dataclass
5
- from dns.message import Message
6
- from functools import cached_property
7
- from httpx_retries import Retry, RetryTransport
8
- from typing import Optional, Self
9
-
10
- from fmtr.tools import Client, logger
11
-
12
- RETRY_STRATEGY = Retry(
13
- total=2, # initial + 1 retry
14
- allowed_methods={"GET", "POST"},
15
- status_forcelist={502, 503, 504},
16
- retry_on_exceptions=None, # defaults to httpx.TransportError etc.
17
- backoff_factor=0.25, # short backoff (e.g. 0.25s, 0.5s)
18
- max_backoff_wait=0.75, # max total delay before giving up
19
- backoff_jitter=0.1, # small jitter to avoid retry bursts
20
- respect_retry_after_header=False, # DoH resolvers probably won't set this
21
- )
22
-
23
-
24
- class HTTPClientDoH(Client):
25
- """
26
-
27
- Base HTTP client for DoH-appropriate retry strategy.
28
-
29
- """
30
- TRANSPORT = RetryTransport(retry=RETRY_STRATEGY)
31
-
32
-
33
- @dataclass
34
- class BaseDNSData:
35
- """
36
-
37
- DNS response object.
38
-
39
- """
40
- wire: bytes
41
-
42
- @cached_property
43
- def message(self) -> Message:
44
- return dns.message.from_wire(self.wire)
45
-
46
- @classmethod
47
- def from_message(cls, message: Message) -> Self:
48
- return cls(message.to_wire())
49
-
50
-
51
- @dataclass
52
- class Response(BaseDNSData):
53
- """
54
-
55
- DNS response object.
56
-
57
- """
58
-
59
- http: Optional[httpx.Response] = None
60
-
61
- @classmethod
62
- def from_http(cls, response: httpx.Response) -> Self:
63
- self = cls(response.content, http=response)
64
- return self
65
-
66
-
67
- @dataclass
68
- class Request(BaseDNSData):
69
- """
70
-
71
- DNS request object.
72
-
73
- """
74
- wire: bytes
75
-
76
- @cached_property
77
- def question(self):
78
- return self.message.question[0]
79
-
80
- @cached_property
81
- def is_valid(self):
82
- return len(self.message.question) != 0
83
-
84
- @cached_property
85
- def type(self):
86
- return self.question.rdtype
87
-
88
- @cached_property
89
- def type_text(self):
90
- return dns.rdatatype.to_text(self.type)
91
-
92
- @cached_property
93
- def name(self):
94
- return self.question.name
95
-
96
- @cached_property
97
- def name_text(self):
98
- return self.name.to_text()
99
-
100
- @cached_property
101
- def blackhole(self) -> Response:
102
- blackhole = dns.message.make_response(self.message)
103
- blackhole.flags |= dns.flags.RA
104
- blackhole.set_rcode(dns.rcode.NXDOMAIN)
105
- response = Response.from_message(blackhole)
106
- return response
107
-
108
-
109
- @dataclass
110
- class Exchange:
111
- """
112
-
113
- Entire DNS exchange for a DNS Proxy: request -> upstream response -> response
114
-
115
- """
116
- ip: str
117
- port: int
118
-
119
- request: Request
120
- response: Optional[Response] = None
121
- response_upstream: Optional[Response] = None
122
-
123
- @classmethod
124
- def from_wire(cls, wire: bytes, ip: str, port: int) -> Self:
125
- request = Request(wire)
126
- return cls(request=request, ip=ip, port=port)
127
-
128
- @cached_property
129
- def client(self):
130
- return f'{self.ip}:{self.port}'
131
-
132
-
133
- class BasePlain:
134
- """
135
-
136
- Base for starting a plain DNS server
137
-
138
- """
139
-
140
- def __init__(self, host, port):
141
- self.host = host
142
- self.port = port
143
- self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
144
-
145
- def resolve(self, exchange: Exchange):
146
- raise NotImplemented
147
-
148
- def start(self):
149
- """
150
-
151
- Listen and resolve via overridden resolve method.
152
-
153
- """
154
- sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
155
- sock.bind((self.host, self.port))
156
- print(f"Listening on {self.host}:{self.port}")
157
- while True:
158
- data, (ip, port) = sock.recvfrom(512)
159
- exchange = Exchange.from_wire(data, ip=ip, port=port)
160
- self.resolve(exchange)
161
- sock.sendto(exchange.response.wire, (ip, port))
162
-
163
-
164
- class BaseDoHProxy(BasePlain):
165
- """
166
-
167
- Base for a DNS Proxy server
168
-
169
- """
170
-
171
- URL = None
172
- HEADERS = {"Content-Type": "application/dns-message"}
173
- client = HTTPClientDoH()
174
-
175
- def process_question(self, exchange: Exchange):
176
- return
177
-
178
- def process_upstream(self, exchange: Exchange):
179
- return
180
-
181
- def from_upstream(self, exchange: Exchange) -> Exchange:
182
-
183
- request = exchange.request
184
- response_doh = self.client.post(self.URL, headers=self.HEADERS, content=request.wire)
185
- response_doh.raise_for_status()
186
- response = Response.from_http(response_doh)
187
- exchange.response_upstream = response
188
-
189
- return exchange
190
-
191
- def resolve(self, exchange: Exchange):
192
- """
193
-
194
- Resolve a request, processing each stage, initial question, upstream response etc.
195
- Subclasses can override the relevant processing methods to implement custom behaviour.
196
-
197
- """
198
-
199
- request = exchange.request
200
-
201
- with logger.span(f'Handling request for {request.name_text} from {exchange.client}...'):
202
-
203
- if not request.is_valid:
204
- raise ValueError(f'Only one question per request is supported. Got {len(request.question)} questions.')
205
-
206
- with logger.span(f'Processing question...'):
207
- self.process_question(exchange)
208
- if exchange.response:
209
- return
210
-
211
- with logger.span(f'Making upstream request for {request.name_text}...'):
212
- self.from_upstream(exchange)
213
-
214
- with logger.span(f'Processing upstream response...'):
215
- self.process_upstream(exchange)
216
-
217
- if exchange.response:
218
- return
219
-
220
- exchange.response = exchange.response_upstream
221
- return