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.
@@ -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