352 lines
10 KiB
Python
352 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
import asyncio
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import signal
|
|
import struct
|
|
import time
|
|
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")
|
|
I2P_MAP_FILE = os.getenv("ROUTER_I2P_MAP_FILE", "").strip()
|
|
I2P_POOL_CIDR = os.getenv("ROUTER_I2P_POOL_CIDR", "172.31.0.0/16")
|
|
I2P_POOL = ipaddress.ip_network(I2P_POOL_CIDR)
|
|
|
|
_i2p_map_mtime = -1.0
|
|
_i2p_map_last_check = 0.0
|
|
_i2p_ip_to_host = {}
|
|
|
|
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 _reload_i2p_map_if_needed() -> None:
|
|
global _i2p_map_mtime, _i2p_map_last_check, _i2p_ip_to_host
|
|
|
|
if not I2P_MAP_FILE:
|
|
return
|
|
|
|
now = time.monotonic()
|
|
if now - _i2p_map_last_check < 1.0:
|
|
return
|
|
_i2p_map_last_check = now
|
|
|
|
try:
|
|
mtime = os.path.getmtime(I2P_MAP_FILE)
|
|
except OSError:
|
|
_i2p_ip_to_host = {}
|
|
_i2p_map_mtime = -1.0
|
|
return
|
|
|
|
if mtime == _i2p_map_mtime:
|
|
return
|
|
|
|
try:
|
|
with open(I2P_MAP_FILE, "r", encoding="utf-8") as fh:
|
|
data = json.load(fh)
|
|
mapping = data.get("ip_to_host", {}) if isinstance(data, dict) else {}
|
|
if isinstance(mapping, dict):
|
|
_i2p_ip_to_host = {str(k): str(v) for k, v in mapping.items()}
|
|
else:
|
|
_i2p_ip_to_host = {}
|
|
_i2p_map_mtime = mtime
|
|
except Exception:
|
|
_i2p_ip_to_host = {}
|
|
|
|
|
|
def lookup_i2p_hostname_for_virtual_ip(ip_str: str) -> Optional[str]:
|
|
if not I2P_MAP_FILE:
|
|
return None
|
|
|
|
try:
|
|
ip = ipaddress.ip_address(ip_str)
|
|
except ValueError:
|
|
return None
|
|
|
|
if ip.version != 4 or ip not in I2P_POOL:
|
|
return None
|
|
|
|
_reload_i2p_map_if_needed()
|
|
host = _i2p_ip_to_host.get(str(ip))
|
|
if host and host.endswith(".i2p"):
|
|
return host
|
|
return None
|
|
|
|
|
|
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]
|
|
|
|
connect_atyp = atyp
|
|
connect_host = dst_host
|
|
connect_raw_addr = raw_addr
|
|
|
|
if atyp == ATYP_IPV4:
|
|
mapped_i2p = lookup_i2p_hostname_for_virtual_ip(dst_host)
|
|
if mapped_i2p:
|
|
connect_atyp = ATYP_DOMAIN
|
|
connect_host = mapped_i2p
|
|
connect_raw_addr = None
|
|
|
|
upstream = pick_upstream(connect_host, connect_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
|
|
if connect_host != dst_host:
|
|
log(
|
|
f"route peer={peer} dst={dst_host}:{dst_port} mapped={connect_host} via={up_host}:{up_port}"
|
|
)
|
|
else:
|
|
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(connect_atyp)
|
|
if connect_atyp == ATYP_DOMAIN:
|
|
try:
|
|
host_bytes = connect_host.encode("idna")
|
|
except UnicodeError:
|
|
host_bytes = connect_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(connect_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)
|