diff --git a/main.py b/main.py index 15cf6dd..5284261 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ import asyncio import json import logging import os +import re import subprocess import sys @@ -140,6 +141,17 @@ def _is_addr_in_use_error(exc: OSError) -> bool: ) +def _bind_target_from_error(exc: OSError, config: dict) -> tuple[str, int]: + text = str(exc) + match = re.search(r"\('([^']+)',\s*(\d+)\)", text) + if match: + return match.group(1), int(match.group(2)) + return ( + config.get("listen_host", "127.0.0.1"), + config.get("listen_port", 8080), + ) + + def main(): args = parse_args() config_path = args.config @@ -216,6 +228,13 @@ def main(): mode = config.get("mode", "domain_fronting") log.info("DomainFront Tunnel starting (mode: %s)", mode) + if config.get("socks5_enabled"): + log.info( + "SOCKS5 address : %s:%d", + config.get("listen_host", "127.0.0.1"), + config.get("socks5_port", 1080), + ) + if mode == "custom_domain": log.info("Custom domain : %s", config["custom_domain"]) elif mode == "google_fronting": @@ -263,8 +282,7 @@ def main(): asyncio.run(ProxyServer(config).start()) except OSError as e: if _is_addr_in_use_error(e): - host = config.get("listen_host", "127.0.0.1") - port = config.get("listen_port", 8080) + host, port = _bind_target_from_error(e, config) log.error("Cannot listen on %s:%d because that address is already in use.", host, port) details = _windows_listener_details(host, port) if details: diff --git a/proxy_server.py b/proxy_server.py index fb9b1d8..9da9b9f 100644 --- a/proxy_server.py +++ b/proxy_server.py @@ -7,11 +7,14 @@ a domain-fronted connection to a CDN worker or Apps Script relay. Supports: - CONNECT method → WebSocket tunnel (modes 1-3) or MITM relay (apps_script) - GET / POST etc. → HTTP forwarding (modes 1-3) or JSON relay (apps_script) + - SOCKS5 CONNECT → Same tunnel/MITM routing as the HTTP proxy """ import asyncio +import contextlib import logging import re +import socket import ssl import time @@ -104,6 +107,8 @@ class ProxyServer: def __init__(self, config: dict): self.host = config.get("listen_host", "127.0.0.1") self.port = config.get("listen_port", 8080) + self.socks5_enabled = bool(config.get("socks5_enabled")) + self.socks5_port = int(config.get("socks5_port", 1080)) self.mode = config.get("mode", "domain_fronting") self.fronter = DomainFronter(config) self.mitm = None @@ -128,13 +133,33 @@ class ProxyServer: raise SystemExit(1) async def start(self): - srv = await asyncio.start_server(self._on_client, self.host, self.port) - log.info( - "Listening on %s:%d — configure your browser HTTP proxy to this address", - self.host, self.port, - ) - async with srv: - await srv.serve_forever() + servers = [] + try: + http_srv = await asyncio.start_server(self._on_client, self.host, self.port) + servers.append(http_srv) + log.info( + "Listening on %s:%d — configure your browser HTTP proxy to this address", + self.host, self.port, + ) + + if self.socks5_enabled: + socks_srv = await asyncio.start_server( + self._on_socks5_client, self.host, self.socks5_port + ) + servers.append(socks_srv) + log.info( + "Listening on %s:%d — SOCKS5 CONNECT (no-auth, TCP only)", + self.host, self.socks5_port, + ) + + async with contextlib.AsyncExitStack() as stack: + for srv in servers: + await stack.enter_async_context(srv) + await asyncio.gather(*(srv.serve_forever() for srv in servers)) + finally: + for srv in servers: + srv.close() + await asyncio.gather(*(srv.wait_closed() for srv in servers), return_exceptions=True) # ── client handler ──────────────────────────────────────────── @@ -176,19 +201,79 @@ class ProxyServer: except Exception: pass + async def _on_socks5_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter): + addr = writer.get_extra_info("peername") + try: + version, nmethods = await asyncio.wait_for(reader.readexactly(2), timeout=15) + if version != 5: + return + + methods = await asyncio.wait_for(reader.readexactly(nmethods), timeout=15) + if 0x00 not in methods: + writer.write(b"\x05\xff") + await writer.drain() + return + + writer.write(b"\x05\x00") + await writer.drain() + + version, cmd, _rsv, atyp = await asyncio.wait_for(reader.readexactly(4), timeout=30) + if version != 5: + return + + host = await self._read_socks5_address(reader, atyp) + if host is None: + await self._send_socks5_reply(writer, rep=0x08) + return + port = int.from_bytes( + await asyncio.wait_for(reader.readexactly(2), timeout=15), "big" + ) + + if cmd != 0x01: + log.debug("SOCKS5 unsupported command %d from %s", cmd, addr) + await self._send_socks5_reply(writer, rep=0x07) + return + + await self._handle_connect_target( + host, + port, + reader, + writer, + protocol="SOCKS5", + ready_cb=lambda: self._send_socks5_reply(writer, rep=0x00), + ) + + except asyncio.IncompleteReadError: + pass + except asyncio.TimeoutError: + log.debug("SOCKS5 timeout: %s", addr) + except Exception as e: + log.error("SOCKS5 error (%s): %s", addr, e) + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + # ── CONNECT (HTTPS tunnelling) ──────────────────────────────── async def _do_connect(self, target: str, reader, writer): - host, _, port = target.rpartition(":") - port = int(port) if port else 443 - if not host: - host, port = target, 443 + host, port = self._split_host_port(target, default_port=443) + await self._handle_connect_target( + host, + port, + reader, + writer, + protocol="CONNECT", + ready_cb=lambda: self._send_http_connect_ok(writer), + ) - log.info("CONNECT → %s:%d", host, port) - - writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") - await writer.drain() + async def _handle_connect_target(self, host: str, port: int, reader, writer, + *, protocol: str, ready_cb): + log.info("%s → %s:%d", protocol, host, port) + await ready_cb() if self.mode == "apps_script": override_ip = self._sni_rewrite_ip(host) if override_ip: @@ -207,6 +292,46 @@ class ProxyServer: else: await self.fronter.tunnel(host, port, reader, writer) + @staticmethod + def _split_host_port(target: str, default_port: int) -> tuple[str, int]: + target = target.strip() + if target.startswith("["): + host, _, port_str = target[1:].partition("]:") + return host, int(port_str) if port_str else default_port + host, _, port_str = target.rpartition(":") + if host: + return host, int(port_str) + return target, default_port + + @staticmethod + async def _send_http_connect_ok(writer): + writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n") + await writer.drain() + + @staticmethod + async def _read_socks5_address(reader: asyncio.StreamReader, atyp: int) -> str | None: + if atyp == 0x01: + return socket.inet_ntoa(await reader.readexactly(4)) + if atyp == 0x03: + length = (await reader.readexactly(1))[0] + return (await reader.readexactly(length)).decode("ascii", errors="replace") + if atyp == 0x04: + return socket.inet_ntop(socket.AF_INET6, await reader.readexactly(16)) + return None + + @staticmethod + async def _send_socks5_reply(writer, *, rep: int, + bind_host: str = "0.0.0.0", bind_port: int = 0): + try: + packed_host = socket.inet_aton(bind_host) + atyp = 0x01 + except OSError: + packed_host = b"\x00\x00\x00\x00" + atyp = 0x01 + reply = b"\x05" + bytes([rep, 0x00, atyp]) + packed_host + bind_port.to_bytes(2, "big") + writer.write(reply) + await writer.drain() + # ── Hosts override (fake DNS) ───────────────────────────────── # Built-in list of domains that must be reached via Google's frontend IP