378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""Core TCP forwarder with DPI bypass.
|
|
|
|
This is the main engine that:
|
|
1. Listens for incoming TCP connections
|
|
2. Reads the first TLS ClientHello from the client
|
|
3. Connects to the configured upstream IP
|
|
4. Applies the chosen DPI bypass strategy
|
|
5. Relays data bidirectionally between client and server
|
|
|
|
When a raw injector is available (Linux + root), it registers each
|
|
outgoing connection so the sniffer can capture the SYN/ACK handshake
|
|
and inject the fake ClientHello with an out-of-window seq number.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
import socket
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from typing import Optional
|
|
|
|
# `resource` is a POSIX-only module (Linux/macOS/BSD). It does not exist on
|
|
# Windows, so we import it defensively and skip the fd-limit tweak there.
|
|
try:
|
|
import resource # type: ignore
|
|
except ImportError: # pragma: no cover -- Windows
|
|
resource = None # type: ignore
|
|
|
|
from .bypass.base import BypassStrategy
|
|
from .tls import ClientHelloBuilder
|
|
|
|
logger = logging.getLogger("snispf")
|
|
|
|
# Buffer size for socket operations
|
|
BUFFER_SIZE = 65535
|
|
|
|
# How many consecutive failures on a single IP before triggering failover
|
|
FAILOVER_THRESHOLD = 3
|
|
|
|
# Rapid failure window -- if we get FAILOVER_THRESHOLD failures within
|
|
# this many seconds, the IP is considered blocked.
|
|
FAILOVER_WINDOW = 30.0
|
|
|
|
# Maximum concurrent connections. Keeps the process well under the
|
|
# OS file-descriptor limit and avoids "Too many open files" crashes
|
|
# that macOS users hit with the default 256 fd limit.
|
|
MAX_CONCURRENT_CONNECTIONS = 512
|
|
|
|
|
|
def _raise_fd_limit():
|
|
"""Try to raise the OS file-descriptor soft limit.
|
|
|
|
macOS defaults to 256, which is far too low for a proxy that handles
|
|
many parallel connections (each needs 2 fds: incoming + outgoing).
|
|
We attempt to raise the soft limit to the hard limit, or at least
|
|
to a reasonable value.
|
|
|
|
No-op on Windows, where the `resource` module does not exist and
|
|
socket count is governed differently.
|
|
"""
|
|
if resource is None:
|
|
return
|
|
try:
|
|
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
if soft < 4096:
|
|
target = min(hard, 65536) if hard > soft else 4096
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard))
|
|
logger.debug("Raised fd limit from %d to %d", soft, target)
|
|
except (ValueError, OSError):
|
|
# On some systems we cannot raise beyond hard limit
|
|
try:
|
|
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
|
|
except (ValueError, OSError):
|
|
pass
|
|
except (AttributeError, OSError):
|
|
# resource module not available (unlikely) or unsupported platform
|
|
pass
|
|
|
|
|
|
class ConnectionTracker:
|
|
"""Tracks per-IP connection failures to detect blocking."""
|
|
|
|
def __init__(self):
|
|
self._failures = {} # ip -> list of failure timestamps
|
|
self._successes = {} # ip -> count
|
|
|
|
def record_failure(self, ip: str) -> int:
|
|
"""Record a failure and return how many occurred within the window."""
|
|
now = time.monotonic()
|
|
if ip not in self._failures:
|
|
self._failures[ip] = []
|
|
self._failures[ip].append(now)
|
|
# Prune old entries
|
|
cutoff = now - FAILOVER_WINDOW
|
|
self._failures[ip] = [t for t in self._failures[ip] if t > cutoff]
|
|
return len(self._failures[ip])
|
|
|
|
def record_success(self, ip: str):
|
|
"""Record a successful connection (resets the failure counter)."""
|
|
self._failures.pop(ip, None)
|
|
self._successes[ip] = self._successes.get(ip, 0) + 1
|
|
|
|
def should_failover(self, ip: str) -> bool:
|
|
count = len(self._failures.get(ip, []))
|
|
return count >= FAILOVER_THRESHOLD
|
|
|
|
def clear(self, ip: str):
|
|
self._failures.pop(ip, None)
|
|
|
|
|
|
# Module-level tracker shared across connections
|
|
_conn_tracker = ConnectionTracker()
|
|
|
|
|
|
async def handle_connection(
|
|
incoming_sock: socket.socket,
|
|
incoming_addr: tuple,
|
|
connect_ip: str,
|
|
connect_port: int,
|
|
fake_sni: str,
|
|
bypass_strategy: BypassStrategy,
|
|
interface_ip: Optional[str] = None,
|
|
raw_injector=None,
|
|
):
|
|
"""Handle a single incoming connection.
|
|
|
|
Flow:
|
|
1. Read first data from client (should be TLS ClientHello)
|
|
2. Create outgoing socket, optionally register with raw injector
|
|
3. Connect to target server (3-way handshake happens here;
|
|
the raw injector captures SYN and injects after 3rd ACK)
|
|
4. Apply the bypass strategy (sends real data, waits for inject confirmation)
|
|
5. Relay data bidirectionally
|
|
"""
|
|
loop = asyncio.get_running_loop()
|
|
outgoing_sock = None
|
|
local_port = None
|
|
active_ip = connect_ip
|
|
active_sni = fake_sni
|
|
|
|
try:
|
|
# Read the first data from client (should be TLS ClientHello)
|
|
first_data = await asyncio.wait_for(
|
|
loop.sock_recv(incoming_sock, BUFFER_SIZE),
|
|
timeout=30.0,
|
|
)
|
|
|
|
if not first_data:
|
|
incoming_sock.close()
|
|
return
|
|
|
|
# Parse to see if it's a TLS ClientHello
|
|
parsed = ClientHelloBuilder.parse_client_hello(first_data)
|
|
client_sni = parsed.get("sni", "unknown")
|
|
logger.info(
|
|
f"[{incoming_addr[0]}:{incoming_addr[1]}] -> "
|
|
f"{active_ip}:{connect_port} | SNI: {client_sni} | "
|
|
f"Fake: {active_sni} | Method: {bypass_strategy.name}"
|
|
)
|
|
|
|
# Create outgoing socket
|
|
outgoing_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
outgoing_sock.setblocking(False)
|
|
|
|
# Bind to specific interface if configured
|
|
if interface_ip:
|
|
outgoing_sock.bind((interface_ip, 0))
|
|
|
|
# Set keepalive
|
|
outgoing_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
try:
|
|
outgoing_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
|
|
outgoing_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
|
|
outgoing_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
|
|
except (AttributeError, OSError):
|
|
pass # Not available on all platforms
|
|
|
|
# If raw injector is available, register the outgoing port
|
|
# BEFORE connecting so the sniffer can see the SYN.
|
|
if raw_injector is not None:
|
|
# We need to bind first to know the local port
|
|
if not interface_ip:
|
|
outgoing_sock.bind(("", 0))
|
|
local_port = outgoing_sock.getsockname()[1]
|
|
fake_hello = ClientHelloBuilder.build_client_hello(sni=active_sni)
|
|
raw_injector.register_port(local_port, fake_hello)
|
|
|
|
# Connect to target server (triggers SYN -> SYN+ACK -> ACK)
|
|
try:
|
|
await asyncio.wait_for(
|
|
loop.sock_connect(outgoing_sock, (active_ip, connect_port)),
|
|
timeout=15.0,
|
|
)
|
|
except (asyncio.TimeoutError, ConnectionRefusedError, OSError) as exc:
|
|
fail_count = _conn_tracker.record_failure(active_ip)
|
|
logger.debug(
|
|
"[%s:%d] Connect to %s failed (%d/%d): %s",
|
|
incoming_addr[0], incoming_addr[1], active_ip,
|
|
fail_count, FAILOVER_THRESHOLD, exc,
|
|
)
|
|
raise
|
|
|
|
# If we didn't know the port before, grab it now
|
|
if local_port is None and raw_injector is not None:
|
|
local_port = outgoing_sock.getsockname()[1]
|
|
|
|
# Apply DPI bypass strategy
|
|
# The strategy handles:
|
|
# - Waiting for raw injection confirmation (if available)
|
|
# - Sending the real ClientHello (fragmented or not)
|
|
success = await bypass_strategy.apply(
|
|
client_sock=incoming_sock,
|
|
server_sock=outgoing_sock,
|
|
fake_sni=active_sni,
|
|
first_data=first_data,
|
|
loop=loop,
|
|
)
|
|
|
|
if not success:
|
|
logger.warning(
|
|
f"[{incoming_addr[0]}:{incoming_addr[1]}] "
|
|
f"Bypass strategy '{bypass_strategy.name}' failed, "
|
|
f"falling back to direct relay"
|
|
)
|
|
# Fallback: just send the data directly
|
|
await loop.sock_sendall(outgoing_sock, first_data)
|
|
|
|
# NOTE: Do NOT mark success yet. We need to verify the server
|
|
# actually responds with valid data (not a block page or RST).
|
|
# Success is recorded only after the first server response
|
|
# is received in the relay loop below.
|
|
|
|
# Bidirectional relay
|
|
done = asyncio.Event()
|
|
server_responded = False
|
|
|
|
async def _relay(s_in, s_out, label):
|
|
nonlocal server_responded
|
|
try:
|
|
while True:
|
|
data = await loop.sock_recv(s_in, BUFFER_SIZE)
|
|
if not data:
|
|
break
|
|
await loop.sock_sendall(s_out, data)
|
|
# Record success only when we get the first
|
|
# response from the server (S->C direction).
|
|
# This proves the connection is actually working
|
|
# and the server accepted our ClientHello.
|
|
if label == "S->C" and not server_responded:
|
|
server_responded = True
|
|
_conn_tracker.record_success(active_ip)
|
|
except (ConnectionResetError, BrokenPipeError, OSError):
|
|
pass
|
|
except Exception:
|
|
logger.debug(f"Relay error ({label}): {traceback.format_exc()}")
|
|
finally:
|
|
done.set()
|
|
|
|
c2s_task = loop.create_task(_relay(incoming_sock, outgoing_sock, "C->S"))
|
|
s2c_task = loop.create_task(_relay(outgoing_sock, incoming_sock, "S->C"))
|
|
|
|
# Wait until one direction closes, then cancel the other
|
|
await done.wait()
|
|
c2s_task.cancel()
|
|
s2c_task.cancel()
|
|
await asyncio.gather(c2s_task, s2c_task, return_exceptions=True)
|
|
|
|
# If the server never responded, record a failure.
|
|
# This catches cases where DPI allows the handshake but
|
|
# blocks or RSTs actual application data.
|
|
if not server_responded:
|
|
_conn_tracker.record_failure(active_ip)
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.debug(f"[{incoming_addr[0]}:{incoming_addr[1]}] Connection timeout")
|
|
except Exception:
|
|
logger.debug(f"Connection handler error: {traceback.format_exc()}")
|
|
finally:
|
|
try:
|
|
incoming_sock.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if outgoing_sock:
|
|
outgoing_sock.close()
|
|
except Exception:
|
|
pass
|
|
# Clean up raw injector port state
|
|
if raw_injector is not None and local_port is not None:
|
|
raw_injector.cleanup_port(local_port)
|
|
|
|
|
|
async def start_server(
|
|
listen_host: str,
|
|
listen_port: int,
|
|
connect_ip: str,
|
|
connect_port: int,
|
|
fake_sni: str,
|
|
bypass_strategy: BypassStrategy,
|
|
interface_ip: Optional[str] = None,
|
|
raw_injector=None,
|
|
):
|
|
"""Start the TCP forwarding server.
|
|
|
|
Creates a listening socket and handles incoming connections,
|
|
applying the DPI bypass strategy to each one.
|
|
"""
|
|
# Raise the OS file-descriptor limit before binding
|
|
_raise_fd_limit()
|
|
|
|
# Create listening socket
|
|
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
server_sock.setblocking(False)
|
|
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
server_sock.bind((listen_host, listen_port))
|
|
|
|
# Set keepalive on the listening socket
|
|
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
try:
|
|
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
|
|
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10)
|
|
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 5)
|
|
except (AttributeError, OSError):
|
|
pass
|
|
|
|
server_sock.listen(128)
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
# Semaphore limits concurrent connections to prevent fd exhaustion.
|
|
# Each proxied connection uses 2 fds (client + server), plus the
|
|
# listening socket itself. This cap prevents the "Too many open
|
|
# files" crash that happens on macOS (default fd limit 256) and
|
|
# Android/Termux when VPN clients open many connections at once.
|
|
conn_semaphore = asyncio.Semaphore(MAX_CONCURRENT_CONNECTIONS)
|
|
|
|
logger.info(f"Listening on {listen_host}:{listen_port}")
|
|
logger.info(f"Forwarding to {connect_ip}:{connect_port}")
|
|
logger.info(f"Fake SNI: {fake_sni}")
|
|
logger.info(f"Bypass strategy: {bypass_strategy.name}")
|
|
if raw_injector is not None:
|
|
logger.info("Raw packet injection: ACTIVE (seq_id trick enabled)")
|
|
else:
|
|
logger.info("Raw packet injection: not available (fragmentation only)")
|
|
logger.info(f"Interface IP: {interface_ip or 'auto'}")
|
|
logger.info("=" * 60)
|
|
logger.info("Ready! Configure your application to use:")
|
|
logger.info(f" Address: 127.0.0.1:{listen_port}")
|
|
logger.info("=" * 60)
|
|
|
|
async def _guarded_handle(sock, addr):
|
|
"""Wrap handle_connection with the concurrency semaphore."""
|
|
async with conn_semaphore:
|
|
await handle_connection(
|
|
incoming_sock=sock,
|
|
incoming_addr=addr,
|
|
connect_ip=connect_ip,
|
|
connect_port=connect_port,
|
|
fake_sni=fake_sni,
|
|
bypass_strategy=bypass_strategy,
|
|
interface_ip=interface_ip,
|
|
raw_injector=raw_injector,
|
|
)
|
|
|
|
try:
|
|
while True:
|
|
incoming_sock, addr = await loop.sock_accept(server_sock)
|
|
incoming_sock.setblocking(False)
|
|
|
|
loop.create_task(_guarded_handle(incoming_sock, addr))
|
|
except asyncio.CancelledError:
|
|
pass
|
|
finally:
|
|
server_sock.close()
|
|
logger.info("Server stopped.")
|