client.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. import ssl as ssl_module
  5. import traceback
  6. import urllib.parse
  7. from collections.abc import AsyncIterator, Generator, Sequence
  8. from types import TracebackType
  9. from typing import Any, Callable, Literal
  10. import trio
  11. from ..asyncio.client import process_exception
  12. from ..client import ClientProtocol, backoff
  13. from ..datastructures import Headers, HeadersLike
  14. from ..exceptions import (
  15. InvalidProxyMessage,
  16. InvalidProxyStatus,
  17. InvalidStatus,
  18. ProxyError,
  19. SecurityError,
  20. )
  21. from ..extensions.base import ClientExtensionFactory
  22. from ..extensions.permessage_deflate import enable_client_permessage_deflate
  23. from ..headers import validate_subprotocols
  24. from ..http11 import USER_AGENT, Response
  25. from ..protocol import CONNECTING, Event
  26. from ..proxy import Proxy, get_proxy, parse_proxy, prepare_connect_request
  27. from ..streams import StreamReader
  28. from ..typing import LoggerLike, Origin, Subprotocol
  29. from ..uri import WebSocketURI, parse_uri
  30. from .connection import Connection
  31. from .utils import race_events
  32. __all__ = ["connect", "unix_connect", "ClientConnection"]
  33. MAX_REDIRECTS = int(os.environ.get("WEBSOCKETS_MAX_REDIRECTS", "10"))
  34. class ClientConnection(Connection):
  35. """
  36. :mod:`trio` implementation of a WebSocket client connection.
  37. :class:`ClientConnection` provides :meth:`recv` and :meth:`send` coroutines
  38. for receiving and sending messages.
  39. It supports asynchronous iteration to receive messages::
  40. async for message in websocket:
  41. await process(message)
  42. The iterator exits normally when the connection is closed with close code
  43. 1000 (OK) or 1001 (going away) or without a close code. It raises a
  44. :exc:`~websockets.exceptions.ConnectionClosedError` when the connection is
  45. closed with any other code.
  46. The ``ping_interval``, ``ping_timeout``, ``close_timeout``, and
  47. ``max_queue`` arguments have the same meaning as in :func:`connect`.
  48. Args:
  49. nursery: Trio nursery.
  50. stream: Trio stream connected to a WebSocket server.
  51. protocol: Sans-I/O connection.
  52. """
  53. def __init__(
  54. self,
  55. nursery: trio.Nursery,
  56. stream: trio.abc.Stream,
  57. protocol: ClientProtocol,
  58. *,
  59. ping_interval: float | None = 20,
  60. ping_timeout: float | None = 20,
  61. close_timeout: float | None = 10,
  62. max_queue: int | None | tuple[int | None, int | None] = 16,
  63. ) -> None:
  64. self.protocol: ClientProtocol
  65. super().__init__(
  66. nursery,
  67. stream,
  68. protocol,
  69. ping_interval=ping_interval,
  70. ping_timeout=ping_timeout,
  71. close_timeout=close_timeout,
  72. max_queue=max_queue,
  73. )
  74. self.response_rcvd = trio.Event()
  75. async def handshake(
  76. self,
  77. additional_headers: HeadersLike | None = None,
  78. user_agent_header: str | None = USER_AGENT,
  79. ) -> None:
  80. """
  81. Perform the opening handshake.
  82. """
  83. self.request = self.protocol.connect()
  84. if additional_headers is not None:
  85. self.request.headers.update(additional_headers)
  86. if user_agent_header is not None:
  87. self.request.headers.setdefault("User-Agent", user_agent_header)
  88. async with self.send_context(expected_state=CONNECTING):
  89. self.protocol.send_request(self.request)
  90. await race_events(self.response_rcvd, self.stream_closed)
  91. # self.protocol.handshake_exc is set when the connection is lost before
  92. # receiving a response, when the response cannot be parsed, or when the
  93. # response fails the handshake.
  94. if self.protocol.handshake_exc is not None:
  95. raise self.protocol.handshake_exc
  96. def process_event(self, event: Event) -> None:
  97. """
  98. Process one incoming event.
  99. """
  100. # First event - handshake response.
  101. if self.response is None:
  102. assert isinstance(event, Response)
  103. self.response = event
  104. self.response_rcvd.set()
  105. # Later events - frames.
  106. else:
  107. super().process_event(event)
  108. # This is spelled in lower case because it's exposed as a callable in the API.
  109. class connect:
  110. """
  111. Connect to the WebSocket server at ``uri``.
  112. This coroutine returns a :class:`ClientConnection` instance, which you can
  113. use to send and receive messages.
  114. :func:`connect` may be used as an asynchronous context manager::
  115. from websockets.trio.client import connect
  116. async with connect(...) as websocket:
  117. ...
  118. The connection is closed automatically when exiting the context.
  119. :func:`connect` can be used as an infinite asynchronous iterator to
  120. reconnect automatically on errors::
  121. async for websocket in connect(...):
  122. try:
  123. ...
  124. except websockets.exceptions.ConnectionClosed:
  125. continue
  126. If the connection fails with a transient error, it is retried with
  127. exponential backoff. If it fails with a fatal error, the exception is
  128. raised, breaking out of the loop.
  129. The connection is closed automatically after each iteration of the loop.
  130. Args:
  131. uri: URI of the WebSocket server.
  132. stream: Preexisting TCP stream. ``stream`` overrides the host and port
  133. from ``uri``. You may call :func:`~trio.open_tcp_stream` to create a
  134. suitable TCP stream.
  135. ssl: Configuration for enabling TLS on the connection.
  136. server_hostname: Host name for the TLS handshake. ``server_hostname``
  137. overrides the host name from ``uri``.
  138. origin: Value of the ``Origin`` header, for servers that require it.
  139. extensions: List of supported extensions, in order in which they
  140. should be negotiated and run.
  141. subprotocols: List of supported subprotocols, in order of decreasing
  142. preference.
  143. compression: The "permessage-deflate" extension is enabled by default.
  144. Set ``compression`` to :obj:`None` to disable it. See the
  145. :doc:`compression guide <../../topics/compression>` for details.
  146. additional_headers: Arbitrary HTTP headers to add to the handshake
  147. request.
  148. user_agent_header: Value of the ``User-Agent`` request header.
  149. It defaults to ``"Python/x.y.z websockets/X.Y"``.
  150. Setting it to :obj:`None` removes the header.
  151. proxy: If a proxy is configured, it is used by default. Set ``proxy``
  152. to :obj:`None` to disable the proxy or to the address of a proxy
  153. to override the system configuration. See the :doc:`proxy docs
  154. <../../topics/proxies>` for details.
  155. proxy_ssl: Configuration for enabling TLS on the proxy connection.
  156. proxy_server_hostname: Host name for the TLS handshake with the proxy.
  157. ``proxy_server_hostname`` overrides the host name from ``proxy``.
  158. process_exception: When reconnecting automatically, tell whether an
  159. error is transient or fatal. The default behavior is defined by
  160. :func:`process_exception`. Refer to its documentation for details.
  161. open_timeout: Timeout for opening the connection in seconds.
  162. :obj:`None` disables the timeout.
  163. ping_interval: Interval between keepalive pings in seconds.
  164. :obj:`None` disables keepalive.
  165. ping_timeout: Timeout for keepalive pings in seconds.
  166. :obj:`None` disables timeouts.
  167. close_timeout: Timeout for closing the connection in seconds.
  168. :obj:`None` disables the timeout.
  169. reconnect_delays: Delays in seconds between reconnection attempts.
  170. Default is exponential backoff with 5s jitter, capped at 60s.
  171. max_size: Maximum size of incoming messages in bytes.
  172. :obj:`None` disables the limit. You may pass a ``(max_message_size,
  173. max_fragment_size)`` tuple to set different limits for messages and
  174. fragments when you expect long messages sent in short fragments.
  175. max_queue: High-water mark of the buffer where frames are received.
  176. It defaults to 16 frames. The low-water mark defaults to ``max_queue
  177. // 4``. You may pass a ``(high, low)`` tuple to set the high-water
  178. and low-water marks. If you want to disable flow control entirely,
  179. you may set it to ``None``, although that's a bad idea.
  180. logger: Logger for this client.
  181. It defaults to ``logging.getLogger("websockets.client")``.
  182. See the :doc:`logging guide <../../topics/logging>` for details.
  183. create_connection: Factory for the :class:`ClientConnection` managing
  184. the connection. Set it to a wrapper or a subclass to customize
  185. connection handling.
  186. Any other keyword arguments are passed to :func:`~trio.open_tcp_stream`.
  187. Raises:
  188. InvalidURI: If ``uri`` isn't a valid WebSocket URI.
  189. InvalidProxy: If ``proxy`` isn't a valid proxy.
  190. OSError: If the TCP connection fails.
  191. InvalidHandshake: If the opening handshake fails.
  192. TimeoutError: If the opening handshake times out.
  193. """
  194. # Arguments of type SSLContext don't render correctly in the documentation
  195. # because of https://github.com/sphinx-doc/sphinx/issues/13838.
  196. def __init__(
  197. self,
  198. uri: str,
  199. *,
  200. # TCP/TLS
  201. stream: trio.abc.Stream | None = None,
  202. ssl: ssl_module.SSLContext | None = None,
  203. server_hostname: str | None = None,
  204. # WebSocket
  205. origin: Origin | None = None,
  206. extensions: Sequence[ClientExtensionFactory] | None = None,
  207. subprotocols: Sequence[Subprotocol] | None = None,
  208. compression: str | None = "deflate",
  209. # HTTP
  210. additional_headers: HeadersLike | None = None,
  211. user_agent_header: str | None = USER_AGENT,
  212. proxy: str | Literal[True] | None = True,
  213. proxy_ssl: ssl_module.SSLContext | None = None,
  214. proxy_server_hostname: str | None = None,
  215. process_exception: Callable[[Exception], Exception | None] = process_exception,
  216. # Timeouts
  217. open_timeout: float | None = 10,
  218. ping_interval: float | None = 20,
  219. ping_timeout: float | None = 20,
  220. close_timeout: float | None = 10,
  221. reconnect_delays: Callable[[], Generator[float]] = backoff,
  222. # Limits
  223. max_size: int | None | tuple[int | None, int | None] = 2**20,
  224. max_queue: int | None | tuple[int | None, int | None] = 16,
  225. # Logging
  226. logger: LoggerLike | None = None,
  227. # Escape hatch for advanced customization
  228. create_connection: type[ClientConnection] | None = None,
  229. # Other keyword arguments are passed to trio.open_tcp_stream
  230. **kwargs: Any,
  231. ) -> None:
  232. self.uri = uri
  233. self.ws_uri = parse_uri(uri)
  234. if not self.ws_uri.secure and ssl is not None:
  235. raise ValueError("ssl argument is incompatible with a ws:// URI")
  236. if subprotocols is not None:
  237. validate_subprotocols(subprotocols)
  238. if compression == "deflate":
  239. extensions = enable_client_permessage_deflate(extensions)
  240. elif compression is not None:
  241. raise ValueError(f"unsupported compression: {compression}")
  242. if logger is None:
  243. logger = logging.getLogger("websockets.client")
  244. if create_connection is None:
  245. create_connection = ClientConnection
  246. self.stream = stream
  247. self.ssl = ssl
  248. self.server_hostname = server_hostname
  249. self.additional_headers = additional_headers
  250. self.user_agent_header = user_agent_header
  251. self.proxy = proxy
  252. self.proxy_ssl = proxy_ssl
  253. self.proxy_server_hostname = proxy_server_hostname
  254. self.process_exception = process_exception
  255. self.open_timeout = open_timeout
  256. self.reconnect_delays = reconnect_delays
  257. self.logger = logger
  258. self.create_connection = create_connection
  259. self.open_tcp_stream_kwargs = kwargs
  260. self.protocol_kwargs = dict(
  261. origin=origin,
  262. extensions=extensions,
  263. subprotocols=subprotocols,
  264. max_size=max_size,
  265. logger=logger,
  266. )
  267. self.connection_kwargs = dict(
  268. ping_interval=ping_interval,
  269. ping_timeout=ping_timeout,
  270. close_timeout=close_timeout,
  271. max_queue=max_queue,
  272. )
  273. async def open_tcp_stream(self) -> trio.abc.Stream:
  274. """Open a TCP or Unix connection to the server, possibly through a proxy."""
  275. kwargs = self.open_tcp_stream_kwargs.copy()
  276. proxy = self.proxy
  277. if kwargs.get("unix", False):
  278. proxy = None
  279. if proxy is True:
  280. proxy = get_proxy(self.ws_uri)
  281. if kwargs.pop("unix", False):
  282. return await trio.open_unix_socket(kwargs["path"])
  283. elif proxy is not None:
  284. proxy_parsed = parse_proxy(proxy)
  285. if proxy_parsed.scheme[:5] == "socks":
  286. return await connect_socks_proxy(
  287. proxy_parsed,
  288. self.ws_uri,
  289. # websockets is consistent with trio while python_socks is
  290. # consistent across implementations.
  291. local_addr=kwargs.pop("local_address", None),
  292. )
  293. elif proxy_parsed.scheme[:4] == "http":
  294. if proxy_parsed.scheme != "https" and self.proxy_ssl is not None:
  295. raise ValueError(
  296. "proxy_ssl argument is incompatible with an http:// proxy"
  297. )
  298. return await connect_http_proxy(
  299. proxy_parsed,
  300. self.ws_uri,
  301. user_agent_header=self.user_agent_header,
  302. ssl=self.proxy_ssl,
  303. server_hostname=self.proxy_server_hostname,
  304. **kwargs,
  305. )
  306. else:
  307. raise AssertionError("parse_proxy returned unsupported proxy")
  308. else: # proxy is None
  309. kwargs.setdefault("host", self.ws_uri.host)
  310. kwargs.setdefault("port", self.ws_uri.port)
  311. return await trio.open_tcp_stream(**kwargs)
  312. async def enable_tls(self, stream: trio.abc.Stream) -> trio.abc.Stream:
  313. """Enable TLS on the connection."""
  314. if self.ssl is None:
  315. ssl = ssl_module.create_default_context()
  316. else:
  317. ssl = self.ssl
  318. if self.server_hostname is None:
  319. server_hostname = self.ws_uri.host
  320. else:
  321. server_hostname = self.server_hostname
  322. ssl_stream = trio.SSLStream(
  323. stream,
  324. ssl,
  325. server_hostname=server_hostname,
  326. https_compatible=True,
  327. )
  328. await ssl_stream.do_handshake()
  329. return ssl_stream
  330. async def open_connection(self, nursery: trio.Nursery) -> ClientConnection:
  331. """Create a WebSocket connection."""
  332. # TCP connection is already established.
  333. if self.stream is None:
  334. stream = await self.open_tcp_stream()
  335. else:
  336. stream = self.stream
  337. try:
  338. if self.ws_uri.secure:
  339. stream = await self.enable_tls(stream)
  340. protocol = ClientProtocol(
  341. self.ws_uri,
  342. **self.protocol_kwargs, # type: ignore
  343. )
  344. # self.create_connection defaults to ClientConnection.
  345. connection = self.create_connection(
  346. nursery,
  347. stream,
  348. protocol,
  349. **self.connection_kwargs, # type: ignore
  350. )
  351. await connection.handshake(
  352. self.additional_headers,
  353. self.user_agent_header,
  354. )
  355. return connection
  356. except trio.Cancelled:
  357. await trio.aclose_forcefully(stream)
  358. # The nursery running this coroutine was canceled.
  359. # The next checkpoint raises trio.Cancelled.
  360. # aclose_forcefully() never returns.
  361. raise AssertionError("nursery should be canceled")
  362. except Exception:
  363. # Always close the connection even though keep-alive is the default
  364. # in HTTP/1.1 because the current implementation ties opening the
  365. # TCP/TLS connection with initializing the WebSocket protocol.
  366. await trio.aclose_forcefully(stream)
  367. raise
  368. def process_redirect(self, exc: Exception) -> Exception | str:
  369. """
  370. Determine whether a connection error is a redirect that can be followed.
  371. Return the new URI if it's a valid redirect. Else, return an exception.
  372. """
  373. if not (
  374. isinstance(exc, InvalidStatus)
  375. and exc.response.status_code
  376. in [
  377. 300, # Multiple Choices
  378. 301, # Moved Permanently
  379. 302, # Found
  380. 303, # See Other
  381. 307, # Temporary Redirect
  382. 308, # Permanent Redirect
  383. ]
  384. and "Location" in exc.response.headers
  385. ):
  386. return exc
  387. old_ws_uri = self.ws_uri
  388. new_uri = urllib.parse.urljoin(self.uri, exc.response.headers["Location"])
  389. new_ws_uri = parse_uri(new_uri)
  390. # If connect() received a stream, it is closed and cannot be reused.
  391. if self.stream is not None:
  392. return ValueError(
  393. f"cannot follow redirect to {new_uri} with a preexisting stream"
  394. )
  395. # TLS downgrade is forbidden.
  396. if old_ws_uri.secure and not new_ws_uri.secure:
  397. return SecurityError(f"cannot follow redirect to non-secure URI {new_uri}")
  398. # Apply restrictions to cross-origin redirects.
  399. if (
  400. old_ws_uri.secure != new_ws_uri.secure
  401. or old_ws_uri.host != new_ws_uri.host
  402. or old_ws_uri.port != new_ws_uri.port
  403. ):
  404. # Cross-origin redirects on Unix sockets don't quite make sense.
  405. if self.open_tcp_stream_kwargs.get("unix", False):
  406. return ValueError(
  407. f"cannot follow cross-origin redirect to {new_uri} "
  408. f"with a Unix socket"
  409. )
  410. # Cross-origin redirects when host and port are overridden are ill-defined.
  411. if (
  412. self.open_tcp_stream_kwargs.get("host") is not None
  413. or self.open_tcp_stream_kwargs.get("port") is not None
  414. ):
  415. return ValueError(
  416. f"cannot follow cross-origin redirect to {new_uri} "
  417. f"with an explicit host or port"
  418. )
  419. # Strip credentials to avoid leaking them to a different origin.
  420. if self.additional_headers is not None:
  421. self.additional_headers = Headers(
  422. (
  423. (key, value)
  424. for key, value in Headers(self.additional_headers).raw_items()
  425. if key.lower()
  426. not in ["authorization", "cookie", "proxy-authorization"]
  427. )
  428. )
  429. return new_uri
  430. async def connect(self, nursery: trio.Nursery) -> ClientConnection:
  431. try:
  432. with (
  433. trio.CancelScope()
  434. if self.open_timeout is None
  435. else trio.fail_after(self.open_timeout)
  436. ):
  437. for _ in range(MAX_REDIRECTS):
  438. try:
  439. connection = await self.open_connection(nursery)
  440. except Exception as exc:
  441. exc_or_uri = self.process_redirect(exc)
  442. if isinstance(exc_or_uri, Exception):
  443. # Response isn't a valid redirect; raise the exception.
  444. if exc_or_uri is exc:
  445. raise
  446. else:
  447. raise exc_or_uri from exc
  448. else:
  449. # Response is a valid redirect; follow it.
  450. self.uri = exc_or_uri
  451. self.ws_uri = parse_uri(exc_or_uri)
  452. continue
  453. else:
  454. connection.start_keepalive()
  455. return connection
  456. else:
  457. raise SecurityError(f"more than {MAX_REDIRECTS} redirects")
  458. except trio.TooSlowError as exc:
  459. # Re-raise exception with an informative error message.
  460. raise TimeoutError("timed out during opening handshake") from exc
  461. # Do not define __await__ for... = await nursery.start(connect, ...)
  462. # because it doesn't look idiomatic in Trio.
  463. # async with connect(...) as ...: ...
  464. async def __aenter__(self) -> ClientConnection:
  465. await self.__aenter_nursery__()
  466. try:
  467. self.connection = await self.connect(self.nursery)
  468. return self.connection
  469. except BaseException as exc:
  470. await self.__aexit_nursery__(type(exc), exc, exc.__traceback__)
  471. raise AssertionError("expected __aexit_nursery__ to re-raise the exception")
  472. async def __aexit__(
  473. self,
  474. exc_type: type[BaseException] | None,
  475. exc_value: BaseException | None,
  476. traceback: TracebackType | None,
  477. ) -> None:
  478. try:
  479. try:
  480. await self.connection.aclose()
  481. finally:
  482. del self.connection
  483. finally:
  484. await self.__aexit_nursery__(exc_type, exc_value, traceback)
  485. async def __aenter_nursery__(self) -> None:
  486. if hasattr(self, "nursery_manager"):
  487. raise RuntimeError("connect() isn't reentrant")
  488. self.nursery_manager = trio.open_nursery()
  489. self.nursery = await self.nursery_manager.__aenter__()
  490. async def __aexit_nursery__(
  491. self,
  492. exc_type: type[BaseException] | None,
  493. exc_value: BaseException | None,
  494. traceback: TracebackType | None,
  495. ) -> None:
  496. # We need a nursery to start the recv_events and keepalive coroutines.
  497. # They aren't expected to raise exceptions; instead they catch and log
  498. # all unexpected errors. To keep the nursery an implementation detail,
  499. # unwrap exceptions raised by user code — per the second option here:
  500. # https://trio.readthedocs.io/en/stable/reference-core.html#designing-for-multiple-errors
  501. try:
  502. await self.nursery_manager.__aexit__(exc_type, exc_value, traceback)
  503. except BaseException as exc:
  504. assert isinstance(exc, BaseExceptionGroup)
  505. try:
  506. trio._util.raise_single_exception_from_group(exc)
  507. except trio._util.MultipleExceptionError:
  508. raise AssertionError(
  509. "unexpected multiple exceptions; please file a bug report"
  510. ) from exc
  511. finally:
  512. del self.nursery_manager
  513. # async for ... in connect(...):
  514. async def __aiter__(self) -> AsyncIterator[ClientConnection]:
  515. delays: Generator[float] | None = None
  516. while True:
  517. try:
  518. async with self as connection:
  519. yield connection
  520. except Exception as exc:
  521. # Determine whether the exception is retryable or fatal.
  522. # The API of process_exception is "return an exception or None";
  523. # "raise an exception" is also supported because it's a frequent
  524. # mistake. It isn't documented in order to keep the API simple.
  525. try:
  526. new_exc = self.process_exception(exc)
  527. except Exception as raised_exc:
  528. new_exc = raised_exc
  529. # The connection failed with a fatal error.
  530. # Raise the exception and exit the loop.
  531. if new_exc is exc:
  532. raise
  533. if new_exc is not None:
  534. raise new_exc from exc
  535. # The connection failed with a retryable error.
  536. # Start or continue backoff and reconnect.
  537. if delays is None:
  538. delays = self.reconnect_delays()
  539. delay = next(delays)
  540. self.logger.info(
  541. "connect failed; reconnecting in %.1f seconds: %s",
  542. delay,
  543. traceback.format_exception_only(exc)[0].strip(),
  544. )
  545. await trio.sleep(delay)
  546. else:
  547. # The connection succeeded. Reset backoff.
  548. delays = None
  549. def unix_connect(
  550. path: str | None = None,
  551. uri: str | None = None,
  552. **kwargs: Any,
  553. ) -> connect:
  554. """
  555. Connect to a WebSocket server listening on a Unix socket.
  556. This function accepts the same keyword arguments as :func:`connect`.
  557. It's only available on Unix.
  558. It's mainly useful for debugging servers listening on Unix sockets.
  559. Args:
  560. path: File system path to the Unix socket.
  561. uri: URI of the WebSocket server. ``uri`` defaults to
  562. ``ws://localhost/`` or, when a ``ssl`` argument is provided, to
  563. ``wss://localhost/``.
  564. """
  565. if uri is None:
  566. if kwargs.get("ssl") is None:
  567. uri = "ws://localhost/"
  568. else:
  569. uri = "wss://localhost/"
  570. return connect(uri=uri, unix=True, path=path, **kwargs)
  571. try:
  572. from python_socks import ProxyType
  573. from python_socks.async_.trio import Proxy as SocksProxy
  574. except ImportError:
  575. async def connect_socks_proxy(
  576. proxy: Proxy,
  577. ws_uri: WebSocketURI,
  578. **kwargs: Any,
  579. ) -> trio.abc.Stream:
  580. raise ImportError("connecting through a SOCKS proxy requires python-socks")
  581. else:
  582. SOCKS_PROXY_TYPES = {
  583. "socks5h": ProxyType.SOCKS5,
  584. "socks5": ProxyType.SOCKS5,
  585. "socks4a": ProxyType.SOCKS4,
  586. "socks4": ProxyType.SOCKS4,
  587. }
  588. SOCKS_PROXY_RDNS = {
  589. "socks5h": True,
  590. "socks5": False,
  591. "socks4a": True,
  592. "socks4": False,
  593. }
  594. async def connect_socks_proxy(
  595. proxy: Proxy,
  596. ws_uri: WebSocketURI,
  597. **kwargs: Any,
  598. ) -> trio.abc.Stream:
  599. """Connect via a SOCKS proxy and return the socket."""
  600. socks_proxy = SocksProxy(
  601. SOCKS_PROXY_TYPES[proxy.scheme],
  602. proxy.host,
  603. proxy.port,
  604. proxy.username,
  605. proxy.password,
  606. SOCKS_PROXY_RDNS[proxy.scheme],
  607. )
  608. # connect() is documented to raise OSError.
  609. # socks_proxy.connect() re-raises trio.TooSlowError as ProxyTimeoutError.
  610. # Wrap other exceptions in ProxyError, a subclass of InvalidHandshake.
  611. try:
  612. return trio.SocketStream(
  613. await socks_proxy.connect(ws_uri.host, ws_uri.port, **kwargs)
  614. )
  615. except OSError:
  616. raise
  617. except Exception as exc:
  618. raise ProxyError("failed to connect to SOCKS proxy") from exc
  619. async def read_connect_response(stream: trio.abc.Stream) -> Response:
  620. reader = StreamReader()
  621. parser = Response.parse(
  622. reader.read_line,
  623. reader.read_exact,
  624. reader.read_to_eof,
  625. proxy=True,
  626. )
  627. try:
  628. while True:
  629. data = await stream.receive_some(4096)
  630. if data:
  631. reader.feed_data(data)
  632. else:
  633. reader.feed_eof()
  634. next(parser)
  635. except StopIteration as exc:
  636. assert isinstance(exc.value, Response) # help mypy
  637. response = exc.value
  638. if 200 <= response.status_code < 300:
  639. return response
  640. else:
  641. raise InvalidProxyStatus(response)
  642. except Exception as exc:
  643. raise InvalidProxyMessage(
  644. "did not receive a valid HTTP response from proxy"
  645. ) from exc
  646. async def connect_http_proxy(
  647. proxy: Proxy,
  648. ws_uri: WebSocketURI,
  649. *,
  650. user_agent_header: str | None = None,
  651. ssl: ssl_module.SSLContext | None = None,
  652. server_hostname: str | None = None,
  653. **kwargs: Any,
  654. ) -> trio.abc.Stream:
  655. stream: trio.abc.Stream
  656. stream = await trio.open_tcp_stream(proxy.host, proxy.port, **kwargs)
  657. try:
  658. # Initialize TLS wrapper and perform TLS handshake
  659. if proxy.scheme == "https":
  660. if ssl is None:
  661. ssl = ssl_module.create_default_context()
  662. if server_hostname is None:
  663. server_hostname = proxy.host
  664. ssl_stream = trio.SSLStream(
  665. stream,
  666. ssl,
  667. server_hostname=server_hostname,
  668. https_compatible=True,
  669. )
  670. await ssl_stream.do_handshake()
  671. stream = ssl_stream
  672. # Send CONNECT request to the proxy and read response.
  673. request = prepare_connect_request(proxy, ws_uri, user_agent_header)
  674. await stream.send_all(request)
  675. await read_connect_response(stream)
  676. except (trio.Cancelled, Exception):
  677. await trio.aclose_forcefully(stream)
  678. raise
  679. return stream