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,195 @@
|
|
|
1
|
+
"""The pool of proxies that were found: selection, scoring and who drops out of the rotation.
|
|
2
|
+
|
|
3
|
+
Selection per connection in three steps:
|
|
4
|
+
1. filters from the request (Selection): country, type – via the user name, e.g. "country-de-type-socks5"
|
|
5
|
+
2. sticky: the same session (or with --sticky the same target site) keeps the same proxy for a while
|
|
6
|
+
3. strategy for everything else: weighted (default), random, round-robin, fastest
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Callable, Dict, List, Optional, Set, Tuple
|
|
16
|
+
|
|
17
|
+
from ..checker import CheckResult
|
|
18
|
+
|
|
19
|
+
DISABLE_AFTER = 3 # this many failures in a row -> out of the rotation
|
|
20
|
+
STRATEGIES = ("weighted", "random", "round-robin", "fastest")
|
|
21
|
+
SESSION_SECONDS = 600 # how long a session (session-…) keeps its proxy when --sticky isn't set
|
|
22
|
+
_TOKEN_RE = re.compile(r"(country|type|session)[-_]([A-Za-z0-9]+)")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Selection:
|
|
27
|
+
"""The client's wishes for this connection – they come from the user name of the proxy login."""
|
|
28
|
+
country: str = ""
|
|
29
|
+
ptype: str = ""
|
|
30
|
+
session: str = ""
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_username(cls, username: str) -> "Selection":
|
|
34
|
+
"""'country-de-session-abc' -> Selection(country='DE', session='abc'). Unknown parts are ignored."""
|
|
35
|
+
found = dict(_TOKEN_RE.findall(username or ""))
|
|
36
|
+
ptype = found.get("type", "").lower()
|
|
37
|
+
return cls(country=found.get("country", "").upper()[:2],
|
|
38
|
+
ptype=ptype if ptype in ("http", "socks4", "socks5") else "",
|
|
39
|
+
session=found.get("session", "")[:64])
|
|
40
|
+
|
|
41
|
+
def __bool__(self) -> bool:
|
|
42
|
+
return bool(self.country or self.ptype or self.session)
|
|
43
|
+
|
|
44
|
+
def describe(self) -> str:
|
|
45
|
+
parts = [self.country, self.ptype, f"session {self.session}" if self.session else ""]
|
|
46
|
+
return " · ".join(p for p in parts if p)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
ANY = Selection() # no special wishes
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class PoolEntry:
|
|
54
|
+
result: CheckResult
|
|
55
|
+
ok: int = 0
|
|
56
|
+
fail: int = 0
|
|
57
|
+
fail_streak: int = 0
|
|
58
|
+
active: int = 0
|
|
59
|
+
disabled: bool = False
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def weight(self) -> float:
|
|
63
|
+
# prefer fast and proven ones, but give everyone a chance
|
|
64
|
+
reliability = (self.ok + 1) / (self.ok + self.fail + 2)
|
|
65
|
+
return reliability / (self.result.latency + 300)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProxyPool:
|
|
69
|
+
def __init__(self, results: List[CheckResult], rng: Optional[random.Random] = None,
|
|
70
|
+
strategy: str = "weighted", sticky_seconds: float = 0,
|
|
71
|
+
clock: Callable[[], float] = time.monotonic, strict_tls: bool = False):
|
|
72
|
+
if strategy not in STRATEGIES:
|
|
73
|
+
raise ValueError(f"unknown strategy {strategy!r} (possible: {', '.join(STRATEGIES)})")
|
|
74
|
+
self.entries = [PoolEntry(r) for r in sorted(results, key=lambda r: r.latency)]
|
|
75
|
+
self.rng = rng or random.Random()
|
|
76
|
+
self.strategy = strategy
|
|
77
|
+
self.sticky_seconds = sticky_seconds
|
|
78
|
+
self.clock = clock
|
|
79
|
+
# TLS only through proxies that passed the verified-TLS test, even when all of those are down –
|
|
80
|
+
# --serve falls back to the rest then, a client that promised verified TLS (agent.PageFetcher) doesn't
|
|
81
|
+
self.strict_tls = strict_tls
|
|
82
|
+
self._sticky: Dict[str, Tuple[PoolEntry, float]] = {} # session/target site -> (proxy, valid until)
|
|
83
|
+
self._next = 0 # for round-robin
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def usable(self) -> List[PoolEntry]:
|
|
87
|
+
return [e for e in self.entries if not e.disabled]
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def tls_capable(self) -> List[PoolEntry]:
|
|
91
|
+
return [e for e in self.usable if e.result.https]
|
|
92
|
+
|
|
93
|
+
def pick(self, exclude: Set[str], tls: bool = False, selection: Selection = ANY,
|
|
94
|
+
target: str = "") -> Optional[PoolEntry]:
|
|
95
|
+
"""Next proxy for a connection to `target` (host:port). None = none fits (any more).
|
|
96
|
+
|
|
97
|
+
For TLS only proxies that passed the HTTPS test (verified TLS) – others often break up the
|
|
98
|
+
encryption. If there are none, all of them. Country and type wishes are strict, though:
|
|
99
|
+
whoever asks for "country-de" would rather get an error than a proxy from another country."""
|
|
100
|
+
candidates = [e for e in self.usable if e.result.key not in exclude and self._matches(e, selection)]
|
|
101
|
+
if tls and (self.strict_tls or any(e.result.https for e in self.usable if self._matches(e, selection))):
|
|
102
|
+
candidates = [e for e in candidates if e.result.https]
|
|
103
|
+
if not candidates:
|
|
104
|
+
return None
|
|
105
|
+
sticky_key = self._sticky_key(selection, target)
|
|
106
|
+
if sticky_key:
|
|
107
|
+
held = self._sticky.get(sticky_key)
|
|
108
|
+
if held and held[1] > self.clock() and held[0] in candidates:
|
|
109
|
+
return held[0]
|
|
110
|
+
entry = self._choose(candidates)
|
|
111
|
+
if sticky_key:
|
|
112
|
+
self._sticky[sticky_key] = (entry, self.clock() + (self.sticky_seconds or SESSION_SECONDS))
|
|
113
|
+
if len(self._sticky) > 10_000: # don't collect old entries forever
|
|
114
|
+
now = self.clock()
|
|
115
|
+
self._sticky = {k: v for k, v in self._sticky.items() if v[1] > now}
|
|
116
|
+
return entry
|
|
117
|
+
|
|
118
|
+
def _sticky_key(self, selection: Selection, target: str) -> str:
|
|
119
|
+
if selection.session:
|
|
120
|
+
return f"session:{selection.session}"
|
|
121
|
+
if self.sticky_seconds and target:
|
|
122
|
+
return f"target:{target}"
|
|
123
|
+
return ""
|
|
124
|
+
|
|
125
|
+
@staticmethod
|
|
126
|
+
def _matches(entry: PoolEntry, selection: Selection) -> bool:
|
|
127
|
+
r = entry.result
|
|
128
|
+
return (not selection.country or r.country == selection.country) and \
|
|
129
|
+
(not selection.ptype or r.ptype == selection.ptype)
|
|
130
|
+
|
|
131
|
+
def _choose(self, candidates: List[PoolEntry]) -> PoolEntry:
|
|
132
|
+
if self.strategy == "random":
|
|
133
|
+
return self.rng.choice(candidates)
|
|
134
|
+
if self.strategy == "fastest":
|
|
135
|
+
# the fastest free one; if all are busy, the least busy one (then the faster one)
|
|
136
|
+
idle = [e for e in candidates if not e.active]
|
|
137
|
+
if idle:
|
|
138
|
+
return min(idle, key=lambda e: (e.result.latency, -e.weight))
|
|
139
|
+
return min(candidates, key=lambda e: (e.active, e.result.latency))
|
|
140
|
+
if self.strategy == "round-robin":
|
|
141
|
+
ordered = sorted(candidates, key=lambda e: e.result.key)
|
|
142
|
+
entry = ordered[self._next % len(ordered)]
|
|
143
|
+
self._next += 1
|
|
144
|
+
return entry
|
|
145
|
+
return self.rng.choices(candidates, weights=[e.weight for e in candidates])[0]
|
|
146
|
+
|
|
147
|
+
def report(self, entry: PoolEntry, ok: bool) -> None:
|
|
148
|
+
if ok:
|
|
149
|
+
entry.ok += 1
|
|
150
|
+
entry.fail_streak = 0
|
|
151
|
+
else:
|
|
152
|
+
entry.fail += 1
|
|
153
|
+
entry.fail_streak += 1
|
|
154
|
+
if entry.fail_streak >= DISABLE_AFTER:
|
|
155
|
+
entry.disabled = True
|
|
156
|
+
# if a session holds this proxy, it should get a different one next time
|
|
157
|
+
self._sticky = {k: v for k, v in self._sticky.items() if v[0] is not entry}
|
|
158
|
+
|
|
159
|
+
def session_entry(self, session: str) -> Optional[PoolEntry]:
|
|
160
|
+
"""The proxy that currently serves a session (username session-NAME) – after failover, the one that worked."""
|
|
161
|
+
held = self._sticky.get(f"session:{session}")
|
|
162
|
+
return held[0] if held else None
|
|
163
|
+
|
|
164
|
+
def retire(self, entry: PoolEntry) -> None:
|
|
165
|
+
"""Out of the rotation right away (e.g. it dropped a download halfway) – until a merge brings it back."""
|
|
166
|
+
entry.disabled = True
|
|
167
|
+
self._sticky = {k: v for k, v in self._sticky.items() if v[0] is not entry}
|
|
168
|
+
|
|
169
|
+
def merge(self, results: List[CheckResult], drop_missing: bool = False) -> int:
|
|
170
|
+
"""Hits of a refill: new ones join the rotation, known ones that were disabled come back with the fresh
|
|
171
|
+
result, disabled ones that didn't pass again are dropped. Counters of known proxies stay. -> added.
|
|
172
|
+
|
|
173
|
+
drop_missing: `results` is the whole truth (a new live list) – whatever isn't in it goes, working or not.
|
|
174
|
+
A refill doesn't set it: it skips the proxies that are serving, so their absence means nothing."""
|
|
175
|
+
fresh = {r.key: r for r in results}
|
|
176
|
+
kept, added = [], 0
|
|
177
|
+
for entry in self.entries:
|
|
178
|
+
r = fresh.pop(entry.result.key, None)
|
|
179
|
+
if r is not None:
|
|
180
|
+
entry.result = r
|
|
181
|
+
if entry.disabled:
|
|
182
|
+
self.revive(entry)
|
|
183
|
+
elif entry.disabled or drop_missing:
|
|
184
|
+
continue # dead (or gone from the list) and not in the fresh results – make room
|
|
185
|
+
kept.append(entry)
|
|
186
|
+
for r in fresh.values():
|
|
187
|
+
kept.append(PoolEntry(r))
|
|
188
|
+
added += 1
|
|
189
|
+
self.entries = sorted(kept, key=lambda e: e.result.latency)
|
|
190
|
+
return added
|
|
191
|
+
|
|
192
|
+
def revive(self, entry: PoolEntry) -> None:
|
|
193
|
+
"""A disabled proxy passed the recheck – back into the rotation."""
|
|
194
|
+
entry.disabled = False
|
|
195
|
+
entry.fail_streak = 0
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""SOCKS5 on the client side: greeting, optional authentication (RFC 1929) and accepting CONNECT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import socket
|
|
7
|
+
from typing import Tuple
|
|
8
|
+
|
|
9
|
+
SOCKS5_VERSION = b"\x05"
|
|
10
|
+
NO_AUTH, USER_PASS, NO_METHOD = 0x00, 0x02, 0xFF
|
|
11
|
+
CMD_CONNECT = 0x01
|
|
12
|
+
ATYP_IPV4, ATYP_DOMAIN, ATYP_IPV6 = 0x01, 0x03, 0x04
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Socks5Refused(Exception):
|
|
16
|
+
"""The client doesn't speak usable SOCKS5 or wants something other than CONNECT – the reply is already sent."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def socks5_reply(code: int) -> bytes:
|
|
20
|
+
"""Reply to the CONNECT request. 0x00 = ok, 0x04 = host unreachable, 0x07 = command not supported."""
|
|
21
|
+
return bytes([5, code, 0, ATYP_IPV4]) + b"\x00" * 6
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def socks5_accept(reader, writer, password: str = "") -> Tuple[str, int, str]:
|
|
25
|
+
"""After the first byte (0x05): negotiate methods, authenticate if needed, read CONNECT.
|
|
26
|
+
|
|
27
|
+
-> (target host, target port, user name). The user name carries wishes like "country-de" (see
|
|
28
|
+
pool.Selection). With `password` set, username/password auth is mandatory and the password must match."""
|
|
29
|
+
count = (await reader.readexactly(1))[0]
|
|
30
|
+
methods = await reader.readexactly(count)
|
|
31
|
+
username = ""
|
|
32
|
+
if USER_PASS in methods:
|
|
33
|
+
writer.write(bytes([5, USER_PASS]))
|
|
34
|
+
await writer.drain()
|
|
35
|
+
await reader.readexactly(1) # version of the sub-negotiation
|
|
36
|
+
username = (await reader.readexactly((await reader.readexactly(1))[0])).decode("utf-8", "replace")
|
|
37
|
+
given = await reader.readexactly((await reader.readexactly(1))[0])
|
|
38
|
+
if password and not hmac.compare_digest(given, password.encode()):
|
|
39
|
+
writer.write(b"\x01\x01")
|
|
40
|
+
await writer.drain()
|
|
41
|
+
raise Socks5Refused("wrong password")
|
|
42
|
+
writer.write(b"\x01\x00")
|
|
43
|
+
elif NO_AUTH in methods and not password:
|
|
44
|
+
writer.write(bytes([5, NO_AUTH]))
|
|
45
|
+
else:
|
|
46
|
+
writer.write(bytes([5, NO_METHOD]))
|
|
47
|
+
await writer.drain()
|
|
48
|
+
raise Socks5Refused("no supported method")
|
|
49
|
+
await writer.drain()
|
|
50
|
+
|
|
51
|
+
version, command, _, atyp = await reader.readexactly(4)
|
|
52
|
+
if atyp == ATYP_IPV4:
|
|
53
|
+
host = socket.inet_ntoa(await reader.readexactly(4))
|
|
54
|
+
elif atyp == ATYP_DOMAIN:
|
|
55
|
+
host = (await reader.readexactly((await reader.readexactly(1))[0])).decode("idna")
|
|
56
|
+
elif atyp == ATYP_IPV6:
|
|
57
|
+
host = socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16))
|
|
58
|
+
else:
|
|
59
|
+
host = ""
|
|
60
|
+
port = int.from_bytes(await reader.readexactly(2), "big")
|
|
61
|
+
if version != 5 or command != CMD_CONNECT or not host or not port:
|
|
62
|
+
writer.write(socks5_reply(0x07))
|
|
63
|
+
await writer.drain()
|
|
64
|
+
raise Socks5Refused("CONNECT only")
|
|
65
|
+
return host, port, username
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Status as JSON (/__proxy-scraper/status), metrics for Prometheus (/__proxy-scraper/metrics) and
|
|
2
|
+
wishes from the proxy login."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import binascii
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from statistics import median
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from ..parsing import PROXY_TYPES
|
|
15
|
+
from ..ui.widgets import shown_proxy
|
|
16
|
+
from .pool import Selection
|
|
17
|
+
|
|
18
|
+
STATUS_PREFIX = b"GET /__proxy-scraper/"
|
|
19
|
+
STATUS_PATH = b"/__proxy-scraper/status"
|
|
20
|
+
METRICS_PATH = b"/__proxy-scraper/metrics"
|
|
21
|
+
METRICS_TYPE = b"text/plain; version=0.0.4; charset=utf-8"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def basic_credentials(headers: List[Tuple[bytes, bytes]], header: bytes = b"proxy-authorization"
|
|
25
|
+
) -> Optional[Tuple[str, str]]:
|
|
26
|
+
"""Basic auth from the given header -> (user, password), None if missing or broken."""
|
|
27
|
+
for name, value in headers:
|
|
28
|
+
if name.lower() == header and value[:6].lower() == b"basic ":
|
|
29
|
+
try:
|
|
30
|
+
decoded = base64.b64decode(value[6:].strip(), validate=True).decode("utf-8", "replace")
|
|
31
|
+
except (binascii.Error, ValueError):
|
|
32
|
+
return None
|
|
33
|
+
user, _, password = decoded.partition(":")
|
|
34
|
+
return user, password
|
|
35
|
+
return None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def password_ok(headers: List[Tuple[bytes, bytes]], password: str, header: bytes = b"proxy-authorization") -> bool:
|
|
39
|
+
"""No password configured, or the Basic auth in `header` carries exactly this password."""
|
|
40
|
+
if not password:
|
|
41
|
+
return True
|
|
42
|
+
creds = basic_credentials(headers, header)
|
|
43
|
+
return creds is not None and hmac.compare_digest(creds[1].encode(), password.encode())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def selection_from_headers(headers: List[Tuple[bytes, bytes]]) -> Selection:
|
|
47
|
+
"""Proxy-Authorization: Basic base64("country-de-session-abc:anything") -> Selection."""
|
|
48
|
+
creds = basic_credentials(headers)
|
|
49
|
+
return Selection.from_username(creds[0]) if creds else Selection()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def status_json(server: Any) -> str:
|
|
53
|
+
"""server: the RotatingServer (not imported, otherwise core and status would depend on each other in a circle)."""
|
|
54
|
+
st, pool = server.stats, server.pool
|
|
55
|
+
entries = sorted(pool.entries, key=lambda e: (e.disabled, -e.ok, e.result.latency))
|
|
56
|
+
payload = {
|
|
57
|
+
"listening": f"{server.host}:{server.port}",
|
|
58
|
+
"uptime_seconds": round(time.perf_counter() - st.started),
|
|
59
|
+
"strategy": pool.strategy,
|
|
60
|
+
"sticky_seconds": pool.sticky_seconds,
|
|
61
|
+
"requests": {"total": st.requests, "ok": st.ok, "failed": st.failed, "active": st.active},
|
|
62
|
+
"bytes": {"up": st.bytes_up, "down": st.bytes_down},
|
|
63
|
+
"pool": {"total": len(pool.entries), "usable": len(pool.usable), "https": len(pool.tls_capable),
|
|
64
|
+
"revived": server.revived, "refilled": server.refilled,
|
|
65
|
+
"last_refill": server.last_refill and round(server.last_refill)},
|
|
66
|
+
"proxies": [
|
|
67
|
+
{"url": f"{e.result.ptype}://{shown_proxy(e.result.proxy)}", "country": e.result.country,
|
|
68
|
+
"latency_ms": e.result.latency, "https": e.result.https, "ok": e.ok, "fail": e.fail,
|
|
69
|
+
"disabled": e.disabled, "active": e.active}
|
|
70
|
+
for e in entries[:100]
|
|
71
|
+
],
|
|
72
|
+
}
|
|
73
|
+
return json.dumps(payload, indent=1, ensure_ascii=False)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def metrics_text(server: Any) -> str:
|
|
77
|
+
"""Prometheus text format, without a dependency – e.g. for Grafana when the server runs permanently."""
|
|
78
|
+
st, pool = server.stats, server.pool
|
|
79
|
+
out: List[str] = []
|
|
80
|
+
|
|
81
|
+
def metric(name: str, kind: str, help_text: str, samples: Dict[str, float]) -> None:
|
|
82
|
+
out.append(f"# HELP proxy_scraper_{name} {help_text}")
|
|
83
|
+
out.append(f"# TYPE proxy_scraper_{name} {kind}")
|
|
84
|
+
out.extend(f"proxy_scraper_{name}{labels} {value:g}" for labels, value in samples.items())
|
|
85
|
+
|
|
86
|
+
metric("uptime_seconds", "gauge", "Seconds since the server started.",
|
|
87
|
+
{"": round(time.perf_counter() - st.started)})
|
|
88
|
+
metric("requests_total", "counter", "Requests handled, by result.",
|
|
89
|
+
{'{result="ok"}': st.ok, '{result="failed"}': st.failed})
|
|
90
|
+
metric("requests_active", "gauge", "Requests in progress.", {"": st.active})
|
|
91
|
+
metric("bytes_total", "counter", "Bytes relayed, by direction.",
|
|
92
|
+
{'{direction="up"}': st.bytes_up, '{direction="down"}': st.bytes_down})
|
|
93
|
+
metric("revived_total", "counter", "Disabled proxies that passed a re-check.", {"": server.revived})
|
|
94
|
+
|
|
95
|
+
proxies, uses, latency = {}, {}, {}
|
|
96
|
+
for t in PROXY_TYPES:
|
|
97
|
+
entries = [e for e in pool.entries if e.result.ptype == t]
|
|
98
|
+
usable = [e for e in entries if not e.disabled]
|
|
99
|
+
proxies[f'{{type="{t}",state="usable"}}'] = len(usable)
|
|
100
|
+
proxies[f'{{type="{t}",state="disabled"}}'] = len(entries) - len(usable)
|
|
101
|
+
uses[f'{{type="{t}",result="ok"}}'] = sum(e.ok for e in entries)
|
|
102
|
+
uses[f'{{type="{t}",result="failed"}}'] = sum(e.fail for e in entries)
|
|
103
|
+
if usable:
|
|
104
|
+
latency[f'{{type="{t}"}}'] = median(e.result.latency for e in usable)
|
|
105
|
+
metric("pool_proxies", "gauge", "Proxies in the pool, by type and state.", proxies)
|
|
106
|
+
metric("proxy_uses_total", "counter", "Upstream attempts, by proxy type and result.", uses)
|
|
107
|
+
metric("pool_latency_median_ms", "gauge", "Median check latency of usable proxies.", latency)
|
|
108
|
+
return "\n".join(out) + "\n"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Connect to the target through a proxy from the pool (HTTP CONNECT, SOCKS4, SOCKS5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import ipaddress
|
|
7
|
+
import socket
|
|
8
|
+
|
|
9
|
+
from ..handshake import (
|
|
10
|
+
ProxyRefused,
|
|
11
|
+
parse_endpoint,
|
|
12
|
+
socks4_connect,
|
|
13
|
+
socks5_connect,
|
|
14
|
+
socks5_domain,
|
|
15
|
+
socks5_ipv4,
|
|
16
|
+
stream_io,
|
|
17
|
+
with_proxy_auth,
|
|
18
|
+
)
|
|
19
|
+
from .pool import PoolEntry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UpstreamError(Exception):
|
|
23
|
+
"""The chosen proxy didn't establish the connection – next attempt."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TargetError(UpstreamError):
|
|
27
|
+
"""The proxy answered but couldn't open the connection to the target (refused tunnel, 502/504, target
|
|
28
|
+
not resolvable). That may be the target's fault, so it only counts against the proxy if another
|
|
29
|
+
proxy reaches the same target."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Unsupported(UpstreamError):
|
|
33
|
+
"""This proxy type can't do this target at all (SOCKS4 and IPv6). Never counts against the proxy."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
GATEWAY_ERRORS = (b"502", b"503", b"504")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def open_upstream(entry: PoolEntry, host: str, port: int, timeout: float, tunnel: bool = True,
|
|
40
|
+
public_only: bool = False):
|
|
41
|
+
"""Connection through the proxy to host:port. tunnel=False means: HTTP upstream in forwarding mode
|
|
42
|
+
(classic proxy request without CONNECT). Raises UpstreamError if the proxy doesn't play along."""
|
|
43
|
+
r = entry.result
|
|
44
|
+
ep = parse_endpoint(r.proxy)
|
|
45
|
+
try:
|
|
46
|
+
reader, writer = await asyncio.wait_for(asyncio.open_connection(ep.host, ep.port), timeout)
|
|
47
|
+
except (OSError, asyncio.TimeoutError) as e:
|
|
48
|
+
raise UpstreamError(f"proxy unreachable: {e!r}") from None
|
|
49
|
+
if r.ptype == "http" and not tunnel:
|
|
50
|
+
return reader, writer
|
|
51
|
+
try:
|
|
52
|
+
await asyncio.wait_for(_handshake(r.ptype, r.proxy, reader, writer, host, port, public_only), timeout)
|
|
53
|
+
except BaseException as e:
|
|
54
|
+
writer.close()
|
|
55
|
+
if isinstance(e, UpstreamError):
|
|
56
|
+
raise
|
|
57
|
+
if isinstance(e, (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, ValueError)):
|
|
58
|
+
raise UpstreamError(f"tunnel failed: {e!r}") from None
|
|
59
|
+
raise
|
|
60
|
+
return reader, writer
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _handshake(ptype: str, proxy: str, reader, writer, host: str, port: int,
|
|
64
|
+
public_only: bool = False) -> None:
|
|
65
|
+
try:
|
|
66
|
+
await _connect_through(ptype, proxy, reader, writer, host, port, public_only)
|
|
67
|
+
except ProxyRefused as e: # the proxy speaks the protocol fine, only the CONNECT to the target failed
|
|
68
|
+
raise TargetError(str(e)) from None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _connect_through(ptype: str, proxy: str, reader, writer, host: str, port: int,
|
|
72
|
+
public_only: bool = False) -> None:
|
|
73
|
+
ep = parse_endpoint(proxy)
|
|
74
|
+
literal = _ip_literal(host)
|
|
75
|
+
if ptype == "http":
|
|
76
|
+
authority = f"[{host}]:{port}" if literal and literal.version == 6 else f"{host}:{port}"
|
|
77
|
+
connect = f"CONNECT {authority} HTTP/1.1\r\nHost: {authority}\r\n\r\n".encode()
|
|
78
|
+
writer.write(with_proxy_auth(connect, ep))
|
|
79
|
+
await writer.drain()
|
|
80
|
+
head = await reader.readuntil(b"\r\n\r\n")
|
|
81
|
+
first = head.split(b"\r\n", 1)[0]
|
|
82
|
+
parts = first.split()
|
|
83
|
+
# exactly 200 – "HTTP/1.1 2000" or similar is not an established tunnel
|
|
84
|
+
if len(parts) < 2 or not parts[0].startswith(b"HTTP/") or parts[1] != b"200":
|
|
85
|
+
error = TargetError if len(parts) > 1 and parts[1] in GATEWAY_ERRORS else UpstreamError
|
|
86
|
+
raise error(first.decode("latin-1"))
|
|
87
|
+
elif ptype == "socks4":
|
|
88
|
+
if literal and literal.version == 6:
|
|
89
|
+
raise Unsupported("SOCKS4 can't reach IPv6 targets")
|
|
90
|
+
ip = await _resolve(host, port, public_only) # SOCKS4 only knows IPv4 addresses
|
|
91
|
+
if not await socks4_connect(*stream_io(reader, writer), ep, socket.inet_aton(ip), port):
|
|
92
|
+
raise UpstreamError("no SOCKS4 answer")
|
|
93
|
+
else:
|
|
94
|
+
if literal and literal.version == 6:
|
|
95
|
+
address = b"\x04" + literal.packed
|
|
96
|
+
elif literal:
|
|
97
|
+
address = socks5_ipv4(literal.packed)
|
|
98
|
+
else:
|
|
99
|
+
address = socks5_domain(host) # host name instead of IP: resolved at the proxy (no DNS leak)
|
|
100
|
+
if not await socks5_connect(*stream_io(reader, writer), ep, address, port):
|
|
101
|
+
raise UpstreamError("SOCKS5 greeting or login failed")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _ip_literal(host: str):
|
|
105
|
+
try:
|
|
106
|
+
return ipaddress.ip_address(host)
|
|
107
|
+
except ValueError:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def _resolve(host: str, port: int, public_only: bool = False) -> str:
|
|
112
|
+
try:
|
|
113
|
+
infos = await asyncio.get_running_loop().getaddrinfo(host, port, family=socket.AF_INET)
|
|
114
|
+
except OSError as e: # no IPv4 address for the target – a SOCKS4 limit, not the proxy's fault
|
|
115
|
+
raise Unsupported(f"can't resolve {host} to IPv4: {e}") from None
|
|
116
|
+
ip = infos[0][4][0]
|
|
117
|
+
if public_only and not ipaddress.ip_address(ip).is_global: # checked once more right before connecting
|
|
118
|
+
raise TargetError(f"{host} resolves to {ip}, a private address")
|
|
119
|
+
return ip
|
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
google-site-verification: googleaac1161b7853c5b5.html
|