proxy-scraper-cli 1.7.1__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.
Files changed (57) hide show
  1. proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
  2. proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
  3. proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
  4. proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
  5. proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
  6. proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
  7. proxyscraper/__init__.py +12 -0
  8. proxyscraper/__main__.py +7 -0
  9. proxyscraper/agent.py +635 -0
  10. proxyscraper/api.py +139 -0
  11. proxyscraper/app.py +595 -0
  12. proxyscraper/asndb.py +181 -0
  13. proxyscraper/blocklist.py +92 -0
  14. proxyscraper/checker.py +514 -0
  15. proxyscraper/cli.py +269 -0
  16. proxyscraper/compat.py +83 -0
  17. proxyscraper/completion.py +196 -0
  18. proxyscraper/exporters.py +118 -0
  19. proxyscraper/fetchcache.py +103 -0
  20. proxyscraper/geo.py +150 -0
  21. proxyscraper/geodb.py +143 -0
  22. proxyscraper/handshake.py +153 -0
  23. proxyscraper/history.py +106 -0
  24. proxyscraper/judges.py +159 -0
  25. proxyscraper/mcp_entry.py +34 -0
  26. proxyscraper/mcp_server.py +220 -0
  27. proxyscraper/netio.py +167 -0
  28. proxyscraper/options.py +274 -0
  29. proxyscraper/output.py +181 -0
  30. proxyscraper/pages.py +212 -0
  31. proxyscraper/parsing.py +192 -0
  32. proxyscraper/paths.py +55 -0
  33. proxyscraper/pipeline.py +434 -0
  34. proxyscraper/preferences.py +24 -0
  35. proxyscraper/publish.py +236 -0
  36. proxyscraper/server/__init__.py +41 -0
  37. proxyscraper/server/core.py +518 -0
  38. proxyscraper/server/http.py +164 -0
  39. proxyscraper/server/pool.py +195 -0
  40. proxyscraper/server/socks.py +65 -0
  41. proxyscraper/server/status.py +108 -0
  42. proxyscraper/server/upstream.py +119 -0
  43. proxyscraper/site/apple-touch-icon.png +0 -0
  44. proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
  45. proxyscraper/site/index.html +934 -0
  46. proxyscraper/site/logo.png +0 -0
  47. proxyscraper/site/og.png +0 -0
  48. proxyscraper/sources.json +395 -0
  49. proxyscraper/sources.py +491 -0
  50. proxyscraper/targets.py +76 -0
  51. proxyscraper/ui/__init__.py +54 -0
  52. proxyscraper/ui/dashboard.py +344 -0
  53. proxyscraper/ui/keys.py +82 -0
  54. proxyscraper/ui/report.py +209 -0
  55. proxyscraper/ui/serve.py +142 -0
  56. proxyscraper/ui/widgets.py +250 -0
  57. proxyscraper/ui/wizard.py +595 -0
