pax_global_header00006660000000000000000000000064136463470540014526gustar00rootroot0000000000000052 comment=e69158473125060879319368e9465d7881cee0b8 socksio-1.0.0/000077500000000000000000000000001364634705400131765ustar00rootroot00000000000000socksio-1.0.0/.flake8000066400000000000000000000000701364634705400143460ustar00rootroot00000000000000[flake8] ignore = W503, E203, B305 max-line-length = 88 socksio-1.0.0/.gitignore000066400000000000000000000032611364634705400151700ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ socksio-1.0.0/.readthedocs.yml000066400000000000000000000001721364634705400162640ustar00rootroot00000000000000version: 2 sphinx: configuration: docs/source/conf.py python: version: 3.7 install: - method: pip path: . socksio-1.0.0/.travis.yml000066400000000000000000000011421364634705400153050ustar00rootroot00000000000000dist: xenial language: python cache: pip services: - docker branches: only: - master matrix: include: - python: 3.7 env: NOX_SESSION=check - python: 3.6 env: NOX_SESSION=test-3.6 - python: 3.7 env: NOX_SESSION=test-3.7 - python: 3.8-dev env: NOX_SESSION=test-3.8 - python: 3.7 script: - pip install . - bash tests/run_acceptance_tests.sh install: - pip install --upgrade nox script: - nox -s ${NOX_SESSION} after_script: - if [ -f .coverage ]; then python -m pip install codecov; codecov --required; fi socksio-1.0.0/CHANGELOG.md000066400000000000000000000012461364634705400150120ustar00rootroot00000000000000# Changelog ## 1.0.0 (2020-04-17) ### Changed - `from_address` methods now accept `str` or `bytes` objects [#46](https://github.com/sethmlarson/socksio/pull/46). ## 0.2.0 (2020-01-28) ### Changed - **BREAKING**: API redesign using request objects and reducing the methods in the different connection classes [#37](https://github.com/sethmlarson/socksio/pull/37). ## 0.1.1 (2020-01-10) ### Fixed - SOCKS5 prefixing domain name with length [#29](https://github.com/sethmlarson/socksio/pull/29). - SOCKS5 requiring authentication even if no authentication method is specified [#30](https://github.com/sethmlarson/socksio/pull/30). ## 0.1.0 (2019-12-03) Initial release. socksio-1.0.0/LICENSE000066400000000000000000000020641364634705400142050ustar00rootroot00000000000000MIT License Copyright (c) 2019 Seth Michael Larson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. socksio-1.0.0/MANIFEST.in000066400000000000000000000000421364634705400147300ustar00rootroot00000000000000include README.md include LICENSE socksio-1.0.0/README.md000066400000000000000000000124211364634705400144550ustar00rootroot00000000000000# SOCKSIO [![Build Status](https://travis-ci.org/sethmlarson/socksio.svg?branch=master)](https://travis-ci.org/sethmlarson/socksio) [![codecov](https://codecov.io/gh/sethmlarson/socksio/branch/master/graph/badge.svg)](https://codecov.io/gh/sethmlarson/socksio) [![Supported Python Versions](https://img.shields.io/pypi/pyversions/socksio.svg)](https://pypi.org/project/socksio) [![PyPI](https://img.shields.io/pypi/v/socksio.svg)](https://pypi.org/project/socksio) Client-side sans-I/O SOCKS proxy implementation. Supports SOCKS4, SOCKS4A, and SOCKS5. `socksio` is a sans-I/O library similar to [`h11`](https://github.com/python-hyper/h11) or [`h2`](https://github.com/python-hyper/hyper-h2/), this means the library itself does not handle the actual sending of the bytes through the network, it only deals with the implementation details of the SOCKS protocols so you can use it in any I/O library you want. ## Current status: stable Features not yet implemented: - SOCKS5 GSS-API authentication. - SOCKS5 UDP associate requests. ## Usage TL;DR check the [examples directory](examples/). Being sans-I/O means that in order to test `socksio` you need an I/O library. And the most basic I/O is, of course, the standard library's `socket` module. You'll need to know ahead of time the type of SOCKS proxy you want to connect to. Assuming we have a SOCKS4 proxy running in our machine on port 8080, we will first create a connection to it: ```python import socket sock = socket.create_connection(("localhost", 8080)) ``` `socksio` exposes modules for SOCKS4, SOCKS4A and SOCKS5, each of them includes a `Connection` class: ```python from socksio import socks4 # The SOCKS4 protocol requires a `user_id` to be supplied. conn = socks4.SOCKS4Connection(user_id=b"socksio") ``` Since `socksio` is a sans-I/O library, we will use the socket to send and receive data to our SOCKS4 proxy. The raw data, however, will be created and parsed by our `SOCKS4Connection`. We need to tell our connection we want to make a request to the proxy. We do that by first creating a request object. In SOCKS4 we only need to send a command along with an IP address and port. `socksio` exposes the different types of commands as enumerables and a convenience `from_address` class method in the request classes to create a valid request object: ```python # SOCKS4 does not allow domain names, below is an IP for google.com request = socks4.SOCKS4Request.from_address( socks4.SOCKS4Command.CONNECT, ("216.58.204.78", 80)) ``` `from_address` methods are available on all request classes in `socksio`, they accept addresses as tuples of `(address, port)` as well as string `address:port`. Now we ask the connection to send our request: ```python conn.send(request) ``` The `SOCKS4Connection` will then compose the necessary `bytes` in the proper format for us to send to our proxy: ```python data = conn.data_to_send() sock.sendall(data) ``` If all goes well the proxy will have sent reply, we just need to read from the socket and pass the data to the `SOCKS4Connection`: ```python data = sock.recv(1024) event = conn.receive_data(data) ``` The connection will parse the data and return an event from it, in this case, a `SOCKS4Reply` that includes attributes for the fields in the SOCKS reply: ```python if event.reply_code != socks4.SOCKS4ReplyCode.REQUEST_GRANTED: raise Exception( "Server could not connect to remote host: {}".format(event.reply_code) ) ``` If all went well the connection has been established correctly and we can start sending our request directly to the proxy: ```python sock.sendall(b"GET / HTTP/1.1\r\nhost: google.com\r\n\r\n") data = receive_data(sock) print(data) # b'HTTP/1.1 301 Moved Permanently\r\nLocation: http://www.google.com/...` ``` The same methodology is used for all protocols, check out the [examples directory](https://github.com/sethmlarson/socksio/tree/master/examples/) for more information. ## Development Install the test requirements with `pip install -r test-requirements.txt`. Install the project in pseudo-editable mode with `flit install -s`. Tests can be ran directly invoking `pytest`. This project uses [`nox`](https://nox.thea.codes/en/stable/) to automate testing and linting tasks. `nox` is installed as part of the test requirements. Invoking `nox` will run all sessions, but you may also run only some them, for example `nox -s lint` will only run the linting session. In order to test against a live proxy server a Docker setup is provided based on the [`Dante`](https://www.inet.no/dante/) SOCKS server. A container will start `danted` listening on port 1080. The docker-compose.yml will start the container and map the ports appropriately. To start the container in the background: ``` docker-compose -f docker/docker-compose.yml up -d ``` To stop it: ``` docker-compose -f docker/docker-compose.yml down ``` Alternatively, remove the `-d` flag to run the containers in the foreground. ## Reference documents Each implementation follows the documents as listed below: - SOCKS4: https://www.openssh.com/txt/socks4.protocol - SOCKS4A: https://www.openssh.com/txt/socks4a.protocol - SOCKS5: https://www.ietf.org/rfc/rfc1928.txt - SOCKS5 username/password authentication: https://www.ietf.org/rfc/rfc1929.txt - SOCKS5 GSS-API authentication: https://www.ietf.org/rfc/rfc1961.txt ## License MIT socksio-1.0.0/docker/000077500000000000000000000000001364634705400144455ustar00rootroot00000000000000socksio-1.0.0/docker/Dockerfile000066400000000000000000000003771364634705400164460ustar00rootroot00000000000000FROM ubuntu:bionic # Add a fake user for Dante to pick up credentials for RUN useradd -s /sbin/nologin socksio RUN echo 'socksio:socksio' | chpasswd RUN apt-get update && apt-get install -y dante-server COPY danted.conf /etc/ EXPOSE 1080 CMD [ "danted" ] socksio-1.0.0/docker/danted.conf000066400000000000000000000005351364634705400165560ustar00rootroot00000000000000logoutput: stderr internal: 0.0.0.0 port = 1080 external: eth0 socksmethod: username none client pass { from: 0.0.0.0/0 to: 0.0.0.0/0 log: error connect disconnect } socks pass { from: 0.0.0.0/0 to: 0.0.0.0/0 protocol: tcp udp command: bind connect udpassociate log: error connect disconnect socksmethod: username none } socksio-1.0.0/docker/docker-compose.yml000066400000000000000000000001171364634705400201010ustar00rootroot00000000000000version: "3.7" services: dante: build: . ports: - "1080:1080" socksio-1.0.0/docs/000077500000000000000000000000001364634705400141265ustar00rootroot00000000000000socksio-1.0.0/docs/Makefile000066400000000000000000000012011364634705400155600ustar00rootroot00000000000000# Minimal makefile for Sphinx documentation # # You can set these variables from the command line, and also # from the environment for the first two. SPHINXOPTS ?= -W SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) socksio-1.0.0/docs/source/000077500000000000000000000000001364634705400154265ustar00rootroot00000000000000socksio-1.0.0/docs/source/api_socks4.rst000066400000000000000000000012771364634705400202260ustar00rootroot00000000000000.. _SOCKS4-API-documentation: .. currentmodule:: socksio.socks4 SOCKS4 and SOCKS4A API documentation ==================================== SOCKS4 and SOCKS4A are almost identical protocols, as such the API is implemented in a single module and most components are shared. The only practical difference is the usage of a :class:`SOCKS4Request` versus :class:`SOCKS4ARequest`. Remember SOCKS4 allows only for IPv4 addresses and SOCKS4A supports domain names. Neither support IPv6. .. autoclass:: SOCKS4Connection :members: .. autoclass:: SOCKS4Request :members: from_address, dumps .. autoclass:: SOCKS4ARequest :members: from_address, dumps .. autoclass:: SOCKS4Reply :members: loads socksio-1.0.0/docs/source/api_socks5.rst000066400000000000000000000010221364634705400202130ustar00rootroot00000000000000.. _SOCKS5-API-documentation: .. currentmodule:: socksio.socks5 SOCKS5 API documentation ==================================== .. autoclass:: SOCKS5Connection :members: .. autoclass:: SOCKS5AuthMethodsRequest :members: dumps .. autoclass:: SOCKS5AuthReply :members: loads .. autoclass:: SOCKS5UsernamePasswordRequest :members: dumps .. autoclass:: SOCKS5UsernamePasswordReply :members: loads .. autoclass:: SOCKS5CommandRequest :members: from_address, dumps .. autoclass:: SOCKS5Reply :members: loads socksio-1.0.0/docs/source/conf.py000066400000000000000000000040421364634705400167250ustar00rootroot00000000000000# Configuration file for the Sphinx documentation builder. # # This file only contains a selection of the most common options. For a full # list see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html # -- Path setup -------------------------------------------------------------- # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # # import os # import sys # sys.path.insert(0, os.path.abspath('.')) import datetime import socksio # -- Project information ----------------------------------------------------- project = "socksio" copyright = "{}, Seth Michael Larson".format(datetime.date.today().year) author = "Seth Michael Larson" # The full version, including alpha/beta/rc tags release = socksio.__version__ # -- General configuration --------------------------------------------------- master_doc = "index" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = ["sphinx.ext.autodoc", "sphinx.ext.napoleon"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] # -- Options for HTML output ------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # html_theme = "sphinx_rtd_theme" html_theme_path = ["_themes"] # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ["_static"] socksio-1.0.0/docs/source/development.rst000066400000000000000000000020271364634705400205030ustar00rootroot00000000000000Development ----------- Install the test requirements with ``pip install -r test-requirements.txt``. Install the project in pseudo-editable mode with ``flit install -s``. Tests can be ran directly invoking ``pytest``. This project uses `nox `_ to automate testing and linting tasks. ``nox`` is installed as part of the test requirements. Invoking ``nox`` will run all sessions, but you may also run only some them, for example ``nox -s lint`` will only run the linting session. In order to test against a live proxy server a Docker setup is provided based on the `Dante `_ SOCKS server. A container will start ``danted`` listening on port 1080. The docker-compose.yml will start the container and map the ports appropriately. To start the container in the background: :: docker-compose -f docker/docker-compose.yml up -d To stop it: :: docker-compose -f docker/docker-compose.yml down Alternatively, remove the ``-d`` flag to run the containers in the foreground. socksio-1.0.0/docs/source/index.rst000066400000000000000000000022321364634705400172660ustar00rootroot00000000000000socksio: Client-side sans-I/O SOCKS proxy implementation ======================================================== ``socksio`` is a sans-I/O library similar to `h11 `_ or `h2 `_, this means the library itself does not handle the actual sending of the bytes through the network, it only deals with the implementation details of the SOCKS protocols so you can use it in any I/O library you want. Current status: stable ---------------------- Features not yet implemented: - SOCKS5 GSS-API authentication. - SOCKS5 UDP associate requests. Contents -------- .. toctree:: :maxdepth: 2 usage.rst development.rst api_socks4.rst api_socks5.rst Reference documents ------------------- Each implementation follows the documents as listed below: - SOCKS4: https://www.openssh.com/txt/socks4.protocol - SOCKS4A: https://www.openssh.com/txt/socks4a.protocol - SOCKS5: https://www.ietf.org/rfc/rfc1928.txt - SOCKS5 username/password authentication: https://www.ietf.org/rfc/rfc1929.txt - SOCKS5 GSS-API authentication: https://www.ietf.org/rfc/rfc1961.txt License ------- MIT socksio-1.0.0/docs/source/usage.rst000066400000000000000000000063521364634705400172720ustar00rootroot00000000000000Usage ----- .. currentmodule:: socksio TL;DR check the `examples directory `_. Being sans-I/O means that in order to test ``socksio`` you need an I/O library. And the most basic I/O is, of course, the standard library’s ``socket`` module. You’ll need to know ahead of time the type of SOCKS proxy you want to connect to. Assuming we have a SOCKS4 proxy running in our machine on port 8080, we will first create a connection to it: .. code:: python import socket sock = socket.create_connection(("localhost", 8080)) ``socksio`` exposes modules for SOCKS4, SOCKS4A and SOCKS5, each of them includes a ``Connection`` class: .. code:: python from socksio import socks4 # The SOCKS4 protocol requires a `user_id` to be supplied. conn = socks4.SOCKS4Connection(user_id=b"socksio") Since ``socksio`` is a sans-I/O library, we will use the socket to send and receive data to our SOCKS4 proxy. The raw data, however, will be created and parsed by our :class:`SOCKS4Connection `. We need to tell our connection we want to make a request to the proxy. We do that by first creating a request object. In SOCKS4 we only need to send a command along with an IP address and port. ``socksio`` exposes the different types of commands as enumerables and a convenience :meth:`~socks4.SOCKS4Request.from_address` class method in the request classes to create a valid request object: .. code:: python # SOCKS4 does not allow domain names, below is an IP for google.com request = socks4.SOCKS4Request.from_address( socks4.SOCKS4Command.CONNECT, ("216.58.204.78", 80)) ``from_address`` methods are available on all request classes in ``socksio``, they accept addresses as tuples of ``(address, port)`` as well as string ``address:port``. Now we ask the connection to send our request: .. code:: python conn.send(request) The :class:`SOCKS4Connection ` will then compose the necessary ``bytes`` in the proper format for us to send to our proxy: .. code:: python data = conn.data_to_send() sock.sendall(data) If all goes well the proxy will have sent reply, we just need to read from the socket and pass the data to the :class:`SOCKS4Connection `: .. code:: python data = sock.recv(1024) event = conn.receive_data(data) The connection will parse the data and return an event from it, in this case, a :class:`SOCKS4Reply ` that includes attributes for the fields in the SOCKS reply: .. code:: python if event.reply_code != socks4.SOCKS4ReplyCode.REQUEST_GRANTED: raise Exception( "Server could not connect to remote host: {}".format(event.reply_code) ) If all went well the connection has been established correctly and we can start sending our request directly to the proxy: .. code:: python sock.sendall(b"GET / HTTP/1.1\r\nhost: google.com\r\n\r\n") data = receive_data(sock) print(data) # b'HTTP/1.1 301 Moved Permanently\r\nLocation: http://www.google.com/...` The same methodology is used for all protocols, check out the `examples directory `_ for more information. socksio-1.0.0/examples/000077500000000000000000000000001364634705400150145ustar00rootroot00000000000000socksio-1.0.0/examples/example_socks4.py000066400000000000000000000023051364634705400203070ustar00rootroot00000000000000import socket from socksio import socks4 def send_data(sock, data): print("sending:", data) sock.sendall(data) def receive_data(sock): data = sock.recv(1024) print("received:", data) return data def main(): # Assuming a running SOCKS4 proxy running in localhost:1080 sock = socket.create_connection(("localhost", 1080)) conn = socks4.SOCKS4Connection(user_id=b"socksio") # Request to connect to google.com port 80 # SOCKS4 does not allow domain names, below is an IP for google.com request = socks4.SOCKS4Request.from_address( socks4.SOCKS4Command.CONNECT, ("216.58.204.78", 80) # or "216.58.204.78:80" ) conn.send(request) send_data(sock, conn.data_to_send()) data = receive_data(sock) event = conn.receive_data(data) print("Request reply:", event) if event.reply_code != socks4.SOCKS4ReplyCode.REQUEST_GRANTED: raise Exception( "Server could not connect to remote host: {}".format(event.reply_code) ) # Send an HTTP request to the connected proxy sock.sendall(b"GET / HTTP/1.1\r\nhost: google.com\r\n\r\n") data = receive_data(sock) print(data) if __name__ == "__main__": main() socksio-1.0.0/examples/example_socks4a.py000066400000000000000000000021371364634705400204530ustar00rootroot00000000000000import socket from socksio import socks4 def send_data(sock, data): print("sending:", data) sock.sendall(data) def receive_data(sock): data = sock.recv(1024) print("received:", data) return data def main(): # Assuming a running SOCKS4 proxy running in localhost:1080 sock = socket.create_connection(("localhost", 1080)) conn = socks4.SOCKS4Connection(user_id=b"socksio") # Request to connect to google.com port 80 request = socks4.SOCKS4ARequest.from_address( socks4.SOCKS4Command.CONNECT, "google.com:80" ) conn.send(request) send_data(sock, conn.data_to_send()) data = receive_data(sock) event = conn.receive_data(data) print("Request reply:", event) if event.reply_code != socks4.SOCKS4ReplyCode.REQUEST_GRANTED: raise Exception( "Server could not connect to remote host: {}".format(event.reply_code) ) # Send an HTTP request to the connected proxy sock.sendall(b"GET / HTTP/1.1\r\nhost: google.com\r\n\r\n") data = receive_data(sock) print(data) if __name__ == "__main__": main() socksio-1.0.0/examples/example_socks5.py000066400000000000000000000037341364634705400203170ustar00rootroot00000000000000import socket from socksio import socks5 def send_data(sock, data): print("Sending:", data) sock.sendall(data) def receive_data(sock): data = sock.recv(1024) print("Received:", data) return data def main(): # Assuming a running SOCKS5 proxy running in localhost:1080 sock = socket.create_connection(("localhost", 1080)) conn = socks5.SOCKS5Connection() # The proxy may return any of these options request = socks5.SOCKS5AuthMethodsRequest( [ socks5.SOCKS5AuthMethod.NO_AUTH_REQUIRED, socks5.SOCKS5AuthMethod.USERNAME_PASSWORD, ] ) conn.send(request) send_data(sock, conn.data_to_send()) data = receive_data(sock) event = conn.receive_data(data) print("Auth reply:", event) # If the proxy requires username/password you'll have to edit them below if event.method == socks5.SOCKS5AuthMethod.USERNAME_PASSWORD: request = socks5.SOCKS5UsernamePasswordRequest(b"socksio", b"socksio") conn.send(request) send_data(sock, conn.data_to_send()) data = receive_data(sock) event = conn.receive_data(data) print("User/pass auth reply:", event) if not event.success: raise Exception("Invalid username/password") # Request to connect to google.com port 80 request = socks5.SOCKS5CommandRequest.from_address( socks5.SOCKS5Command.CONNECT, ("google.com", 80) ) conn.send(request) send_data(sock, conn.data_to_send()) data = receive_data(sock) event = conn.receive_data(data) print("Request reply:", event) if event.reply_code != socks5.SOCKS5ReplyCode.SUCCEEDED: raise Exception( "Server could not connect to remote host: {}".format(event.reply_code) ) # Send an HTTP request to the connected proxy sock.sendall(b"GET / HTTP/1.1\r\nhost: google.com\r\n\r\n") data = receive_data(sock) print("Response", data) if __name__ == "__main__": main() socksio-1.0.0/mypy.ini000066400000000000000000000002321364634705400146720ustar00rootroot00000000000000# mypy does not support pyproject.toml yet # https://github.com/python/mypy/issues/5205 [mypy] disallow_untyped_defs = true ignore_missing_imports = true socksio-1.0.0/noxfile.py000066400000000000000000000025431364634705400152200ustar00rootroot00000000000000import nox nox.options.stop_on_first_error = True source_files = ("socksio", "tests/", "noxfile.py", "examples/", "docs/source/") @nox.session() def lint(session): session.install("autoflake", "black", "flake8", "isort", "seed-isort-config") session.run("autoflake", "--in-place", "--recursive", *source_files) session.run("seed-isort-config", "--application-directories=socksio") session.run("isort", "--project=socksio", "--recursive", "--apply", *source_files) session.run("black", "--target-version=py36", *source_files) check(session) @nox.session(reuse_venv=True) def check(session): session.install( "black", "flake8", "flake8-bugbear", "flake8-comprehensions", "mypy", "isort" ) session.run( "isort", "--project=socksio", "--recursive", "--check-only", *source_files ) session.run("black", "--check", "--diff", "--target-version=py36", *source_files) session.run("flake8", *source_files) session.run("mypy", "--strict", "socksio") @nox.session(python=["3.6", "3.7", "3.8"]) def test(session): session.install("-r", "test-requirements.txt") session.run("python", "-m", "pytest", *session.posargs) @nox.session(reuse_venv=True) def docs(session): session.install("sphinx", "sphinx_rtd_theme", ".") session.run("sphinx-build", "-b", "html", "docs/source/", "docs/build/html/") socksio-1.0.0/pyproject.toml000066400000000000000000000015321364634705400161130ustar00rootroot00000000000000[build-system] requires = ["flit_core >=2,<3"] build-backend = "flit_core.buildapi" [tool.flit.metadata] module = "socksio" author = "Seth Michael Larson" author-email = "sethmichaellarson@gmail.com" home-page = "https://github.com/sethmlarson/socksio" requires-python=">=3.6" description-file="README.md" classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3 :: Only", "Topic :: Internet :: Proxy Servers", ] [tool.isort] combine_as_imports = true force_grid_wrap = 0 include_trailing_comma = true known_first_party = "socksio,tests" known_third_party = ["nox", "pytest", "socksio"] line_length = 88 multi_line_output = 3 socksio-1.0.0/pytest.ini000066400000000000000000000002641364634705400152310ustar00rootroot00000000000000# pytest does not support pyproject.toml yet # https://github.com/pytest-dev/pytest/issues/1556 [pytest] addopts = --cov=socksio --cov=tests --cov-branch --cov-report=term-missing socksio-1.0.0/socksio/000077500000000000000000000000001364634705400146505ustar00rootroot00000000000000socksio-1.0.0/socksio/__init__.py000066400000000000000000000017061364634705400167650ustar00rootroot00000000000000"""Sans-I/O implementation of SOCKS4, SOCKS4A, and SOCKS5.""" from .exceptions import ProtocolError, SOCKSError from .socks4 import ( SOCKS4ARequest, SOCKS4Command, SOCKS4Connection, SOCKS4Reply, SOCKS4ReplyCode, SOCKS4Request, ) from .socks5 import ( SOCKS5AType, SOCKS5AuthMethod, SOCKS5AuthMethodsRequest, SOCKS5AuthReply, SOCKS5Command, SOCKS5CommandRequest, SOCKS5Connection, SOCKS5Reply, SOCKS5ReplyCode, SOCKS5UsernamePasswordRequest, ) __version__ = "1.0.0" __all__ = [ "SOCKS4Request", "SOCKS4ARequest", "SOCKS4Reply", "SOCKS4Connection", "SOCKS4Command", "SOCKS4ReplyCode", "SOCKS5AType", "SOCKS5AuthMethodsRequest", "SOCKS5AuthReply", "SOCKS5AuthMethod", "SOCKS5Connection", "SOCKS5Command", "SOCKS5CommandRequest", "SOCKS5ReplyCode", "SOCKS5Reply", "SOCKS5UsernamePasswordRequest", "SOCKSError", "ProtocolError", ] socksio-1.0.0/socksio/_types.py000066400000000000000000000000651364634705400165260ustar00rootroot00000000000000import typing StrOrBytes = typing.Union[str, bytes] socksio-1.0.0/socksio/compat.py000066400000000000000000000067441364634705400165200ustar00rootroot00000000000000"""Backport of @functools.singledispatchmethod to Python <3.7. Adapted from https://github.com/ikalnytskyi/singledispatchmethod removing 2.7 specific code. """ import functools import typing if hasattr(functools, "singledispatchmethod"): # pragma: nocover singledispatchmethod = functools.singledispatchmethod # type: ignore else: update_wrapper = functools.update_wrapper singledispatch = functools.singledispatch # The type: ignore below is to avoid mypy erroring due to a # "already defined" singledispatchmethod, oddly this does not # happen when using `if sys.version_info >= (3, 8)` class singledispatchmethod(object): # type: ignore """Single-dispatch generic method descriptor. TODO: Figure out how to type this: `mypy --strict` returns errors like the following for all decorated methods: "Untyped decorator makes function "send" untyped." But this is not a normal function-base decorator, it's a class and it doesn't have a __call__ method. When decorating the "base" method __init__ is called, but of course its return type is None. """ def __init__(self, func: typing.Callable[..., typing.Any]) -> None: if not callable(func) and not hasattr(func, "__get__"): raise TypeError("{!r} is not callable or a descriptor".format(func)) self.dispatcher = singledispatch(func) self.func = func def register( self, cls: typing.Callable[..., typing.Any], method: typing.Optional[typing.Callable[..., typing.Any]] = None, ) -> typing.Callable[..., typing.Any]: """Register a method on a class for a particular type. Note in Python <= 3.6 this methods cannot infer the type from the argument's type annotation, users *must* supply it manually on decoration, i.e. @my_method.register(TypeToDispatch) def _(self, arg: TypeToDispatch) -> None: ... Versus in Python 3.7+: @my_method.register def _(self, arg: TypeToDispatch) -> None: ... """ # mypy wants method to be non-optional, but it is required to be # for decoration to work correctly in our case. # https://github.com/python/cpython/blob/3.8/Lib/functools.py#L887-L920 # is not type annotated either. return self.dispatcher.register(cls, func=method) # type: ignore def __get__( self, obj: typing.Any, cls: typing.Callable[[typing.Any], typing.Any] ) -> typing.Callable[..., typing.Any]: def _method(*args: typing.Any, **kwargs: typing.Any) -> typing.Any: method = self.dispatcher.dispatch(args[0].__class__) # type: typing.Any return method.__get__(obj, cls)(*args, **kwargs) # The type: ignore below is due to `_method` being given a strict # "Callable[[VarArg(Any), KwArg(Any)], Any]" which causes a # 'has no attribute "__isabstractmethod__" error' # felt safe enough to ignore _method.__isabstractmethod__ = self.__isabstractmethod__ # type: ignore _method.register = self.register # type: ignore update_wrapper(_method, self.func) return _method @property def __isabstractmethod__(self) -> typing.Any: return getattr(self.func, "__isabstractmethod__", False) socksio-1.0.0/socksio/exceptions.py000066400000000000000000000002031364634705400173760ustar00rootroot00000000000000class SOCKSError(Exception): """Generic exception for when something goes wrong""" class ProtocolError(SOCKSError): pass socksio-1.0.0/socksio/py.typed000066400000000000000000000000001364634705400163350ustar00rootroot00000000000000socksio-1.0.0/socksio/socks4.py000066400000000000000000000172361364634705400164410ustar00rootroot00000000000000import enum import typing from ._types import StrOrBytes from .exceptions import ProtocolError, SOCKSError from .utils import ( AddressType, decode_address, encode_address, get_address_port_tuple_from_address, ) class SOCKS4ReplyCode(bytes, enum.Enum): """Enumeration of SOCKS4 reply codes.""" REQUEST_GRANTED = b"\x5A" REQUEST_REJECTED_OR_FAILED = b"\x5B" CONNECTION_FAILED = b"\x5C" AUTHENTICATION_FAILED = b"\x5D" class SOCKS4Command(bytes, enum.Enum): """Enumeration of SOCKS4 command codes.""" CONNECT = b"\x01" BIND = b"\x02" class SOCKS4Request(typing.NamedTuple): """Encapsulates a request to the SOCKS4 proxy server Args: command: The command to request. port: The port number to connect to on the target host. addr: IP address of the target host. user_id: Optional user ID to be included in the request, if not supplied the user *must* provide one in the packing operation. """ command: SOCKS4Command port: int addr: bytes user_id: typing.Optional[bytes] = None @classmethod def from_address( cls, command: SOCKS4Command, address: typing.Union[StrOrBytes, typing.Tuple[StrOrBytes, int]], user_id: typing.Optional[bytes] = None, ) -> "SOCKS4Request": """Convenience class method to build an instance from command and address. Args: command: The command to request. address: A string in the form 'HOST:PORT' or a tuple of ip address string and port number. user_id: Optional user ID. Returns: A SOCKS4Request instance. Raises: SOCKSError: If a domain name or IPv6 address was supplied. """ address, port = get_address_port_tuple_from_address(address) atype, encoded_addr = encode_address(address) if atype != AddressType.IPV4: raise SOCKSError( "IPv6 addresses and domain names are not supported by SOCKS4" ) return cls(command=command, addr=encoded_addr, port=port, user_id=user_id) def dumps(self, user_id: typing.Optional[bytes] = None) -> bytes: """Packs the instance into a raw binary in the appropriate form. Args: user_id: Optional user ID as an override, if not provided the instance's will be used, if none was provided at initialization an error is raised. Returns: The packed request. Raises: SOCKSError: If no user was specified in this call or on initialization. """ user_id = user_id or self.user_id if user_id is None: raise SOCKSError("SOCKS4 requires a user_id, none was specified") return b"".join( [ b"\x04", self.command, (self.port).to_bytes(2, byteorder="big"), self.addr, user_id, b"\x00", ] ) class SOCKS4ARequest(typing.NamedTuple): """Encapsulates a request to the SOCKS4A proxy server Args: command: The command to request. port: The port number to connect to on the target host. addr: IP address of the target host. user_id: Optional user ID to be included in the request, if not supplied the user *must* provide one in the packing operation. """ command: SOCKS4Command port: int addr: bytes user_id: typing.Optional[bytes] = None @classmethod def from_address( cls, command: SOCKS4Command, address: typing.Union[StrOrBytes, typing.Tuple[StrOrBytes, int]], user_id: typing.Optional[bytes] = None, ) -> "SOCKS4ARequest": """Convenience class method to build an instance from command and address. Args: command: The command to request. address: A string in the form 'HOST:PORT' or a tuple of ip address string and port number. user_id: Optional user ID. Returns: A SOCKS4ARequest instance. """ address, port = get_address_port_tuple_from_address(address) atype, encoded_addr = encode_address(address) return cls(command=command, addr=encoded_addr, port=port, user_id=user_id) def dumps(self, user_id: typing.Optional[bytes] = None) -> bytes: """Packs the instance into a raw binary in the appropriate form. Args: user_id: Optional user ID as an override, if not provided the instance's will be used, if none was provided at initialization an error is raised. Returns: The packed request. Raises: SOCKSError: If no user was specified in this call or on initialization. """ user_id = user_id or self.user_id if user_id is None: raise SOCKSError("SOCKS4 requires a user_id, none was specified") return b"".join( [ b"\x04", self.command, (self.port).to_bytes(2, byteorder="big"), b"\x00\x00\x00\xFF", # arbitrary final non-zero byte user_id, b"\x00", self.addr, b"\x00", ] ) class SOCKS4Reply(typing.NamedTuple): """Encapsulates a reply from the SOCKS4 proxy server Args: reply_code: The code representing the type of reply. port: The port number returned. addr: Optional IP address returned. """ reply_code: SOCKS4ReplyCode port: int addr: typing.Optional[str] @classmethod def loads(cls, data: bytes) -> "SOCKS4Reply": """Unpacks the reply data into an instance. Returns: The unpacked reply instance. Raises: ProtocolError: If the data does not match the spec. """ if len(data) != 8 or data[0:1] != b"\x00": raise ProtocolError("Malformed reply") try: return cls( reply_code=SOCKS4ReplyCode(data[1:2]), port=int.from_bytes(data[2:4], byteorder="big"), addr=decode_address(AddressType.IPV4, data[4:8]), ) except ValueError as exc: raise ProtocolError("Malformed reply") from exc class SOCKS4Connection: """Encapsulates a SOCKS4 and SOCKS4A connection. Packs request objects into data suitable to be send and unpacks reply data into their appropriate reply objects. Args: user_id: The user ID to be sent as part of the requests. """ def __init__(self, user_id: bytes): self.user_id = user_id self._data_to_send = bytearray() self._received_data = bytearray() def send(self, request: typing.Union[SOCKS4Request, SOCKS4ARequest]) -> None: """Packs a request object and adds it to the send data buffer. Args: request: The request instance to be packed. """ user_id = request.user_id or self.user_id self._data_to_send += request.dumps(user_id=user_id) def receive_data(self, data: bytes) -> SOCKS4Reply: """Unpacks response data into a reply object. Args: data: The raw response data from the proxy server. Returns: The appropriate reply object. """ self._received_data += data return SOCKS4Reply.loads(bytes(self._received_data)) def data_to_send(self) -> bytes: """Returns the data to be sent via the I/O library of choice. Also clears the connection's buffer. """ data = bytes(self._data_to_send) self._data_to_send = bytearray() return data socksio-1.0.0/socksio/socks5.py000066400000000000000000000274531364634705400164440ustar00rootroot00000000000000import enum import typing from ._types import StrOrBytes from .compat import singledispatchmethod from .exceptions import ProtocolError from .utils import ( AddressType, decode_address, encode_address, get_address_port_tuple_from_address, ) class SOCKS5AuthMethod(bytes, enum.Enum): """Enumeration of SOCKS5 authentication methods.""" NO_AUTH_REQUIRED = b"\x00" GSSAPI = b"\x01" USERNAME_PASSWORD = b"\x02" NO_ACCEPTABLE_METHODS = b"\xFF" class SOCKS5Command(bytes, enum.Enum): """Enumeration of SOCKS5 commands.""" CONNECT = b"\x01" BIND = b"\x02" UDP_ASSOCIATE = b"\x03" class SOCKS5AType(bytes, enum.Enum): """Enumeration of SOCKS5 address types.""" IPV4_ADDRESS = b"\x01" DOMAIN_NAME = b"\x03" IPV6_ADDRESS = b"\x04" @classmethod def from_atype(cls, atype: AddressType) -> "SOCKS5AType": if atype == AddressType.IPV4: return SOCKS5AType.IPV4_ADDRESS elif atype == AddressType.DN: return SOCKS5AType.DOMAIN_NAME elif atype == AddressType.IPV6: return SOCKS5AType.IPV6_ADDRESS raise ValueError(atype) class SOCKS5ReplyCode(bytes, enum.Enum): """Enumeration of SOCKS5 reply codes.""" SUCCEEDED = b"\x00" GENERAL_SERVER_FAILURE = b"\x01" CONNECTION_NOT_ALLOWED_BY_RULESET = b"\x02" NETWORK_UNREACHABLE = b"\x03" HOST_UNREACHABLE = b"\x04" CONNECTION_REFUSED = b"\x05" TTL_EXPIRED = b"\x06" COMMAND_NOT_SUPPORTED = b"\x07" ADDRESS_TYPE_NOT_SUPPORTED = b"\x08" class SOCKS5AuthMethodsRequest(typing.NamedTuple): """Encapsulates a request to the proxy for available authentication methods. Args: methods: A list of acceptable authentication methods. """ methods: typing.List[SOCKS5AuthMethod] def dumps(self) -> bytes: """Packs the instance into a raw binary in the appropriate form.""" return b"".join( [ b"\x05", len(self.methods).to_bytes(1, byteorder="big"), b"".join(self.methods), ] ) class SOCKS5AuthReply(typing.NamedTuple): """Encapsulates a reply from the proxy with the authentication method to be used. Args: method: The authentication method to be used. Raises: ProtocolError: If the data does not conform with the expected structure. """ method: SOCKS5AuthMethod @classmethod def loads(cls, data: bytes) -> "SOCKS5AuthReply": """Unpacks the authentication reply data into an instance. Returns: The unpacked authentication reply instance. Raises: ProtocolError: If the data does not match the spec. """ if len(data) != 2: raise ProtocolError("Malformed reply") try: return cls(method=SOCKS5AuthMethod(data[1:2])) except ValueError as exc: raise ProtocolError("Malformed reply") from exc class SOCKS5UsernamePasswordRequest(typing.NamedTuple): """Encapsulates a username/password authentication request to the proxy server.""" username: bytes password: bytes def dumps(self) -> bytes: """Packs the instance into a raw binary in the appropriate form. Returns: The packed request. """ return b"".join( [ b"\x01", len(self.username).to_bytes(1, byteorder="big"), self.username, len(self.password).to_bytes(1, byteorder="big"), self.password, ] ) class SOCKS5UsernamePasswordReply(typing.NamedTuple): """Encapsulates a username/password authentication reply from the proxy server.""" success: bool @classmethod def loads(cls, data: bytes) -> "SOCKS5UsernamePasswordReply": """Unpacks the reply authentication data into an instance. Returns: The unpacked authentication reply instance. """ return cls(success=data == b"\x01\x00") class SOCKS5CommandRequest(typing.NamedTuple): """Encapsulates a command request to the proxy server. Args: command: The command to request. atype: The address type of the addr field. addr: Address of the target host. port: The port number to connect to on the target host. """ command: SOCKS5Command atype: SOCKS5AType addr: bytes port: int @classmethod def from_address( cls, command: SOCKS5Command, address: typing.Union[StrOrBytes, typing.Tuple[StrOrBytes, int]], ) -> "SOCKS5CommandRequest": """Convenience class method to build an instance from command and address. Args: command: The command to request. address: A string in the form 'HOST:PORT' or a tuple of ip address string and port number. The address type will be inferred. Returns: A SOCKS5CommandRequest instance. Raises: SOCKSError: If a domain name or IPv6 address was supplied. """ address, port = get_address_port_tuple_from_address(address) atype, encoded_addr = encode_address(address) return cls( command=command, atype=SOCKS5AType.from_atype(atype), addr=encoded_addr, port=port, ) def dumps(self) -> bytes: """Packs the instance into a raw binary in the appropriate form. Returns: The packed request. """ return b"".join( [ b"\x05", self.command, b"\x00", self.atype, self.packed_addr, (self.port).to_bytes(2, byteorder="big"), ] ) @property def packed_addr(self) -> bytes: """Property returning the packed address in the correct form for its type.""" if self.atype == SOCKS5AType.IPV4_ADDRESS: assert len(self.addr) == 4 return self.addr elif self.atype == SOCKS5AType.IPV6_ADDRESS: assert len(self.addr) == 16 return self.addr else: length = len(self.addr) return length.to_bytes(1, byteorder="big") + self.addr class SOCKS5Reply(typing.NamedTuple): """Encapsulates a reply from the SOCKS5 proxy server Args: reply_code: The code representing the type of reply. atype: The address type of the addr field. addr: Optional IP address returned. port: The port number returned. """ reply_code: SOCKS5ReplyCode atype: SOCKS5AType addr: str port: int @classmethod def loads(cls, data: bytes) -> "SOCKS5Reply": """Unpacks the reply data into an instance. Returns: The unpacked reply instance. Raises: ProtocolError: If the data does not match the spec. """ if data[0:1] != b"\x05": raise ProtocolError("Malformed reply") try: atype = SOCKS5AType(data[3:4]) return cls( reply_code=SOCKS5ReplyCode(data[1:2]), atype=atype, addr=decode_address(AddressType.from_socks5_atype(atype), data[4:-2]), port=int.from_bytes(data[-2:], byteorder="big"), ) except ValueError as exc: raise ProtocolError("Malformed reply") from exc class SOCKS5Datagram(typing.NamedTuple): """Encapsulates a SOCKS5 datagram for UDP connections. Currently not implemented. """ atype: SOCKS5AType addr: bytes port: int data: bytes fragment: int last_fragment: bool @classmethod def loads(cls, data: bytes) -> "SOCKS5Datagram": raise NotImplementedError() # pragma: nocover def dumps(self) -> bytes: raise NotImplementedError() # pragma: nocover class SOCKS5State(enum.IntEnum): """Enumeration of SOCKS5 protocol states.""" CLIENT_AUTH_REQUIRED = 1 SERVER_AUTH_REPLY = 2 CLIENT_AUTHENTICATED = 3 TUNNEL_READY = 4 CLIENT_WAITING_FOR_USERNAME_PASSWORD = 5 SERVER_VERIFY_USERNAME_PASSWORD = 6 MUST_CLOSE = 7 SOCKS5RequestType = typing.Union[SOCKS5AuthMethodsRequest, SOCKS5CommandRequest] class SOCKS5Connection: """Encapsulates a SOCKS5 connection. Packs request objects into data suitable to be send and unpacks reply data into their appropriate reply objects. """ def __init__(self) -> None: self._data_to_send = bytearray() self._received_data = bytearray() self._state = SOCKS5State.CLIENT_AUTH_REQUIRED @property def state(self) -> SOCKS5State: """Returns the current state of the protocol.""" return self._state @singledispatchmethod # type: ignore def send(self, request: SOCKS5RequestType) -> None: """Packs a request object and adds it to the send data buffer. Also progresses the protocol state of the connection. Args: request: The request instance to be packed. """ raise NotImplementedError() # pragma: nocover @send.register(SOCKS5AuthMethodsRequest) # type: ignore def _auth_methods(self, request: SOCKS5AuthMethodsRequest) -> None: self._data_to_send += request.dumps() self._state = SOCKS5State.SERVER_AUTH_REPLY @send.register(SOCKS5UsernamePasswordRequest) # type: ignore def _auth_username_password(self, request: SOCKS5UsernamePasswordRequest) -> None: if self._state != SOCKS5State.CLIENT_WAITING_FOR_USERNAME_PASSWORD: raise ProtocolError("Not currently waiting for username and password") self._state = SOCKS5State.SERVER_VERIFY_USERNAME_PASSWORD self._data_to_send += request.dumps() @send.register(SOCKS5CommandRequest) # type: ignore def _command(self, request: SOCKS5AuthMethodsRequest) -> None: if self._state < SOCKS5State.CLIENT_AUTHENTICATED: raise ProtocolError( "SOCKS5 connections must be authenticated before sending a request" ) self._data_to_send += request.dumps() def receive_data( self, data: bytes ) -> typing.Union[SOCKS5AuthReply, SOCKS5Reply, SOCKS5UsernamePasswordReply]: """Unpacks response data into a reply object. Args: data: The raw response data from the proxy server. Returns: A reply instance corresponding to the connection state and reply data. """ if self._state == SOCKS5State.SERVER_AUTH_REPLY: auth_reply = SOCKS5AuthReply.loads(data) if auth_reply.method == SOCKS5AuthMethod.USERNAME_PASSWORD: self._state = SOCKS5State.CLIENT_WAITING_FOR_USERNAME_PASSWORD elif auth_reply.method == SOCKS5AuthMethod.NO_AUTH_REQUIRED: self._state = SOCKS5State.CLIENT_AUTHENTICATED return auth_reply if self._state == SOCKS5State.SERVER_VERIFY_USERNAME_PASSWORD: username_password_reply = SOCKS5UsernamePasswordReply.loads(data) if username_password_reply.success: self._state = SOCKS5State.CLIENT_AUTHENTICATED else: self._state = SOCKS5State.MUST_CLOSE return username_password_reply if self._state == SOCKS5State.CLIENT_AUTHENTICATED: reply = SOCKS5Reply.loads(data) if reply.reply_code == SOCKS5ReplyCode.SUCCEEDED: self._state = SOCKS5State.TUNNEL_READY else: self._state = SOCKS5State.MUST_CLOSE return reply raise NotImplementedError() # pragma: nocover def data_to_send(self) -> bytes: """Returns the data to be sent via the I/O library of choice. Also clears the connection's buffer. """ data = bytes(self._data_to_send) self._data_to_send = bytearray() return data socksio-1.0.0/socksio/utils.py000066400000000000000000000062421364634705400163660ustar00rootroot00000000000000import enum import functools import re import socket import typing from ._types import StrOrBytes if typing.TYPE_CHECKING: from socksio.socks5 import SOCKS5AType # pragma: nocover IP_V6_WITH_PORT_REGEX = re.compile(r"^\[(?P
[^\]]+)\]:(?P\d+)$") class AddressType(enum.Enum): IPV4 = "IPV4" IPV6 = "IPV6" DN = "DN" @classmethod def from_socks5_atype(cls, socks5atype: "SOCKS5AType") -> "AddressType": from socksio.socks5 import SOCKS5AType if socks5atype == SOCKS5AType.IPV4_ADDRESS: return AddressType.IPV4 elif socks5atype == SOCKS5AType.DOMAIN_NAME: return AddressType.DN elif socks5atype == SOCKS5AType.IPV6_ADDRESS: return AddressType.IPV6 raise ValueError(socks5atype) @functools.lru_cache(maxsize=64) def encode_address(addr: StrOrBytes) -> typing.Tuple[AddressType, bytes]: """Determines the type of address and encodes it into the format SOCKS expects""" addr = addr.decode() if isinstance(addr, bytes) else addr try: return AddressType.IPV6, socket.inet_pton(socket.AF_INET6, addr) except OSError: try: return AddressType.IPV4, socket.inet_pton(socket.AF_INET, addr) except OSError: return AddressType.DN, addr.encode() @functools.lru_cache(maxsize=64) def decode_address(address_type: AddressType, encoded_addr: bytes) -> str: """Decodes the address from a SOCKS reply""" if address_type == AddressType.IPV6: return socket.inet_ntop(socket.AF_INET6, encoded_addr) elif address_type == AddressType.IPV4: return socket.inet_ntop(socket.AF_INET, encoded_addr) else: assert address_type == AddressType.DN return encoded_addr.decode() def split_address_port_from_string(address: StrOrBytes) -> typing.Tuple[str, int]: """Returns a tuple (address: str, port: int) from an address string with a port i.e. '127.0.0.1:8080', '[0:0:0:0:0:0:0:1]:3080' or 'localhost:8080'. Note no validation is done on the domain or IP itself. """ address = address.decode() if isinstance(address, bytes) else address match = re.match(IP_V6_WITH_PORT_REGEX, address) if match: address, str_port = match.group("address"), match.group("port") else: address, _, str_port = address.partition(":") try: return address, int(str_port) except ValueError: raise ValueError( "Invalid address + port. Please supply a valid domain name, IPV4 or IPV6 " "address with the port as a suffix, i.e. `127.0.0.1:3080`, " "`[0:0:0:0:0:0:0:1]:3080` or `localhost:3080`" ) from None def get_address_port_tuple_from_address( address: typing.Union[StrOrBytes, typing.Tuple[StrOrBytes, int]] ) -> typing.Tuple[str, int]: """Returns an (address, port) from an address string-like or tuple.""" if isinstance(address, tuple): address, port = address if isinstance(address, bytes): address = address.decode() if isinstance(port, (str, bytes)): port = int(port) else: address, port = split_address_port_from_string(address) return address, port socksio-1.0.0/test-requirements.txt000066400000000000000000000001301364634705400174310ustar00rootroot00000000000000black flake8 flake8-bugbear flake8-comprehensions flit isort mypy nox pytest pytest-cov socksio-1.0.0/tests/000077500000000000000000000000001364634705400143405ustar00rootroot00000000000000socksio-1.0.0/tests/run_acceptance_tests.sh000077500000000000000000000004041364634705400210710ustar00rootroot00000000000000#!/bin/bash set -exo docker-compose -f docker/docker-compose.yml up -d # TODO: SOCKS4A doesn't seem to work correctly in Dante # Wondering if this is a Dante bug? PySocks also doesn't work. python examples/example_socks4.py python examples/example_socks5.py socksio-1.0.0/tests/test_compat.py000066400000000000000000000011661364634705400172400ustar00rootroot00000000000000import pytest from socksio.compat import singledispatchmethod def test_singledispatchmethod(): class Foo: @singledispatchmethod def bar(): raise NotImplementedError() # pragma: nocover @bar.register(str) def _(self, arg: str) -> None: return f"<< {arg} >>" @bar.register(int) def _(self, arg: int) -> None: return f"|| {arg} ||" obj = Foo() assert obj.bar("bar") == "<< bar >>" assert obj.bar(1) == "|| 1 ||" def test_singledispatchmethod_error(): with pytest.raises(TypeError): singledispatchmethod("error") socksio-1.0.0/tests/test_socks4.py000066400000000000000000000112551364634705400171630ustar00rootroot00000000000000import pytest from socksio import ( ProtocolError, SOCKS4ARequest, SOCKS4Command, SOCKS4Connection, SOCKS4Reply, SOCKS4ReplyCode, SOCKS4Request, SOCKSError, ) @pytest.mark.parametrize( "address,expected_address,expected_port", [ (("127.0.0.1", 3080), b"\x7f\x00\x00\x01", 3080), (("127.0.0.1", "3080"), b"\x7f\x00\x00\x01", 3080), ("127.0.0.1:8080", b"\x7f\x00\x00\x01", 8080), ((b"127.0.0.1", 3080), b"\x7f\x00\x00\x01", 3080), ((b"127.0.0.1", b"3080"), b"\x7f\x00\x00\x01", 3080), (b"127.0.0.1:8080", b"\x7f\x00\x00\x01", 8080), ], ) def test_socks4request_from_address(address, expected_address, expected_port) -> None: req = SOCKS4Request.from_address(SOCKS4Command.CONNECT, address, user_id=b"socksio") assert req.command == SOCKS4Command.CONNECT assert req.addr == expected_address assert req.port == expected_port assert req.user_id == b"socksio" @pytest.mark.parametrize( "address,user_id", [ (("::1", 3080), b"socksio"), # IPV6 ("localhost:3080", b"socksio"), # Domain names ], ) def test_socks4request_from_address_errors(address, user_id) -> None: with pytest.raises(SOCKSError): SOCKS4Request.from_address( command=SOCKS4Command.BIND, address=address, user_id=user_id ) def test_socks4request_from_address_dump_raises_if_no_user_id(): req = SOCKS4Request.from_address(SOCKS4Command.CONNECT, "127.0.0.1:8080") with pytest.raises(SOCKSError): req.dumps() @pytest.mark.parametrize( "address,expected_address,expected_port", [ (("127.0.0.1", 3080), b"\x7f\x00\x00\x01", 3080), (("127.0.0.1", "3080"), b"\x7f\x00\x00\x01", 3080), ("127.0.0.1:8080", b"\x7f\x00\x00\x01", 8080), ((b"127.0.0.1", 3080), b"\x7f\x00\x00\x01", 3080), ((b"127.0.0.1", b"3080"), b"\x7f\x00\x00\x01", 3080), (b"127.0.0.1:8080", b"\x7f\x00\x00\x01", 8080), ], ) def test_socks4arequest_from_address(address, expected_address, expected_port) -> None: req = SOCKS4ARequest.from_address( SOCKS4Command.CONNECT, address, user_id=b"socksio" ) assert req.command == SOCKS4Command.CONNECT assert req.addr == expected_address assert req.port == expected_port assert req.user_id == b"socksio" def test_socks4arequest_from_address_dump_raises_if_no_user_id(): req = SOCKS4ARequest.from_address(SOCKS4Command.CONNECT, "127.0.0.1:8080") with pytest.raises(SOCKSError): req.dumps() @pytest.mark.parametrize("command", [SOCKS4Command.BIND, SOCKS4Command.CONNECT]) def test_socks4_connection_request(command: SOCKS4Command) -> None: conn = SOCKS4Connection(user_id=b"socks") request = SOCKS4Request.from_address(command=command, address=("127.0.0.1", 8080)) conn.send(request) data = conn.data_to_send() assert len(data) == 9 + 5 assert data[0:1] == b"\x04" assert data[1:2] == command assert data[2:4] == (8080).to_bytes(2, byteorder="big") assert data[4:8] == b"\x7f\x00\x00\x01" assert data[8:13] == b"socks" assert data[13] == 0 @pytest.mark.parametrize("request_reply_code", list(SOCKS4ReplyCode)) def test_socks4_receive_data(request_reply_code: bytes) -> None: conn = SOCKS4Connection(user_id=b"socks") reply = conn.receive_data( b"".join( [ b"\x00", request_reply_code, (8080).to_bytes(2, byteorder="big"), b"\x7f\x00\x00\x01", ] ) ) assert reply == SOCKS4Reply( reply_code=SOCKS4ReplyCode(request_reply_code), port=8080, addr="127.0.0.1" ) @pytest.mark.parametrize( "data", [ b"\x00Z\x1f\x90\x7f\x00\x00", # missing one byte b"\x0FZ\x1f\x90\x7f\x00\x00\x01", # not starting with 0 b"\x00\xFF\x1f\x90\x7f\x00\x00\x01", # incorrect reply code ], ) def test_socks4_receive_malformed_data(data: bytes) -> None: conn = SOCKS4Connection(user_id=b"socks") with pytest.raises(ProtocolError): conn.receive_data(data) @pytest.mark.parametrize("command", [SOCKS4Command.BIND, SOCKS4Command.CONNECT]) def test_SOCKS4A_connection_request(command: SOCKS4Command) -> None: conn = SOCKS4Connection(user_id=b"socks") request = SOCKS4ARequest.from_address( command=command, address=("proxy.example.com", 8080) ) conn.send(request) data = conn.data_to_send() assert len(data) == 32 assert data[0:1] == b"\x04" assert data[1:2] == command assert data[2:4] == (8080).to_bytes(2, byteorder="big") assert data[4:8] == b"\x00\x00\x00\xFF" assert data[8:14] == b"socks\x00" assert data[14:] == b"proxy.example.com\x00" socksio-1.0.0/tests/test_socks5.py000066400000000000000000000266471364634705400171770ustar00rootroot00000000000000import pytest from socksio import ( ProtocolError, SOCKS5AType, SOCKS5AuthMethod, SOCKS5AuthMethodsRequest, SOCKS5AuthReply, SOCKS5Command, SOCKS5CommandRequest, SOCKS5Connection, SOCKS5Reply, SOCKS5ReplyCode, SOCKS5UsernamePasswordRequest, ) from socksio.socks5 import SOCKS5State from socksio.utils import AddressType @pytest.mark.parametrize( "atype,expected", [ (AddressType.IPV4, SOCKS5AType.IPV4_ADDRESS), (AddressType.IPV6, SOCKS5AType.IPV6_ADDRESS), (AddressType.DN, SOCKS5AType.DOMAIN_NAME), ], ) def test_socks5atype_from_address_type( atype: AddressType, expected: SOCKS5AType ) -> None: assert SOCKS5AType.from_atype(atype) == expected def test_socks5atype_unknown_address_type_raises() -> None: with pytest.raises(ValueError): SOCKS5AType.from_atype("FOOBAR") # type: ignore @pytest.mark.parametrize( "address,expected_atype,expected_address,expected_port", [ (("127.0.0.1", 3080), SOCKS5AType.IPV4_ADDRESS, b"\x7f\x00\x00\x01", 3080), (("127.0.0.1", "3080"), SOCKS5AType.IPV4_ADDRESS, b"\x7f\x00\x00\x01", 3080), ("127.0.0.1:8080", SOCKS5AType.IPV4_ADDRESS, b"\x7f\x00\x00\x01", 8080), ( "[0:0:0:0:0:0:0:1]:3080", SOCKS5AType.IPV6_ADDRESS, b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01", 3080, ), ((b"127.0.0.1", 3080), SOCKS5AType.IPV4_ADDRESS, b"\x7f\x00\x00\x01", 3080), ((b"127.0.0.1", b"3080"), SOCKS5AType.IPV4_ADDRESS, b"\x7f\x00\x00\x01", 3080), (b"127.0.0.1:8080", SOCKS5AType.IPV4_ADDRESS, b"\x7f\x00\x00\x01", 8080), ( b"[0:0:0:0:0:0:0:1]:3080", SOCKS5AType.IPV6_ADDRESS, b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01", 3080, ), ], ) def test_socks5commandrequest_from_address( address, expected_atype, expected_address, expected_port ) -> None: cmd = SOCKS5CommandRequest.from_address(SOCKS5Command.CONNECT, address) assert cmd.command == SOCKS5Command.CONNECT assert cmd.atype == expected_atype assert cmd.addr == expected_address assert cmd.port == expected_port def test_socks5_auth_request() -> None: conn = SOCKS5Connection() auth_methods = [SOCKS5AuthMethod.GSSAPI, SOCKS5AuthMethod.USERNAME_PASSWORD] auth_request = SOCKS5AuthMethodsRequest(auth_methods) conn.send(auth_request) data = conn.data_to_send() assert len(data) == 4 assert data[0:1] == b"\x05" assert data[1:2] == len(auth_methods).to_bytes(1, byteorder="big") assert data[2:3] == SOCKS5AuthMethod.GSSAPI assert data[3:] == SOCKS5AuthMethod.USERNAME_PASSWORD @pytest.mark.parametrize( "auth_method", [ SOCKS5AuthMethod.NO_AUTH_REQUIRED, SOCKS5AuthMethod.USERNAME_PASSWORD, SOCKS5AuthMethod.GSSAPI, ], ) def test_socks5_auth_reply_accepted(auth_method: SOCKS5AuthMethod) -> None: conn = SOCKS5Connection() auth_methods = [ SOCKS5AuthMethod.NO_AUTH_REQUIRED, SOCKS5AuthMethod.USERNAME_PASSWORD, SOCKS5AuthMethod.GSSAPI, ] auth_request = SOCKS5AuthMethodsRequest(auth_methods) conn.send(auth_request) reply = conn.receive_data(b"\x05" + auth_method) assert reply == SOCKS5AuthReply(method=auth_method) def test_socks5_auth_reply_no_acceptable_auth_method() -> None: conn = SOCKS5Connection() auth_request = SOCKS5AuthMethodsRequest([SOCKS5AuthMethod.USERNAME_PASSWORD]) conn.send(auth_request) reply = conn.receive_data(b"\x05\xFF") assert reply == SOCKS5AuthReply(method=SOCKS5AuthMethod.NO_ACCEPTABLE_METHODS) @pytest.mark.parametrize( "data", [b"\x05", b"\x05\x10"] # missing method byte , incorrect method value ) def test_socks5_auth_reply_malformed(data: bytes) -> None: conn = SOCKS5Connection() auth_request = SOCKS5AuthMethodsRequest([SOCKS5AuthMethod.USERNAME_PASSWORD]) conn.send(auth_request) with pytest.raises(ProtocolError): conn.receive_data(data) def test_socks5_no_auth_required_reply_sets_client_authenticated_state() -> None: conn = SOCKS5Connection() auth_methods = [ SOCKS5AuthMethod.NO_AUTH_REQUIRED, SOCKS5AuthMethod.USERNAME_PASSWORD, SOCKS5AuthMethod.GSSAPI, ] auth_request = SOCKS5AuthMethodsRequest(auth_methods) conn.send(auth_request) _ = conn.receive_data(b"\x05" + SOCKS5AuthMethod.NO_AUTH_REQUIRED) assert conn.state == SOCKS5State.CLIENT_AUTHENTICATED def test_socks5_auth_username_password_requires_connect_waiting() -> None: conn = SOCKS5Connection() auth_request = SOCKS5UsernamePasswordRequest(username=b"user", password=b"pass") with pytest.raises(ProtocolError): conn.send(auth_request) def test_socks5_auth_username_password_success() -> None: conn = SOCKS5Connection() auth_methods_request = SOCKS5AuthMethodsRequest( [SOCKS5AuthMethod.USERNAME_PASSWORD] ) conn.send(auth_methods_request) conn.data_to_send() conn.receive_data(b"\x05" + SOCKS5AuthMethod.USERNAME_PASSWORD) auth_request = SOCKS5UsernamePasswordRequest( username=b"username", password=b"password" ) conn.send(auth_request) assert conn.data_to_send() == b"\x01\x08username\x08password" conn.receive_data(b"\x01\x00") assert conn.state == SOCKS5State.CLIENT_AUTHENTICATED def test_socks5_auth_username_password_fail() -> None: conn = SOCKS5Connection() auth_methods_request = SOCKS5AuthMethodsRequest( [SOCKS5AuthMethod.USERNAME_PASSWORD] ) conn.send(auth_methods_request) conn.data_to_send() conn.receive_data(b"\x05" + SOCKS5AuthMethod.USERNAME_PASSWORD) auth_request = SOCKS5UsernamePasswordRequest( username=b"username", password=b"password" ) conn.send(auth_request) assert conn.data_to_send() == b"\x01\x08username\x08password" conn.receive_data(b"\x01") assert conn.state == SOCKS5State.MUST_CLOSE def test_socks5_request_require_authentication() -> None: conn = SOCKS5Connection() cmd_request = SOCKS5CommandRequest.from_address( SOCKS5Command.CONNECT, "127.0.0.1:1080" ) with pytest.raises(ProtocolError): conn.send(cmd_request) @pytest.fixture def authenticated_conn() -> SOCKS5Connection: conn = SOCKS5Connection() auth_methods_request = SOCKS5AuthMethodsRequest( [SOCKS5AuthMethod.USERNAME_PASSWORD] ) conn.send(auth_methods_request) conn.data_to_send() conn.receive_data(b"\x05" + SOCKS5AuthMethod.USERNAME_PASSWORD) auth_request = SOCKS5UsernamePasswordRequest( username=b"username", password=b"password" ) conn.send(auth_request) conn.receive_data(b"\x01\x00") _ = conn.data_to_send() # purge the buffer for further tests return conn @pytest.mark.parametrize("command", (SOCKS5Command.CONNECT, SOCKS5Command.BIND)) def test_socks5_request_ipv4( authenticated_conn: SOCKS5Connection, command: SOCKS5Command ) -> None: cmd_request = SOCKS5CommandRequest.from_address(command, "127.0.0.1:1080") authenticated_conn.send(cmd_request) data = authenticated_conn.data_to_send() assert len(data) == 10 assert data[0:1] == b"\x05" assert data[1:2] == command assert data[2:3] == b"\x00" assert data[3:4] == b"\x01" assert data[4:8] == b"\x7f\x00\x00\x01" assert data[8:] == (1080).to_bytes(2, byteorder="big") @pytest.mark.parametrize("command", (SOCKS5Command.CONNECT, SOCKS5Command.BIND)) def test_socks5_request_domain_name( authenticated_conn: SOCKS5Connection, command: SOCKS5Command ) -> None: cmd_request = SOCKS5CommandRequest.from_address(command, "localhost:1080") authenticated_conn.send(cmd_request) data = authenticated_conn.data_to_send() assert len(data) == 16 assert data[0:1] == b"\x05" assert data[1:2] == command assert data[2:3] == b"\x00" assert data[3:4] == b"\x03" assert data[4:5] == (9).to_bytes(1, byteorder="big") assert data[5:14] == b"localhost" assert data[14:] == (1080).to_bytes(2, byteorder="big") @pytest.mark.parametrize("command", (SOCKS5Command.CONNECT, SOCKS5Command.BIND)) def test_socks5_request_ipv6( authenticated_conn: SOCKS5Connection, command: SOCKS5Command ) -> None: cmd_request = SOCKS5CommandRequest.from_address( command, address="[0:0:0:0:0:0:0:1]:1080" ) authenticated_conn.send(cmd_request) data = authenticated_conn.data_to_send() assert len(data) == 22 assert data[0:1] == b"\x05" assert data[1:2] == command assert data[2:3] == b"\x00" assert data[3:4] == b"\x04" assert ( data[4:20] == b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" ) assert data[20:] == (1080).to_bytes(2, byteorder="big") @pytest.mark.parametrize( "atype,addr,expected_atype,expected_addr", [ (b"\x01", b"\x7f\x00\x00\x01", SOCKS5AType.IPV4_ADDRESS, "127.0.0.1"), (b"\x03", b"localhost", SOCKS5AType.DOMAIN_NAME, "localhost"), ( b"\x04", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01", SOCKS5AType.IPV6_ADDRESS, "::1", ), ], ) def test_socks5_reply_success( authenticated_conn: SOCKS5Connection, atype: bytes, addr: bytes, expected_atype: SOCKS5AType, expected_addr: str, ) -> None: data = b"".join( [ b"\x05", # protocol version b"\x00", # reply b"\x00", # reserved atype, addr, (1080).to_bytes(2, byteorder="big"), # port ] ) reply = authenticated_conn.receive_data(data) assert authenticated_conn.state == SOCKS5State.TUNNEL_READY assert reply == SOCKS5Reply( reply_code=SOCKS5ReplyCode.SUCCEEDED, atype=expected_atype, addr=expected_addr, port=1080, ) @pytest.mark.parametrize( "data", [ b"\x00\x00\x00\x01\x7f\x00\x00\x01\x048", # incorrect protocol version b"\x05\x00\x00\x01\x7f\x00\x00\x01\x04", # missing one byte of port number b"\x05\x00\x00\x01\x7f\x00\x00\x048", # missing one byte of address ], ) def test_socks5_receive_malformed_data( authenticated_conn: SOCKS5Connection, data: bytes ) -> None: with pytest.raises(ProtocolError): authenticated_conn.receive_data(data) @pytest.mark.parametrize("error_code", list(SOCKS5ReplyCode)[1:]) @pytest.mark.parametrize( "atype,addr,expected_atype,expected_addr", [ (b"\x01", b"\x7f\x00\x00\x01", SOCKS5AType.IPV4_ADDRESS, "127.0.0.1"), (b"\x03", b"localhost", SOCKS5AType.DOMAIN_NAME, "localhost"), ( b"\x04", b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01", SOCKS5AType.IPV6_ADDRESS, "::1", ), ], ) def test_socks5_reply_error( error_code: SOCKS5ReplyCode, authenticated_conn: SOCKS5Connection, atype: bytes, addr: bytes, expected_atype: SOCKS5AType, expected_addr: str, ) -> None: data = b"".join( [ b"\x05", # protocol version error_code, b"\x00", # reserved atype, addr, (1080).to_bytes(2, byteorder="big"), # port ] ) reply = authenticated_conn.receive_data(data) assert authenticated_conn.state == SOCKS5State.MUST_CLOSE assert reply == SOCKS5Reply( reply_code=error_code, atype=expected_atype, addr=expected_addr, port=1080 ) socksio-1.0.0/tests/test_utils.py000066400000000000000000000025021364634705400171100ustar00rootroot00000000000000import pytest from socksio.socks5 import SOCKS5AType from socksio.utils import AddressType, split_address_port_from_string @pytest.mark.parametrize( "socks5_atype,expected", [ (SOCKS5AType.IPV4_ADDRESS, AddressType.IPV4), (SOCKS5AType.IPV6_ADDRESS, AddressType.IPV6), (SOCKS5AType.DOMAIN_NAME, AddressType.DN), ], ) def test_address_type_from_socks5atype( socks5_atype: SOCKS5AType, expected: AddressType ) -> None: assert AddressType.from_socks5_atype(socks5_atype) == expected def test_socks5atype_unknown_address_type_raises() -> None: with pytest.raises(ValueError): AddressType.from_socks5_atype("FOOBAR") @pytest.mark.parametrize( "address_str,expected_address,expected_port", [ ("127.0.0.1:8080", "127.0.0.1", 8080), ("[0:0:0:0:0:0:0:1]:3080", "0:0:0:0:0:0:0:1", 3080), ], ) def test_split_address_port_from_string( address_str, expected_address, expected_port ) -> None: assert split_address_port_from_string(address_str) == ( expected_address, expected_port, ) @pytest.mark.parametrize( "address_str", ["127.0.0.1", "::1", "127.0.0.1:", "[::1]:foobar"] ) def test_split_address_port_from_string_errors(address_str) -> None: with pytest.raises(ValueError): split_address_port_from_string(address_str)