dominusnode 2.0.0__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.
- dominusnode/__init__.py +45 -0
- dominusnode/_ops.py +122 -0
- dominusnode/client.py +192 -0
- dominusnode/constants.py +24 -0
- dominusnode/errors.py +153 -0
- dominusnode/http_client.py +354 -0
- dominusnode/messages.py +79 -0
- dominusnode/pow.py +33 -0
- dominusnode/proxy_fetch.py +341 -0
- dominusnode/py.typed +0 -0
- dominusnode/resources.py +791 -0
- dominusnode/secret.py +60 -0
- dominusnode/signer.py +56 -0
- dominusnode/ssrf.py +176 -0
- dominusnode/token_manager.py +150 -0
- dominusnode/types.py +99 -0
- dominusnode/validation.py +117 -0
- dominusnode-2.0.0.dist-info/METADATA +177 -0
- dominusnode-2.0.0.dist-info/RECORD +22 -0
- dominusnode-2.0.0.dist-info/WHEEL +5 -0
- dominusnode-2.0.0.dist-info/licenses/LICENSE +21 -0
- dominusnode-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Proxy username builder and the GET/HEAD fetch helper (stdlib sockets + ssl, no extra deps)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import http.client
|
|
7
|
+
import io
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import ssl
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
14
|
+
|
|
15
|
+
from .constants import DEFAULT_TLS_PROXY_PORT, MAX_RESPONSE_BYTES
|
|
16
|
+
from .errors import ProxyError, invalid_arg, scrub_credentials
|
|
17
|
+
from .ssrf import LookupAll, assert_public_resolution, validate_target_url
|
|
18
|
+
from .validation import UUID_RE
|
|
19
|
+
|
|
20
|
+
OFAC_BLOCKED = frozenset({"CU", "IR", "KP", "RU", "SY"})
|
|
21
|
+
IP_CLASSES = ("residential", "dc", "mobile")
|
|
22
|
+
_COUNTRY_RE = re.compile(r"^[A-Za-z]{2}\Z")
|
|
23
|
+
_SESSION_RE = re.compile(r"^[A-Za-z0-9]{1,64}\Z")
|
|
24
|
+
_TOKEN_RE = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+\Z")
|
|
25
|
+
_CTL_RE = re.compile(r"[\r\n\0]")
|
|
26
|
+
|
|
27
|
+
FORBIDDEN_HEADERS = frozenset(
|
|
28
|
+
{
|
|
29
|
+
"host", "connection", "content-length", "transfer-encoding", "proxy-authorization",
|
|
30
|
+
"proxy-connection", "authorization", "upgrade", "te", "trailer", "keep-alive",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_proxy_username(
|
|
36
|
+
country: Optional[str] = None,
|
|
37
|
+
ip_class: Optional[str] = None,
|
|
38
|
+
session_id: Optional[str] = None,
|
|
39
|
+
agentic_wallet_id: Optional[str] = None,
|
|
40
|
+
allow_http: bool = False,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Gateway username: hyphen-joined routing flags in fixed order; ``relay`` when none."""
|
|
43
|
+
parts = []
|
|
44
|
+
if country is not None:
|
|
45
|
+
if not isinstance(country, str) or not _COUNTRY_RE.match(country):
|
|
46
|
+
raise invalid_arg("country must be a 2-letter ISO code")
|
|
47
|
+
c = country.upper()
|
|
48
|
+
if c in OFAC_BLOCKED:
|
|
49
|
+
raise invalid_arg(f"country {c} is sanctioned (OFAC) and cannot be targeted")
|
|
50
|
+
parts.append(f"country-{c}")
|
|
51
|
+
if ip_class is not None:
|
|
52
|
+
if ip_class not in IP_CLASSES:
|
|
53
|
+
raise invalid_arg("ip_class must be residential, dc or mobile")
|
|
54
|
+
parts.append(f"class-{ip_class}")
|
|
55
|
+
if session_id is not None:
|
|
56
|
+
if not isinstance(session_id, str) or not _SESSION_RE.match(session_id):
|
|
57
|
+
raise invalid_arg("session_id must be 1-64 alphanumeric characters")
|
|
58
|
+
parts.append(f"session-{session_id}")
|
|
59
|
+
if agentic_wallet_id is not None:
|
|
60
|
+
if not isinstance(agentic_wallet_id, str) or not UUID_RE.match(agentic_wallet_id):
|
|
61
|
+
raise invalid_arg("agentic_wallet_id must be a UUID")
|
|
62
|
+
parts.append(f"wallet-{agentic_wallet_id.lower()}")
|
|
63
|
+
if allow_http is True:
|
|
64
|
+
parts.append("allowhttp-1")
|
|
65
|
+
return "-".join(parts) if parts else "relay"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ProxyFetchResponse:
|
|
70
|
+
status: int
|
|
71
|
+
status_text: str
|
|
72
|
+
headers: Dict[str, str]
|
|
73
|
+
body: bytes = field(repr=False)
|
|
74
|
+
truncated: bool
|
|
75
|
+
|
|
76
|
+
def text(self) -> str:
|
|
77
|
+
return self.body.decode("utf-8", errors="replace")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _clean_headers(headers: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
81
|
+
out: Dict[str, str] = {}
|
|
82
|
+
if not headers:
|
|
83
|
+
return out
|
|
84
|
+
for k, v in headers.items():
|
|
85
|
+
if (
|
|
86
|
+
not isinstance(k, str)
|
|
87
|
+
or not isinstance(v, str)
|
|
88
|
+
or _CTL_RE.search(k)
|
|
89
|
+
or _CTL_RE.search(v)
|
|
90
|
+
or not _TOKEN_RE.match(k)
|
|
91
|
+
):
|
|
92
|
+
raise invalid_arg(f'header "{str(k)[:40]}" contains invalid characters')
|
|
93
|
+
if k.lower() not in FORBIDDEN_HEADERS:
|
|
94
|
+
out[k] = v
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class _Prepared:
|
|
100
|
+
scheme: str
|
|
101
|
+
host: str
|
|
102
|
+
port: int
|
|
103
|
+
host_header: str
|
|
104
|
+
path: str
|
|
105
|
+
method: str
|
|
106
|
+
headers: Dict[str, str]
|
|
107
|
+
proxy_auth: str
|
|
108
|
+
timeout: float
|
|
109
|
+
max_bytes: int
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def prepare_fetch(
|
|
113
|
+
api_key: str,
|
|
114
|
+
url: str,
|
|
115
|
+
method: str = "GET",
|
|
116
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
117
|
+
timeout_ms: Optional[float] = None,
|
|
118
|
+
max_bytes: Optional[int] = None,
|
|
119
|
+
country: Optional[str] = None,
|
|
120
|
+
ip_class: Optional[str] = None,
|
|
121
|
+
session_id: Optional[str] = None,
|
|
122
|
+
agentic_wallet_id: Optional[str] = None,
|
|
123
|
+
allow_http: bool = False,
|
|
124
|
+
) -> _Prepared:
|
|
125
|
+
"""Steps 1-5: every check that needs no network I/O."""
|
|
126
|
+
m = str(method or "GET").upper()
|
|
127
|
+
if m not in ("GET", "HEAD"):
|
|
128
|
+
raise invalid_arg("proxy.fetch only allows GET and HEAD")
|
|
129
|
+
u = validate_target_url(url)
|
|
130
|
+
scheme = u.scheme.lower()
|
|
131
|
+
if scheme == "http" and allow_http is not True:
|
|
132
|
+
raise invalid_arg(
|
|
133
|
+
"plaintext http:// requires allow_http=True (the exit node can read and modify it); prefer https://"
|
|
134
|
+
)
|
|
135
|
+
username = build_proxy_username(country, ip_class, session_id, agentic_wallet_id, allow_http)
|
|
136
|
+
clean = _clean_headers(headers)
|
|
137
|
+
t = 30_000.0 if timeout_ms is None else float(timeout_ms)
|
|
138
|
+
if t != t: # NaN guard
|
|
139
|
+
raise invalid_arg("timeout_ms must be a number")
|
|
140
|
+
t = min(max(t, 1.0), 120_000.0)
|
|
141
|
+
mb = MAX_RESPONSE_BYTES if max_bytes is None else max_bytes
|
|
142
|
+
if not isinstance(mb, int) or isinstance(mb, bool):
|
|
143
|
+
raise invalid_arg("max_bytes must be an integer")
|
|
144
|
+
mb = min(max(mb, 1), MAX_RESPONSE_BYTES)
|
|
145
|
+
host = (u.hostname or "").lower().rstrip(".")
|
|
146
|
+
default_port = 443 if scheme == "https" else 80
|
|
147
|
+
port = u.port or default_port
|
|
148
|
+
bracketed = f"[{host}]" if ":" in host else host
|
|
149
|
+
host_header = bracketed if port == default_port else f"{bracketed}:{port}"
|
|
150
|
+
path = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
151
|
+
token = base64.b64encode(f"{username}:{api_key}".encode("utf-8")).decode("ascii")
|
|
152
|
+
return _Prepared(scheme, host, port, host_header, path, m, clean, "Basic " + token, t / 1000.0, mb)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _read_head(sock: socket.socket, limit: int = 64 * 1024) -> bytes:
|
|
156
|
+
buf = b""
|
|
157
|
+
while b"\r\n\r\n" not in buf:
|
|
158
|
+
chunk = sock.recv(1)
|
|
159
|
+
if not chunk:
|
|
160
|
+
break
|
|
161
|
+
buf += chunk
|
|
162
|
+
if len(buf) > limit:
|
|
163
|
+
raise ProxyError("Proxy response header too large")
|
|
164
|
+
return buf
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _request_bytes(method: str, target: str, host_header: str, headers: Dict[str, str], extra: Dict[str, str]) -> bytes:
|
|
168
|
+
lines = [f"{method} {target} HTTP/1.1", f"Host: {host_header}"]
|
|
169
|
+
lines += [f"{k}: {v}" for k, v in headers.items()]
|
|
170
|
+
lines += [f"{k}: {v}" for k, v in extra.items()]
|
|
171
|
+
lines.append("Connection: close")
|
|
172
|
+
return ("\r\n".join(lines) + "\r\n\r\n").encode("latin-1")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _collect(sock: socket.socket, method: str, max_bytes: int, deadline: float) -> ProxyFetchResponse:
|
|
176
|
+
resp = http.client.HTTPResponse(sock, method=method) # type: ignore[arg-type]
|
|
177
|
+
try:
|
|
178
|
+
resp.begin()
|
|
179
|
+
headers: Dict[str, str] = {}
|
|
180
|
+
for k, v in resp.getheaders():
|
|
181
|
+
lk = k.lower()
|
|
182
|
+
headers[lk] = f"{headers[lk]}, {v}" if lk in headers else v
|
|
183
|
+
chunks = []
|
|
184
|
+
size = 0
|
|
185
|
+
truncated = False
|
|
186
|
+
if method != "HEAD":
|
|
187
|
+
while size < max_bytes:
|
|
188
|
+
if time.monotonic() > deadline:
|
|
189
|
+
raise socket.timeout("deadline")
|
|
190
|
+
c = resp.read(min(65536, max_bytes - size))
|
|
191
|
+
if not c:
|
|
192
|
+
break
|
|
193
|
+
chunks.append(c)
|
|
194
|
+
size += len(c)
|
|
195
|
+
if size >= max_bytes and resp.read(1):
|
|
196
|
+
truncated = True
|
|
197
|
+
return ProxyFetchResponse(resp.status, resp.reason or "", headers, b"".join(chunks), truncated)
|
|
198
|
+
finally:
|
|
199
|
+
resp.close()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class GatewayTls:
|
|
204
|
+
"""TLS to the gateway's HTTPS proxy listener (PROXY_TLS_PORT). Verification is always on;
|
|
205
|
+
``ca`` (PEM) is added to the system roots, ``servername`` overrides SNI/verified name."""
|
|
206
|
+
|
|
207
|
+
port: int = DEFAULT_TLS_PROXY_PORT
|
|
208
|
+
ca: Optional[Union[str, bytes]] = field(default=None, repr=False)
|
|
209
|
+
servername: Optional[str] = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _tls_context(ca: Optional[Union[str, bytes]] = None) -> ssl.SSLContext:
|
|
213
|
+
ctx = ssl.create_default_context()
|
|
214
|
+
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
|
|
215
|
+
ctx.check_hostname = True
|
|
216
|
+
ctx.verify_mode = ssl.CERT_REQUIRED
|
|
217
|
+
if ca is not None:
|
|
218
|
+
ctx.load_verify_locations(cadata=ca.decode("ascii") if isinstance(ca, bytes) else ca)
|
|
219
|
+
return ctx
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class _RawTls(io.RawIOBase):
|
|
223
|
+
def __init__(self, conn: "_TlsOverStream") -> None:
|
|
224
|
+
self._conn = conn
|
|
225
|
+
|
|
226
|
+
def readable(self) -> bool:
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
def readinto(self, b: Any) -> int:
|
|
230
|
+
data = self._conn.recv(len(b))
|
|
231
|
+
b[: len(data)] = data
|
|
232
|
+
return len(data)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class _TlsOverStream:
|
|
236
|
+
"""TLS client over an existing (already TLS) stream: TLS-in-TLS via MemoryBIO.
|
|
237
|
+
|
|
238
|
+
``ssl.wrap_socket`` on an SSLSocket would write to the raw fd and skip the outer layer."""
|
|
239
|
+
|
|
240
|
+
def __init__(self, outer: ssl.SSLSocket, ctx: ssl.SSLContext, server_hostname: str) -> None:
|
|
241
|
+
self._outer = outer
|
|
242
|
+
self._in = ssl.MemoryBIO()
|
|
243
|
+
self._out = ssl.MemoryBIO()
|
|
244
|
+
self._obj = ctx.wrap_bio(self._in, self._out, server_hostname=server_hostname)
|
|
245
|
+
self._run(self._obj.do_handshake)
|
|
246
|
+
|
|
247
|
+
def _flush(self) -> None:
|
|
248
|
+
data = self._out.read()
|
|
249
|
+
if data:
|
|
250
|
+
self._outer.sendall(data)
|
|
251
|
+
|
|
252
|
+
def _run(self, fn: Any, *args: Any) -> Any:
|
|
253
|
+
while True:
|
|
254
|
+
try:
|
|
255
|
+
r = fn(*args)
|
|
256
|
+
self._flush()
|
|
257
|
+
return r
|
|
258
|
+
except ssl.SSLWantReadError:
|
|
259
|
+
self._flush()
|
|
260
|
+
d = self._outer.recv(65536)
|
|
261
|
+
if d:
|
|
262
|
+
self._in.write(d)
|
|
263
|
+
else:
|
|
264
|
+
self._in.write_eof()
|
|
265
|
+
|
|
266
|
+
def sendall(self, data: bytes) -> None:
|
|
267
|
+
view = memoryview(data)
|
|
268
|
+
while view:
|
|
269
|
+
n = self._run(self._obj.write, view)
|
|
270
|
+
view = view[n:]
|
|
271
|
+
|
|
272
|
+
def recv(self, n: int) -> bytes:
|
|
273
|
+
try:
|
|
274
|
+
return self._run(self._obj.read, n)
|
|
275
|
+
except (ssl.SSLZeroReturnError, ssl.SSLEOFError):
|
|
276
|
+
return b""
|
|
277
|
+
|
|
278
|
+
def makefile(self, mode: str = "rb", *args: Any, **kw: Any) -> io.BufferedReader:
|
|
279
|
+
return io.BufferedReader(_RawTls(self))
|
|
280
|
+
|
|
281
|
+
def close(self) -> None:
|
|
282
|
+
self._outer.close()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def execute_fetch(
|
|
286
|
+
proxy_host: str,
|
|
287
|
+
proxy_port: int,
|
|
288
|
+
p: _Prepared,
|
|
289
|
+
lookup: Optional[LookupAll] = None,
|
|
290
|
+
gateway_tls: Optional[GatewayTls] = None,
|
|
291
|
+
) -> ProxyFetchResponse:
|
|
292
|
+
"""Steps 6-9: DNS-rebinding check, then the request through the gateway. Redirects are returned, never followed.
|
|
293
|
+
|
|
294
|
+
With ``gateway_tls`` the connection goes to ``proxy_host:gateway_tls.port`` over verified TLS and
|
|
295
|
+
everything (CONNECT included) runs inside it; https targets then use TLS-in-TLS."""
|
|
296
|
+
assert_public_resolution(p.host, lookup)
|
|
297
|
+
deadline = time.monotonic() + p.timeout
|
|
298
|
+
sock: Any = None
|
|
299
|
+
try:
|
|
300
|
+
port = gateway_tls.port if gateway_tls is not None else proxy_port
|
|
301
|
+
sock = socket.create_connection((proxy_host, port), timeout=p.timeout)
|
|
302
|
+
if gateway_tls is not None:
|
|
303
|
+
sock = _tls_context(gateway_tls.ca).wrap_socket(sock, server_hostname=gateway_tls.servername or proxy_host)
|
|
304
|
+
if p.scheme == "https":
|
|
305
|
+
bracketed = f"[{p.host}]" if ":" in p.host else p.host
|
|
306
|
+
authority = f"{bracketed}:{p.port}"
|
|
307
|
+
sock.sendall(
|
|
308
|
+
(
|
|
309
|
+
f"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n"
|
|
310
|
+
f"Proxy-Authorization: {p.proxy_auth}\r\n\r\n"
|
|
311
|
+
).encode("latin-1")
|
|
312
|
+
)
|
|
313
|
+
head = _read_head(sock)
|
|
314
|
+
m = re.match(rb"^HTTP/\d\.\d (\d{3})", head)
|
|
315
|
+
status = int(m.group(1)) if m else 0
|
|
316
|
+
if status != 200:
|
|
317
|
+
raise ProxyError(f"Proxy refused CONNECT (HTTP {status})", status or None)
|
|
318
|
+
# The inner TLS session rides the CONNECT tunnel socket; never dial the target directly.
|
|
319
|
+
if gateway_tls is not None:
|
|
320
|
+
sock = _TlsOverStream(sock, _tls_context(), p.host)
|
|
321
|
+
else:
|
|
322
|
+
sock = _tls_context().wrap_socket(sock, server_hostname=p.host)
|
|
323
|
+
sock.sendall(_request_bytes(p.method, p.path, p.host_header, p.headers, {}))
|
|
324
|
+
else:
|
|
325
|
+
target = f"http://{p.host_header}{p.path}"
|
|
326
|
+
sock.sendall(
|
|
327
|
+
_request_bytes(p.method, target, p.host_header, p.headers, {"Proxy-Authorization": p.proxy_auth})
|
|
328
|
+
)
|
|
329
|
+
return _collect(sock, p.method, p.max_bytes, deadline)
|
|
330
|
+
except ProxyError:
|
|
331
|
+
raise
|
|
332
|
+
except (socket.timeout, TimeoutError):
|
|
333
|
+
raise ProxyError(f"Proxy request timed out after {int(p.timeout * 1000)}ms") from None
|
|
334
|
+
except Exception as err: # noqa: BLE001
|
|
335
|
+
raise ProxyError(scrub_credentials(f"Proxy request failed: {err}")) from None
|
|
336
|
+
finally:
|
|
337
|
+
if sock is not None:
|
|
338
|
+
try:
|
|
339
|
+
sock.close()
|
|
340
|
+
except OSError:
|
|
341
|
+
pass
|
dominusnode/py.typed
ADDED
|
File without changes
|