Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Support socks proxy with socksio #187

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
75 changes: 75 additions & 0 deletions httpcore/_async/connection.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from ssl import SSLContext
from typing import List, Optional, Tuple

from socksio import socks5

from .._backends.auto import AsyncBackend, AsyncLock, AsyncSocketStream, AutoBackend
from .._types import URL, Headers, Origin, TimeoutDict
from .._utils import get_logger, url_to_origin
Expand Down Expand Up @@ -162,3 +164,76 @@ async def aclose(self) -> None:
async with self.request_lock:
if self.connection is not None:
await self.connection.aclose()


class AsyncSOCKSConnection(AsyncHTTPConnection):
def __init__(
self,
origin: Origin,
http2: bool = False,
uds: str = None,
ssl_context: SSLContext = None,
socket: AsyncSocketStream = None,
local_address: str = None,
backend: AsyncBackend = None,
*,
proxy_origin: Origin,
):
assert proxy_origin[0] in (b"socks5",)

super().__init__(
origin, http2, uds, ssl_context, socket, local_address, backend
)
self.proxy_origin = proxy_origin
self.proxy_connection = socks5.SOCKS5Connection()

async def _open_socket(self, timeout: TimeoutDict = None) -> AsyncSocketStream:
_, proxy_hostname, proxy_port = self.proxy_origin
scheme, hostname, port = self.origin
ssl_context = self.ssl_context if scheme == b"https" else None
timeout = timeout or {}

proxy_socket = await self.backend.open_tcp_stream(
proxy_hostname,
proxy_port,
None,
timeout,
local_address=self.local_address,
)

auth_request = socks5.SOCKS5AuthMethodsRequest(
[
socks5.SOCKS5AuthMethod.NO_AUTH_REQUIRED,
socks5.SOCKS5AuthMethod.USERNAME_PASSWORD,
]
)

self.proxy_connection.send(auth_request)

bytes_to_send = self.proxy_connection.data_to_send()
await proxy_socket.write(bytes_to_send, timeout)

data = await proxy_socket.read(1024, timeout)
event = self.proxy_connection.receive_data(data)

# development only assert
assert event.method == socks5.SOCKS5AuthMethod.NO_AUTH_REQUIRED # type: ignore

connect_request = socks5.SOCKS5CommandRequest.from_address(
socks5.SOCKS5Command.CONNECT, (hostname, port)
)

self.proxy_connection.send(connect_request)
bytes_to_send = self.proxy_connection.data_to_send()

await proxy_socket.write(bytes_to_send, timeout)
data = await proxy_socket.read(1024, timeout)
event = self.proxy_connection.receive_data(data)

# development only assert
assert event.reply_code == socks5.SOCKS5ReplyCode.SUCCEEDED # type: ignore

if ssl_context:
proxy_socket = await proxy_socket.start_tls(hostname, ssl_context, timeout)

return proxy_socket
75 changes: 75 additions & 0 deletions httpcore/_sync/connection.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from ssl import SSLContext
from typing import List, Optional, Tuple

from socksio import socks5

from .._backends.sync import SyncBackend, SyncLock, SyncSocketStream, SyncBackend
from .._types import URL, Headers, Origin, TimeoutDict
from .._utils import get_logger, url_to_origin
Expand Down Expand Up @@ -162,3 +164,76 @@ def close(self) -> None:
with self.request_lock:
if self.connection is not None:
self.connection.close()


class SyncSOCKSConnection(SyncHTTPConnection):
def __init__(
self,
origin: Origin,
http2: bool = False,
uds: str = None,
ssl_context: SSLContext = None,
socket: SyncSocketStream = None,
local_address: str = None,
backend: SyncBackend = None,
*,
proxy_origin: Origin,
):
assert proxy_origin[0] in (b"socks5",)

super().__init__(
origin, http2, uds, ssl_context, socket, local_address, backend
)
self.proxy_origin = proxy_origin
self.proxy_connection = socks5.SOCKS5Connection()

def _open_socket(self, timeout: TimeoutDict = None) -> SyncSocketStream:
_, proxy_hostname, proxy_port = self.proxy_origin
scheme, hostname, port = self.origin
ssl_context = self.ssl_context if scheme == b"https" else None
timeout = timeout or {}

proxy_socket = self.backend.open_tcp_stream(
proxy_hostname,
proxy_port,
None,
timeout,
local_address=self.local_address,
)

