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
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""Local rotating proxy server (--serve).
|
|
2
|
+
|
|
3
|
+
Accepts HTTP proxy requests (CONNECT for HTTPS and plain HTTP requests) and sends every
|
|
4
|
+
connection through one of the proxies that were found. Fast, reliable proxies are preferred;
|
|
5
|
+
if one fails, the next one is tried automatically, and one that fails several times in a row
|
|
6
|
+
drops out of the rotation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import time
|
|
14
|
+
from collections import deque
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from typing import Deque, Dict, Optional, Set, Tuple
|
|
17
|
+
|
|
18
|
+
from ..handshake import parse_endpoint, with_proxy_auth
|
|
19
|
+
from .http import (
|
|
20
|
+
TLS_HANDSHAKE,
|
|
21
|
+
ResponseScreen,
|
|
22
|
+
forward_request,
|
|
23
|
+
origin_request,
|
|
24
|
+
parse_request_head,
|
|
25
|
+
plausible_answer,
|
|
26
|
+
request_body_length,
|
|
27
|
+
)
|
|
28
|
+
from .pool import ANY, ProxyPool, Selection
|
|
29
|
+
from .socks import SOCKS5_VERSION, Socks5Refused, socks5_accept, socks5_reply
|
|
30
|
+
from .status import (
|
|
31
|
+
METRICS_PATH,
|
|
32
|
+
METRICS_TYPE,
|
|
33
|
+
STATUS_PATH,
|
|
34
|
+
STATUS_PREFIX,
|
|
35
|
+
metrics_text,
|
|
36
|
+
password_ok,
|
|
37
|
+
selection_from_headers,
|
|
38
|
+
status_json,
|
|
39
|
+
)
|
|
40
|
+
from .upstream import TargetError, Unsupported, UpstreamError, open_upstream
|
|
41
|
+
|
|
42
|
+
MAX_ATTEMPTS = 3 # this many proxies per request before the client gets an error
|
|
43
|
+
FIRST_CHUNK_WAIT = 5.0 # how long to wait for the client's first packet in the tunnel
|
|
44
|
+
MAX_REPLAY_BODY = 1024 * 1024 # request bodies up to this size are buffered for switching proxies
|
|
45
|
+
HTTP_ESTABLISHED = b"HTTP/1.1 200 Connection established\r\n\r\n"
|
|
46
|
+
HEAD_LIMIT = 64 * 1024
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class RequestLog:
|
|
51
|
+
client: str
|
|
52
|
+
target: str
|
|
53
|
+
via: str
|
|
54
|
+
ok: bool
|
|
55
|
+
ms: int
|
|
56
|
+
attempts: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class ServerStats:
|
|
61
|
+
requests: int = 0
|
|
62
|
+
ok: int = 0
|
|
63
|
+
failed: int = 0
|
|
64
|
+
active: int = 0
|
|
65
|
+
bytes_up: int = 0
|
|
66
|
+
bytes_down: int = 0
|
|
67
|
+
recent: Deque[RequestLog] = field(default_factory=lambda: deque(maxlen=10))
|
|
68
|
+
started: float = field(default_factory=time.perf_counter)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class RotatingServer:
|
|
72
|
+
def __init__(self, pool: ProxyPool, host: str = "127.0.0.1", port: int = 8899, timeout: float = 10.0,
|
|
73
|
+
password: str = "", max_attempts: int = MAX_ATTEMPTS, public_targets_only: bool = False):
|
|
74
|
+
self.pool = pool
|
|
75
|
+
# names this machine resolves for SOCKS4 upstreams must not lead to private addresses (DNS rebinding)
|
|
76
|
+
self.public_targets_only = public_targets_only
|
|
77
|
+
self.max_attempts = max_attempts # proxies per request before the client gets a 502
|
|
78
|
+
self.password = password # empty = no authentication (fine on 127.0.0.1)
|
|
79
|
+
self.host = host
|
|
80
|
+
self.port = port
|
|
81
|
+
self.timeout = timeout
|
|
82
|
+
self.stats = ServerStats()
|
|
83
|
+
self.revived = 0 # proxies that passed the recheck after being disabled
|
|
84
|
+
self.refilled = 0 # new proxies added by --serve-refill
|
|
85
|
+
self.last_refill: Optional[float] = None # time.time() of the last finished refill
|
|
86
|
+
self._server: Optional[asyncio.AbstractServer] = None
|
|
87
|
+
self._handlers: Set[asyncio.Task] = set() # open client connections, ended by close()
|
|
88
|
+
self._sessions: Dict[str, Set[asyncio.Task]] = {} # session name -> its connections (abort_session)
|
|
89
|
+
|
|
90
|
+
async def start(self) -> None:
|
|
91
|
+
self._server = await asyncio.start_server(self._handle, self.host, self.port, limit=HEAD_LIMIT)
|
|
92
|
+
self.port = self._server.sockets[0].getsockname()[1]
|
|
93
|
+
|
|
94
|
+
async def close(self) -> None:
|
|
95
|
+
"""Stop listening and end open connections – a tunnel whose target never hangs up would otherwise keep
|
|
96
|
+
running, and from Python 3.12 on wait_closed() waits for every connection."""
|
|
97
|
+
if self._server:
|
|
98
|
+
self._server.close()
|
|
99
|
+
for task in list(self._handlers):
|
|
100
|
+
task.cancel()
|
|
101
|
+
await asyncio.gather(*self._handlers, return_exceptions=True)
|
|
102
|
+
await self._server.wait_closed()
|
|
103
|
+
|
|
104
|
+
def abort_session(self, session: str) -> int:
|
|
105
|
+
"""End every connection of a session (username session-NAME). The client sees its connection closed – the
|
|
106
|
+
one way to wake a thread that is blocked reading it on every platform. -> connections ended."""
|
|
107
|
+
tasks = self._sessions.pop(session, set())
|
|
108
|
+
for task in tasks:
|
|
109
|
+
task.cancel()
|
|
110
|
+
return len(tasks)
|
|
111
|
+
|
|
112
|
+
def _forget(self, session: str, task: asyncio.Task) -> None:
|
|
113
|
+
tasks = self._sessions.get(session)
|
|
114
|
+
if tasks is not None:
|
|
115
|
+
tasks.discard(task)
|
|
116
|
+
if not tasks:
|
|
117
|
+
del self._sessions[session]
|
|
118
|
+
|
|
119
|
+
async def _handle(self, reader, writer) -> None:
|
|
120
|
+
task = asyncio.current_task()
|
|
121
|
+
if task is not None:
|
|
122
|
+
self._handlers.add(task)
|
|
123
|
+
task.add_done_callback(self._handlers.discard)
|
|
124
|
+
peer = writer.get_extra_info("peername") or ("?", 0)
|
|
125
|
+
client = f"{peer[0]}:{peer[1]}"
|
|
126
|
+
self.stats.active += 1
|
|
127
|
+
try:
|
|
128
|
+
try:
|
|
129
|
+
# SOCKS5 and HTTP on the same port: SOCKS5 always starts with 0x05, HTTP with a letter
|
|
130
|
+
first = await asyncio.wait_for(reader.readexactly(1), self.timeout)
|
|
131
|
+
if first == SOCKS5_VERSION:
|
|
132
|
+
await self._serve_socks5(reader, writer, client)
|
|
133
|
+
return
|
|
134
|
+
head = first + await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), self.timeout)
|
|
135
|
+
if head.startswith(STATUS_PREFIX):
|
|
136
|
+
await self._serve_status(writer, head)
|
|
137
|
+
return
|
|
138
|
+
method, host, port, path, headers = parse_request_head(head)
|
|
139
|
+
if not password_ok(headers, self.password):
|
|
140
|
+
writer.write(b"HTTP/1.1 407 Proxy Authentication Required\r\n"
|
|
141
|
+
b'Proxy-Authenticate: Basic realm="proxy-scraper"\r\n'
|
|
142
|
+
b"Content-Length: 0\r\nConnection: close\r\n\r\n")
|
|
143
|
+
return
|
|
144
|
+
except (ValueError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, asyncio.TimeoutError,
|
|
145
|
+
UnicodeDecodeError):
|
|
146
|
+
writer.write(b"HTTP/1.1 400 Bad Request\r\nX-Proxy-Scraper: bad-request\r\n"
|
|
147
|
+
b"Content-Length: 0\r\nConnection: close\r\n\r\n")
|
|
148
|
+
return
|
|
149
|
+
selection = selection_from_headers(headers)
|
|
150
|
+
if selection.session and task is not None:
|
|
151
|
+
self._sessions.setdefault(selection.session, set()).add(task)
|
|
152
|
+
task.add_done_callback(lambda t, name=selection.session: self._forget(name, t))
|
|
153
|
+
await self._serve_request(reader, writer, client, method, host, port, path, headers, selection)
|
|
154
|
+
except (ConnectionError, OSError, asyncio.IncompleteReadError):
|
|
155
|
+
pass # client hung up (also in the middle of a request body)
|
|
156
|
+
finally:
|
|
157
|
+
self.stats.active -= 1
|
|
158
|
+
writer.close()
|
|
159
|
+
|
|
160
|
+
async def _serve_request(self, reader, writer, client, method, host, port, path, headers,
|
|
161
|
+
selection: Selection = ANY) -> None:
|
|
162
|
+
self.stats.requests += 1
|
|
163
|
+
started = time.perf_counter()
|
|
164
|
+
if method == "CONNECT":
|
|
165
|
+
served = await self._serve_tunnel(reader, writer, client, host, port, started, selection)
|
|
166
|
+
else:
|
|
167
|
+
served = await self._serve_http(reader, writer, client, method, host, port, path, headers, started,
|
|
168
|
+
selection)
|
|
169
|
+
if not served:
|
|
170
|
+
self.stats.failed += 1
|
|
171
|
+
|
|
172
|
+
async def _serve_tunnel(self, reader, writer, client, host, port, started, selection: Selection = ANY,
|
|
173
|
+
established: bytes = HTTP_ESTABLISHED, refuse=None) -> bool:
|
|
174
|
+
"""CONNECT: open a tunnel first, then "200" to the client – if none works, it gets a 502.
|
|
175
|
+
|
|
176
|
+
After that a proxy only counts as successful once it answers. The client's first packet (for HTTPS
|
|
177
|
+
the start of the TLS handshake) is buffered and goes to the next proxy unnoticed if needed.
|
|
178
|
+
"""
|
|
179
|
+
tried: Set[str] = set()
|
|
180
|
+
# CONNECT is almost always TLS (also on ports like 8443) – the first ClientHello should only go through
|
|
181
|
+
# proxies that passed the HTTPS test; without any, pick() falls back to all of them
|
|
182
|
+
opened = await self._open_next(tried, host, port, tls=True, tunnel=True, selection=selection)
|
|
183
|
+
if opened is None:
|
|
184
|
+
self._log(client, host, port, None, False, started, len(tried))
|
|
185
|
+
await (refuse or self._bad_gateway)(writer)
|
|
186
|
+
return False
|
|
187
|
+
writer.write(established)
|
|
188
|
+
await writer.drain()
|
|
189
|
+
first_out = await self._first_client_chunk(reader)
|
|
190
|
+
tls = first_out[:1] == TLS_HANDSHAKE
|
|
191
|
+
|
|
192
|
+
while opened is not None:
|
|
193
|
+
entry, up_reader, up_writer = opened
|
|
194
|
+
first_in = await self._exchange(up_reader, up_writer, first_out)
|
|
195
|
+
if first_in is not None:
|
|
196
|
+
await self._relay(reader, writer, client, host, port, started, len(tried), entry,
|
|
197
|
+
up_reader, up_writer, first_out, first_in)
|
|
198
|
+
return True
|
|
199
|
+
self._give_up(entry, up_writer)
|
|
200
|
+
opened = await self._open_next(tried, host, port, tls=tls, tunnel=True, selection=selection)
|
|
201
|
+
self._log(client, host, port, None, False, started, len(tried))
|
|
202
|
+
return False # after "200 Connection established" all that's left is to close the connection
|
|
203
|
+
|
|
204
|
+
async def _serve_http(self, reader, writer, client, method, host, port, path, headers, started,
|
|
205
|
+
selection: Selection = ANY) -> bool:
|
|
206
|
+
"""Plain HTTP request. HTTP upstreams get it as a classic proxy request (without CONNECT),
|
|
207
|
+
SOCKS upstreams in the form for the target server. Small bodies are buffered for a switch."""
|
|
208
|
+
body, replayable = await self._read_body(reader, headers)
|
|
209
|
+
tried: Set[str] = set()
|
|
210
|
+
while True:
|
|
211
|
+
opened = await self._open_next(tried, host, port, tls=False, tunnel=False, selection=selection)
|
|
212
|
+
if opened is None:
|
|
213
|
+
break
|
|
214
|
+
entry, up_reader, up_writer = opened
|
|
215
|
+
if entry.result.ptype == "http":
|
|
216
|
+
head = with_proxy_auth(forward_request(method, path, host, port, headers),
|
|
217
|
+
parse_endpoint(entry.result.proxy))
|
|
218
|
+
else:
|
|
219
|
+
head = origin_request(method, path, host, port, headers)
|
|
220
|
+
first_out = head + body
|
|
221
|
+
if not replayable:
|
|
222
|
+
# large or streamed body: no switch possible – send the head, pass the rest through
|
|
223
|
+
up_writer.write(first_out)
|
|
224
|
+
await up_writer.drain()
|
|
225
|
+
return await self._relay(reader, writer, client, host, port, started, len(tried), entry,
|
|
226
|
+
up_reader, up_writer, first_out, b"", upload=request_body_length(headers))
|
|
227
|
+
first_in = await self._exchange(up_reader, up_writer, first_out)
|
|
228
|
+
if first_in is not None:
|
|
229
|
+
await self._relay(reader, writer, client, host, port, started, len(tried), entry,
|
|
230
|
+
up_reader, up_writer, first_out, first_in, upload=0)
|
|
231
|
+
return True
|
|
232
|
+
self._give_up(entry, up_writer)
|
|
233
|
+
self._log(client, host, port, None, False, started, len(tried))
|
|
234
|
+
await self._bad_gateway(writer)
|
|
235
|
+
return False
|
|
236
|
+
|
|
237
|
+
async def _open_next(self, tried: Set[str], host: str, port: int, tls: bool, tunnel: bool,
|
|
238
|
+
selection: Selection = ANY):
|
|
239
|
+
"""Open the next proxy from the pool (at most max_attempts per request). None = none left.
|
|
240
|
+
|
|
241
|
+
A proxy that is reachable but can't reach the target is only blamed once another proxy reaches it:
|
|
242
|
+
if every attempt fails that way, the target is the problem and nobody in the pool gets disabled."""
|
|
243
|
+
refused = [] # proxies that answered but couldn't open the connection to the target
|
|
244
|
+
while len(tried) < self.max_attempts:
|
|
245
|
+
entry = self.pool.pick(tried, tls=tls, selection=selection, target=f"{host}:{port}")
|
|
246
|
+
if entry is None:
|
|
247
|
+
return None
|
|
248
|
+
tried.add(entry.result.key)
|
|
249
|
+
# count as busy while connecting already – otherwise concurrent requests would see the fastest
|
|
250
|
+
# proxy as free (important for --rotate fastest); released in _give_up or at the end of _relay
|
|
251
|
+
entry.active += 1
|
|
252
|
+
try:
|
|
253
|
+
up_reader, up_writer = await open_upstream(entry, host, port, self.timeout, tunnel=tunnel,
|
|
254
|
+
public_only=self.public_targets_only)
|
|
255
|
+
except Unsupported:
|
|
256
|
+
entry.active -= 1 # this type can't do this target – not a failure of the proxy
|
|
257
|
+
continue
|
|
258
|
+
except TargetError:
|
|
259
|
+
entry.active -= 1
|
|
260
|
+
refused.append(entry)
|
|
261
|
+
continue
|
|
262
|
+
except UpstreamError:
|
|
263
|
+
entry.active -= 1
|
|
264
|
+
self.pool.report(entry, False)
|
|
265
|
+
continue
|
|
266
|
+
for other in refused: # the target is reachable after all – those proxies were the problem
|
|
267
|
+
self.pool.report(other, False)
|
|
268
|
+
return entry, up_reader, up_writer
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
async def _exchange(self, up_reader, up_writer, first_out: bytes) -> Optional[bytes]:
|
|
272
|
+
"""Send the first packet, wait for the first response. None = this proxy is no good right now."""
|
|
273
|
+
try:
|
|
274
|
+
if first_out:
|
|
275
|
+
up_writer.write(first_out)
|
|
276
|
+
await up_writer.drain()
|
|
277
|
+
first_in = await asyncio.wait_for(up_reader.read(65536), self.timeout)
|
|
278
|
+
if first_in and first_out[:1] != TLS_HANDSHAKE:
|
|
279
|
+
first_in = await self._screen_first_answer(up_reader, first_in)
|
|
280
|
+
except (OSError, asyncio.TimeoutError):
|
|
281
|
+
return None
|
|
282
|
+
if not first_in or not plausible_answer(first_out, first_in):
|
|
283
|
+
return None
|
|
284
|
+
return first_in
|
|
285
|
+
|
|
286
|
+
async def _screen_first_answer(self, up_reader, first_in: bytes) -> bytes:
|
|
287
|
+
"""Read up to the final status line (past 100 Continue and the like). b"" = the proxy wants a login."""
|
|
288
|
+
screen = ResponseScreen()
|
|
289
|
+
out, verdict = screen.feed(first_in)
|
|
290
|
+
while verdict is None:
|
|
291
|
+
more = await asyncio.wait_for(up_reader.read(65536), self.timeout)
|
|
292
|
+
if not more:
|
|
293
|
+
return b"" # hung up before a final response came – that's not a success
|
|
294
|
+
data, verdict = screen.feed(more)
|
|
295
|
+
out += data
|
|
296
|
+
return b"" if verdict == "407" else out
|
|
297
|
+
|
|
298
|
+
async def _relay(self, reader, writer, client, host, port, started, attempts, entry,
|
|
299
|
+
up_reader, up_writer, first_out: bytes, first_in: bytes, upload=None) -> bool:
|
|
300
|
+
"""Pass both directions through. Success only counts once the upstream has answered – for
|
|
301
|
+
streamed bodies (first_in empty) that means once any data comes back at all.
|
|
302
|
+
|
|
303
|
+
upload: for plain HTTP only the rest of this request's body may go up (a byte count or a ChunkedEnd).
|
|
304
|
+
Whatever the client sends after it – a second keep-alive request with its Proxy-Authorization,
|
|
305
|
+
for example – never reaches the free proxy. None = tunnel, everything goes through."""
|
|
306
|
+
if first_in:
|
|
307
|
+
self._account(client, host, port, entry, True, started, attempts)
|
|
308
|
+
try:
|
|
309
|
+
self.stats.bytes_up += len(first_out)
|
|
310
|
+
self.stats.bytes_down += len(first_in)
|
|
311
|
+
if first_in:
|
|
312
|
+
writer.write(first_in)
|
|
313
|
+
await writer.drain()
|
|
314
|
+
_, received = await asyncio.gather(
|
|
315
|
+
self._pipe(reader, up_writer, up=True) if upload is None
|
|
316
|
+
else self._send_body(reader, up_writer, upload),
|
|
317
|
+
# without a first response up front (streamed body) watch for a 407 from the proxy here
|
|
318
|
+
self._pipe(up_reader, writer, up=False, reject_proxy_auth=not first_in),
|
|
319
|
+
)
|
|
320
|
+
finally:
|
|
321
|
+
entry.active -= 1
|
|
322
|
+
up_writer.close()
|
|
323
|
+
if not first_in:
|
|
324
|
+
self._account(client, host, port, entry, received > 0, started, attempts)
|
|
325
|
+
return bool(first_in) or received > 0
|
|
326
|
+
|
|
327
|
+
def _give_up(self, entry, up_writer) -> None:
|
|
328
|
+
"""This proxy didn't deliver: count it as a failure, close the connection, release the reservation."""
|
|
329
|
+
entry.active -= 1
|
|
330
|
+
self.pool.report(entry, False)
|
|
331
|
+
up_writer.close()
|
|
332
|
+
|
|
333
|
+
def _account(self, client, host, port, entry, ok: bool, started, attempts) -> None:
|
|
334
|
+
self.pool.report(entry, ok)
|
|
335
|
+
if ok:
|
|
336
|
+
self.stats.ok += 1
|
|
337
|
+
self._log(client, host, port, entry, ok, started, attempts)
|
|
338
|
+
|
|
339
|
+
async def _read_body(self, reader, headers) -> Tuple[bytes, bool]:
|
|
340
|
+
"""Read the request body if it's small enough to buffer -> (body, repeatable?)."""
|
|
341
|
+
values = {name.lower(): value for name, value in headers}
|
|
342
|
+
if b"chunked" in values.get(b"transfer-encoding", b"").lower():
|
|
343
|
+
return b"", False
|
|
344
|
+
if b"100-continue" in values.get(b"expect", b"").lower():
|
|
345
|
+
return b"", False # the client only sends the body after "100 Continue" from the target
|
|
346
|
+
length = values.get(b"content-length", b"0").strip()
|
|
347
|
+
if not length.isdigit() or int(length) > MAX_REPLAY_BODY:
|
|
348
|
+
return b"", False
|
|
349
|
+
return (await reader.readexactly(int(length)) if int(length) else b""), True
|
|
350
|
+
|
|
351
|
+
async def _first_client_chunk(self, reader) -> bytes:
|
|
352
|
+
"""The client's first packet in the tunnel (for HTTPS: TLS ClientHello). Empty if the server is
|
|
353
|
+
supposed to talk first (e.g. SSH) – then we wait for the other side right away."""
|
|
354
|
+
try:
|
|
355
|
+
data = await asyncio.wait_for(reader.read(65536), FIRST_CHUNK_WAIT)
|
|
356
|
+
if data[:1] == TLS_HANDSHAKE:
|
|
357
|
+
data = await self._complete_tls_record(reader, data)
|
|
358
|
+
return data
|
|
359
|
+
except asyncio.TimeoutError:
|
|
360
|
+
return b""
|
|
361
|
+
|
|
362
|
+
async def _complete_tls_record(self, reader, data: bytes) -> bytes:
|
|
363
|
+
"""Collect the first TLS record completely – it can be spread over several TCP packets, and when
|
|
364
|
+
switching proxies not just a fragment of the ClientHello should go out. If the rest is slow, what
|
|
365
|
+
arrived so far goes out anyway – dropping it would break the handshake for good."""
|
|
366
|
+
while True:
|
|
367
|
+
# first the 5-byte header, then the length is known (at most one buffer full)
|
|
368
|
+
needed = 5 if len(data) < 5 else min(5 + int.from_bytes(data[3:5], "big"), 65536)
|
|
369
|
+
if len(data) >= needed:
|
|
370
|
+
return data
|
|
371
|
+
try:
|
|
372
|
+
more = await asyncio.wait_for(reader.read(needed - len(data)), FIRST_CHUNK_WAIT)
|
|
373
|
+
except asyncio.TimeoutError:
|
|
374
|
+
return data
|
|
375
|
+
if not more:
|
|
376
|
+
return data
|
|
377
|
+
data += more
|
|
378
|
+
|
|
379
|
+
async def _bad_gateway(self, writer) -> None:
|
|
380
|
+
# the marker tells clients like agent.PageFetcher that no proxy got through – the target never answered
|
|
381
|
+
writer.write(b"HTTP/1.1 502 Bad Gateway\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
|
382
|
+
b"X-Proxy-Scraper: no-proxy-answered\r\n"
|
|
383
|
+
b"Connection: close\r\n\r\nNo proxy from the pool answered.\n")
|
|
384
|
+
await writer.drain()
|
|
385
|
+
|
|
386
|
+
async def _send_body(self, reader, writer, body) -> int:
|
|
387
|
+
"""Pass exactly the rest of one request body upstream, then stop reading from the client."""
|
|
388
|
+
total = 0
|
|
389
|
+
with contextlib.suppress(ConnectionError, OSError, ValueError):
|
|
390
|
+
while body:
|
|
391
|
+
data = await reader.read(min(body, 65536) if isinstance(body, int) else 65536)
|
|
392
|
+
if not data:
|
|
393
|
+
break
|
|
394
|
+
if isinstance(body, int):
|
|
395
|
+
body -= len(data)
|
|
396
|
+
else:
|
|
397
|
+
end = body.feed(data)
|
|
398
|
+
if end is not None:
|
|
399
|
+
data, body = data[:end], 0
|
|
400
|
+
total += len(data)
|
|
401
|
+
self.stats.bytes_up += len(data)
|
|
402
|
+
writer.write(data)
|
|
403
|
+
await writer.drain()
|
|
404
|
+
return total
|
|
405
|
+
|
|
406
|
+
async def _pipe(self, reader, writer, up: bool, reject_proxy_auth: bool = False) -> int:
|
|
407
|
+
"""Pass data through until one side stops; returns the number of bytes.
|
|
408
|
+
|
|
409
|
+
reject_proxy_auth: if the final response is a 407 (also after 100 Continue), the client gets
|
|
410
|
+
a 502 instead and -1 is returned (the proxy counts as failed)."""
|
|
411
|
+
total = 0
|
|
412
|
+
screen = ResponseScreen() if reject_proxy_auth else None
|
|
413
|
+
try:
|
|
414
|
+
while True:
|
|
415
|
+
data = await reader.read(65536)
|
|
416
|
+
if not data:
|
|
417
|
+
if screen: # hung up before a final response came – a 502 for the client
|
|
418
|
+
await self._bad_gateway(writer)
|
|
419
|
+
return -1
|
|
420
|
+
break
|
|
421
|
+
if screen:
|
|
422
|
+
data, verdict = screen.feed(data)
|
|
423
|
+
if verdict == "407":
|
|
424
|
+
await self._bad_gateway(writer)
|
|
425
|
+
return -1
|
|
426
|
+
if verdict == "ok":
|
|
427
|
+
screen = None
|
|
428
|
+
if not data:
|
|
429
|
+
continue
|
|
430
|
+
total += len(data)
|
|
431
|
+
if up:
|
|
432
|
+
self.stats.bytes_up += len(data)
|
|
433
|
+
else:
|
|
434
|
+
self.stats.bytes_down += len(data)
|
|
435
|
+
writer.write(data)
|
|
436
|
+
await writer.drain()
|
|
437
|
+
except (ConnectionError, OSError, asyncio.TimeoutError):
|
|
438
|
+
# one side hung up – that normally ends the relay just fine. But if no final response came yet
|
|
439
|
+
# (the upstream aborts with an RST, for example, because our body sat unread in its buffer),
|
|
440
|
+
# that's just as much a failure as a clean hang-up.
|
|
441
|
+
if screen:
|
|
442
|
+
with contextlib.suppress(ConnectionError, OSError):
|
|
443
|
+
await self._bad_gateway(writer)
|
|
444
|
+
return -1
|
|
445
|
+
finally:
|
|
446
|
+
try:
|
|
447
|
+
writer.write_eof()
|
|
448
|
+
except (OSError, RuntimeError, AttributeError):
|
|
449
|
+
writer.close()
|
|
450
|
+
return total
|
|
451
|
+
|
|
452
|
+
def _log(self, client, host, port, entry, ok, started, attempts) -> None:
|
|
453
|
+
via = f"{entry.result.ptype}://{entry.result.proxy}" if entry else "–"
|
|
454
|
+
target = host if port in (80, 443) else f"{host}:{port}"
|
|
455
|
+
self.stats.recent.append(RequestLog(client, target, via, ok, round((time.perf_counter() - started) * 1000),
|
|
456
|
+
attempts))
|
|
457
|
+
|
|
458
|
+
# ------------------------------------------------------------------ SOCKS5, status, refreshing
|
|
459
|
+
|
|
460
|
+
async def _serve_socks5(self, reader, writer, client) -> None:
|
|
461
|
+
"""SOCKS5 on the same port. Authentication optional – the user name carries the same wishes as with
|
|
462
|
+
HTTP ("country-de-session-abc"). After that everything works like CONNECT, including switching on errors."""
|
|
463
|
+
try:
|
|
464
|
+
host, port, username = await asyncio.wait_for(socks5_accept(reader, writer, self.password), self.timeout)
|
|
465
|
+
except (Socks5Refused, asyncio.IncompleteReadError, asyncio.TimeoutError, ValueError, UnicodeError):
|
|
466
|
+
return # refused, hung up half-way, too slow or a broken address – close the connection
|
|
467
|
+
self.stats.requests += 1
|
|
468
|
+
started = time.perf_counter()
|
|
469
|
+
|
|
470
|
+
async def refuse(w) -> None:
|
|
471
|
+
w.write(socks5_reply(0x04)) # host unreachable – none of the proxies got through
|
|
472
|
+
await w.drain()
|
|
473
|
+
|
|
474
|
+
served = await self._serve_tunnel(reader, writer, client, host, port, started,
|
|
475
|
+
Selection.from_username(username), socks5_reply(0x00), refuse)
|
|
476
|
+
if not served:
|
|
477
|
+
self.stats.failed += 1
|
|
478
|
+
|
|
479
|
+
async def _serve_status(self, writer, head: bytes) -> None:
|
|
480
|
+
target = head.split(b" ", 2)[1].split(b"?", 1)[0]
|
|
481
|
+
kind = b"application/json"
|
|
482
|
+
headers = [tuple(part.strip() for part in line.split(b":", 1)) for line in head.split(b"\r\n")[1:]
|
|
483
|
+
if b":" in line]
|
|
484
|
+
if not password_ok(headers, self.password, b"authorization"):
|
|
485
|
+
writer.write(b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"proxy-scraper\"\r\n"
|
|
486
|
+
b"Content-Length: 0\r\nConnection: close\r\n\r\n")
|
|
487
|
+
await writer.drain()
|
|
488
|
+
return
|
|
489
|
+
if target == STATUS_PATH:
|
|
490
|
+
status, body = b"200 OK", status_json(self).encode()
|
|
491
|
+
elif target == METRICS_PATH:
|
|
492
|
+
status, body, kind = b"200 OK", metrics_text(self).encode(), METRICS_TYPE
|
|
493
|
+
else: # only exactly these paths – typos shouldn't silently return the status
|
|
494
|
+
status, body = b"404 Not Found", b'{"error": "unknown path, try /__proxy-scraper/status or /metrics"}'
|
|
495
|
+
writer.write(b"HTTP/1.1 " + status + b"\r\nContent-Type: " + kind + b"\r\nCache-Control: no-store\r\n"
|
|
496
|
+
b"Content-Length: %d\r\nConnection: close\r\n\r\n" % len(body) + body)
|
|
497
|
+
await writer.drain()
|
|
498
|
+
|
|
499
|
+
async def keep_fresh(self, recheck, interval: float = 300.0) -> None:
|
|
500
|
+
"""Recheck disabled proxies regularly and bring them back when they work again.
|
|
501
|
+
|
|
502
|
+
`recheck(result)` is the normal check (True = works). Runs until the task is cancelled."""
|
|
503
|
+
while True:
|
|
504
|
+
await asyncio.sleep(interval)
|
|
505
|
+
await self.refresh_once(recheck)
|
|
506
|
+
|
|
507
|
+
async def refresh_once(self, recheck) -> int:
|
|
508
|
+
dead = [e for e in self.pool.entries if e.disabled]
|
|
509
|
+
if not dead:
|
|
510
|
+
return 0
|
|
511
|
+
results = await asyncio.gather(*(recheck(e.result) for e in dead), return_exceptions=True)
|
|
512
|
+
revived = 0
|
|
513
|
+
for entry, ok in zip(dead, results):
|
|
514
|
+
if ok is True:
|
|
515
|
+
self.pool.revive(entry)
|
|
516
|
+
revived += 1
|
|
517
|
+
self.revived += revived
|
|
518
|
+
return revived
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""HTTP on the client and the upstream side: take requests apart and rebuild them, check responses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import List, Tuple
|
|
7
|
+
|
|
8
|
+
TLS_HANDSHAKE, TLS_ALERT = b"\x16", b"\x15" # first byte of a TLS record
|
|
9
|
+
HOP_BY_HOP = {b"proxy-connection", b"connection", b"keep-alive", b"proxy-authorization", b"te", b"upgrade"}
|
|
10
|
+
PROXY_AUTH_REQUIRED = re.compile(rb"HTTP/1\.[01] 407\b")
|
|
11
|
+
STATUS_LEN = len(b"HTTP/1.1 407 ")
|
|
12
|
+
STATUS_RE = re.compile(rb"HTTP/1\.[01] (\d{3})[ \r\n]") # exactly three digits – "4070" is not a 407
|
|
13
|
+
SCREEN_LIMIT = 16384
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_request_head(head: bytes) -> Tuple[str, str, int, bytes, List[Tuple[bytes, bytes]]]:
|
|
17
|
+
"""-> (method, host, port, path, headers). Supports CONNECT host:port and absolute URLs."""
|
|
18
|
+
lines = head.split(b"\r\n")
|
|
19
|
+
method, target, _version = lines[0].split(b" ", 2)
|
|
20
|
+
headers = []
|
|
21
|
+
for line in lines[1:]:
|
|
22
|
+
if line:
|
|
23
|
+
name, _, value = line.partition(b":")
|
|
24
|
+
headers.append((name.strip(), value.strip()))
|
|
25
|
+
if method == b"CONNECT":
|
|
26
|
+
host, _, port = target.decode("ascii").rpartition(":")
|
|
27
|
+
return "CONNECT", host.strip("[]"), _valid_port(port), b"", headers
|
|
28
|
+
if not target.lower().startswith(b"http://"):
|
|
29
|
+
raise ValueError("only absolute http:// URLs or CONNECT")
|
|
30
|
+
rest = target[7:]
|
|
31
|
+
hostport, slash, path = rest.partition(b"/")
|
|
32
|
+
authority = hostport.decode("ascii")
|
|
33
|
+
if authority.startswith("["): # IPv6 literal: http://[2606:4700::1]:8080/
|
|
34
|
+
host, _, after = authority[1:].partition("]")
|
|
35
|
+
port = after[1:] if after.startswith(":") else ""
|
|
36
|
+
else:
|
|
37
|
+
host, _, port = authority.partition(":")
|
|
38
|
+
return method.decode("ascii"), host, _valid_port(port or "80"), b"/" + path if slash else b"/", headers
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _valid_port(text: str) -> int:
|
|
42
|
+
port = int(text)
|
|
43
|
+
if not 0 < port < 65536:
|
|
44
|
+
raise ValueError(f"invalid port {port}")
|
|
45
|
+
return port
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def origin_request(method: str, path: bytes, host: str, port: int, headers: List[Tuple[bytes, bytes]]) -> bytes:
|
|
49
|
+
"""Request for the target server: path instead of an absolute URL, no proxy headers, one request per connection."""
|
|
50
|
+
lines = [f"{method} ".encode() + path + b" HTTP/1.1"]
|
|
51
|
+
if not any(name.lower() == b"host" for name, _ in headers):
|
|
52
|
+
lines.append(b"Host: " + (host if port == 80 else f"{host}:{port}").encode())
|
|
53
|
+
lines += [name + b": " + value for name, value in headers if name.lower() not in HOP_BY_HOP]
|
|
54
|
+
lines.append(b"Connection: close")
|
|
55
|
+
return b"\r\n".join(lines) + b"\r\n\r\n"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def forward_request(method: str, path: bytes, host: str, port: int, headers: List[Tuple[bytes, bytes]]) -> bytes:
|
|
59
|
+
"""Request to an HTTP upstream proxy: absolute URL as sent by the client, just without proxy headers."""
|
|
60
|
+
authority = host if port == 80 else f"{host}:{port}"
|
|
61
|
+
request = origin_request(method, path, host, port, headers)
|
|
62
|
+
rest = request.split(b"\r\n", 1)[1] # the first line is replaced by the absolute URL
|
|
63
|
+
return f"{method} http://{authority}".encode() + path + b" HTTP/1.1\r\n" + rest
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ChunkedEnd:
|
|
67
|
+
"""Finds where a chunked request body ends, fed piece by piece. feed() returns how many bytes of this
|
|
68
|
+
piece still belong to the body once the end is in it, otherwise None. Garbage raises ValueError."""
|
|
69
|
+
|
|
70
|
+
MAX_LINE = 4096
|
|
71
|
+
|
|
72
|
+
def __init__(self):
|
|
73
|
+
self.line = b""
|
|
74
|
+
self.left = 0 # bytes of chunk data (plus its CRLF) still to come
|
|
75
|
+
self.trailer = False
|
|
76
|
+
|
|
77
|
+
def feed(self, data: bytes):
|
|
78
|
+
i = 0
|
|
79
|
+
while i < len(data):
|
|
80
|
+
if self.left:
|
|
81
|
+
take = min(self.left, len(data) - i)
|
|
82
|
+
self.left -= take
|
|
83
|
+
i += take
|
|
84
|
+
continue
|
|
85
|
+
j = data.find(b"\n", i)
|
|
86
|
+
if j < 0:
|
|
87
|
+
self.line += data[i:]
|
|
88
|
+
if len(self.line) > self.MAX_LINE:
|
|
89
|
+
raise ValueError("chunk line too long")
|
|
90
|
+
return None
|
|
91
|
+
line = (self.line + data[i:j]).rstrip(b"\r")
|
|
92
|
+
self.line = b""
|
|
93
|
+
i = j + 1
|
|
94
|
+
if self.trailer:
|
|
95
|
+
if not line:
|
|
96
|
+
return i
|
|
97
|
+
continue
|
|
98
|
+
size = int(line.split(b";", 1)[0].strip(), 16)
|
|
99
|
+
if size < 0:
|
|
100
|
+
raise ValueError("negative chunk size")
|
|
101
|
+
if size:
|
|
102
|
+
self.left = size + 2
|
|
103
|
+
else:
|
|
104
|
+
self.trailer = True
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def request_body_length(headers: List[Tuple[bytes, bytes]]):
|
|
109
|
+
"""How much body follows the head: a byte count, or a ChunkedEnd for chunked bodies."""
|
|
110
|
+
values = {name.lower(): value for name, value in headers}
|
|
111
|
+
if b"chunked" in values.get(b"transfer-encoding", b"").lower():
|
|
112
|
+
return ChunkedEnd()
|
|
113
|
+
length = values.get(b"content-length", b"0").strip()
|
|
114
|
+
return int(length) if length.isdigit() else 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class ResponseScreen:
|
|
118
|
+
"""Checks the start of an upstream response until the final status line is there.
|
|
119
|
+
|
|
120
|
+
Interim responses (100 Continue and the like) go to the client right away; if a 407 follows,
|
|
121
|
+
the client should never see it. feed() returns what may already go on, and the decision:
|
|
122
|
+
None = still open, "ok" = just pass everything else through, "407" = the proxy wants a login
|
|
123
|
+
(or sends an implausibly long interim response – both mean: don't use this upstream)."""
|
|
124
|
+
|
|
125
|
+
def __init__(self):
|
|
126
|
+
self.buf = b""
|
|
127
|
+
|
|
128
|
+
def feed(self, data: bytes):
|
|
129
|
+
self.buf += data
|
|
130
|
+
out = b""
|
|
131
|
+
while True:
|
|
132
|
+
head = self.buf
|
|
133
|
+
if len(head) < STATUS_LEN and b"\n" not in head and head[:5] == b"HTTP/"[:len(head[:5])]:
|
|
134
|
+
return out, None # status line still incomplete
|
|
135
|
+
m = STATUS_RE.match(head)
|
|
136
|
+
if not m:
|
|
137
|
+
return out + self._flush(), "ok" # not an HTTP response (e.g. a tunnel) – nothing to check
|
|
138
|
+
code = m.group(1)
|
|
139
|
+
if code == b"407":
|
|
140
|
+
return out, "407"
|
|
141
|
+
if not code.startswith(b"1") or code == b"101": # 101 Switching Protocols is final
|
|
142
|
+
return out + self._flush(), "ok"
|
|
143
|
+
end = head.find(b"\r\n\r\n")
|
|
144
|
+
if end < 0:
|
|
145
|
+
if len(head) > SCREEN_LIMIT:
|
|
146
|
+
return out, "407" # huge interim response – better count it as failed than let it through blindly
|
|
147
|
+
return out, None # interim response not complete yet
|
|
148
|
+
out += head[:end + 4]
|
|
149
|
+
self.buf = head[end + 4:]
|
|
150
|
+
if not self.buf:
|
|
151
|
+
return out, None
|
|
152
|
+
|
|
153
|
+
def _flush(self) -> bytes:
|
|
154
|
+
data, self.buf = self.buf, b""
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def plausible_answer(first_out: bytes, first_in: bytes) -> bool:
|
|
159
|
+
"""Does the first response fit the request? If the client starts with a TLS handshake (0x16), the
|
|
160
|
+
other side has to speak TLS too – some proxies send an HTTP error page inside the tunnel instead.
|
|
161
|
+
A 407 always comes from the proxy itself (login missing or wrong), never from the target site."""
|
|
162
|
+
if first_out[:1] == TLS_HANDSHAKE:
|
|
163
|
+
return first_in[:1] in (TLS_HANDSHAKE, TLS_ALERT)
|
|
164
|
+
return not PROXY_AUTH_REQUIRED.match(first_in)
|