Skip to content

Commit c202e64

Browse files
committed
Spike HTTP(S) proxy implementation for sync client.
1 parent 513073b commit c202e64

File tree

7 files changed

+169
-7
lines changed

7 files changed

+169
-7
lines changed

docs/reference/features.rst

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,12 +166,11 @@ Client
166166
| Perform HTTP Digest Authentication |||||
167167
| (`#784`_) | | | | |
168168
+------------------------------------+--------+--------+--------+--------+
169-
| Connect via HTTP proxy (`#364`_) | | |||
169+
| Connect via HTTP proxy | | |||
170170
+------------------------------------+--------+--------+--------+--------+
171171
| Connect via SOCKS5 proxy |||||
172172
+------------------------------------+--------+--------+--------+--------+
173173

174-
.. _#364: https://github.com/python-websockets/websockets/issues/364
175174
.. _#784: https://github.com/python-websockets/websockets/issues/784
176175

177176
Known limitations

docs/topics/proxies.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,8 @@ SOCKS proxy is configured in the operating system, python-socks uses SOCKS5h.
6464

6565
python-socks supports username/password authentication for SOCKS5 (:rfc:`1929`)
6666
but does not support other authentication methods such as GSSAPI (:rfc:`1961`).
67+
68+
HTTP proxies
69+
------------
70+
71+
TODO

