#!/usr/bin/python3.14
# SOCKSv5 proxy server for network-sandbox
# Copyright 2015-2024 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

import asyncio
import errno
import os
import socket
import struct
import sys

if hasattr(asyncio, "ensure_future"):
    # Python >=3.4.4.
    asyncio_ensure_future = asyncio.ensure_future
else:
    # getattr() necessary because async is a keyword in Python >=3.7.
    asyncio_ensure_future = getattr(asyncio, "async")

try:
    current_task = asyncio.current_task
except AttributeError:
    # Deprecated since Python 3.7
    current_task = asyncio.Task.current_task


class Socks5Server:
    """
    An asynchronous SOCKSv5 server.
    """

    async def handle_proxy_conn(self, reader, writer):
        """
        Handle incoming client connection. Perform SOCKSv5 request
        exchange, open a proxied connection and start relaying.

        @param reader: Read side of the socket
        @type reader: asyncio.StreamReader
        @param writer: Write side of the socket
        @type writer: asyncio.StreamWriter
        """

        try:
            # SOCKS hello
            data = await reader.readexactly(2)
            vers, method_no = struct.unpack("!BB", data)

            if vers != 0x05:
                # disconnect on invalid packet -- we have no clue how
                # to reply in alien :)
                writer.close()
                return

            # ...and auth method list
            data = await reader.readexactly(method_no)
            for method in data:
                if method == 0x00:
                    break
            else:
                # no supported method
                method = 0xFF

            # auth reply
            repl = struct.pack("!BB", 0x05, method)
            writer.write(repl)
            await writer.drain()
            if method == 0xFF:
                writer.close()
                return

            # request
            data = await reader.readexactly(4)
            vers, cmd, rsv, atyp = struct.unpack("!BBBB", data)

            if vers != 0x05 or rsv != 0x00:
                # disconnect on malformed packet
                self.close()
                return

            # figure out if we can handle it
            rpl = 0x00
            if cmd != 0x01:  # CONNECT
                rpl = 0x07  # command not supported
            elif atyp == 0x01:  # IPv4
                data = await reader.readexactly(4)
                addr = socket.inet_ntoa(data)
            elif atyp == 0x03:  # domain name
                data = await reader.readexactly(1)
                (addr_len,) = struct.unpack("!B", data)
                addr = await reader.readexactly(addr_len)
                try:
                    addr = addr.decode("idna")
                except UnicodeDecodeError:
                    rpl = 0x04  # host unreachable

            elif atyp == 0x04:  # IPv6
                data = await reader.readexactly(16)
                addr = socket.inet_ntop(socket.AF_INET6, data)
            else:
                rpl = 0x08  # address type not supported

            # try to connect if we can handle it
            if rpl == 0x00:
                data = await reader.readexactly(2)
                (port,) = struct.unpack("!H", data)

                try:
                    # open a proxied connection
                    proxied_reader, proxied_writer = await asyncio.open_connection(
                        addr, port
                    )
                except (socket.gaierror, socket.herror):
                    # DNS failure
                    rpl = 0x04  # host unreachable
                except OSError as e:
                    # connection failure
                    if e.errno in (errno.ENETUNREACH, errno.ENETDOWN):
                        rpl = 0x03  # network unreachable
                    elif e.errno in (errno.EHOSTUNREACH, errno.EHOSTDOWN):
                        rpl = 0x04  # host unreachable
                    elif e.errno in (errno.ECONNREFUSED, errno.ETIMEDOUT):
                        rpl = 0x05  # connection refused
                    else:
                        raise
                else:
                    # get socket details that we can send back to the client
                    # local address (sockname) in particular -- but we need
                    # to ask for the whole socket since Python's sockaddr
                    # does not list the family...
                    sock = proxied_writer.get_extra_info("socket")
                    addr = sock.getsockname()
                    if sock.family == socket.AF_INET:
                        host, port = addr
                        bin_host = socket.inet_aton(host)

                        repl_addr = struct.pack("!B4sH", 0x01, bin_host, port)
                    elif sock.family == socket.AF_INET6:
                        # discard flowinfo, scope_id
                        host, port = addr[:2]
                        bin_host = socket.inet_pton(sock.family, host)

                        repl_addr = struct.pack("!B16sH", 0x04, bin_host, port)

            if rpl != 0x00:
                # fallback to 0.0.0.0:0
                repl_addr = struct.pack("!BLH", 0x01, 0x00000000, 0x0000)

            # reply to the request
            repl = struct.pack("!BBB", 0x05, rpl, 0x00)
            writer.write(repl + repl_addr)
            await writer.drain()

            # close if an error occured
            if rpl != 0x00:
                writer.close()
                return

            # otherwise, start two loops:
            # remote -> local...
            t = asyncio_ensure_future(
                self.handle_proxied_conn(proxied_reader, writer, current_task())
            )

            # and local -> remote...
            try:
                try:
                    while True:
                        data = await reader.read(4096)
                        if data == b"":
                            # client disconnected, stop relaying from
                            # remote host
                            t.cancel()
                            break

                        proxied_writer.write(data)
                        await proxied_writer.drain()
                except OSError:
                    # read or write failure
                    t.cancel()
                except:
                    t.cancel()
                    raise
            finally:
                # always disconnect in the end :)
                proxied_writer.close()
                writer.close()

        except (OSError, asyncio.IncompleteReadError, asyncio.CancelledError):
            writer.close()
            return
        except:
            writer.close()
            raise

    async def handle_proxied_conn(self, proxied_reader, writer, parent_task):
        """
        Handle the proxied connection. Relay incoming data
        to the client.

        @param reader: Read side of the socket
        @type reader: asyncio.StreamReader
        @param writer: Write side of the socket
        @type writer: asyncio.StreamWriter
        """

        try:
            try:
                while True:
                    data = await proxied_reader.read(4096)
                    if data == b"":
                        break

                    writer.write(data)
                    await writer.drain()
            finally:
                parent_task.cancel()
        except (OSError, asyncio.CancelledError):
            return


async def run_socks5_server(socket_path):
    s = Socks5Server()
    server = await asyncio.start_unix_server(s.handle_proxy_conn, socket_path)

    try:
        await asyncio.get_running_loop().create_future()
    finally:
        server.close()
        await server.wait_closed()
        os.unlink(socket_path)


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <socket-path>")
        sys.exit(1)

    asyncio.run(run_socks5_server(sys.argv[1]))
