diff --git a/3proxy/Dockerfile b/3proxy/Dockerfile index 37e2c49..f6862bd 100644 --- a/3proxy/Dockerfile +++ b/3proxy/Dockerfile @@ -57,6 +57,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ iproute2 \ iputils-ping \ + python3 \ && rm -rf /var/lib/apt/lists/* # Copy binaries using the correct absolute paths from Stage 1 @@ -68,10 +69,12 @@ COPY --from=builder_yggdrasil /out/genkeys /usr/bin/genkeys # Copy local runtime helpers/config COPY ./entrypoint.sh /entrypoint.sh +COPY ./socks_router.py /usr/local/bin/socks_router.py COPY ./yggdrasil.conf /etc/yggdrasil/yggdrasil.conf # Setup config directories RUN mkdir -p /etc/3proxy /etc/yggdrasil \ + && chmod +x /usr/local/bin/socks_router.py \ && chmod +x /entrypoint.sh # Ensure these files exist in your local directory where you run 'docker build' diff --git a/3proxy/entrypoint.sh b/3proxy/entrypoint.sh index c9011a5..916f059 100644 --- a/3proxy/entrypoint.sh +++ b/3proxy/entrypoint.sh @@ -6,10 +6,12 @@ YGG_CONNECT_WAIT_SECONDS="${YGG_CONNECT_WAIT_SECONDS:-8}" YGG_RUNTIME_CONF="${YGG_RUNTIME_CONF:-/run/yggdrasil.conf}" PROXY_CFG_PRIMARY="${PROXY_CFG_PRIMARY:-/etc/3proxy/first-instanse.cfg}" PROXY_CFG_SECONDARY="${PROXY_CFG_SECONDARY:-/etc/3proxy/second-instanse.cfg}" +SOCKS_ROUTER_BIN="${SOCKS_ROUTER_BIN:-/usr/local/bin/socks_router.py}" ygg_pid="" proxy_primary_pid="" proxy_secondary_pid="" +router_pid="" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" @@ -22,6 +24,9 @@ cleanup() { if [[ -n "${proxy_secondary_pid}" ]]; then kill -TERM "${proxy_secondary_pid}" 2>/dev/null || true fi + if [[ -n "${router_pid}" ]]; then + kill -TERM "${router_pid}" 2>/dev/null || true + fi if [[ -n "${ygg_pid}" ]]; then kill -TERM "${ygg_pid}" 2>/dev/null || true fi @@ -45,6 +50,11 @@ if [[ ! -f "${PROXY_CFG_SECONDARY}" ]]; then exit 1 fi +if [[ ! -f "${SOCKS_ROUTER_BIN}" ]]; then + log "Missing SOCKS router at ${SOCKS_ROUTER_BIN}" + exit 1 +fi + cp "${YGG_CONF}" "${YGG_RUNTIME_CONF}" # Populate keys once if config still has empty key fields. @@ -87,6 +97,16 @@ if ! kill -0 "${proxy_secondary_pid}" 2>/dev/null; then exit 1 fi +log "Starting hostname-preserving SOCKS router (${SOCKS_ROUTER_BIN})" +python3 "${SOCKS_ROUTER_BIN}" & +router_pid=$! + +sleep 1 +if ! kill -0 "${router_pid}" 2>/dev/null; then + log "SOCKS router exited during startup" + exit 1 +fi + log "Starting primary 3proxy instance (${PROXY_CFG_PRIMARY})" /usr/bin/3proxy "${PROXY_CFG_PRIMARY}" & proxy_primary_pid=$! @@ -97,7 +117,7 @@ if ! kill -0 "${proxy_primary_pid}" 2>/dev/null; then exit 1 fi -wait -n "${proxy_secondary_pid}" "${proxy_primary_pid}" "${ygg_pid}" +wait -n "${proxy_secondary_pid}" "${proxy_primary_pid}" "${router_pid}" "${ygg_pid}" exit_code=$? log "A managed process exited, shutting down" cleanup diff --git a/3proxy/first-instanse.cfg b/3proxy/first-instanse.cfg index 50e15dc..80bf45c 100644 --- a/3proxy/first-instanse.cfg +++ b/3proxy/first-instanse.cfg @@ -52,5 +52,5 @@ flush allow * parent 1000 socks5+ 10.5.0.7 9050 -socks -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -i0.0.0.0 -p1080 +socks -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -i0.0.0.0 -p1088 proxy -olSO_REUSEADDR,SO_REUSEPORT -ocTCP_TIMESTAMPS,TCP_NODELAY -osTCP_NODELAY -46 -a1 -i0.0.0.0 -p3128 diff --git a/3proxy/socks_router.py b/3proxy/socks_router.py new file mode 100644 index 0000000..84f1f6f --- /dev/null +++ b/3proxy/socks_router.py @@ -0,0 +1,274 @@ +#!/usr/bin/env python3 +import asyncio +import ipaddress +import os +import signal +import struct +import sys +from datetime import datetime +from typing import Optional, Tuple + +SOCKS_VERSION = 5 +CMD_CONNECT = 1 +ATYP_IPV4 = 1 +ATYP_DOMAIN = 3 +ATYP_IPV6 = 4 + +REP_SUCCEEDED = 0 +REP_GENERAL_FAILURE = 1 +REP_CONNECTION_NOT_ALLOWED = 2 +REP_NETWORK_UNREACHABLE = 3 +REP_HOST_UNREACHABLE = 4 +REP_COMMAND_NOT_SUPPORTED = 7 +REP_ADDRESS_TYPE_NOT_SUPPORTED = 8 + +YGG_NET = ipaddress.ip_network("200::/7") + + +def env_endpoint(var_name: str, default: str) -> Tuple[str, int]: + raw = os.getenv(var_name, default).strip() + host, sep, port_s = raw.rpartition(":") + if not sep: + raise ValueError(f"Invalid endpoint for {var_name}: {raw}") + return host, int(port_s) + + +LISTEN_HOST = os.getenv("ROUTER_LISTEN_HOST", "0.0.0.0") +LISTEN_PORT = int(os.getenv("ROUTER_LISTEN_PORT", "1080")) +UPSTREAM_TOR = env_endpoint("ROUTER_UPSTREAM_TOR", "10.5.0.7:9050") +UPSTREAM_I2P = env_endpoint("ROUTER_UPSTREAM_I2P", "10.5.0.2:4447") +UPSTREAM_YGG = env_endpoint("ROUTER_UPSTREAM_YGG", "127.0.0.1:1085") + +BLOCKLIST = [ + item.strip().lower() + for item in os.getenv("ROUTER_BLOCKLIST", "vk.com,ok.ru,mail.ru,gosuslugi.ru").split(",") + if item.strip() +] + + +def log(msg: str) -> None: + ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{ts}] [socks-router] {msg}", flush=True) + + +def is_blocked_domain(hostname: str) -> bool: + host = hostname.rstrip(".").lower() + for blocked in BLOCKLIST: + if host == blocked or host.endswith("." + blocked): + return True + return False + + +def pick_upstream(host: str, atyp: int) -> Optional[Tuple[str, int]]: + if atyp == ATYP_DOMAIN: + dhost = host.rstrip(".").lower() + if is_blocked_domain(dhost): + return None + if dhost.endswith(".onion"): + return UPSTREAM_TOR + if dhost.endswith(".i2p"): + return UPSTREAM_I2P + if dhost.endswith(".ygg") or dhost.endswith(".meshname") or dhost.endswith(".meship"): + return UPSTREAM_YGG + return UPSTREAM_TOR + + if atyp == ATYP_IPV6: + try: + ip = ipaddress.ip_address(host) + if ip in YGG_NET: + return UPSTREAM_YGG + except ValueError: + return None + return UPSTREAM_TOR + + if atyp == ATYP_IPV4: + return UPSTREAM_TOR + + return None + + +async def read_exact(reader: asyncio.StreamReader, n: int) -> bytes: + data = await reader.readexactly(n) + if len(data) != n: + raise asyncio.IncompleteReadError(partial=data, expected=n) + return data + + +async def send_reply(writer: asyncio.StreamWriter, rep: int) -> None: + writer.write(b"\x05" + bytes([rep]) + b"\x00\x01\x00\x00\x00\x00\x00\x00") + await writer.drain() + + +async def pipe(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + try: + while True: + chunk = await reader.read(65536) + if not chunk: + break + writer.write(chunk) + await writer.drain() + except Exception: + pass + finally: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + + +async def handle_client(client_reader: asyncio.StreamReader, client_writer: asyncio.StreamWriter) -> None: + peer = client_writer.get_extra_info("peername") + upstream_writer = None + + try: + header = await read_exact(client_reader, 2) + ver, n_methods = header[0], header[1] + if ver != SOCKS_VERSION: + client_writer.close() + await client_writer.wait_closed() + return + + _ = await read_exact(client_reader, n_methods) + client_writer.write(b"\x05\x00") + await client_writer.drain() + + req = await read_exact(client_reader, 4) + ver, cmd, _rsv, atyp = req + if ver != SOCKS_VERSION or cmd != CMD_CONNECT: + await send_reply(client_writer, REP_COMMAND_NOT_SUPPORTED) + return + + if atyp == ATYP_IPV4: + raw_addr = await read_exact(client_reader, 4) + dst_host = str(ipaddress.IPv4Address(raw_addr)) + elif atyp == ATYP_IPV6: + raw_addr = await read_exact(client_reader, 16) + dst_host = str(ipaddress.IPv6Address(raw_addr)) + elif atyp == ATYP_DOMAIN: + dlen = (await read_exact(client_reader, 1))[0] + raw_addr = await read_exact(client_reader, dlen) + dst_host = raw_addr.decode("ascii", errors="ignore") + else: + await send_reply(client_writer, REP_ADDRESS_TYPE_NOT_SUPPORTED) + return + + dst_port = struct.unpack("!H", await read_exact(client_reader, 2))[0] + + upstream = pick_upstream(dst_host, atyp) + if upstream is None: + await send_reply(client_writer, REP_CONNECTION_NOT_ALLOWED) + log(f"deny peer={peer} dst={dst_host}:{dst_port}") + return + + up_host, up_port = upstream + log(f"route peer={peer} dst={dst_host}:{dst_port} via={up_host}:{up_port}") + + try: + upstream_reader, upstream_writer = await asyncio.open_connection(up_host, up_port) + except Exception: + await send_reply(client_writer, REP_NETWORK_UNREACHABLE) + return + + # SOCKS5 greeting to upstream. + upstream_writer.write(b"\x05\x01\x00") + await upstream_writer.drain() + up_auth = await read_exact(upstream_reader, 2) + if up_auth[0] != SOCKS_VERSION or up_auth[1] != 0x00: + await send_reply(client_writer, REP_GENERAL_FAILURE) + return + + connect_req = bytearray(b"\x05\x01\x00") + connect_req.append(atyp) + if atyp == ATYP_DOMAIN: + try: + host_bytes = dst_host.encode("idna") + except UnicodeError: + host_bytes = dst_host.encode("ascii", errors="ignore") + if len(host_bytes) > 255: + await send_reply(client_writer, REP_HOST_UNREACHABLE) + return + connect_req.append(len(host_bytes)) + connect_req.extend(host_bytes) + else: + connect_req.extend(raw_addr) + connect_req.extend(struct.pack("!H", dst_port)) + + upstream_writer.write(connect_req) + await upstream_writer.drain() + + up_resp = await read_exact(upstream_reader, 4) + if up_resp[0] != SOCKS_VERSION: + await send_reply(client_writer, REP_GENERAL_FAILURE) + return + + up_rep = up_resp[1] + up_atyp = up_resp[3] + if up_atyp == ATYP_IPV4: + _ = await read_exact(upstream_reader, 4) + elif up_atyp == ATYP_IPV6: + _ = await read_exact(upstream_reader, 16) + elif up_atyp == ATYP_DOMAIN: + ln = (await read_exact(upstream_reader, 1))[0] + _ = await read_exact(upstream_reader, ln) + else: + await send_reply(client_writer, REP_GENERAL_FAILURE) + return + _ = await read_exact(upstream_reader, 2) + + if up_rep != REP_SUCCEEDED: + await send_reply(client_writer, up_rep if up_rep <= REP_ADDRESS_TYPE_NOT_SUPPORTED else REP_GENERAL_FAILURE) + return + + await send_reply(client_writer, REP_SUCCEEDED) + + await asyncio.gather( + pipe(client_reader, upstream_writer), + pipe(upstream_reader, client_writer), + ) + + except asyncio.IncompleteReadError: + pass + except Exception as exc: + log(f"error peer={peer} err={exc}") + try: + await send_reply(client_writer, REP_GENERAL_FAILURE) + except Exception: + pass + finally: + try: + client_writer.close() + await client_writer.wait_closed() + except Exception: + pass + + +async def main() -> int: + server = await asyncio.start_server(handle_client, LISTEN_HOST, LISTEN_PORT) + sockets = ", ".join(str(s.getsockname()) for s in (server.sockets or [])) + log(f"listening on {sockets}") + + stop_event = asyncio.Event() + + def _stop() -> None: + stop_event.set() + + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, _stop) + except NotImplementedError: + pass + + await stop_event.wait() + server.close() + await server.wait_closed() + log("shutdown complete") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(asyncio.run(main())) + except KeyboardInterrupt: + raise SystemExit(0) diff --git a/README.md b/README.md index 2e9d760..65f3925 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ blocking/forwarding behavior. This container runs the following vontainers in the stack. -3proxy-eagle - Socks5/http proxy +dark3proxy - SOCKS5/http proxy with hostname-preserving router - DNS - pihole - ad blocking and metrics - PopuraDNS - non-recursive DNS forwards requests to the following: @@ -46,8 +46,18 @@ This container runs the following vontainers in the stack. Proxy access is exposed on a single host port: -* `:2000` -> direct Tor SOCKS (`darktor:9050`), externally reachable. - - Supports `.onion` and clearnet via `--socks5-hostname`. +* `:2000` -> `dark3proxy` SOCKS (`dark3proxy:1080`), externally reachable. + - Intended as the single client entrypoint for clearnet, onion, and Ygg/I2P routing rules. + +Router internals for `:2000`: + +* Front-end process: `3proxy/socks_router.py` (SOCKS5), keeps destination hostnames intact. +* Upstream dispatch: + - `*.onion` -> Tor SOCKS (`10.5.0.7:9050`) + - `*.i2p` -> i2pd SOCKS (`10.5.0.2:4447`) + - `*.ygg`, `*.meshname`, `*.meship`, and `200::/7` -> local Ygg-guarded SOCKS (`127.0.0.1:1085`) + - all other targets -> Tor SOCKS (`10.5.0.7:9050`) +* Existing 3proxy primary instance still runs for admin/legacy rules, with SOCKS moved to internal port `1088` to avoid collision. Additional non-proxy endpoint: