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,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)