├── .gitignore ├── README.md ├── example-credentials.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── socks5 ├── __init__.py ├── auth.py ├── cli.py ├── exceptions.py ├── log.py ├── protocol.py └── server.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # Installer logs 29 | pip-log.txt 30 | 31 | # Unit test / coverage reports 32 | htmlcov/ 33 | .tox/ 34 | .coverage 35 | .coverage.* 36 | .cache 37 | nosetests.xml 38 | coverage.xml 39 | *.cover 40 | .hypothesis/ 41 | .pytest_cache/ 42 | 43 | 44 | # Sphinx documentation 45 | docs/_build/ 46 | 47 | # pyenv 48 | .python-version 49 | 50 | 51 | # Environments 52 | .env 53 | .venv 54 | env/ 55 | venv/ 56 | ENV/ 57 | env.bak/ 58 | venv.bak/ 59 | 60 | # mkdocs documentation 61 | /site 62 | 63 | # mypy 64 | .mypy_cache/ 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # socks5 2 | 3 | A socks5 server in Python using asyncio. 4 | 5 | Works with python >= 3.6 6 | 7 | # Installation 8 | 9 | This package is available on [pypi](https://pypi.org/project/socks5server/) 10 | 11 | Install it with pip: 12 | 13 | ```sh 14 | $ pip install socks5server 15 | ``` 16 | 17 | Requires: click, kaviar 18 | 19 | # Usage 20 | 21 | ``` 22 | $ socks5.server --help 23 | Usage: socks5.server [OPTIONS] 24 | 25 | Runs a SOCK5 server. 26 | 27 | Options: 28 | --host TEXT The interfaces to listen on 29 | --port INTEGER The port to listen on 30 | --allow-no-auth Whether to allow clients that do not use 31 | authentication 32 | --basic-auth-file PATH File containing username/password combinations 33 | --help Show this message and exit. 34 | ``` 35 | 36 | # Authentication 37 | 38 | The only method currently supported is basic auth, which can be configured 39 | using the --basic-auth-file option. This should point to a file storing 40 | credentials in the format: 41 | 42 | ```txt 43 | :[:] 44 | ``` 45 | -------------------------------------------------------------------------------- /example-credentials.txt: -------------------------------------------------------------------------------- 1 | jon:snow:The king in the north 2 | matt:snider:The project creator 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | click==6.6 2 | kaviar==1.0 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-snider/socks5/de263605f7fa9eefdb113ccfcc55be8d5d44d123/setup.cfg -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | 4 | from codecs import open 5 | 6 | from setuptools import setup, find_packages 7 | 8 | 9 | # Get the long description from the README file 10 | with open('README.md', 'r', 'utf-8') as f: 11 | long_description = f.read() 12 | 13 | 14 | setup( 15 | name='socks5server', 16 | version='0.1.0', 17 | description='A simple asyncio-based socks5 server', 18 | long_description=long_description, 19 | url='https://github.com/matt-snider/socks5', 20 | author='matt-snider', 21 | author_email='matt.snider@protonmail.com', 22 | classifiers=[ 23 | 'Development Status :: 3 - Alpha', 24 | 'Intended Audience :: Developers', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Programming Language :: Python :: 3.6', 27 | 'Programming Language :: Python :: 3.7', 28 | ], 29 | keywords='socks socks5 proxy asyncio', 30 | packages=find_packages(exclude=['contrib', 'docs', 'tests']), 31 | python_requires=">=3.6", 32 | install_requires=[ 33 | 'click', 34 | 'kaviar', 35 | ], 36 | entry_points={ 37 | 'console_scripts': [ 38 | 'socks5.server=socks5.cli:run_server', 39 | ], 40 | }, 41 | project_urls={ 42 | 'Source': 'https://github.com/matt-snider/socks5', 43 | }, 44 | ) 45 | -------------------------------------------------------------------------------- /socks5/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matt-snider/socks5/de263605f7fa9eefdb113ccfcc55be8d5d44d123/socks5/__init__.py -------------------------------------------------------------------------------- /socks5/auth.py: -------------------------------------------------------------------------------- 1 | from . import exceptions 2 | 3 | 4 | async def user_password(reader, writer, credentials): 5 | _, user_length = await reader.readexactly(2) 6 | username = await reader.readexactly(int(user_length)) 7 | password_length, = await reader.readexactly(1) 8 | password = await reader.readexactly(password_length) 9 | 10 | username = username.decode() 11 | password = password.decode() 12 | 13 | try: 14 | success = (password == credentials[username]) 15 | except KeyError: 16 | success = False 17 | 18 | writer.write(b'\x01' + bytes([not success])) 19 | await writer.drain() 20 | if not success: 21 | raise exceptions.AuthFailed("Bad password for '{}'".format(username)) 22 | return username 23 | 24 | -------------------------------------------------------------------------------- /socks5/cli.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import click 3 | 4 | from .exceptions import ImproperlyConfigured 5 | from .server import Socks5Server 6 | 7 | 8 | @click.command() 9 | @click.option('--host', default='127.0.0.1', 10 | help='The interfaces to listen on') 11 | @click.option('--port', default=1080, 12 | help='The port to listen on') 13 | @click.option('--allow-no-auth', is_flag=True, 14 | help='Whether to allow clients that do not use authentication') 15 | @click.option('--basic-auth-file', type=click.Path(exists=True), 16 | help='File containing username/password combinations') 17 | def run_server(host, port, allow_no_auth, basic_auth_file): 18 | """Runs a SOCK5 server.""" 19 | loop = asyncio.get_event_loop() 20 | try: 21 | server = Socks5Server(allow_no_auth=allow_no_auth, 22 | basic_auth_user_file=basic_auth_file) 23 | f = server.start_server(host, port) 24 | loop.run_until_complete(f) 25 | loop.run_forever() 26 | except ImproperlyConfigured as e: 27 | raise click.UsageError(str(e)) 28 | -------------------------------------------------------------------------------- /socks5/exceptions.py: -------------------------------------------------------------------------------- 1 | class BadSocksVersion(Exception): 2 | pass 3 | 4 | 5 | class AuthFailed(Exception): 6 | pass 7 | 8 | 9 | class ImproperlyConfigured(Exception): 10 | pass 11 | 12 | 13 | class ProtocolException(Exception): 14 | error_code = b'\x01' 15 | 16 | 17 | class CommandNotSupported(ProtocolException): 18 | error_code = b'\x07' 19 | 20 | 21 | class AddressTypeNotSupported(ProtocolException): 22 | error_code = b'\x08' 23 | 24 | -------------------------------------------------------------------------------- /socks5/log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from kaviar import EventKvLoggerAdapter 3 | 4 | logging.basicConfig(level=logging.DEBUG) 5 | logger = EventKvLoggerAdapter.get_logger(__name__) 6 | 7 | -------------------------------------------------------------------------------- /socks5/protocol.py: -------------------------------------------------------------------------------- 1 | import struct 2 | import uuid 3 | 4 | from collections import namedtuple 5 | from enum import Enum 6 | 7 | from . import exceptions 8 | 9 | 10 | class Socks5Connection: 11 | 12 | def __init__(self, reader, writer, **info): 13 | self.id = str(uuid.uuid4()) 14 | self.reader = reader 15 | self.writer = writer 16 | self.info = info 17 | self.info['id'] = self.id 18 | 19 | async def negotiate_auth_method(self, supported_methods): 20 | version, nmethods = await self.reader.readexactly(2) 21 | if version != 5: 22 | raise exceptions.BadSocksVersion(version) 23 | client_methods = set(AuthMethod(bytes([x])) for x in 24 | await self.reader.readexactly(nmethods)) 25 | 26 | # Use the best matching auth method using the order 27 | # of `supported_methods` as a preference 28 | common_methods = [x for x in supported_methods if x in client_methods] 29 | selected = common_methods[0] if common_methods else AuthMethod.not_acceptable 30 | self.writer.write(b'\x05' + selected) 31 | await self.writer.drain() 32 | if selected == AuthMethod.not_acceptable: 33 | raise exceptions.AuthFailed('No acceptable methods: {}' 34 | .format(client_methods)) 35 | return selected 36 | 37 | async def read_request(self): 38 | version, cmd, _, atyp = await self.reader.readexactly(4) 39 | cmd, atyp = Command(bytes([cmd])), AddressType(bytes([atyp])) 40 | if atyp == AddressType.domain_name: 41 | read_len, = await self.reader.readexactly(1) 42 | elif atyp in AddressType.ipv4 + AddressType.ipv6: 43 | read_len = 4 * struct.unpack('B', atyp)[0] 44 | else: 45 | raise exceptions.AddressTypeNotSupported(atyp) 46 | dest_addr = '.'.join(str(int(x)) for x in await self.reader.readexactly(read_len)) 47 | dest_port, = struct.unpack(b'!H', (await self.reader.readexactly(2))) 48 | self.request_received = True 49 | return Request(version=version, command=cmd, address_type=atyp, 50 | dest_address=dest_addr, dest_port=dest_port) 51 | 52 | async def _write_reply(self, reply_code): 53 | self.writer.write(struct.pack('!BBxBxxxxxx', 5, reply_code, 0x01)) 54 | await self.writer.drain() 55 | 56 | async def write_error(self, error): 57 | await self._write_reply(error.error_code) 58 | 59 | async def write_success(self): 60 | await self._write_reply(0) 61 | 62 | 63 | class AuthMethod(bytes, Enum): 64 | none = b'\x00' 65 | gssapi = b'\x01' 66 | username_password = b'\x02' 67 | not_acceptable = b'\xff' 68 | 69 | 70 | class Command(bytes, Enum): 71 | connect = b'\x01' 72 | bind = b'\x02' 73 | udp_associate = b'\x03' 74 | 75 | 76 | class AddressType(bytes, Enum): 77 | domain_name = b'\x03' 78 | ipv4 = b'\x01' 79 | ipv6 = b'\x04' 80 | 81 | 82 | Request = namedtuple('Request', ['version', 'command', 'address_type', 83 | 'dest_address', 'dest_port']) 84 | 85 | -------------------------------------------------------------------------------- /socks5/server.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from functools import partial 3 | 4 | from . import exceptions, auth 5 | from .log import logger 6 | from .protocol import AuthMethod, Command, Socks5Connection 7 | 8 | 9 | class Socks5Server: 10 | 11 | def __init__(self, basic_auth_user_file=None, allow_no_auth=False): 12 | self.basic_auth_credentials = {} 13 | self.auth_methods = {} 14 | self.connections = {} 15 | 16 | if allow_no_auth: 17 | self.auth_methods[AuthMethod.none] = None 18 | if basic_auth_user_file: 19 | self.basic_auth_credentials = self.load_basic_auth_file(basic_auth_user_file) 20 | basic_auth = partial(auth.user_password, 21 | credentials=self.basic_auth_credentials) 22 | self.auth_methods[AuthMethod.username_password] = basic_auth 23 | 24 | # When configuration is done, we *must* have at least one auth method 25 | if not self.auth_methods: 26 | raise exceptions.ImproperlyConfigured('No auth methods configured') 27 | 28 | def start_server(self, host, port): 29 | logger.info('START_SERVER', host=host, port=port) 30 | return asyncio.start_server(self.accept_client, host=host, port=port) 31 | 32 | def accept_client(self, reader, writer): 33 | host, port, *_ = writer.get_extra_info('peername') 34 | conn = Socks5Connection(reader, writer, host=host, port=port) 35 | 36 | future = asyncio.ensure_future(self.handle_client(conn)) 37 | future.add_done_callback(self.close_client) 38 | self.connections[conn.id] = conn 39 | logger.info('OPEN_CONNECTION', **conn.info) 40 | 41 | def close_client(self, future): 42 | conn = self.connections.pop(future.result()) 43 | conn.writer.close() 44 | logger.info('CLOSE_CONNECTION', **conn.info) 45 | 46 | async def handle_client(self, conn): 47 | try: 48 | # Do auth negotiation 49 | auth_method = await conn.negotiate_auth_method(self.auth_methods) 50 | logger.info('AUTH_METHOD_NEGOTIATED', method=repr(auth_method)) 51 | 52 | # Do auth subnegotiation 53 | result = await self.auth_subnegotiation(auth_method, conn.reader, 54 | conn.writer) 55 | logger.info('AUTH_COMPLETED', result=result) 56 | 57 | # Receive request 58 | request = await conn.read_request() 59 | logger.info('REQUEST_RECEIVED', request=str(request)) 60 | 61 | # We only handle connect requests for now 62 | if request.command != Command.connect: 63 | raise exceptions.CommandNotSupported(request.command) 64 | 65 | # Send client response: version, rep, rsv (0), atyp, bnd addr, bnd port 66 | await conn.write_success() 67 | 68 | # Let data flow freely between client and remote 69 | await self.splice(conn, request) 70 | except exceptions.ProtocolException as e: 71 | if conn.request_received: 72 | await conn.write_error(e) 73 | except exceptions.BadSocksVersion as e: 74 | logger.warning('UNSUPPORTED_VERSION', version=e.args) 75 | except exceptions.AuthFailed as e: 76 | logger.warning('AUTH_FAILED', reason=e.args[0]) 77 | except Exception as e: 78 | logger.exception('Exception!') 79 | finally: 80 | return conn.id 81 | 82 | async def splice(self, client_conn, socks_request): 83 | remote_reader, remote_writer = ( 84 | await asyncio.open_connection( 85 | host=socks_request.dest_address, 86 | port=socks_request.dest_port 87 | ) 88 | ) 89 | client_read = asyncio.ensure_future(client_conn.reader.read(1024)) 90 | remote_read = asyncio.ensure_future(remote_reader.read(1024)) 91 | while True: 92 | logger.debug('LOOP') 93 | done, pending = await asyncio.wait([client_read, remote_read], 94 | return_when=asyncio.FIRST_COMPLETED) 95 | if client_read in done: 96 | data = client_read.result() 97 | if not data: 98 | remote_read.cancel() 99 | return 100 | 101 | remote_writer.write(data) 102 | await remote_writer.drain() 103 | client_read = asyncio.ensure_future(client_conn.reader.read(1024)) 104 | logger.debug('CLIENT_READ', data=data) 105 | if remote_read in done: 106 | data = remote_read.result() 107 | if not data: 108 | client_read.cancel() 109 | return 110 | 111 | client_conn.writer.write(data) 112 | await client_conn.writer.drain() 113 | remote_read = asyncio.ensure_future(remote_reader.read(1024)) 114 | logger.debug('REMOTE_READ', data=data) 115 | client_read.cancel() 116 | remote_read.cancel() 117 | 118 | async def auth_subnegotiation(self, auth_method, reader, writer): 119 | subnegotiation = self.auth_methods[auth_method] 120 | if subnegotiation: 121 | return await subnegotiation(reader, writer) 122 | 123 | def load_basic_auth_file(self, path): 124 | """Loads a dict mapping usernames to passwords from the given file. 125 | 126 | Each line has the format :[:] 127 | """ 128 | credentials = {} 129 | with open(path) as f: 130 | for line in f.readlines(): 131 | username, password, _ = line.split(':') 132 | credentials[username] = password 133 | return credentials 134 | 135 | 136 | if __name__ == '__main__': 137 | from .cli import run_server 138 | run_server() 139 | --------------------------------------------------------------------------------