proxy.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. from __future__ import annotations
  2. import dataclasses
  3. import urllib.parse
  4. import urllib.request
  5. from .datastructures import Headers
  6. from .exceptions import InvalidProxy
  7. from .headers import build_authorization_basic, build_host
  8. from .http11 import USER_AGENT
  9. from .uri import DELIMS, WebSocketURI
  10. __all__ = ["get_proxy", "parse_proxy", "Proxy"]
  11. @dataclasses.dataclass
  12. class Proxy:
  13. """
  14. Proxy address.
  15. Attributes:
  16. scheme: ``"socks5h"``, ``"socks5"``, ``"socks4a"``, ``"socks4"``,
  17. ``"https"``, or ``"http"``.
  18. host: Normalized to lower case.
  19. port: Always set even if it's the default.
  20. username: Available when the proxy address contains `User Information`_.
  21. password: Available when the proxy address contains `User Information`_.
  22. .. _User Information: https://datatracker.ietf.org/doc/html/rfc3986#section-3.2.1
  23. """
  24. scheme: str
  25. host: str
  26. port: int
  27. username: str | None = None
  28. password: str | None = None
  29. @property
  30. def user_info(self) -> tuple[str, str] | None:
  31. if self.username is None:
  32. return None
  33. assert self.password is not None
  34. return (self.username, self.password)
  35. def parse_proxy(proxy: str) -> Proxy:
  36. """
  37. Parse and validate a proxy.
  38. Args:
  39. proxy: proxy.
  40. Returns:
  41. Parsed proxy.
  42. Raises:
  43. InvalidProxy: If ``proxy`` isn't a valid proxy.
  44. """
  45. parsed = urllib.parse.urlparse(proxy)
  46. if parsed.scheme not in ["socks5h", "socks5", "socks4a", "socks4", "https", "http"]:
  47. raise InvalidProxy(proxy, f"scheme {parsed.scheme} isn't supported")
  48. if parsed.hostname is None:
  49. raise InvalidProxy(proxy, "hostname isn't provided")
  50. if parsed.path not in ["", "/"]:
  51. raise InvalidProxy(proxy, "path is meaningless")
  52. if parsed.query != "":
  53. raise InvalidProxy(proxy, "query is meaningless")
  54. if parsed.fragment != "":
  55. raise InvalidProxy(proxy, "fragment is meaningless")
  56. scheme = parsed.scheme
  57. host = parsed.hostname
  58. port = parsed.port or (443 if parsed.scheme == "https" else 80)
  59. username = parsed.username
  60. password = parsed.password
  61. # urllib.parse.urlparse accepts URLs with a username but without a
  62. # password. This doesn't make sense for HTTP Basic Auth credentials.
  63. if username is not None and password is None:
  64. raise InvalidProxy(proxy, "username provided without password")
  65. try:
  66. proxy.encode("ascii")
  67. except UnicodeEncodeError:
  68. # Input contains non-ASCII characters.
  69. # It must be an IRI. Convert it to a URI.
  70. host = host.encode("idna").decode()
  71. if username is not None:
  72. assert password is not None
  73. username = urllib.parse.quote(username, safe=DELIMS)
  74. password = urllib.parse.quote(password, safe=DELIMS)
  75. return Proxy(scheme, host, port, username, password)
  76. def get_proxy(uri: WebSocketURI) -> str | None:
  77. """
  78. Return the proxy to use for connecting to the given WebSocket URI, if any.
  79. """
  80. if urllib.request.proxy_bypass(f"{uri.host}:{uri.port}"):
  81. return None
  82. # According to the _Proxy Usage_ section of RFC 6455, use a SOCKS5 proxy if
  83. # available, else favor the proxy for HTTPS connections over the proxy for
  84. # HTTP connections.
  85. # The priority of a proxy for WebSocket connections is unspecified. We give
  86. # it the highest priority. This makes it easy to configure a specific proxy
  87. # for websockets.
  88. # getproxies() may return SOCKS proxies as {"socks": "http://host:port"} or
  89. # as {"https": "socks5h://host:port"} depending on whether they're declared
  90. # in the operating system or in environment variables.
  91. proxies = urllib.request.getproxies()
  92. if uri.secure:
  93. schemes = ["wss", "socks", "https"]
  94. else:
  95. schemes = ["ws", "socks", "https", "http"]
  96. for scheme in schemes:
  97. proxy = proxies.get(scheme)
  98. if proxy is not None:
  99. if scheme == "socks" and proxy.startswith("http://"):
  100. proxy = "socks5h://" + proxy[7:]
  101. return proxy
  102. else:
  103. return None
  104. def prepare_connect_request(
  105. proxy: Proxy,
  106. ws_uri: WebSocketURI,
  107. user_agent_header: str | None = USER_AGENT,
  108. ) -> bytes:
  109. host = build_host(ws_uri.host, ws_uri.port, ws_uri.secure, always_include_port=True)
  110. headers = Headers()
  111. headers["Host"] = build_host(ws_uri.host, ws_uri.port, ws_uri.secure)
  112. if user_agent_header is not None:
  113. headers["User-Agent"] = user_agent_header
  114. if proxy.username is not None:
  115. assert proxy.password is not None # enforced by parse_proxy()
  116. headers["Proxy-Authorization"] = build_authorization_basic(
  117. proxy.username, proxy.password
  118. )
  119. # We cannot use the Request class because it supports only GET requests.
  120. return f"CONNECT {host} HTTP/1.1\r\n".encode() + headers.serialize()