src/websockets/asyncio/client.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import logging
55
import os
66
import socket
7+
import ssl
78
import traceback
89
import urllib.parse
910
from collections.abc import AsyncIterator, Generator, Sequence
@@ -215,6 +216,9 @@ class connect:
215216
to :obj:`None` to disable the proxy or to the address of a proxy
216217
to override the system configuration. See the :doc:`proxy docs
217218
<../../topics/proxies>` for details.
219+
proxy_ssl: Configuration for enabling TLS on the proxy connection.
220+
proxy_server_hostname: Host name for the TLS handshake with the proxy.
221+
``proxy_server_hostname`` overrides the host name from ``proxy``.
218222
process_exception: When reconnecting automatically, tell whether an
219223
error is transient or fatal. The default behavior is defined by
220224
:func:`process_exception`. Refer to its documentation for details.
@@ -257,7 +261,7 @@ class connect:
257261
the TLS handshake.
258262
259263
* You can set ``host`` and ``port`` to connect to a different host and port
260-
from those found in ``uri``. This only changes the destination of the TCP
264+
from those found in ``uri``. This only changes the ws_uri of the TCP
261265
connection. The host name from ``uri`` is still used in the TLS handshake
262266
for secure connections and in the ``Host`` header.
263267
@@ -288,6 +292,8 @@ def __init__(
288292
additional_headers: HeadersLike | None = None,
289293
user_agent_header: str | None = USER_AGENT,
290294
proxy: str | Literal[True] | None = True,
295+
proxy_ssl: ssl.SSLContext | None = None,
296+
proxy_server_hostname: str | None = None,
291297
process_exception: Callable[[Exception], Exception | None] = process_exception,
292298
# Timeouts
293299
open_timeout: float | None = 10,
@@ -645,6 +651,17 @@ async def connect_socks_proxy(
645651
raise ImportError("python-socks is required to use a SOCKS proxy")
646652

647653

654+
async def connect_http_proxy(
655+
proxy: Proxy,
656+
ws_uri: WebSocketURI,
657+
*,
658+
proxy_ssl: ssl.SSLContext | None = None,
659+
proxy_server_hostname: str | None = None,
660+
**kwargs: Any,
661+
) -> socket.socket:
662+
raise NotImplementedError("HTTP proxy support is not implemented")
663+
664+
648665
async def connect_proxy(
649666
proxy: Proxy,
650667
ws_uri: WebSocketURI,
@@ -654,5 +671,7 @@ async def connect_proxy(
654671
# parse_proxy() validates proxy.scheme.
655672
if proxy.scheme[:5] == "socks":
656673
return await connect_socks_proxy(proxy, ws_uri, **kwargs)
674+
elif proxy.scheme[:4] == "http":
675+
return await connect_http_proxy(proxy, ws_uri, **kwargs)
657676
else:
658677
raise AssertionError("unsupported proxy")

src/websockets/http11.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ def parse(
210210
read_line: Callable[[int], Generator[None, None, bytes]],
211211
read_exact: Callable[[int], Generator[None, None, bytes]],
212212
read_to_eof: Callable[[int], Generator[None, None, bytes]],
213+
include_body: bool = True,
213214
) -> Generator[None, None, Response]:
214215
"""
215216
Parse a WebSocket handshake response.
@@ -265,9 +266,12 @@ def parse(
265266

266267
headers = yield from parse_headers(read_line)
267268

268-
body = yield from read_body(
269-
status_code, headers, read_line, read_exact, read_to_eof
270-
)
269+
if include_body:
270+
body = yield from read_body(
271+
status_code, headers, read_line, read_exact, read_to_eof
272+
)
273+
else:
274+
body = b""
271275

272276
return cls(status_code, reason, headers, body)
273277

src/websockets/sync/client.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,19 @@
88
from typing import Any, Literal
99

1010
from ..client import ClientProtocol
11+
<<<<<<< HEAD
1112
from ..datastructures import HeadersLike
1213
from ..exceptions import ProxyError
14+
=======
15+
from ..datastructures import Headers, HeadersLike
16+
from ..exceptions import InvalidProxyMessage, InvalidProxyStatus
17+
>>>>>>> c4a8ab8 (Spike HTTP(S) proxy implementation for sync client.)
1318
from ..extensions.base import ClientExtensionFactory
1419
from ..extensions.permessage_deflate import enable_client_permessage_deflate
15-
from ..headers import validate_subprotocols
20+
from ..headers import build_authorization_basic, build_host, validate_subprotocols
1621
from ..http11 import USER_AGENT, Response
1722
from ..protocol import CONNECTING, Event
23+
from ..streams import StreamReader
1824
from ..typing import LoggerLike, Origin, Subprotocol
1925
from ..uri import Proxy, WebSocketURI, get_proxy, parse_proxy, parse_uri
2026
from .connection import Connection
@@ -141,6 +147,8 @@ def connect(
141147
additional_headers: HeadersLike | None = None,
142148
user_agent_header: str | None = USER_AGENT,
143149
proxy: str | Literal[True] | None = True,
150+
proxy_ssl: ssl_module.SSLContext | None = None,
151+
proxy_server_hostname: str | None = None,
144152
# Timeouts
145153
open_timeout: float | None = 10,
146154
ping_interval: float | None = 20,
@@ -195,6 +203,9 @@ def connect(
195203
to :obj:`None` to disable the proxy or to the address of a proxy
196204
to override the system configuration. See the :doc:`proxy docs
197205
<../../topics/proxies>` for details.
206+
proxy_ssl: Configuration for enabling TLS on the proxy connection.
207+
proxy_server_hostname: Host name for the TLS handshake with the proxy.
208+
``proxy_server_hostname`` overrides the host name from ``proxy``.
198209
open_timeout: Timeout for opening the connection in seconds.
199210
:obj:`None` disables the timeout.
200211
ping_interval: Interval between keepalive pings in seconds.
@@ -291,6 +302,8 @@ def connect(
291302
# websockets is consistent with the socket module while
292303
# python_socks is consistent across implementations.
293304
local_addr=kwargs.pop("source_address", None),
305+
proxy_ssl=proxy_ssl,
306+
proxy_server_hostname=proxy_server_hostname,
294307
)
295308
else:
296309
kwargs.setdefault("timeout", deadline.timeout())
@@ -441,6 +454,84 @@ def connect_socks_proxy(
441454
raise ImportError("python-socks is required to use a SOCKS proxy")
442455

443456

457+
def connect_http_proxy(
458+
proxy: Proxy,
459+
ws_uri: WebSocketURI,
460+
deadline: Deadline,
461+
*,
462+
proxy_ssl: ssl_module.SSLContext | None = None,
463+
proxy_server_hostname: str | None = None,
464+
**kwargs: Any,
465+
) -> socket.socket:
466+
if proxy.scheme[:5] != "https" and proxy_ssl is not None:
467+
raise ValueError("proxy_ssl argument is incompatible with an http:// proxy")
468+
469+
# Connect socket
470+
471+
kwargs.setdefault("timeout", deadline.timeout())
472+
sock = socket.create_connection((proxy.host, proxy.port), **kwargs)
473+
474+
# Initialize TLS wrapper and perform TLS handshake
475+
476+
if proxy.scheme[:5] == "https":
477+
if proxy_ssl is None:
478+
proxy_ssl = ssl_module.create_default_context()
479+
if proxy_server_hostname is None:
480+
proxy_server_hostname = proxy.host
481+
sock.settimeout(deadline.timeout())
482+
sock = proxy_ssl.wrap_socket(sock, server_hostname=proxy_server_hostname)
483+
sock.settimeout(None)
484+
485+
# Send CONNECT request to the proxy.
486+
487+
proxy_headers = Headers()
488+
proxy_headers["Host"] = build_host(ws_uri.host, ws_uri.port, ws_uri.secure)
489+
if proxy.username is not None:
490+
assert proxy.password is not None # enforced by parse_proxy
491+
proxy_headers["Proxy-Authorization"] = build_authorization_basic(
492+
proxy.username,
493+
proxy.password,
494+
)
495+
496+
connect_host = build_host(
497+
ws_uri.host,
498+
ws_uri.port,
499+
ws_uri.secure,
500+
always_include_port=True,
501+
)
502+
# We cannot use the Request class because it supports only GET requests.
503+
proxy_request = f"CONNECT {connect_host} HTTP/1.1\r\n".encode()
504+
proxy_request += proxy_headers.serialize()
505+
sock.sendall(proxy_request)
506+
507+
# Read response from the proxy.
508+
509+
reader = StreamReader()
510+
parser = Response.parse(
511+
reader.read_line,
512+
reader.read_exact,
513+
reader.read_to_eof,
514+
include_body=False,
515+
)
516+
try:
517+
while True:
518+
sock.settimeout(deadline.timeout())
519+
reader.feed_data(sock.recv(4096))
520+
next(parser)
521+
except StopIteration as exc:
522+
response = exc.value
523+
except Exception as exc:
524+
raise InvalidProxyMessage(
525+
"did not receive a valid HTTP response from proxy"
526+
) from exc
527+
finally:
528+
sock.settimeout(None)
529+
if not 200 <= response.status_code < 300:
530+
raise InvalidProxyStatus(response)
531+
532+
return sock
533+
534+
444535
def connect_proxy(
445536
proxy: Proxy,
446537
ws_uri: WebSocketURI,
@@ -451,5 +542,7 @@ def connect_proxy(
451542
# parse_proxy() validates proxy.scheme.
452543
if proxy.scheme[:5] == "socks":
453544
return connect_socks_proxy(proxy, ws_uri, deadline, **kwargs)
545+
elif proxy.scheme[:4] == "http":
546+
return connect_http_proxy(proxy, ws_uri, deadline, **kwargs)
454547
else:
455548
raise AssertionError("unsupported proxy")

tests/asyncio/test_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,18 @@ async def socks_proxy(self, auth=None):
574574
with patch_environ({"socks_proxy": proxy_uri}):
575575
yield record_flows
576576

577+
@contextlib.asynccontextmanager
578+
async def http_proxy(self, auth=None):
579+
if auth:
580+
proxyauth = "hello:iloveyou"
581+
proxy_uri = "http://hello:iloveyou@localhost:8080"
582+
else:
583+
proxyauth = None
584+
proxy_uri = "http://localhost:8080"
585+
async with async_proxy(mode=["regular"], proxyauth=proxyauth) as record_flows:
586+
with patch_environ({"https_proxy": proxy_uri}):
587+
yield record_flows
588+
577589
async def test_socks_proxy(self):
578590
"""Client connects to server through a SOCKS5 proxy."""
579591
async with self.socks_proxy() as proxy:
@@ -646,6 +658,15 @@ async def test_socks_proxy_connection_timeout(self):
646658
"timed out during handshake",
647659
)
648660

661+
@unittest.expectedFailure
662+
async def test_http_proxy(self):
663+
"""Client connects to server through a HTTP proxy."""
664+
async with self.http_proxy() as proxy:
665+
async with serve(*args) as server:
666+
async with connect(get_uri(server)) as client:
667+
self.assertEqual(client.protocol.state.name, "OPEN")
668+
self.assertEqual(len(proxy.get_flows()), 1)
669+
649670
async def test_explicit_proxy(self):
650671
"""Client connects to server through a proxy set explicitly."""
651672
async with async_proxy(mode=["socks5@51080"]) as proxy:

tests/sync/test_client.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,19 @@ def socks_proxy(self, auth=None):
316316
with patch_environ({"socks_proxy": proxy_uri}):
317317
yield record_flows
318318

319+
@contextlib.contextmanager
320+
def http_proxy(self, auth=None):
321+
if auth:
322+
proxyauth = "hello:iloveyou"
323+
proxy_uri = "http://hello:iloveyou@localhost:8080"
324+
else:
325+
proxyauth = None
326+
proxy_uri = "http://localhost:8080"
327+
328+
with sync_proxy(mode=["regular"], proxyauth=proxyauth) as record_flows:
329+
with patch_environ({"https_proxy": proxy_uri}):
330+
yield record_flows
331+
319332
def test_socks_proxy(self):
320333
"""Client connects to server through a SOCKS5 proxy."""
321334
with self.socks_proxy() as proxy:
@@ -388,6 +401,14 @@ def test_socks_proxy_timeout(self):
388401
# Don't test str(raised.exception) because we don't control it.
389402
self.assertIsInstance(raised.exception, SocksProxyTimeoutError)
390403

404+
def test_http_proxy(self):
405+
"""Client connects to server through a HTTP proxy."""
406+
with self.http_proxy() as proxy:
407+
with run_server() as server:
408+
with connect(get_uri(server)) as client:
409+
self.assertEqual(client.protocol.state.name, "OPEN")
410+
self.assertEqual(len(proxy.get_flows()), 1)
411+
391412
def test_explicit_proxy(self):
392413
"""Client connects to server through a proxy set explicitly."""
393414
with sync_proxy(mode=["socks5@51080"]) as proxy:

0 commit comments

Comments
 (0)