socks_proxy.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. from __future__ import annotations
  2. import logging
  3. import ssl
  4. import socksio
  5. from .._backends.sync import SyncBackend
  6. from .._backends.base import NetworkBackend, NetworkStream
  7. from .._exceptions import ConnectionNotAvailable, ProxyError
  8. from .._models import URL, Origin, Request, Response, enforce_bytes, enforce_url
  9. from .._ssl import default_ssl_context
  10. from .._synchronization import Lock
  11. from .._trace import Trace
  12. from .connection_pool import ConnectionPool
  13. from .http11 import HTTP11Connection
  14. from .interfaces import ConnectionInterface
  15. logger = logging.getLogger("httpcore.socks")
  16. AUTH_METHODS = {
  17. b"\x00": "NO AUTHENTICATION REQUIRED",
  18. b"\x01": "GSSAPI",
  19. b"\x02": "USERNAME/PASSWORD",
  20. b"\xff": "NO ACCEPTABLE METHODS",
  21. }
  22. REPLY_CODES = {
  23. b"\x00": "Succeeded",
  24. b"\x01": "General SOCKS server failure",
  25. b"\x02": "Connection not allowed by ruleset",
  26. b"\x03": "Network unreachable",
  27. b"\x04": "Host unreachable",
  28. b"\x05": "Connection refused",
  29. b"\x06": "TTL expired",
  30. b"\x07": "Command not supported",
  31. b"\x08": "Address type not supported",
  32. }
  33. def _init_socks5_connection(
  34. stream: NetworkStream,
  35. *,
  36. host: bytes,
  37. port: int,
  38. auth: tuple[bytes, bytes] | None = None,
  39. ) -> None:
  40. conn = socksio.socks5.SOCKS5Connection()
  41. # Auth method request
  42. auth_method = (
  43. socksio.socks5.SOCKS5AuthMethod.NO_AUTH_REQUIRED
  44. if auth is None
  45. else socksio.socks5.SOCKS5AuthMethod.USERNAME_PASSWORD
  46. )
  47. conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method]))
  48. outgoing_bytes = conn.data_to_send()
  49. stream.write(outgoing_bytes)
  50. # Auth method response
  51. incoming_bytes = stream.read(max_bytes=4096)
  52. response = conn.receive_data(incoming_bytes)
  53. assert isinstance(response, socksio.socks5.SOCKS5AuthReply)
  54. if response.method != auth_method:
  55. requested = AUTH_METHODS.get(auth_method, "UNKNOWN")
  56. responded = AUTH_METHODS.get(response.method, "UNKNOWN")
  57. raise ProxyError(
  58. f"Requested {requested} from proxy server, but got {responded}."
  59. )
  60. if response.method == socksio.socks5.SOCKS5AuthMethod.USERNAME_PASSWORD:
  61. # Username/password request
  62. assert auth is not None
  63. username, password = auth
  64. conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password))
  65. outgoing_bytes = conn.data_to_send()
  66. stream.write(outgoing_bytes)
  67. # Username/password response
  68. incoming_bytes = stream.read(max_bytes=4096)
  69. response = conn.receive_data(incoming_bytes)
  70. assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply)
  71. if not response.success:
  72. raise ProxyError("Invalid username/password")
  73. # Connect request
  74. conn.send(
  75. socksio.socks5.SOCKS5CommandRequest.from_address(
  76. socksio.socks5.SOCKS5Command.CONNECT, (host, port)
  77. )
  78. )
  79. outgoing_bytes = conn.data_to_send()
  80. stream.write(outgoing_bytes)
  81. # Connect response
  82. incoming_bytes = stream.read(max_bytes=4096)
  83. response = conn.receive_data(incoming_bytes)
  84. assert isinstance(response, socksio.socks5.SOCKS5Reply)
  85. if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED:
  86. reply_code = REPLY_CODES.get(response.reply_code, "UNKOWN")
  87. raise ProxyError(f"Proxy Server could not connect: {reply_code}.")
  88. class SOCKSProxy(ConnectionPool): # pragma: nocover
  89. """
  90. A connection pool that sends requests via an HTTP proxy.
  91. """
  92. def __init__(
  93. self,
  94. proxy_url: URL | bytes | str,
  95. proxy_auth: tuple[bytes | str, bytes | str] | None = None,
  96. ssl_context: ssl.SSLContext | None = None,
  97. max_connections: int | None = 10,
  98. max_keepalive_connections: int | None = None,
  99. keepalive_expiry: float | None = None,
  100. http1: bool = True,
  101. http2: bool = False,
  102. retries: int = 0,
  103. network_backend: NetworkBackend | None = None,
  104. ) -> None:
  105. """
  106. A connection pool for making HTTP requests.
  107. Parameters:
  108. proxy_url: The URL to use when connecting to the proxy server.
  109. For example `"http://127.0.0.1:8080/"`.
  110. ssl_context: An SSL context to use for verifying connections.
  111. If not specified, the default `httpcore.default_ssl_context()`
  112. will be used.
  113. max_connections: The maximum number of concurrent HTTP connections that
  114. the pool should allow. Any attempt to send a request on a pool that
  115. would exceed this amount will block until a connection is available.
  116. max_keepalive_connections: The maximum number of idle HTTP connections
  117. that will be maintained in the pool.
  118. keepalive_expiry: The duration in seconds that an idle HTTP connection
  119. may be maintained for before being expired from the pool.
  120. http1: A boolean indicating if HTTP/1.1 requests should be supported
  121. by the connection pool. Defaults to True.
  122. http2: A boolean indicating if HTTP/2 requests should be supported by
  123. the connection pool. Defaults to False.
  124. retries: The maximum number of retries when trying to establish
  125. a connection.
  126. local_address: Local address to connect from. Can also be used to
  127. connect using a particular address family. Using
  128. `local_address="0.0.0.0"` will connect using an `AF_INET` address
  129. (IPv4), while using `local_address="::"` will connect using an
  130. `AF_INET6` address (IPv6).
  131. uds: Path to a Unix Domain Socket to use instead of TCP sockets.
  132. network_backend: A backend instance to use for handling network I/O.
  133. """
  134. super().__init__(
  135. ssl_context=ssl_context,
  136. max_connections=max_connections,
  137. max_keepalive_connections=max_keepalive_connections,
  138. keepalive_expiry=keepalive_expiry,
  139. http1=http1,
  140. http2=http2,
  141. network_backend=network_backend,
  142. retries=retries,
  143. )
  144. self._ssl_context = ssl_context
  145. self._proxy_url = enforce_url(proxy_url, name="proxy_url")
  146. if proxy_auth is not None:
  147. username, password = proxy_auth
  148. username_bytes = enforce_bytes(username, name="proxy_auth")
  149. password_bytes = enforce_bytes(password, name="proxy_auth")
  150. self._proxy_auth: tuple[bytes, bytes] | None = (
  151. username_bytes,
  152. password_bytes,
  153. )
  154. else:
  155. self._proxy_auth = None
  156. def create_connection(self, origin: Origin) -> ConnectionInterface:
  157. return Socks5Connection(
  158. proxy_origin=self._proxy_url.origin,
  159. remote_origin=origin,
  160. proxy_auth=self._proxy_auth,
  161. ssl_context=self._ssl_context,
  162. keepalive_expiry=self._keepalive_expiry,
  163. http1=self._http1,
  164. http2=self._http2,
  165. network_backend=self._network_backend,
  166. )
  167. class Socks5Connection(ConnectionInterface):
  168. def __init__(
  169. self,
  170. proxy_origin: Origin,
  171. remote_origin: Origin,
  172. proxy_auth: tuple[bytes, bytes] | None = None,
  173. ssl_context: ssl.SSLContext | None = None,
  174. keepalive_expiry: float | None = None,
  175. http1: bool = True,
  176. http2: bool = False,
  177. network_backend: NetworkBackend | None = None,
  178. ) -> None:
  179. self._proxy_origin = proxy_origin
  180. self._remote_origin = remote_origin
  181. self._proxy_auth = proxy_auth
  182. self._ssl_context = ssl_context
  183. self._keepalive_expiry = keepalive_expiry
  184. self._http1 = http1
  185. self._http2 = http2
  186. self._network_backend: NetworkBackend = (
  187. SyncBackend() if network_backend is None else network_backend
  188. )
  189. self._connect_lock = Lock()
  190. self._connection: ConnectionInterface | None = None
  191. self._connect_failed = False
  192. def handle_request(self, request: Request) -> Response:
  193. timeouts = request.extensions.get("timeout", {})
  194. sni_hostname = request.extensions.get("sni_hostname", None)
  195. timeout = timeouts.get("connect", None)
  196. with self._connect_lock:
  197. if self._connection is None:
  198. try:
  199. # Connect to the proxy
  200. kwargs = {
  201. "host": self._proxy_origin.host.decode("ascii"),
  202. "port": self._proxy_origin.port,
  203. "timeout": timeout,
  204. }
  205. with Trace("connect_tcp", logger, request, kwargs) as trace:
  206. stream = self._network_backend.connect_tcp(**kwargs)
  207. trace.return_value = stream
  208. # Connect to the remote host using socks5
  209. kwargs = {
  210. "stream": stream,
  211. "host": self._remote_origin.host.decode("ascii"),
  212. "port": self._remote_origin.port,
  213. "auth": self._proxy_auth,
  214. }
  215. with Trace(
  216. "setup_socks5_connection", logger, request, kwargs
  217. ) as trace:
  218. _init_socks5_connection(**kwargs)
  219. trace.return_value = stream
  220. # Upgrade the stream to SSL
  221. if self._remote_origin.scheme == b"https":
  222. ssl_context = (
  223. default_ssl_context()
  224. if self._ssl_context is None
  225. else self._ssl_context
  226. )
  227. alpn_protocols = (
  228. ["http/1.1", "h2"] if self._http2 else ["http/1.1"]
  229. )
  230. ssl_context.set_alpn_protocols(alpn_protocols)
  231. kwargs = {
  232. "ssl_context": ssl_context,
  233. "server_hostname": sni_hostname
  234. or self._remote_origin.host.decode("ascii"),
  235. "timeout": timeout,
  236. }
  237. with Trace("start_tls", logger, request, kwargs) as trace:
  238. stream = stream.start_tls(**kwargs)
  239. trace.return_value = stream
  240. # Determine if we should be using HTTP/1.1 or HTTP/2
  241. ssl_object = stream.get_extra_info("ssl_object")
  242. http2_negotiated = (
  243. ssl_object is not None
  244. and ssl_object.selected_alpn_protocol() == "h2"
  245. )
  246. # Create the HTTP/1.1 or HTTP/2 connection
  247. if http2_negotiated or (
  248. self._http2 and not self._http1
  249. ): # pragma: nocover
  250. from .http2 import HTTP2Connection
  251. self._connection = HTTP2Connection(
  252. origin=self._remote_origin,
  253. stream=stream,
  254. keepalive_expiry=self._keepalive_expiry,
  255. )
  256. else:
  257. self._connection = HTTP11Connection(
  258. origin=self._remote_origin,
  259. stream=stream,
  260. keepalive_expiry=self._keepalive_expiry,
  261. )
  262. except Exception as exc:
  263. self._connect_failed = True
  264. raise exc
  265. elif not self._connection.is_available(): # pragma: nocover
  266. raise ConnectionNotAvailable()
  267. return self._connection.handle_request(request)
  268. def can_handle_request(self, origin: Origin) -> bool:
  269. return origin == self._remote_origin
  270. def close(self) -> None:
  271. if self._connection is not None:
  272. self._connection.close()
  273. def is_available(self) -> bool:
  274. if self._connection is None: # pragma: nocover
  275. # If HTTP/2 support is enabled, and the resulting connection could
  276. # end up as HTTP/2 then we should indicate the connection as being
  277. # available to service multiple requests.
  278. return (
  279. self._http2
  280. and (self._remote_origin.scheme == b"https" or not self._http1)
  281. and not self._connect_failed
  282. )
  283. return self._connection.is_available()
  284. def has_expired(self) -> bool:
  285. if self._connection is None: # pragma: nocover
  286. return self._connect_failed
  287. return self._connection.has_expired()
  288. def is_idle(self) -> bool:
  289. if self._connection is None: # pragma: nocover
  290. return self._connect_failed
  291. return self._connection.is_idle()
  292. def is_closed(self) -> bool:
  293. if self._connection is None: # pragma: nocover
  294. return self._connect_failed
  295. return self._connection.is_closed()
  296. def info(self) -> str:
  297. if self._connection is None: # pragma: nocover
  298. return "CONNECTION FAILED" if self._connect_failed else "CONNECTING"
  299. return self._connection.info()
  300. def __repr__(self) -> str:
  301. return f"<{self.__class__.__name__} [{self.info()}]>"