Skip to content

Commit 8dc9b6b

Browse files
SOCKS proxy support (#2034)
1 parent 7f0d43d commit 8dc9b6b

File tree

9 files changed

+157
-91
lines changed

9 files changed

+157
-91
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,16 @@ The HTTPX project relies on these excellent libraries:
129129

130130
* `httpcore` - The underlying transport implementation for `httpx`.
131131
* `h11` - HTTP/1.1 support.
132-
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*
133132
* `certifi` - SSL certificates.
134133
* `charset_normalizer` - Charset auto-detection.
135134
* `rfc3986` - URL parsing & normalization.
136135
* `idna` - Internationalized domain name support.
137136
* `sniffio` - Async library autodetection.
137+
138+
As well as these optional installs:
139+
140+
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*
141+
* `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)*
138142
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
139143
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
140144
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*

docs/advanced.md

Lines changed: 13 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -387,8 +387,6 @@ client = httpx.Client(trust_env=False)
387387

388388
HTTPX supports setting up [HTTP proxies](https://en.wikipedia.org/wiki/Proxy_server#Web_proxy_servers) via the `proxies` parameter to be passed on client initialization or top-level API functions like `httpx.get(..., proxies=...)`.
389389

390-
_Note: SOCKS proxies are not supported yet._
391-
392390
<div align="center">
393391
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/2/27/Open_proxy_h2g2bob.svg/480px-Open_proxy_h2g2bob.svg.png"/>
394392
<figcaption><em>Diagram of how a proxy works (source: Wikipedia). The left hand side "Internet" blob may be your HTTPX client requesting <code>example.com</code> through a proxy.</em></figcaption>
@@ -565,44 +563,34 @@ See documentation on [`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`](environment_vari
565563
In general, the flow for making an HTTP request through a proxy is as follows:
566564

567565
1. The client connects to the proxy (initial connection request).
568-
1. The proxy somehow transfers data to the server on your behalf.
566+
2. The proxy transfers data to the server on your behalf.
569567

570568
How exactly step 2/ is performed depends on which of two proxying mechanisms is used:
571569

572570
* **Forwarding**: the proxy makes the request for you, and sends back the response it obtained from the server.
573-
* **Tunneling**: the proxy establishes a TCP connection to the server on your behalf, and the client reuses this connection to send the request and receive the response. This is known as an [HTTP Tunnel](https://en.wikipedia.org/wiki/HTTP_tunnel). This mechanism is how you can access websites that use HTTPS from an HTTP proxy (the client "upgrades" the connection to HTTPS by performing the TLS handshake with the server over the TCP connection provided by the proxy).
571+
* **Tunnelling**: the proxy establishes a TCP connection to the server on your behalf, and the client reuses this connection to send the request and receive the response. This is known as an [HTTP Tunnel](https://en.wikipedia.org/wiki/HTTP_tunnel). This mechanism is how you can access websites that use HTTPS from an HTTP proxy (the client "upgrades" the connection to HTTPS by performing the TLS handshake with the server over the TCP connection provided by the proxy).
574572

575-
#### Default behavior
573+
### Troubleshooting proxies
576574

577-
Given the technical definitions above, by default (and regardless of whether you're using an HTTP or HTTPS proxy), HTTPX will:
575+
If you encounter issues when setting up proxies, please refer to our [Troubleshooting guide](troubleshooting.md#proxies).
578576

579-
* Use forwarding for HTTP requests.
580-
* Use tunneling for HTTPS requests.
577+
## SOCKS
581578

582-
This ensures that you can make HTTP and HTTPS requests in all cases (i.e. regardless of which type of proxy you're using).
579+
In addition to HTTP proxies, `httpcore` also supports proxies using the SOCKS protocol.
580+
This is an optional feature that requires an additional third-party library be installed before use.
583581

584-
#### Forcing the proxy mechanism
582+
You can install SOCKS support using `pip`:
585583

586-
In most cases, the default behavior should work just fine as well as provide enough security.
584+
```shell
585+
$ pip install httpx[socks]
586+
```
587587

588-
But if you know what you're doing and you want to force which mechanism to use, you can do so by passing an `httpx.Proxy()` instance, setting the `mode` to either `FORWARD_ONLY` or `TUNNEL_ONLY`. For example...
588+
You can now configure a client to make requests via a proxy using the SOCKS protocol:
589589

590590
```python
591-
# Route all requests through an HTTPS proxy, using tunneling only.
592-
proxies = httpx.Proxy(
593-
url="https://localhost:8030",
594-
mode="TUNNEL_ONLY",
595-
)
596-
597-
with httpx.Client(proxies=proxies) as client:
598-
# This HTTP request will be tunneled instead of forwarded.
599-
r = client.get("http://example.com")
591+
httpx.Client(proxies='socks5://user:pass@host:port')
600592
```
601593

602-
### Troubleshooting proxies
603-
604-
If you encounter issues when setting up proxies, please refer to our [Troubleshooting guide](troubleshooting.md#proxies).
605-
606594
## Timeout Configuration
607595

608596
HTTPX is careful to enforce timeouts everywhere by default.

docs/index.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,16 @@ The HTTPX project relies on these excellent libraries:
112112

113113
* `httpcore` - The underlying transport implementation for `httpx`.
114114
* `h11` - HTTP/1.1 support.
115-
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*
116115
* `certifi` - SSL certificates.
117116
* `charset_normalizer` - Charset auto-detection.
118117
* `rfc3986` - URL parsing & normalization.
119118
* `idna` - Internationalized domain name support.
120119
* `sniffio` - Async library autodetection.
120+
121+
As well as these optional installs:
122+
123+
* `h2` - HTTP/2 support. *(Optional, with `httpx[http2]`)*
124+
* `socksio` - SOCKS proxy support. *(Optional, with `httpx[socks]`)*
121125
* `rich` - Rich terminal support. *(Optional, with `httpx[cli]`)*
122126
* `click` - Command line client support. *(Optional, with `httpx[cli]`)*
123127
* `brotli` or `brotlicffi` - Decoding for "brotli" compressed responses. *(Optional, with `httpx[brotli]`)*

httpx/_config.py

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import os
22
import ssl
33
import typing
4-
from base64 import b64encode
54
from pathlib import Path
65

76
import certifi
@@ -316,32 +315,46 @@ def __repr__(self) -> str:
316315

317316

318317
class Proxy:
319-
def __init__(self, url: URLTypes, *, headers: HeaderTypes = None):
318+
def __init__(
319+
self,
320+
url: URLTypes,
321+
*,
322+
auth: typing.Tuple[str, str] = None,
323+
headers: HeaderTypes = None,
324+
):
320325
url = URL(url)
321326
headers = Headers(headers)
322327

323-
if url.scheme not in ("http", "https"):
328+
if url.scheme not in ("http", "https", "socks5"):
324329
raise ValueError(f"Unknown scheme for proxy URL {url!r}")
325330

326331
if url.username or url.password:
327-
headers.setdefault(
328-
"Proxy-Authorization",
329-
self._build_auth_header(url.username, url.password),
330-
)
331-
# Remove userinfo from the URL authority, e.g.:
332-
# 'username:password@proxy_host:proxy_port' -> 'proxy_host:proxy_port'
332+
# Remove any auth credentials from the URL.
333+
auth = (url.username, url.password)
333334
url = url.copy_with(username=None, password=None)
334335

335336
self.url = url
337+
self.auth = auth
336338
self.headers = headers
337339

338-
def _build_auth_header(self, username: str, password: str) -> str:
339-
userpass = (username.encode("utf-8"), password.encode("utf-8"))
340-
token = b64encode(b":".join(userpass)).decode()
341-
return f"Basic {token}"
340+
@property
341+
def raw_auth(self) -> typing.Optional[typing.Tuple[bytes, bytes]]:
342+
# The proxy authentication as raw bytes.
343+
return (
344+
None
345+
if self.auth is None
346+
else (self.auth[0].encode("utf-8"), self.auth[1].encode("utf-8"))
347+
)
342348

343349
def __repr__(self) -> str:
344-
return f"Proxy(url={str(self.url)!r}, headers={dict(self.headers)!r})"
350+
# The authentication is represented with the password component masked.
351+
auth = (self.auth[0], "********") if self.auth else None
352+
353+
# Build a nice concise representation.
354+
url_str = f"{str(self.url)!r}"
355+
auth_str = f", auth={auth!r}" if auth else ""
356+
headers_str = f", headers={dict(self.headers)!r}" if self.headers else ""
357+
return f"Proxy({url_str}{auth_str}{headers_str})"
345358

346359

347360
DEFAULT_TIMEOUT_CONFIG = Timeout(timeout=5.0)

httpx/_transports/default.py

Lines changed: 76 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -137,37 +137,51 @@ def __init__(
137137
local_address=local_address,
138138
retries=retries,
139139
)
140-
else:
140+
elif proxy.url.scheme in ("http", "https"):
141+
self._pool = httpcore.HTTPProxy(
142+
proxy_url=httpcore.URL(
143+
scheme=proxy.url.raw_scheme,
144+
host=proxy.url.raw_host,
145+
port=proxy.url.port,
146+
target=proxy.url.raw_path,
147+
),
148+
proxy_auth=proxy.raw_auth,
149+
proxy_headers=proxy.headers.raw,
150+
ssl_context=ssl_context,
151+
max_connections=limits.max_connections,
152+
max_keepalive_connections=limits.max_keepalive_connections,
153+
keepalive_expiry=limits.keepalive_expiry,
154+
http1=http1,
155+
http2=http2,
156+
)
157+
elif proxy.url.scheme == "socks5":
141158
try:
142-
self._pool = httpcore.HTTPProxy(
143-
proxy_url=httpcore.URL(
144-
scheme=proxy.url.raw_scheme,
145-
host=proxy.url.raw_host,
146-
port=proxy.url.port,
147-
target=proxy.url.raw_path,
148-
),
149-
proxy_headers=proxy.headers.raw,
150-
ssl_context=ssl_context,
151-
max_connections=limits.max_connections,
152-
max_keepalive_connections=limits.max_keepalive_connections,
153-
keepalive_expiry=limits.keepalive_expiry,
154-
http1=http1,
155-
http2=http2,
156-
)
157-
except TypeError: # pragma: nocover
158-
self._pool = httpcore.HTTPProxy(
159-
proxy_url=httpcore.URL(
160-
scheme=proxy.url.raw_scheme,
161-
host=proxy.url.raw_host,
162-
port=proxy.url.port,
163-
target=proxy.url.raw_path,
164-
),
165-
proxy_headers=proxy.headers.raw,
166-
ssl_context=ssl_context,
167-
max_connections=limits.max_connections,
168-
max_keepalive_connections=limits.max_keepalive_connections,
169-
keepalive_expiry=limits.keepalive_expiry,
170-
)
159+
import socksio # noqa
160+
except ImportError: # pragma: nocover
161+
raise ImportError(
162+
"Using SOCKS proxy, but the 'socksio' package is not installed. "
163+
"Make sure to install httpx using `pip install httpx[socks]`."
164+
) from None
165+
166+
self._pool = httpcore.SOCKSProxy(
167+
proxy_url=httpcore.URL(
168+
scheme=proxy.url.raw_scheme,
169+
host=proxy.url.raw_host,
170+
port=proxy.url.port,
171+
target=proxy.url.raw_path,
172+
),
173+
proxy_auth=proxy.raw_auth,
174+
ssl_context=ssl_context,
175+
max_connections=limits.max_connections,
176+
max_keepalive_connections=limits.max_keepalive_connections,
177+
keepalive_expiry=limits.keepalive_expiry,
178+
http1=http1,
179+
http2=http2,
180+
)
181+
else: # pragma: nocover
182+
raise ValueError(
183+
f"Proxy protocol must be either 'http', 'https', or 'socks5', but got {proxy.url.scheme!r}."
184+
)
171185

172186
def __enter__(self: T) -> T: # Use generics for subclass support.
173187
self._pool.__enter__()
@@ -258,19 +272,50 @@ def __init__(
258272
local_address=local_address,
259273
retries=retries,
260274
)
261-
else:
275+
elif proxy.url.scheme in ("http", "https"):
262276
self._pool = httpcore.AsyncHTTPProxy(
263277
proxy_url=httpcore.URL(
264278
scheme=proxy.url.raw_scheme,
265279
host=proxy.url.raw_host,
266280
port=proxy.url.port,
267281
target=proxy.url.raw_path,
268282
),
283+
proxy_auth=proxy.raw_auth,
269284
proxy_headers=proxy.headers.raw,
270285
ssl_context=ssl_context,
271286
max_connections=limits.max_connections,
272287
max_keepalive_connections=limits.max_keepalive_connections,
273288
keepalive_expiry=limits.keepalive_expiry,
289+
http1=http1,
290+
http2=http2,
291+
)
292+
elif proxy.url.scheme == "socks5":
293+
try:
294+
import socksio # noqa
295+
except ImportError: # pragma: nocover
296+
raise ImportError(
297+
"Using SOCKS proxy, but the 'socksio' package is not installed. "
298+
"Make sure to install httpx using `pip install httpx[socks]`."
299+
) from None
300+
301+
self._pool = httpcore.AsyncSOCKSProxy(
302+
proxy_url=httpcore.URL(
303+
scheme=proxy.url.raw_scheme,
304+
host=proxy.url.raw_host,
305+
port=proxy.url.port,
306+
target=proxy.url.raw_path,
307+
),
308+
proxy_auth=proxy.raw_auth,
309+
ssl_context=ssl_context,
310+
max_connections=limits.max_connections,
311+
max_keepalive_connections=limits.max_keepalive_connections,
312+
keepalive_expiry=limits.keepalive_expiry,
313+
http1=http1,
314+
http2=http2,
315+
)
316+
else: # pragma: nocover
317+
raise ValueError(
318+
f"Proxy protocol must be either 'http', 'https', or 'socks5', but got {proxy.url.scheme!r}."
274319
)
275320

276321
async def __aenter__(self: A) -> A: # Use generics for subclass support.

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# On the other hand, we're not pinning package dependencies, because our tests
33
# needs to pass with the latest version of the packages.
44
# Reference: https://github.com/encode/httpx/pull/1721#discussion_r661241588
5-
-e .[cli,http2,brotli]
5+
-e .[brotli,cli,http2,socks]
66

77
charset-normalizer==2.0.6
88

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def get_packages(package):
6565
],
6666
extras_require={
6767
"http2": "h2>=3,<5",
68+
"socks": "socksio==1.*",
6869
"brotli": [
6970
"brotli; platform_python_implementation == 'CPython'",
7071
"brotlicffi; platform_python_implementation != 'CPython'"

tests/client/test_proxies.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,20 @@ def test_proxies_parameter(proxies, expected_proxies):
4747
assert len(expected_proxies) == len(client._mounts)
4848

4949

50+
def test_socks_proxy():
51+
url = httpx.URL("http://www.example.com")
52+
53+
client = httpx.Client(proxies="socks5://localhost/")
54+
transport = client._transport_for_url(url)
55+
assert isinstance(transport, httpx.HTTPTransport)
56+
assert isinstance(transport._pool, httpcore.SOCKSProxy)
57+
58+
async_client = httpx.AsyncClient(proxies="socks5://localhost/")
59+
async_transport = async_client._transport_for_url(url)
60+
assert isinstance(async_transport, httpx.AsyncHTTPTransport)
61+
assert isinstance(async_transport._pool, httpcore.AsyncSOCKSProxy)
62+
63+
5064
PROXY_URL = "http://[::1]"
5165

5266

tests/test_config.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -199,25 +199,22 @@ def test_ssl_config_support_for_keylog_file(tmpdir, monkeypatch): # pragma: noc
199199
assert context.keylog_filename is None # type: ignore
200200

201201

202-
@pytest.mark.parametrize(
203-
"url,expected_url,expected_headers",
204-
[
205-
("https://example.com", "https://example.com", {}),
206-
(
207-
"https://user:[email protected]",
208-
"https://example.com",
209-
{"proxy-authorization": "Basic dXNlcjpwYXNz"},
210-
),
211-
],
212-
)
213-
def test_proxy_from_url(url, expected_url, expected_headers):
214-
proxy = httpx.Proxy(url)
202+
def test_proxy_from_url():
203+
proxy = httpx.Proxy("https://example.com")
215204

216-
assert str(proxy.url) == expected_url
217-
assert dict(proxy.headers) == expected_headers
218-
assert repr(proxy) == "Proxy(url='{}', headers={})".format(
219-
expected_url, str(expected_headers)
220-
)
205+
assert str(proxy.url) == "https://example.com"
206+
assert proxy.auth is None
207+
assert proxy.headers == {}
208+
assert repr(proxy) == "Proxy('https://example.com')"
209+
210+
211+
def test_proxy_with_auth_from_url():
212+
proxy = httpx.Proxy("https://username:[email protected]")
213+
214+
assert str(proxy.url) == "https://example.com"
215+
assert proxy.auth == ("username", "password")
216+
assert proxy.headers == {}
217+
assert repr(proxy) == "Proxy('https://example.com', auth=('username', '********'))"
221218

222219

223220
def test_invalid_proxy_scheme():

0 commit comments

Comments
 (0)