Skip to content

Commit fac7f97

Browse files
committed
Add support for SOCKS proxies.
Fix #475.
1 parent 4e1be22 commit fac7f97

File tree

12 files changed

+479
-19
lines changed

12 files changed

+479
-19
lines changed

docs/project/changelog.rst

+17-2
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,28 @@ fixing regressions shortly after a release.
2525
Only documented APIs are public. Undocumented, private APIs may change without
2626
notice.
2727

28-
.. _14.3:
28+
.. _15.0:
2929

30-
14.3
30+
15.0
3131
----
3232

3333
*In development*
3434

35+
Backwards-incompatible changes
36+
..............................
37+
38+
.. admonition:: Client connections use SOCKS proxies automatically.
39+
:class: important
40+
41+
If a proxy is configured in the operating system or with an environment
42+
variable, websockets uses it automatically when connecting to a server.
43+
This feature requires installing the third-party library `python-socks`_.
44+
45+
If you want to disable the proxy, add ``proxy=None`` when calling
46+
:func:`~asyncio.client.connect`. See :doc:`../topics/proxies` for details.
47+
48+
.. _python-socks: https://github.com/romis2012/python-socks
49+
3550
New features
3651
............
3752

docs/reference/features.rst

+1-2
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,10 @@ Client
168168
+------------------------------------+--------+--------+--------+--------+
169169
| Connect via HTTP proxy (`#364`_) |||||
170170
+------------------------------------+--------+--------+--------+--------+
171-
| Connect via SOCKS5 proxy (`#475`_) | | |||
171+
| Connect via SOCKS5 proxy | | |||
172172
+------------------------------------+--------+--------+--------+--------+
173173

174174
.. _#364: https://github.com/python-websockets/websockets/issues/364
175-
.. _#475: https://github.com/python-websockets/websockets/issues/475
176175
.. _#784: https://github.com/python-websockets/websockets/issues/784
177176

178177
Known limitations

docs/topics/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ Get a deeper understanding of how websockets is built and why.
1515
memory
1616
security
1717
performance
18+
proxies

docs/topics/proxies.rst

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
Proxies
2+
=======
3+
4+
.. currentmodule:: websockets
5+
6+
If a proxy is configured in the operating system or with an environment
7+
variable, websockets uses it automatically when connecting to a server.
8+
9+
Configuration
10+
-------------
11+
12+
First, if the server is in the proxy bypass list of the operating system or in
13+
the ``no_proxy`` environment variable, websockets connects directly.
14+
15+
Then, it looks for a proxy in the following locations:
16+
17+
1. The ``wss_proxy`` or ``ws_proxy`` environment variables for ``wss://`` and
18+
``ws://`` connections respectively. They allow configuring a specific proxy
19+
for WebSocket connections.
20+
2. A SOCKS proxy configured in the operating system.
21+
3. An HTTP proxy configured in the operating system or in the ``https_proxy``
22+
environment variable, for both ``wss://`` and ``ws://`` connections.
23+
4. An HTTP proxy configured in the operating system or in the ``http_proxy``
24+
environment variable, only for ``ws://`` connections.
25+
26+
Finally, if no proxy is found, websockets connects directly.
27+
28+
While environment variables are case-insensitive, the lower-case spelling is the
29+
most common, for `historical reasons`_, and recommended.
30+
31+
.. _historical reasons: https://unix.stackexchange.com/questions/212894/
32+
33+
.. admonition:: Any environment variable can configure a SOCKS proxy or an HTTP proxy.
34+
:class: tip
35+
36+
For example, ``https_proxy=socks5h://proxy:1080/`` configures a SOCKS proxy
37+
for all WebSocket connections. Likewise, ``wss_proxy=http://proxy:8080/``
38+
configures an HTTP proxy only for ``wss://`` connections.
39+
40+
.. admonition:: What if websockets doesn't select the right proxy?
41+
:class: hint
42+
43+
websockets relies on :func:`~urllib.request.getproxies()` to read the proxy
44+
configuration. Check that it returns what you expect. If it doesn't, review
45+
your proxy configuration.
46+
47+
You can override the default configuration and configure a proxy explicitly with
48+
the ``proxy`` argument of :func:`~asyncio.client.connect`. Set ``proxy=None`` to
49+
disable the proxy.
50+
51+
SOCKS proxies
52+
-------------
53+
54+
Connecting through a SOCKS proxy requires installing the third-party library
55+
`python-socks`_::
56+
57+
$ pip install python-socks\[asyncio\]
58+
59+
.. _python-socks: https://github.com/romis2012/python-socks
60+
61+
python-socks supports SOCKS4, SOCKS4a, SOCKS5, and SOCKS5h. The protocol version
62+
is configured in the address of the proxy e.g. ``socks5h://proxy:1080/``. When a
63+
SOCKS proxy is configured in the operating system, python-socks uses SOCKS5h.
64+
65+
python-socks supports username/password authentication for SOCKS5 (:rfc:`1929`)
66+
but does not support other authentication methods such as GSSAPI (:rfc:`1961`).