@@ -0,0 +1,514 @@
1
+ """Proxy checks with our own protocol handshakes (HTTP, SOCKS4, SOCKS5).
2
+
3
+ Basic check: one TCP connection per proxy, fetching the exit IP from checkip.amazonaws.com.
4
+ Confirmation (only for proxies that pass the basic check, so only a few):
5
+ - a second, independent request to httpbin.org/get – filters out honeypots that answer only the
6
+ check request with "200 + IP" and reject everything else. The same response shows the headers
7
+ that arrive and with that the anonymity level.
8
+ Detail check:
9
+ - HTTPS: open a tunnel to port 443 and a verified TLS connection inside it – fails for proxies
10
+ that can't CONNECT or that break up TLS (MITM).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import errno
17
+ import hashlib
18
+ import ipaddress
19
+ import json
20
+ import re
21
+ import socket
22
+ import ssl
23
+ import time
24
+ from dataclasses import dataclass, field
25
+ from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
26
+
27
+ from .handshake import Endpoint, parse_endpoint, socks4, socks5, socks5_ipv4, stream_io, with_proxy_auth
28
+ from .judges import DEFAULT_JUDGE, Judge
29
+ from .netio import USER_AGENT, dechunk, http_request, read_response, ssl_context
30
+ from .parsing import normalize_public_ip, split_key
31
+ from .targets import Target
32
+
33
+ # Check target: returns the IP that reaches the server (plain text, very small).
34
+ # Deliberately NOT behind Cloudflare – otherwise any Cloudflare IP would "work" as a fake proxy.
35
+ JUDGE_HOST = DEFAULT_JUDGE.host # for your own IP; checks go through Checker.judge
36
+ # Second, independent check target: returns JSON with the sender IP ("origin") and the received headers
37
+ CONFIRM_HOST = "httpbin.org"
38
+ CONFIRM_PORT = 80
39
+ # Deliberately without a Proxy-Connection header – it would show up as a proxy trace itself
40
+ _CONFIRM_HEADERS = f"Host: {CONFIRM_HOST}\r\nUser-Agent: {USER_AGENT}\r\nAccept: */*\r\nConnection: close\r\n\r\n"
41
+ CONFIRM_REQUEST = f"GET /get HTTP/1.1\r\n{_CONFIRM_HEADERS}".encode()
42
+ HTTP_PROXY_CONFIRM_REQUEST = f"GET http://{CONFIRM_HOST}/get HTTP/1.1\r\n{_CONFIRM_HEADERS}".encode()
43
+ # Integrity: a static HTML page that has to arrive through the proxy exactly as it does directly.
44
+ # Measured on 270 working proxies, 54 (20 %) returned a modified page – mostly with an injected
45
+ # <script src="http://…">.
46
+ INTEGRITY_REQUEST = f"GET /html HTTP/1.1\r\n{_CONFIRM_HEADERS}".encode()
47
+ HTTP_PROXY_INTEGRITY_REQUEST = f"GET http://{CONFIRM_HOST}/html HTTP/1.1\r\n{_CONFIRM_HEADERS}".encode()
48
+
49
+ CONTENT_LENGTH_RE = re.compile(rb"(?im)^content-length:\s*(\d+)")
50
+ # At most this many HTTPS/target-site connections at once. Every hit opens 1 + target-site connections
51
+ # in parallel – without a limit that quickly adds up to thousands with 2000 workers (EMFILE).
52
+ DETAIL_CONNECTIONS = 256
53
+ UNREACHABLE_ERRNOS = {errno.ECONNREFUSED, errno.EHOSTUNREACH, errno.ENETUNREACH, errno.ETIMEDOUT}
54
+ # Headers that give away the proxy (or the client)
55
+ PROXY_HEADERS = {
56
+ "via", "x-forwarded-for", "forwarded", "x-real-ip", "client-ip", "x-client-ip",
57
+ "x-proxy-id", "proxy-connection", "x-proxy-connection", "proxy-agent", "x-forwarded-host",
58
+ "x-forwarded-proto", "x-bluecoat-via", "x-originating-ip",
59
+ }
60
+ ANONYMITY_RANK = {"transparent": 0, "anonymous": 1, "elite": 2}
61
+
62
+
63
+ async def wait_for(coro, timeout: float):
64
+ """asyncio.wait_for that leaves no unretrieved exception behind when cancelled.
65
+
66
+ If a check is cancelled with Ctrl+C while its inner connect is just failing, Python < 3.12
67
+ returns the connection error instead of CancelledError. The inner task then ends with an
68
+ exception nobody retrieves -> "Task exception was never retrieved" plus a traceback on exit.
69
+ The callback retrieves it in any case.
70
+ """
71
+ fut = asyncio.ensure_future(coro)
72
+ fut.add_done_callback(_consume_exception)
73
+ return await asyncio.wait_for(fut, timeout)
74
+
75
+
76
+ def _consume_exception(fut: asyncio.Future) -> None:
77
+ if not fut.cancelled():
78
+ fut.exception()
79
+
80
+
81
+ @dataclass
82
+ class CheckResult:
83
+ key: str
84
+ ptype: str
85
+ proxy: str
86
+ latency: int
87
+ exit_ip: str
88
+ https: Optional[bool] = None
89
+ anonymity: str = ""
90
+ country: str = ""
91
+ targets: Dict[str, bool] = field(default_factory=dict) # target site URL -> reachable?
92
+ asn: int = 0 # provider of the exit IP (DB-IP), 0 = unknown
93
+ org: str = ""
94
+ hosting: Optional[bool] = None # exit probably in a datacenter? None = unknown
95
+ blocklisted: Optional[bool] = None # exit IP on the SpamCop blocklist? None = unknown
96
+
97
+ @property
98
+ def url(self) -> str:
99
+ """'socks5://1.2.3.4:1080' – the way curl, requests and friends expect it."""
100
+ return f"{self.ptype}://{self.proxy}"
101
+
102
+
103
+ class Checker:
104
+ def __init__(self, judge_ip: str, own_ips: Iterable[str], timeout: float, connect_timeout: float,
105
+ confirm_ip: Optional[str] = None, detail_timeout: Optional[float] = None,
106
+ detail_connect_timeout: Optional[float] = None,
107
+ targets: Sequence[Tuple[Target, str]] = (), https_test: bool = True,
108
+ judge: Judge = DEFAULT_JUDGE, integrity_reference: Optional[bytes] = None):
109
+ self.use_judge(judge, judge_ip)
110
+ # hash of the page as it arrives directly – without a reference there is no tampering check
111
+ self.integrity_reference = integrity_reference
112
+ # without a reachable confirmation target nothing is confirmed (otherwise every proxy would fail)
113
+ self.confirm_ip_bytes = socket.inet_aton(confirm_ip) if confirm_ip else None
114
+ # several possible: e.g. the real IP via HTTPS, but iCloud Private Relay or a corporate proxy on port 80
115
+ self.own_ips = set(own_ips)
116
+ self.timeout = timeout
117
+ self._detail_slots: Optional[asyncio.Semaphore] = None
118
+ self.https_test = https_test # --fast: no HTTPS test, target sites still
119
+ # target sites with a pre-resolved IP (SOCKS4 can't do host names)
120
+ self.targets = [(target, socket.inet_aton(ip)) for target, ip in targets]
121
+ # confirmation and HTTPS test may take longer than the (possibly latency-limited) basic check
122
+ self.detail_timeout = detail_timeout or timeout
123
+ self.detail_connect_timeout = min(detail_connect_timeout or connect_timeout, self.detail_timeout)
124
+ # the vast majority of dead proxies already fail at the TCP connect – they shouldn't
125
+ # block a slot for the full timeout.
126
+ self.connect_timeout = min(connect_timeout, timeout)
127
+ self.unreachable: Set[str] = set()
128
+
129
+ def use_judge(self, judge: Judge, ip: str) -> None:
130
+ """Set the check target or switch it mid-run (running checks still use the old one)."""
131
+ self.judge = judge
132
+ self.judge_ip = ip
133
+ self.judge_ip_bytes = socket.inet_aton(ip)
134
+ self.request = (
135
+ f"GET {judge.path} HTTP/1.1\r\nHost: {judge.authority}\r\nUser-Agent: Mozilla/5.0\r\n"
136
+ f"Connection: close\r\n\r\n"
137
+ ).encode()
138
+ # HTTP proxies need the absolute URL
139
+ self.http_proxy_request = (
140
+ f"GET http://{judge.authority}{judge.path} HTTP/1.1\r\nHost: {judge.authority}\r\n"
141
+ f"User-Agent: Mozilla/5.0\r\n"
142
+ f"Connection: close\r\nProxy-Connection: close\r\n\r\n"
143
+ ).encode()
144
+
145
+ @property
146
+ def confirms(self) -> bool:
147
+ return self.confirm_ip_bytes is not None
148
+
149
+ # ------------------------------------------------------------------ basic check
150
+
151
+ async def check(self, key: str) -> Optional[CheckResult]:
152
+ """Working proxy -> CheckResult, otherwise None."""
153
+ ptype, proxy = split_key(key)
154
+ start = time.perf_counter()
155
+ try:
156
+ body = await wait_for(self._check(ptype, proxy), self.timeout)
157
+ except Exception: # timeout, connection error, broken response – all mean "no good"
158
+ return None
159
+
160
+ if body is None:
161
+ return None
162
+ exit_ip = body.strip().decode("ascii", "ignore")
163
+ try:
164
+ ipaddress.IPv4Address(exit_ip)
165
+ except ValueError:
166
+ return None # proxy returns garbage/ads/a login page -> unusable
167
+ if normalize_public_ip(exit_ip.encode()) != exit_ip:
168
+ return None # 127.0.0.1, 10.x and the like aren't a real exit IP – the proxy answers itself
169
+ if exit_ip in self.own_ips:
170
+ return None # transparent proxy reveals your real IP
171
+ return CheckResult(key, ptype, proxy, round((time.perf_counter() - start) * 1000), exit_ip)
172
+
173
+ async def _check(self, ptype: str, proxy: str):
174
+ # Many ip:port entries appear under several types in the lists – whatever fails at the
175
+ # TCP connect fails for the other types just the same.
176
+ ep = parse_endpoint(proxy)
177
+ if ep.address in self.unreachable:
178
+ return None
179
+ # keep target IP and request together – if the check target changes mid-way, both still match
180
+ ip_bytes, port = self.judge_ip_bytes, self.judge.port
181
+ request = self.http_proxy_request if ptype == "http" else self.request
182
+ reader, writer = await self._connect(proxy)
183
+ try:
184
+ if not await self._handshake(ptype, ep, reader, writer, ip_bytes, port):
185
+ return None
186
+ writer.write(with_proxy_auth(request, ep) if ptype == "http" else request)
187
+ await writer.drain()
188
+ return await _read_http_200(reader)
189
+ finally:
190
+ writer.close()
191
+
192
+ async def _connect(self, proxy: str, detail: bool = False):
193
+ """TCP connection to the proxy. Only the basic check remembers unreachable proxies and uses the
194
+ (possibly latency-limited) short timeout – detail checks get the normal one."""
195
+ ep = parse_endpoint(proxy)
196
+ timeout = self.detail_connect_timeout if detail else self.connect_timeout
197
+ try:
198
+ reader, writer = await asyncio.wait_for(asyncio.open_connection(ep.host, ep.port), timeout)
199
+ except (asyncio.TimeoutError, ConnectionRefusedError):
200
+ if not detail:
201
+ self.unreachable.add(ep.address)
202
+ raise
203
+ except OSError as e:
204
+ # not e.g. EMFILE – that's our fault, not the proxy's
205
+ if not detail and e.errno in UNREACHABLE_ERRNOS:
206
+ self.unreachable.add(ep.address)
207
+ raise
208
+ return reader, writer
209
+
210
+ async def _handshake(self, ptype: str, ep: Endpoint, reader, writer, ip_bytes: bytes, port: int) -> bool:
211
+ """Open a SOCKS connection to ip:port; HTTP proxies don't need a handshake."""
212
+ send, recv_exact = stream_io(reader, writer)
213
+ if ptype == "socks4":
214
+ return await socks4(send, recv_exact, ep, ip_bytes, port)
215
+ if ptype == "socks5":
216
+ return await socks5(send, recv_exact, ep, socks5_ipv4(ip_bytes), port)
217
+ return True
218
+
219
+ # ------------------------------------------------------------------ confirmation
220
+
221
+ async def confirm(self, result: CheckResult) -> bool:
222
+ """A second, independent request through the same proxy. False = fake/honeypot or unstable.
223
+
224
+ Also sets the anonymity level from the headers that reach the target.
225
+ """
226
+ if not self.confirms:
227
+ return True
228
+ try:
229
+ body = await wait_for(self._confirm(result.ptype, result.proxy), self.detail_timeout)
230
+ except Exception: # an error on the second request means: not reliable
231
+ return False
232
+ anonymity = classify_confirmation(body, self.own_ips, result.exit_ip) if body is not None else None
233
+ if anonymity is None:
234
+ return False
235
+ result.anonymity = anonymity
236
+ return True
237
+
238
+ async def _confirm(self, ptype: str, proxy: str, request: bytes = CONFIRM_REQUEST,
239
+ http_proxy_request: bytes = HTTP_PROXY_CONFIRM_REQUEST) -> Optional[bytes]:
240
+ ep = parse_endpoint(proxy)
241
+ reader, writer = await self._connect(proxy, detail=True)
242
+ try:
243
+ if not await self._handshake(ptype, ep, reader, writer, self.confirm_ip_bytes, CONFIRM_PORT):
244
+ return None
245
+ writer.write(with_proxy_auth(http_proxy_request, ep) if ptype == "http" else request)
246
+ await writer.drain()
247
+ # the response comes from the (untrusted) proxy – read only a limited amount
248
+ return await _read_http_200(reader)
249
+ finally:
250
+ writer.close()
251
+
252
+ async def tampers(self, result: CheckResult) -> bool:
253
+ """Does the proxy modify content (ads, scripts)? True only for a clearly modified page –
254
+ timeouts or error pages say nothing about that, the other checks cover those."""
255
+ if self.integrity_reference is None or not self.confirms:
256
+ return False
257
+ try:
258
+ body = await wait_for(self._confirm(result.ptype, result.proxy, INTEGRITY_REQUEST,
259
+ HTTP_PROXY_INTEGRITY_REQUEST), self.detail_timeout)
260
+ except Exception:
261
+ return False
262
+ return body is not None and page_hash(body) != self.integrity_reference
263
+
264
+ # ------------------------------------------------------------------ details
265
+
266
+ async def enrich(self, result: CheckResult) -> None:
267
+ """Add HTTPS support and target sites, all in parallel (anonymity comes from the confirmation)."""
268
+ https = self._safe(self.check_https(result.ptype, result.proxy)) if self.https_test else _none()
269
+ outcomes = await asyncio.gather(
270
+ https,
271
+ *(self._safe(self.check_target(result.ptype, result.proxy, t, ip)) for t, ip in self.targets),
272
+ )
273
+ result.https = bool(outcomes[0]) if self.https_test else None
274
+ result.targets = {t.url: bool(ok) for (t, _), ok in zip(self.targets, outcomes[1:])}
275
+
276
+ async def _safe(self, coro):
277
+ if self._detail_slots is None: # only here: before Python 3.10 a semaphore is bound to the event loop
278
+ self._detail_slots = asyncio.Semaphore(DETAIL_CONNECTIONS)
279
+ try:
280
+ async with self._detail_slots:
281
+ return await wait_for(coro, self.detail_timeout)
282
+ except Exception: # detail check failed -> "no"/"unknown", the basic result stays
283
+ return None
284
+
285
+ async def check_https(self, ptype: str, proxy: str) -> bool:
286
+ """Tunnel to the check target on port 443 + verified TLS + fetch the exit IP."""
287
+ judge, ip_bytes, request = self.judge, self.judge_ip_bytes, self.request # in case it switches mid-way
288
+ opened = await self._tls_tunnel(ptype, proxy, judge.host, ip_bytes, 443)
289
+ if opened is None:
290
+ return False
291
+ reader, writer = opened
292
+ try:
293
+ writer.write(request)
294
+ await writer.drain()
295
+ # after verified TLS the real server is talking here, not the proxy
296
+ status, _, body = await read_response(reader)
297
+ finally:
298
+ writer.close()
299
+ try:
300
+ ipaddress.IPv4Address(body.strip().decode("ascii", "ignore"))
301
+ except ValueError:
302
+ return False
303
+ return status == 200
304
+
305
+ async def check_target(self, ptype: str, proxy: str, target: Target, ip_bytes: bytes) -> bool:
306
+ """A real request to a target site through the proxy; 2xx/3xx = reachable.
307
+
308
+ Only the response head is read – nobody needs to download a 500 KB home page.
309
+ """
310
+ request = (
311
+ f"GET {{path}} HTTP/1.1\r\nHost: {target.host_header}\r\nUser-Agent: {USER_AGENT}\r\n"
312
+ f"Accept: text/html,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.8\r\nConnection: close\r\n\r\n"
313
+ )
314
+ if target.tls:
315
+ opened = await self._tls_tunnel(ptype, proxy, target.host, ip_bytes, target.port)
316
+ if opened is None:
317
+ return False
318
+ reader, writer = opened
319
+ path = target.path
320
+ else:
321
+ reader, writer = await self._connect(proxy, detail=True)
322
+ # HTTP proxies want the absolute URL for plain HTTP
323
+ path = target.url if ptype == "http" else target.path
324
+ ep = parse_endpoint(proxy)
325
+ try:
326
+ # handshake inside the try: if it fails with an exception, the socket is still closed
327
+ if not target.tls and not await self._handshake(ptype, ep, reader, writer, ip_bytes, target.port):
328
+ return False
329
+ data = request.format(path=path).encode()
330
+ writer.write(with_proxy_auth(data, ep) if ptype == "http" and not target.tls else data)
331
+ await writer.drain()
332
+ status = await _read_status(reader)
333
+ finally:
334
+ writer.close()
335
+ return 200 <= status < 400
336
+
337
+ async def _tls_tunnel(self, ptype: str, proxy: str, host: str, ip_bytes: bytes, port: int):
338
+ """Tunnel through the proxy to host:port with verified TLS inside. None = tunnel refused or MITM."""
339
+ loop = asyncio.get_running_loop()
340
+ ep = parse_endpoint(proxy)
341
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
342
+ sock.setblocking(False)
343
+ try:
344
+ await loop.sock_connect(sock, (ep.host, ep.port))
345
+ if not await self._open_tunnel(loop, sock, ptype, ep, host, ip_bytes, port):
346
+ sock.close()
347
+ return None
348
+ return await asyncio.open_connection(sock=sock, ssl=ssl_context(), server_hostname=host)
349
+ except ssl.SSLCertVerificationError:
350
+ sock.close()
351
+ return None # proxy breaks up TLS (MITM) -> useless for HTTPS
352
+ except BaseException:
353
+ sock.close()
354
+ raise
355
+
356
+ async def _open_tunnel(self, loop, sock: socket.socket, ptype: str, ep: Endpoint, host: str,
357
+ ip_bytes: bytes, port: int) -> bool:
358
+ async def recv_exact(n: int) -> bytes:
359
+ buf = b""
360
+ while len(buf) < n:
361
+ chunk = await loop.sock_recv(sock, n - len(buf))
362
+ if not chunk:
363
+ raise ConnectionError("connection closed")
364
+ buf += chunk
365
+ return buf
366
+
367
+ if ptype == "http":
368
+ connect = f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode()
369
+ await loop.sock_sendall(sock, with_proxy_auth(connect, ep))
370
+ head = b""
371
+ while b"\r\n\r\n" not in head and len(head) < 8192:
372
+ chunk = await loop.sock_recv(sock, 1) # byte by byte: don't swallow anything of the TLS stream
373
+ if not chunk:
374
+ return False
375
+ head += chunk
376
+ first = head.split(b"\r\n", 1)[0]
377
+ return first.startswith(b"HTTP/") and b" 200" in first
378
+
379
+ async def send(data: bytes) -> None:
380
+ await loop.sock_sendall(sock, data)
381
+
382
+ if ptype == "socks4":
383
+ return await socks4(send, recv_exact, ep, ip_bytes, port)
384
+ return await socks5(send, recv_exact, ep, socks5_ipv4(ip_bytes), port)
385
+
386
+
387
+ async def _none() -> None:
388
+ return None
389
+
390
+
391
+ async def _read_status(reader) -> int:
392
+ """Read only the status code of an HTTP response (head at most 64 KiB)."""
393
+ head = await reader.readuntil(b"\r\n\r\n")
394
+ parts = head.split(b"\r\n", 1)[0].split()
395
+ return int(parts[1]) if len(parts) > 1 and parts[0].startswith(b"HTTP/") and parts[1].isdigit() else 0
396
+
397
+
398
+ def confirmation_origins(body: bytes) -> Optional[List[str]]:
399
+ """IPs from an httpbin response ("origin": "1.2.3.4" or "1.2.3.4, 5.6.7.8"), None if unusable."""
400
+ try:
401
+ data = json.loads(body)
402
+ except ValueError:
403
+ return None
404
+ # httpbin always returns "origin" and "headers" – if one is missing, it's not the expected response
405
+ if not isinstance(data, dict) or not isinstance(data.get("headers"), dict):
406
+ return None
407
+ origins = [part.strip() for part in str(data.get("origin", "")).split(",")]
408
+ return [o for o in origins if _is_ipv4(o)] or None
409
+
410
+
411
+ def classify_confirmation(body: bytes, own_ips: Iterable[str], exit_ip: str) -> Optional[str]:
412
+ """Check the response from httpbin.org/get -> anonymity level, or None for an unusable response.
413
+
414
+ Honeypots don't return JSON with a valid sender IP here. The proxy also has to show the same
415
+ exit IP on both requests – rotating or chained exits aren't reliable.
416
+ """
417
+ origins = confirmation_origins(body)
418
+ if origins is None or exit_ip not in origins:
419
+ return None
420
+ return classify_anonymity(body, own_ips)
421
+
422
+
423
+ async def probe_confirm_target(ip: str, timeout: float, port: int = CONFIRM_PORT) -> bool:
424
+ """Does `ip` answer directly (without a proxy) exactly the way the confirmation expects?
425
+
426
+ The same request, the same size limit, the same evaluation – and exactly the IP that is used
427
+ later. A redirect (e.g. to HTTPS), a captive portal or an error page doesn't count.
428
+ """
429
+ async def probe() -> Optional[bytes]:
430
+ reader, writer = await asyncio.open_connection(ip, port)
431
+ try:
432
+ writer.write(CONFIRM_REQUEST)
433
+ await writer.drain()
434
+ return await _read_http_200(reader)
435
+ finally:
436
+ writer.close()
437
+
438
+ try:
439
+ body = await wait_for(probe(), timeout)
440
+ except Exception: # unreachable -> continue without confirmation
441
+ return False
442
+ return body is not None and confirmation_origins(body) is not None
443
+
444
+
445
+ def page_hash(body: bytes) -> bytes:
446
+ return hashlib.sha256(body).digest()
447
+
448
+
449
+ async def integrity_reference(timeout: float, fetch=None) -> Optional[bytes]:
450
+ """Hash of the reference page, fetched directly – over verified HTTPS. Over HTTP a captive portal
451
+ or a filter on your own network could already falsify the reference. Both ways return the same bytes."""
452
+ try:
453
+ status, _, body = await (fetch or http_request)(f"https://{CONFIRM_HOST}/html", timeout=timeout,
454
+ max_redirects=0, insecure_fallback=False)
455
+ except Exception:
456
+ return None
457
+ return page_hash(body) if status == 200 and body else None
458
+
459
+
460
+ def _is_ipv4(text: str) -> bool:
461
+ try:
462
+ ipaddress.IPv4Address(text)
463
+ return True
464
+ except ValueError:
465
+ return False
466
+
467
+
468
+ def _contains_ip(body: bytes, ip: str) -> bool:
469
+ """The whole address, not a piece of a longer one: 1.2.3.4 is not in 11.2.3.45."""
470
+ return re.search(rb"(?<![\d.])" + re.escape(ip.encode()) + rb"(?![\d.])", body) is not None
471
+
472
+
473
+ def classify_anonymity(body: bytes, own_ips: Iterable[str]) -> Optional[str]:
474
+ """Response from httpbin.org (with "headers") -> transparent / anonymous / elite."""
475
+ if any(ip and _contains_ip(body, ip) for ip in own_ips):
476
+ return "transparent"
477
+ try:
478
+ headers = json.loads(body).get("headers", {})
479
+ except (ValueError, AttributeError):
480
+ return None
481
+ if not isinstance(headers, dict):
482
+ return None # unexpected response – don't crash, just don't confirm
483
+ names = {str(name).lower() for name in headers}
484
+ return "anonymous" if names & PROXY_HEADERS else "elite"
485
+
486
+
487
+ async def _read_http_200(reader) -> Optional[bytes]:
488
+ """Read a small HTTP response; the body only for status 200, otherwise None."""
489
+ data = bytearray()
490
+ want = None # total length from Content-Length, once the header is there
491
+ while len(data) < 16384:
492
+ try:
493
+ chunk = await reader.read(4096)
494
+ except ConnectionResetError:
495
+ break
496
+ if not chunk:
497
+ break
498
+ data += chunk
499
+ if want is None:
500
+ end = data.find(b"\r\n\r\n")
501
+ if end >= 0:
502
+ if not data.startswith(b"HTTP/") or b" 200" not in data[: data.find(b"\r\n")]:
503
+ return None # bail out early, the rest of the response doesn't matter
504
+ m = CONTENT_LENGTH_RE.search(data, 0, end)
505
+ want = end + 4 + int(m.group(1)) if m else -1
506
+ if want is not None and 0 <= want <= len(data):
507
+ break
508
+
509
+ head, _, body = bytes(data).partition(b"\r\n\r\n")
510
+ if not head.startswith(b"HTTP/") or b" 200" not in head.split(b"\r\n", 1)[0]:
511
+ return None
512
+ if b"chunked" in head.lower():
513
+ body = dechunk(body)
514
+ return body