fmtr.tools 1.1.1__py3-none-any.whl → 1.4.37__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.
- fmtr/tools/__init__.py +86 -52
- fmtr/tools/ai_tools/__init__.py +2 -2
- fmtr/tools/ai_tools/agentic_tools.py +151 -32
- fmtr/tools/ai_tools/inference_tools.py +2 -1
- fmtr/tools/api_tools.py +73 -12
- fmtr/tools/async_tools.py +4 -0
- fmtr/tools/av_tools.py +7 -0
- fmtr/tools/caching_tools.py +101 -3
- fmtr/tools/constants.py +41 -0
- fmtr/tools/context_tools.py +23 -0
- fmtr/tools/data_modelling_tools.py +227 -14
- fmtr/tools/database_tools/__init__.py +6 -0
- fmtr/tools/database_tools/document.py +51 -0
- fmtr/tools/datatype_tools.py +22 -2
- fmtr/tools/datetime_tools.py +12 -0
- fmtr/tools/debugging_tools.py +60 -1
- fmtr/tools/dns_tools/__init__.py +7 -0
- fmtr/tools/dns_tools/client.py +97 -0
- fmtr/tools/dns_tools/dm.py +257 -0
- fmtr/tools/dns_tools/proxy.py +66 -0
- fmtr/tools/dns_tools/server.py +138 -0
- fmtr/tools/docker_tools/__init__.py +6 -0
- fmtr/tools/entrypoints/__init__.py +0 -0
- fmtr/tools/entrypoints/cache_hfh.py +3 -0
- fmtr/tools/entrypoints/ep_test.py +2 -0
- fmtr/tools/entrypoints/install_yamlscript.py +8 -0
- fmtr/tools/{console_script_tools.py → entrypoints/remote_debug_test.py} +1 -6
- fmtr/tools/entrypoints/shell_debug.py +8 -0
- fmtr/tools/environment_tools.py +3 -2
- fmtr/tools/function_tools.py +77 -1
- fmtr/tools/google_api_tools.py +15 -4
- fmtr/tools/ha_tools/__init__.py +8 -0
- fmtr/tools/ha_tools/constants.py +9 -0
- fmtr/tools/ha_tools/core.py +16 -0
- fmtr/tools/ha_tools/supervisor.py +16 -0
- fmtr/tools/ha_tools/utils.py +46 -0
- fmtr/tools/http_tools.py +52 -0
- fmtr/tools/inherit_tools.py +27 -0
- fmtr/tools/interface_tools/__init__.py +8 -0
- fmtr/tools/interface_tools/context.py +13 -0
- fmtr/tools/interface_tools/controls.py +354 -0
- fmtr/tools/interface_tools/interface_tools.py +189 -0
- fmtr/tools/iterator_tools.py +122 -1
- fmtr/tools/logging_tools.py +99 -18
- fmtr/tools/mqtt_tools.py +89 -0
- fmtr/tools/networking_tools.py +73 -0
- fmtr/tools/packaging_tools.py +14 -0
- fmtr/tools/path_tools/__init__.py +12 -0
- fmtr/tools/path_tools/app_path_tools.py +40 -0
- fmtr/tools/{path_tools.py → path_tools/path_tools.py} +217 -14
- fmtr/tools/path_tools/type_path_tools.py +3 -0
- fmtr/tools/pattern_tools.py +277 -0
- fmtr/tools/pdf_tools.py +39 -1
- fmtr/tools/settings_tools.py +27 -6
- fmtr/tools/setup_tools/__init__.py +8 -0
- fmtr/tools/setup_tools/setup_tools.py +481 -0
- fmtr/tools/string_tools.py +92 -13
- fmtr/tools/tabular_tools.py +61 -0
- fmtr/tools/tools.py +27 -2
- fmtr/tools/version +1 -1
- fmtr/tools/version_tools/__init__.py +12 -0
- fmtr/tools/version_tools/version_tools.py +51 -0
- fmtr/tools/webhook_tools.py +17 -0
- fmtr/tools/yaml_tools.py +64 -5
- fmtr/tools/youtube_tools.py +128 -0
- fmtr_tools-1.4.37.data/scripts/add-service +14 -0
- fmtr_tools-1.4.37.data/scripts/add-user-path +8 -0
- fmtr_tools-1.4.37.data/scripts/apt-headless +23 -0
- fmtr_tools-1.4.37.data/scripts/compose-update +10 -0
- fmtr_tools-1.4.37.data/scripts/docker-sandbox +43 -0
- fmtr_tools-1.4.37.data/scripts/docker-sandbox-init +23 -0
- fmtr_tools-1.4.37.data/scripts/docs-deploy +6 -0
- fmtr_tools-1.4.37.data/scripts/docs-serve +5 -0
- fmtr_tools-1.4.37.data/scripts/download +9 -0
- fmtr_tools-1.4.37.data/scripts/fmtr-test-script +3 -0
- fmtr_tools-1.4.37.data/scripts/ftu +3 -0
- fmtr_tools-1.4.37.data/scripts/ha-addon-launch +16 -0
- fmtr_tools-1.4.37.data/scripts/install-browser +8 -0
- fmtr_tools-1.4.37.data/scripts/parse-args +43 -0
- fmtr_tools-1.4.37.data/scripts/set-password +5 -0
- fmtr_tools-1.4.37.data/scripts/snips-install +14 -0
- fmtr_tools-1.4.37.data/scripts/ssh-auth +28 -0
- fmtr_tools-1.4.37.data/scripts/ssh-serve +15 -0
- fmtr_tools-1.4.37.data/scripts/vlc-tn +10 -0
- fmtr_tools-1.4.37.data/scripts/vm-launch +17 -0
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/METADATA +178 -54
- fmtr_tools-1.4.37.dist-info/RECORD +122 -0
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/WHEEL +1 -1
- fmtr_tools-1.4.37.dist-info/entry_points.txt +6 -0
- fmtr_tools-1.4.37.dist-info/top_level.txt +1 -0
- fmtr/tools/docker_tools.py +0 -30
- fmtr/tools/interface_tools.py +0 -64
- fmtr/tools/version_tools.py +0 -62
- fmtr_tools-1.1.1.dist-info/RECORD +0 -65
- fmtr_tools-1.1.1.dist-info/entry_points.txt +0 -3
- fmtr_tools-1.1.1.dist-info/top_level.txt +0 -2
- {fmtr_tools-1.1.1.dist-info → fmtr_tools-1.4.37.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from dns import query as dnspython_query, message as dnspython_message, rdatatype as dnspython_rdatatype, rcode as dnspython_rcode
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
from httpx_retries import Retry, RetryTransport
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from fmtr.tools import http_tools as http
|
|
8
|
+
from fmtr.tools.dns_tools.dm import Exchange, Response
|
|
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
|
+
@dataclass
|
|
33
|
+
class Plain:
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
Plain DNS
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
host: str
|
|
40
|
+
port: int = 53
|
|
41
|
+
ttl_min: Optional[int] = None
|
|
42
|
+
|
|
43
|
+
def resolve(self, exchange: Exchange):
|
|
44
|
+
|
|
45
|
+
with logger.span(f'UDP {self.host}:{self.port}'):
|
|
46
|
+
response_plain = dnspython_query.udp(q=exchange.query_last, where=self.host, port=self.port)
|
|
47
|
+
response = Response.from_message(response_plain)
|
|
48
|
+
for answer in response.message.answer:
|
|
49
|
+
answer.ttl = max(answer.ttl, self.ttl_min or answer.ttl)
|
|
50
|
+
|
|
51
|
+
exchange.response = response
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class HTTP:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
DNS over HTTP
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
HEADERS = {"Content-Type": "application/dns-message"}
|
|
63
|
+
CLIENT = HTTPClientDoH()
|
|
64
|
+
BOOTSTRAP = Plain('8.8.8.8')
|
|
65
|
+
|
|
66
|
+
host: str
|
|
67
|
+
url: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cached_property
|
|
71
|
+
def ip(self):
|
|
72
|
+
message = dnspython_message.make_query(self.host, dnspython_rdatatype.A, flags=0)
|
|
73
|
+
exchange = Exchange.from_wire(message.to_wire(), ip=None, port=None)
|
|
74
|
+
self.BOOTSTRAP.resolve(exchange)
|
|
75
|
+
ip = next(iter(exchange.response.answer.items.keys())).address
|
|
76
|
+
return ip
|
|
77
|
+
|
|
78
|
+
def resolve(self, exchange: Exchange):
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
Resolve via DoH
|
|
82
|
+
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
headers = self.HEADERS | dict(Host=self.host)
|
|
86
|
+
url = self.url.format(host=self.ip)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
response_doh = self.CLIENT.post(url, headers=headers, content=exchange.query_last.to_wire())
|
|
90
|
+
response_doh.raise_for_status()
|
|
91
|
+
response = Response.from_http(response_doh)
|
|
92
|
+
exchange.response = response
|
|
93
|
+
|
|
94
|
+
except Exception as exception:
|
|
95
|
+
exchange.response.message.set_rcode(dnspython_rcode.SERVFAIL)
|
|
96
|
+
exchange.response.is_complete = True
|
|
97
|
+
logger.exception(exception)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import dns
|
|
2
|
+
import httpx
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from dns import rcode as dnspython_rcode, reversename as dnspython_reversename
|
|
5
|
+
from dns.message import Message, QueryMessage
|
|
6
|
+
from dns.rrset import RRset
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import Self, Optional, List
|
|
9
|
+
|
|
10
|
+
from fmtr.tools.string_tools import join
|
|
11
|
+
|
|
12
|
+
TTL_CODE_DEFAULTS = {
|
|
13
|
+
dnspython_rcode.NOERROR: 300, # Successful query
|
|
14
|
+
dnspython_rcode.FORMERR: 60, # Format error
|
|
15
|
+
dnspython_rcode.SERVFAIL: 10, # Server failure
|
|
16
|
+
dnspython_rcode.NXDOMAIN: 60 * 60, # Non-existent domain
|
|
17
|
+
dnspython_rcode.NOTIMP: 60, # Not implemented
|
|
18
|
+
dnspython_rcode.REFUSED: 60, # Refused
|
|
19
|
+
dnspython_rcode.YXDOMAIN: 600, # Name exists when it should not
|
|
20
|
+
dnspython_rcode.YXRRSET: 600, # RR Set exists when it should not
|
|
21
|
+
dnspython_rcode.NXRRSET: 300, # RR Set that should exist does not
|
|
22
|
+
dnspython_rcode.NOTAUTH: 60, # Not authorized
|
|
23
|
+
dnspython_rcode.NOTZONE: 60 # Name not contained in zone
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class BaseDNSData:
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
DNS response object.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
wire: bytes
|
|
34
|
+
|
|
35
|
+
@cached_property
|
|
36
|
+
def message(self) -> Message:
|
|
37
|
+
return dns.message.from_wire(self.wire)
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_message(cls, message: Message) -> Self:
|
|
41
|
+
return cls(message.to_wire())
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Response(BaseDNSData):
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
DNS response object.
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
http: Optional[httpx.Response] = None
|
|
52
|
+
blocked_by: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def from_http(cls, response: httpx.Response) -> Self:
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
Initialise from an HTTP response.
|
|
59
|
+
|
|
60
|
+
"""
|
|
61
|
+
self = cls(response.content, http=response)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def answer(self) -> Optional[RRset]:
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
Get the latest answer, if one exists.
|
|
69
|
+
|
|
70
|
+
"""
|
|
71
|
+
if not self.message.answer:
|
|
72
|
+
return None
|
|
73
|
+
return self.message.answer[-1]
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def rcode(self) -> dnspython_rcode.Rcode:
|
|
77
|
+
return self.message.rcode()
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def rcode_text(self) -> str:
|
|
81
|
+
return dnspython_rcode.to_text(self.rcode)
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def ttl(self) -> int:
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
Get median TTL from answers, falling back to authority, then to error-code defaults.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
answers = self.message.answer or self.message.authority
|
|
91
|
+
if answers:
|
|
92
|
+
ttls = [answer.ttl for answer in answers]
|
|
93
|
+
ttl = min(ttls)
|
|
94
|
+
return ttl
|
|
95
|
+
|
|
96
|
+
ttl = TTL_CODE_DEFAULTS.get(self.rcode, dnspython_rcode.NXDOMAIN)
|
|
97
|
+
return ttl
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def __str__(self):
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
Put answer and ID text in string representation.
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
answer = self.answer
|
|
108
|
+
|
|
109
|
+
if answer:
|
|
110
|
+
answer = join(answer.to_text().splitlines(), sep=', ')
|
|
111
|
+
|
|
112
|
+
string = join([answer, self.message.flags], sep=', ')
|
|
113
|
+
string = f'{self.__class__.__name__}({string})'
|
|
114
|
+
return string
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class Request(BaseDNSData):
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
DNS request object.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
wire: bytes
|
|
126
|
+
|
|
127
|
+
@cached_property
|
|
128
|
+
def question(self) -> RRset:
|
|
129
|
+
return self.message.question[0]
|
|
130
|
+
|
|
131
|
+
@cached_property
|
|
132
|
+
def is_valid(self):
|
|
133
|
+
return len(self.message.question) != 0
|
|
134
|
+
|
|
135
|
+
@cached_property
|
|
136
|
+
def type(self):
|
|
137
|
+
return self.question.rdtype
|
|
138
|
+
|
|
139
|
+
@cached_property
|
|
140
|
+
def type_text(self):
|
|
141
|
+
return dns.rdatatype.to_text(self.type)
|
|
142
|
+
|
|
143
|
+
@cached_property
|
|
144
|
+
def name(self):
|
|
145
|
+
return self.question.name
|
|
146
|
+
|
|
147
|
+
@cached_property
|
|
148
|
+
def name_text(self):
|
|
149
|
+
return self.name.to_text()
|
|
150
|
+
|
|
151
|
+
def get_response_template(self):
|
|
152
|
+
message = dns.message.make_response(self.message)
|
|
153
|
+
message.flags |= dns.flags.RA
|
|
154
|
+
return message
|
|
155
|
+
|
|
156
|
+
@cached_property
|
|
157
|
+
def blackhole(self) -> Response:
|
|
158
|
+
blackhole = self.get_response_template()
|
|
159
|
+
blackhole.set_rcode(dns.rcode.NXDOMAIN)
|
|
160
|
+
response = Response.from_message(blackhole)
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class Exchange:
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
Entire DNS exchange for a DNS Proxy: request -> upstream response -> response
|
|
169
|
+
|
|
170
|
+
"""
|
|
171
|
+
ip: str
|
|
172
|
+
port: int
|
|
173
|
+
|
|
174
|
+
request: Request
|
|
175
|
+
response: Optional[Response] = None
|
|
176
|
+
answers_pre: List[RRset] = field(default_factory=list)
|
|
177
|
+
is_internal: bool = False
|
|
178
|
+
client_name: Optional[str] = None
|
|
179
|
+
is_complete: bool = False
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def addr(self):
|
|
183
|
+
return self.ip, self.port
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def from_wire(cls, wire: bytes, **kwargs) -> Self:
|
|
187
|
+
request = Request(wire)
|
|
188
|
+
response = Response.from_message(request.get_response_template())
|
|
189
|
+
return cls(request=request, response=response, **kwargs)
|
|
190
|
+
|
|
191
|
+
@cached_property
|
|
192
|
+
def client(self):
|
|
193
|
+
return f'{self.ip}:{self.port}'
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def question_last(self) -> RRset:
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
Create an RRset surrogate representing the latest/current question.
|
|
200
|
+
This can be the original question - or a hybrid one if we've injected our own answers into the Exchange.
|
|
201
|
+
If there's a response, use its answers, else fall back to answers_pre, else to the original question.
|
|
202
|
+
|
|
203
|
+
"""
|
|
204
|
+
answers = self.answers_pre
|
|
205
|
+
if self.response:
|
|
206
|
+
answers = self.response.message.answer or answers
|
|
207
|
+
|
|
208
|
+
if answers:
|
|
209
|
+
rrset = answers[-1]
|
|
210
|
+
rdtype = self.request.type
|
|
211
|
+
ttl = self.request.question.ttl
|
|
212
|
+
rdclass = self.request.question.rdclass
|
|
213
|
+
name = next(iter(rrset.items.keys())).to_text()
|
|
214
|
+
rrset_surrogate = dns.rrset.from_text(
|
|
215
|
+
name=name,
|
|
216
|
+
ttl=ttl,
|
|
217
|
+
rdtype=rdtype,
|
|
218
|
+
rdclass=rdclass,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return rrset_surrogate
|
|
222
|
+
else:
|
|
223
|
+
return self.request.question
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def query_last(self) -> QueryMessage:
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
Create a query (e.g. for use by upstream) based on the last question.
|
|
230
|
+
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
question_last = self.question_last
|
|
234
|
+
query = dns.message.make_query(qname=question_last.name, rdclass=question_last.rdclass, rdtype=question_last.rdtype, id=self.request.message.id)
|
|
235
|
+
return query
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def key(self):
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
Hashable key for caching
|
|
242
|
+
|
|
243
|
+
"""
|
|
244
|
+
data = tuple(self.request.question.to_text().split())
|
|
245
|
+
return data
|
|
246
|
+
|
|
247
|
+
@cached_property
|
|
248
|
+
def reverse(self) -> Self:
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
Create an Exchange for a reverse lookup of this Exchange's client IP.
|
|
252
|
+
|
|
253
|
+
"""
|
|
254
|
+
name = dnspython_reversename.from_address(self.ip)
|
|
255
|
+
query = dns.message.make_query(name, dns.rdatatype.PTR)
|
|
256
|
+
exchange = self.__class__.from_wire(query.to_wire(), ip=self.ip, port=self.port, is_internal=True)
|
|
257
|
+
return exchange
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from fmtr.tools.dns_tools import server, client
|
|
4
|
+
from fmtr.tools.dns_tools.dm import Exchange
|
|
5
|
+
from fmtr.tools.logging_tools import logger
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(kw_only=True, eq=False)
|
|
9
|
+
class Proxy(server.Plain):
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
Base for a DNS Proxy server (plain server) TODO: Allow subclassing of any server type.
|
|
13
|
+
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
client: client.HTTP
|
|
17
|
+
|
|
18
|
+
def process_question(self, exchange: Exchange):
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
Modify exchange based on initial question.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
def process_upstream(self, exchange: Exchange):
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
Modify exchange after upstream response.
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
def finalize(self, exchange: Exchange):
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
Finalize a still open exchange.
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
exchange.is_complete = True
|
|
41
|
+
|
|
42
|
+
async def resolve(self, exchange: Exchange) -> Exchange:
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
Resolve a request, processing each stage, initial question, upstream response etc.
|
|
46
|
+
Subclasses can override the relevant processing methods to implement custom behaviour.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
with logger.span(f'Processing question...'):
|
|
50
|
+
self.process_question(exchange)
|
|
51
|
+
if exchange.is_complete:
|
|
52
|
+
return exchange
|
|
53
|
+
|
|
54
|
+
with logger.span(f'Making upstream request...'):
|
|
55
|
+
self.client.resolve(exchange)
|
|
56
|
+
if exchange.is_complete:
|
|
57
|
+
return exchange
|
|
58
|
+
|
|
59
|
+
with logger.span(f'Processing upstream response...'):
|
|
60
|
+
self.process_upstream(exchange)
|
|
61
|
+
if exchange.is_complete:
|
|
62
|
+
return exchange
|
|
63
|
+
|
|
64
|
+
self.finalize(exchange)
|
|
65
|
+
|
|
66
|
+
return exchange
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import timedelta
|
|
4
|
+
from dns import rcode as dnspython_rcode
|
|
5
|
+
from functools import cached_property
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from fmtr.tools import caching_tools as caching
|
|
9
|
+
from fmtr.tools.dns_tools.dm import Exchange
|
|
10
|
+
from fmtr.tools.logging_tools import logger
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(kw_only=True, eq=False)
|
|
14
|
+
class Plain(asyncio.DatagramProtocol):
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
Async base class for a plain DNS server using asyncio DatagramProtocol.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
host: str
|
|
21
|
+
port: int
|
|
22
|
+
transport: Optional[asyncio.DatagramTransport] = field(default=None, init=False)
|
|
23
|
+
|
|
24
|
+
@cached_property
|
|
25
|
+
def loop(self):
|
|
26
|
+
return asyncio.get_event_loop()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@cached_property
|
|
30
|
+
def cache(self):
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
Overridable cache.
|
|
34
|
+
"""
|
|
35
|
+
cache = caching.TLRU(maxsize=1_024, ttu_static=timedelta(hours=1), desc='DNS Request')
|
|
36
|
+
return cache
|
|
37
|
+
|
|
38
|
+
def connection_made(self, transport: asyncio.DatagramTransport):
|
|
39
|
+
self.transport = transport
|
|
40
|
+
logger.info(f'Listening on {self.host}:{self.port}')
|
|
41
|
+
|
|
42
|
+
def datagram_received(self, data: bytes, addr):
|
|
43
|
+
ip, port = addr
|
|
44
|
+
exchange = Exchange.from_wire(data, ip=ip, port=port)
|
|
45
|
+
asyncio.create_task(self.handle(exchange))
|
|
46
|
+
|
|
47
|
+
async def start(self):
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
Start the async UDP server.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
logger.info(f'Starting async DNS server on {self.host}:{self.port}...')
|
|
54
|
+
await self.loop.create_datagram_endpoint(
|
|
55
|
+
lambda: self,
|
|
56
|
+
local_addr=(self.host, self.port)
|
|
57
|
+
)
|
|
58
|
+
await asyncio.Future() # Prevent exit by blocking forever
|
|
59
|
+
|
|
60
|
+
async def resolve(self, exchange: Exchange) -> Exchange:
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
To be defined in subclasses.
|
|
64
|
+
|
|
65
|
+
"""
|
|
66
|
+
raise NotImplementedError
|
|
67
|
+
|
|
68
|
+
def check_cache(self, exchange: Exchange):
|
|
69
|
+
if exchange.key in self.cache:
|
|
70
|
+
logger.info(f'Request found in cache.')
|
|
71
|
+
exchange.response = self.cache[exchange.key]
|
|
72
|
+
exchange.response.message.id = exchange.request.message.id
|
|
73
|
+
exchange.is_complete = True
|
|
74
|
+
|
|
75
|
+
def get_span(self, exchange: Exchange):
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
Get handling span
|
|
79
|
+
|
|
80
|
+
"""
|
|
81
|
+
request = exchange.request
|
|
82
|
+
span = logger.span(
|
|
83
|
+
f'Handling request {exchange.client_name=} {request.message.id=} {request.type_text} {request.name_text} {request.question=}...'
|
|
84
|
+
)
|
|
85
|
+
return span
|
|
86
|
+
|
|
87
|
+
def log_response(self, exchange: Exchange):
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
Log when resolution complete
|
|
91
|
+
|
|
92
|
+
"""
|
|
93
|
+
request = exchange.request
|
|
94
|
+
response = exchange.response
|
|
95
|
+
logger.info(
|
|
96
|
+
f'Resolution complete {exchange.client_name=} {request.message.id=} {request.type_text} {request.name_text} {request.question=} {exchange.is_complete=} {response.rcode=} {response.rcode_text=} {response.answer=} {response.blocked_by=}...'
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def log_dns_errors(self, exchange: Exchange):
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
Warn about any errors
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
if exchange.response.rcode != dnspython_rcode.NOERROR:
|
|
106
|
+
logger.warning(f'Error {exchange.response.rcode_text=}')
|
|
107
|
+
|
|
108
|
+
async def handle(self, exchange: Exchange):
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
Warn about any errors
|
|
112
|
+
|
|
113
|
+
"""
|
|
114
|
+
if not exchange.request.is_valid:
|
|
115
|
+
raise ValueError(f'Only one question per request is supported. Got {len(exchange.request.question)} questions.')
|
|
116
|
+
|
|
117
|
+
if not exchange.is_internal:
|
|
118
|
+
await self.handle(exchange.reverse)
|
|
119
|
+
client_name = exchange.reverse.question_last.name.to_text()
|
|
120
|
+
if not exchange.reverse.response.answer:
|
|
121
|
+
logger.warning(f'Client name could not be resolved {client_name=}.')
|
|
122
|
+
exchange.client_name = client_name
|
|
123
|
+
|
|
124
|
+
with self.get_span(exchange):
|
|
125
|
+
with logger.span(f'Checking cache...'):
|
|
126
|
+
self.check_cache(exchange)
|
|
127
|
+
|
|
128
|
+
if not exchange.is_complete:
|
|
129
|
+
exchange = await self.resolve(exchange)
|
|
130
|
+
self.cache[exchange.key] = exchange.response
|
|
131
|
+
|
|
132
|
+
self.log_dns_errors(exchange)
|
|
133
|
+
self.log_response(exchange)
|
|
134
|
+
|
|
135
|
+
if exchange.is_internal:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
self.transport.sendto(exchange.response.message.to_wire(), exchange.addr)
|
|
File without changes
|
fmtr/tools/environment_tools.py
CHANGED
|
@@ -28,7 +28,7 @@ def get_dict() -> Dict[str, str]:
|
|
|
28
28
|
Return environment variables as a standard dictionary.
|
|
29
29
|
|
|
30
30
|
"""
|
|
31
|
-
environment_dict = dict(os.environ)
|
|
31
|
+
environment_dict = dict(sorted(dict(os.environ).items()))
|
|
32
32
|
return environment_dict
|
|
33
33
|
|
|
34
34
|
|
|
@@ -76,4 +76,5 @@ get_date = get_getter(date.fromisoformat)
|
|
|
76
76
|
get_datetime = get_getter(datetime.fromisoformat)
|
|
77
77
|
get_path = get_getter(Path)
|
|
78
78
|
|
|
79
|
-
|
|
79
|
+
IS_DEV = get_bool(Constants.FMTR_DEV_KEY, default=False)
|
|
80
|
+
CHANNEL = Constants.DEVELOPMENT if IS_DEV else Constants.PRODUCTION
|