src/websockets/asyncio/client.py

+61-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import urllib.parse
88
from collections.abc import AsyncIterator, Generator, Sequence
99
from types import TracebackType
10-
from typing import Any, Callable
10+
from typing import Any, Callable, Literal
1111

1212
from ..client import ClientProtocol, backoff
1313
from ..datastructures import HeadersLike
@@ -18,7 +18,7 @@
1818
from ..http11 import USER_AGENT, Response
1919
from ..protocol import CONNECTING, Event
2020
from ..typing import LoggerLike, Origin, Subprotocol
21-
from ..uri import WebSocketURI, parse_uri
21+
from ..uri import Proxy, WebSocketURI, get_proxy, parse_proxy, parse_uri
2222
from .compatibility import TimeoutError, asyncio_timeout
2323
from .connection import Connection
2424

@@ -208,6 +208,10 @@ class connect:
208208
user_agent_header: Value of the ``User-Agent`` request header.
209209
It defaults to ``"Python/x.y.z websockets/X.Y"``.
210210
Setting it to :obj:`None` removes the header.
211+
proxy: If a proxy is configured, it is used by default. Set ``proxy``
212+
to :obj:`None` to disable the proxy or to the address of a proxy
213+
to override the system configuration. See the :doc:`proxy docs
214+
<../../topics/proxies>` for details.
211215
process_exception: When reconnecting automatically, tell whether an
212216
error is transient or fatal. The default behavior is defined by
213217
:func:`process_exception`. Refer to its documentation for details.
@@ -279,6 +283,7 @@ def __init__(
279283
# HTTP
280284
additional_headers: HeadersLike | None = None,
281285
user_agent_header: str | None = USER_AGENT,
286+
proxy: str | Literal[True] | None = True,
282287
process_exception: Callable[[Exception], Exception | None] = process_exception,
283288
# Timeouts
284289
open_timeout: float | None = 10,
@@ -333,6 +338,7 @@ def protocol_factory(uri: WebSocketURI) -> ClientConnection:
333338
)
334339
return connection
335340

