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