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.
- proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
- proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
- proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
- proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
- proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
- proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
- proxyscraper/__init__.py +12 -0
- proxyscraper/__main__.py +7 -0
- proxyscraper/agent.py +635 -0
- proxyscraper/api.py +139 -0
- proxyscraper/app.py +595 -0
- proxyscraper/asndb.py +181 -0
- proxyscraper/blocklist.py +92 -0
- proxyscraper/checker.py +514 -0
- proxyscraper/cli.py +269 -0
- proxyscraper/compat.py +83 -0
- proxyscraper/completion.py +196 -0
- proxyscraper/exporters.py +118 -0
- proxyscraper/fetchcache.py +103 -0
- proxyscraper/geo.py +150 -0
- proxyscraper/geodb.py +143 -0
- proxyscraper/handshake.py +153 -0
- proxyscraper/history.py +106 -0
- proxyscraper/judges.py +159 -0
- proxyscraper/mcp_entry.py +34 -0
- proxyscraper/mcp_server.py +220 -0
- proxyscraper/netio.py +167 -0
- proxyscraper/options.py +274 -0
- proxyscraper/output.py +181 -0
- proxyscraper/pages.py +212 -0
- proxyscraper/parsing.py +192 -0
- proxyscraper/paths.py +55 -0
- proxyscraper/pipeline.py +434 -0
- proxyscraper/preferences.py +24 -0
- proxyscraper/publish.py +236 -0
- proxyscraper/server/__init__.py +41 -0
- proxyscraper/server/core.py +518 -0
- proxyscraper/server/http.py +164 -0
- proxyscraper/server/pool.py +195 -0
- proxyscraper/server/socks.py +65 -0
- proxyscraper/server/status.py +108 -0
- proxyscraper/server/upstream.py +119 -0
- proxyscraper/site/apple-touch-icon.png +0 -0
- proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
- proxyscraper/site/index.html +934 -0
- proxyscraper/site/logo.png +0 -0
- proxyscraper/site/og.png +0 -0
- proxyscraper/sources.json +395 -0
- proxyscraper/sources.py +491 -0
- proxyscraper/targets.py +76 -0
- proxyscraper/ui/__init__.py +54 -0
- proxyscraper/ui/dashboard.py +344 -0
- proxyscraper/ui/keys.py +82 -0
- proxyscraper/ui/report.py +209 -0
- proxyscraper/ui/serve.py +142 -0
- proxyscraper/ui/widgets.py +250 -0
- proxyscraper/ui/wizard.py +595 -0
proxyscraper/checker.py
ADDED
|
@@ -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
|