341+
self.proxy = proxy
336342
self.protocol_factory = protocol_factory
337343
self.handshake_args = (
338344
additional_headers,
@@ -346,9 +352,20 @@ def protocol_factory(uri: WebSocketURI) -> ClientConnection:
346352
async def create_connection(self) -> ClientConnection:
347353
"""Create TCP or Unix connection."""
348354
loop = asyncio.get_running_loop()
355+
kwargs = self.connection_kwargs.copy()
349356

350357
ws_uri = parse_uri(self.uri)
351-
kwargs = self.connection_kwargs.copy()
358+
359+
proxy = self.proxy
360+
proxy_uri: Proxy | None = None
361+
if kwargs.get("unix", False):
362+
proxy = None
363+
if kwargs.get("sock") is not None:
364+
proxy = None
365+
if proxy is True:
366+
proxy = get_proxy(ws_uri)
367+
if proxy is not None:
368+
proxy_uri = parse_proxy(proxy)
352369

353370
def factory() -> ClientConnection:
354371
return self.protocol_factory(ws_uri)
@@ -365,6 +382,47 @@ def factory() -> ClientConnection:
365382
if kwargs.pop("unix", False):
366383
_, connection = await loop.create_unix_connection(factory, **kwargs)
367384
else:
385+
if proxy_uri is not None:
386+
if proxy_uri.scheme[:5] == "socks":
387+
try:
388+
from python_socks import ProxyType
389+
from python_socks.async_.asyncio import Proxy
390+
except ImportError:
391+
raise ImportError(
392+
"python-socks is required to use a SOCKS proxy"
393+
)
394+
if proxy_uri.scheme == "socks5h":
395+
proxy_type = ProxyType.SOCKS5
396+
rdns = True
397+
elif proxy_uri.scheme == "socks5":
398+
proxy_type = ProxyType.SOCKS5
399+
rdns = False
400+
# We use mitmproxy for testing and it doesn't support SOCKS4.
401+
elif proxy_uri.scheme == "socks4a": # pragma: no cover
402+
proxy_type = ProxyType.SOCKS4
403+
rdns = True
404+
elif proxy_uri.scheme == "socks4": # pragma: no cover
405+
proxy_type = ProxyType.SOCKS4
406+
rdns = False
407+
# Proxy types are enforced in parse_proxy().
408+
else:
409+
raise AssertionError("unsupported SOCKS proxy")
410+
socks_proxy = Proxy(
411+
proxy_type,
412+
proxy_uri.host,
413+
proxy_uri.port,
414+
proxy_uri.username,
415+
proxy_uri.password,
416+
rdns,
417+
)
418+
kwargs["sock"] = await socks_proxy.connect(
419+
ws_uri.host,
420+
ws_uri.port,
421+
local_addr=kwargs.pop("local_addr", None),
422+
)
423+
# Proxy types are enforced in parse_proxy().
424+
else:
425+
raise AssertionError("unsupported proxy")
368426
if kwargs.get("sock") is None:
369427
kwargs.setdefault("host", ws_uri.host)
370428
kwargs.setdefault("port", ws_uri.port)

src/websockets/sync/client.py

+64-4
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import threading
66
import warnings
77
from collections.abc import Sequence
8-
from typing import Any
8+
from typing import Any, Literal
99

1010
from ..client import ClientProtocol
1111
from ..datastructures import HeadersLike
@@ -15,7 +15,7 @@
1515
from ..http11 import USER_AGENT, Response
1616
from ..protocol import CONNECTING, Event
1717
from ..typing import LoggerLike, Origin, Subprotocol
18-
from ..uri import parse_uri
18+
from ..uri import Proxy, get_proxy, parse_proxy, parse_uri
1919
from .connection import Connection
2020
from .utils import Deadline
2121

@@ -139,6 +139,7 @@ def connect(
139139
# HTTP
140140
additional_headers: HeadersLike | None = None,
141141
user_agent_header: str | None = USER_AGENT,
142+
proxy: str | Literal[True] | None = True,
142143
# Timeouts
143144
open_timeout: float | None = 10,
144145
ping_interval: float | None = 20,
@@ -189,6 +190,10 @@ def connect(
189190
user_agent_header: Value of the ``User-Agent`` request header.
190191
It defaults to ``"Python/x.y.z websockets/X.Y"``.
191192
Setting it to :obj:`None` removes the header.
193+
proxy: If a proxy is configured, it is used by default. Set ``proxy``
194+
to :obj:`None` to disable the proxy or to the address of a proxy
195+
to override the system configuration. See the :doc:`proxy docs
196+
<../../topics/proxies>` for details.
192197
open_timeout: Timeout for opening the connection in seconds.
193198
:obj:`None` disables the timeout.
194199
ping_interval: Interval between keepalive pings in seconds.
@@ -253,6 +258,16 @@ def connect(
253258
elif compression is not None:
254259
raise ValueError(f"unsupported compression: {compression}")
255260

261+
proxy_uri: Proxy | None = None
262+
if unix:
263+
proxy = None
264+
if sock is not None:
265+
proxy = None
266+
if proxy is True:
267+
proxy = get_proxy(ws_uri)
268+
if proxy is not None:
269+
proxy_uri = parse_proxy(proxy)
270+
256271
# Calculate timeouts on the TCP, TLS, and WebSocket handshakes.
257272
# The TCP and TLS timeouts must be set on the socket, then removed
258273
# to avoid conflicting with the WebSocket timeout in handshake().
@@ -271,8 +286,53 @@ def connect(
271286
assert path is not None # mypy cannot figure this out
272287
sock.connect(path)
273288
else:
274-
kwargs.setdefault("timeout", deadline.timeout())
275-
sock = socket.create_connection((ws_uri.host, ws_uri.port), **kwargs)
289+
if proxy_uri is not None:
290+
if proxy_uri.scheme[:5] == "socks":
291+
try:
292+
from python_socks import ProxyType
293+
from python_socks.sync import Proxy
294+
except ImportError:
295+
raise ImportError(
296+
"python-socks is required to use a SOCKS proxy"
297+
)
298+
if proxy_uri.scheme == "socks5h":
299+
proxy_type = ProxyType.SOCKS5
300+
rdns = True
301+
elif proxy_uri.scheme == "socks5":
302+
proxy_type = ProxyType.SOCKS5
303+
rdns = False
304+
# We use mitmproxy for testing and it doesn't support SOCKS4.
305+
elif proxy_uri.scheme == "socks4a": # pragma: no cover
306+
proxy_type = ProxyType.SOCKS4
307+
rdns = True
308+
elif proxy_uri.scheme == "socks4": # pragma: no cover
309+
proxy_type = ProxyType.SOCKS4
310+
rdns = False
311+
# Proxy types are enforced in parse_proxy().
312+
else:
313+
raise AssertionError("unsupported SOCKS proxy")
314+
socks_proxy = Proxy(
315+
proxy_type,
316+
proxy_uri.host,
317+
proxy_uri.port,
318+
proxy_uri.username,
319+
proxy_uri.password,
320+
rdns,
321+
)
322+
sock = socks_proxy.connect(
323+
ws_uri.host,
324+
ws_uri.port,
325+
timeout=deadline.timeout(),
326+
local_addr=kwargs.pop("local_addr", None),
327+
)
328+
# Proxy types are enforced in parse_proxy().
329+
else:
330+
raise AssertionError("unsupported proxy")
331+
else:
332+
kwargs.setdefault("timeout", deadline.timeout())
333+
sock = socket.create_connection(
334+
(ws_uri.host, ws_uri.port), **kwargs
335+
)
276336
sock.settimeout(None)
277337

278338
# Disable Nagle algorithm

src/websockets/version.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
released = False
2222

23-
tag = version = commit = "14.3"
23+
tag = version = commit = "15.0"
2424

2525

2626
if not released: # pragma: no cover

0 commit comments

Comments
 (0)