auth_request = socks5.SOCKS5AuthMethodsRequest(
[
socks5.SOCKS5AuthMethod.NO_AUTH_REQUIRED,
socks5.SOCKS5AuthMethod.USERNAME_PASSWORD,
]
)

self.proxy_connection.send(auth_request)

bytes_to_send = self.proxy_connection.data_to_send()
proxy_socket.write(bytes_to_send, timeout)

data = proxy_socket.read(1024, timeout)
event = self.proxy_connection.receive_data(data)

# development only assert
assert event.method == socks5.SOCKS5AuthMethod.NO_AUTH_REQUIRED # type: ignore

connect_request = socks5.SOCKS5CommandRequest.from_address(
socks5.SOCKS5Command.CONNECT, (hostname, port)
)

self.proxy_connection.send(connect_request)
bytes_to_send = self.proxy_connection.data_to_send()

proxy_socket.write(bytes_to_send, timeout)
data = proxy_socket.read(1024, timeout)
event = self.proxy_connection.receive_data(data)

# development only assert
assert event.reply_code == socks5.SOCKS5ReplyCode.SUCCEEDED # type: ignore

if ssl_context:
proxy_socket = proxy_socket.start_tls(hostname, ssl_context, timeout)

return proxy_socket
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ pytest==6.0.2
pytest-cov==2.10.1
trustme==0.6.0
uvicorn==0.11.8
pproxy==2.3.5
socksio==1.0.0
26 changes: 26 additions & 0 deletions tests/async_tests/test_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,29 @@ async def test_explicit_backend_name() -> None:
assert status_code == 200
assert reason == b"OK"
assert len(http._connections[url[:3]]) == 1 # type: ignore


@pytest.mark.anyio
@pytest.mark.parametrize(
"url",
[
(b"http", b"example.com", 80, b"/"),
(b"https", b"example.com", 443, b"/"),
],
)
@pytest.mark.parametrize("http2", [True, False])
async def test_socks5_proxy_connection_without_auth(socks5_proxy, url, http2):
(protocol, hostname, port, path) = url
origin = (protocol, hostname, port)
headers = [(b"host", hostname)]
method = b"GET"

async with httpcore._async.connection.AsyncSOCKSConnection(
origin, http2=http2, proxy_origin=socks5_proxy
) as connection:
http_version, status_code, reason, headers, stream = await connection.request(
method, url, headers
)

assert status_code == 200
assert reason == b"OK"
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import contextlib
import os
import ssl
import subprocess
import threading
import time
import typing
Expand Down Expand Up @@ -141,3 +142,14 @@ def uds_server() -> typing.Iterator[Server]:
yield server
finally:
os.remove(uds)


@pytest.fixture(scope="session")
def socks5_proxy() -> tuple: # type: ignore
proc = subprocess.Popen(["pproxy", "-l", "socks5://localhost:1085"])

try:
time.sleep(0.5) # a small delay to let the pproxy start to serve
yield b"socks5", b"localhost", 1085
finally:
proc.kill()
26 changes: 26 additions & 0 deletions tests/sync_tests/test_interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,29 @@ def test_explicit_backend_name() -> None:
assert status_code == 200
assert reason == b"OK"
assert len(http._connections[url[:3]]) == 1 # type: ignore



@pytest.mark.parametrize(
"url",
[
(b"http", b"example.com", 80, b"/"),
(b"https", b"example.com", 443, b"/"),
],
)
@pytest.mark.parametrize("http2", [True, False])
def test_socks5_proxy_connection_without_auth(socks5_proxy, url, http2):
(protocol, hostname, port, path) = url
origin = (protocol, hostname, port)
headers = [(b"host", hostname)]
method = b"GET"

with httpcore._sync.connection.SyncSOCKSConnection(
origin, http2=http2, proxy_origin=socks5_proxy
) as connection:
http_version, status_code, reason, headers, stream = connection.request(
method, url, headers
)

assert status_code == 200
assert reason == b"OK"
1 change: 1 addition & 0 deletions unasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
('AsyncIteratorByteStream', 'IteratorByteStream'),
('AsyncIterator', 'Iterator'),
('AutoBackend', 'SyncBackend'),
('httpcore._async.connection.AsyncSOCKSConnection', 'httpcore._sync.connection.SyncSOCKSConnection'),
('Async([A-Z][A-Za-z0-9_]*)', r'Sync\2'),
('async def', 'def'),
('async with', 'with'),
Expand Down