sni/sni_spoofing/forwarder.py
2026-05-28 02:18:25 +04:30

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.")