Files
shadowsocks-libev/tests/interop.py
Max Lv 201e70c4bd Modernize C builds with offline dependencies and portable runtime helpers (#3050)
Bundle pinned offline dependencies, remove libcork/libipset and submodule requirements, and add portable runtime helpers with relocatable static/shared library installations.

Add Clang static build validation for Linux/musl, macOS and Windows, real TCP/UDP and SIP003 interoperability coverage, and recorded performance tradeoffs. All 21 hosted checks pass at the reviewed PR head 8d504e6218.
2026-09-11 13:42:11 +08:00

276 lines
12 KiB
Python

#!/usr/bin/env python3
"""Real TCP/UDP interoperability, independent of HTTP proxy environment variables."""
import argparse
import base64
import concurrent.futures
import contextlib
import os
from pathlib import Path
import shutil
import socket
import socketserver
import struct
import subprocess
import tempfile
import threading
import time
class TCPHandler(socketserver.BaseRequestHandler):
def handle(self):
self.request.settimeout(15)
try:
while chunk := self.request.recv(16384):
self.request.sendall(chunk)
except (OSError, TimeoutError):
pass
class UDPHandler(socketserver.BaseRequestHandler):
def handle(self):
data, sock = self.request
sock.sendto(data, self.client_address)
class TCPOrigin(socketserver.ThreadingTCPServer):
daemon_threads = True
class UDPOrigin(socketserver.ThreadingUDPServer):
daemon_threads = True
def receive(sock, size):
data = bytearray()
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise AssertionError(f"unexpected EOF after {len(data)}/{size} bytes")
data.extend(chunk)
return bytes(data)
def address(sock, kind):
if kind == 1:
host = socket.inet_ntop(socket.AF_INET, receive(sock, 4))
elif kind == 4:
host = socket.inet_ntop(socket.AF_INET6, receive(sock, 16))
elif kind == 3:
host = receive(sock, receive(sock, 1)[0]).decode("ascii")
else:
raise AssertionError(f"invalid SOCKS address type {kind}")
return host, struct.unpack("!H", receive(sock, 2))[0]
def socks(proxy_port, command, destination):
sock = socket.create_connection(("127.0.0.1", proxy_port), timeout=10)
try:
sock.sendall(b"\x05\x01\x00")
assert receive(sock, 2) == b"\x05\x00", "SOCKS authentication failed"
host, port = destination
encoded = host.encode("ascii")
sock.sendall(bytes([5, command, 0, 3, len(encoded)]) + encoded + struct.pack("!H", port))
header = receive(sock, 4)
assert header[:3] == b"\x05\x00\x00", f"SOCKS request failed: {header.hex()}"
relay = address(sock, header[3])
return sock, relay
except BaseException:
sock.close()
raise
def tcp_case(proxy_port, origin_port, size):
sock, _ = socks(proxy_port, 1, ("localhost", origin_port))
payload = os.urandom(size)
with sock, concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
# Upload and download concurrently to exercise backpressure in both directions.
upload = pool.submit(sock.sendall, payload)
assert receive(sock, len(payload)) == payload, "TCP payload mismatch"
upload.result(timeout=10)
def udp_case(proxy_port, origin_port):
control, relay = socks(proxy_port, 3, ("0.0.0.0", 0))
host, port = relay
if host in ("0.0.0.0", "::"):
host = "127.0.0.1"
with control, socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(10)
for size in (1, 128, 1200, 4096):
payload = os.urandom(size)
header = b"\x00\x00\x00\x01" + socket.inet_aton("127.0.0.1") + struct.pack("!H", origin_port)
sock.sendto(header + payload, (host, port))
try:
reply, sender = sock.recvfrom(65535)
except TimeoutError as error:
raise AssertionError(f"UDP timeout for {size}-byte payload") from error
assert sender[1] == port, "UDP reply bypassed relay"
assert reply[:3] == b"\x00\x00\x00", "fragmented or invalid SOCKS UDP reply"
if reply[3] == 1:
offset = 10
elif reply[3] == 4:
offset = 22
elif reply[3] == 3:
offset = 7 + reply[4]
else:
raise AssertionError("invalid UDP address type")
assert reply[offset:] == payload, "UDP payload mismatch"
def free_port():
# TCP and UDP have separate port reservations, especially on Windows.
# Probe both while holding the TCP socket so a TCP-only free port cannot
# select a reserved or occupied UDP endpoint.
for _ in range(100):
with socket.socket() as tcp, socket.socket(type=socket.SOCK_DGRAM) as udp:
tcp.bind(("127.0.0.1", 0))
port = tcp.getsockname()[1]
try:
udp.bind(("127.0.0.1", port))
except OSError:
continue
return port
raise RuntimeError("could not find a free TCP/UDP port")
def wait_ready(process, port):
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if process.poll() is not None:
raise AssertionError(f"peer exited with {process.returncode}")
try:
with socket.create_connection(("127.0.0.1", port), timeout=.1):
return
except OSError:
time.sleep(.03)
raise AssertionError(f"peer failed to listen on port {port}")
@contextlib.contextmanager
def peers(commands, ports, environment=None):
processes = []
with tempfile.TemporaryDirectory(prefix="ss-interop-") as temp, contextlib.ExitStack() as stack:
logs = []
try:
for index, (command, port) in enumerate(zip(commands, ports)):
path = Path(temp) / f"peer-{index}.log"
log = stack.enter_context(path.open("wb"))
logs.append(path)
process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, env=environment)
processes.append(process)
wait_ready(process, port)
yield
except BaseException:
for path in logs:
print(path.name + ":\n" + path.read_text(errors="replace")[-6000:], flush=True)
raise
finally:
for process in reversed(processes):
if process.poll() is None:
process.terminate()
for process in reversed(processes):
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--self", action="store_true", help="use this build for both peers")
parser.add_argument("--method", help="run one cipher")
parser.add_argument("--bin", default=os.environ.get("SS_BIN_DIR", "build/bin"), help="program directory")
parser.add_argument("--plugin", help="SIP003 fixture executable (requires --self)")
parser.add_argument("--isolate-windows-runtime", action="store_true",
help="remove MSYS2/toolchain DLL directories from child PATH")
args = parser.parse_args()
if args.plugin and not args.self:
parser.error("--plugin requires --self")
environment = None
if args.isolate_windows_runtime:
if os.name != "nt":
parser.error("--isolate-windows-runtime requires Windows")
environment = os.environ.copy()
system_root = os.environ["SYSTEMROOT"]
environment["PATH"] = os.pathsep.join((system_root, os.path.join(system_root, "System32")))
binary_dir = Path(args.bin).resolve()
suffix = ".exe" if os.name == "nt" else ""
local, server = [str(binary_dir / (name + suffix)) for name in ("ss-local", "ss-server")]
rust_local, rust_server = shutil.which("sslocal"), shutil.which("ssserver")
missing = [p for p in (local, server) if not os.path.isfile(p)]
if not args.self:
missing += [name for name, found in (("sslocal", rust_local), ("ssserver", rust_server)) if not found]
if missing:
print("Missing interoperability prerequisites: " + ", ".join(missing))
return 1 if os.environ.get("SS_REQUIRE_INTEROP") == "1" else 77
methods = ["2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm",
"2022-blake3-chacha20-poly1305", "aes-128-gcm", "aes-256-gcm",
"chacha20-ietf-poly1305"]
if args.method:
methods = [args.method]
failures = 0
with TCPOrigin(("127.0.0.1", 0), TCPHandler) as tcp, UDPOrigin(("127.0.0.1", 0), UDPHandler) as udp:
for origin in (tcp, udp):
threading.Thread(target=origin.serve_forever, daemon=True).start()
try:
for method in methods:
key_size = 16 if method == "2022-blake3-aes-128-gcm" else 32
psk = base64.b64encode(os.urandom(key_size)).decode("ascii")
directions = ("self",) if args.self else ("C client -> Rust server", "Rust client -> C server")
for direction in directions:
label = f"{method}: {direction}"
print("RUN " + label, flush=True)
server_port, local_port = free_port(), free_port()
c_server = [server, "-s", "127.0.0.1", "-p", str(server_port), "-k", psk, "-m", method, "-u"]
c_local = [local, "-s", "127.0.0.1", "-p", str(server_port), "-l", str(local_port), "-k", psk, "-m", method, "-u"]
commands = [c_server, c_local]
plugin_temp = tempfile.TemporaryDirectory(prefix="ss-plugin-") if args.plugin else None
markers = []
if args.plugin:
for mode, command in zip(("server", "client"), commands):
marker = Path(plugin_temp.name) / mode
markers.append(marker)
command.extend(["--plugin", str(Path(args.plugin).resolve()),
"--plugin-opts", f"{mode};marker={marker}"])
if direction.startswith("C client"):
commands[0] = [rust_server, "-s", f"127.0.0.1:{server_port}", "-k", psk, "-m", method, "-U"]
elif direction.startswith("Rust client"):
commands[1] = [rust_local, "-b", f"127.0.0.1:{local_port}", "-s", f"127.0.0.1:{server_port}", "-k", psk, "-m", method, "-U"]
try:
with peers(commands, [server_port, local_port], environment):
for marker in markers:
deadline = time.monotonic() + 10
while not marker.exists() and time.monotonic() < deadline:
time.sleep(.03)
assert marker.exists(), "plugin did not become ready"
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
cases = [pool.submit(tcp_case, local_port, tcp.server_address[1], size)
for size in (1, 65536, 1048576)]
for case in cases:
case.result(timeout=30)
udp_case(local_port, udp.server_address[1])
for marker in markers:
pid = int(marker.read_text())
try:
os.kill(pid, 0)
except ProcessLookupError:
pass
else:
raise AssertionError(f"plugin child {pid} survived parent shutdown")
print("PASS TCP (3 concurrent streams) + UDP: " + label, flush=True)
except (OSError, AssertionError, TimeoutError) as error:
failures += 1
print(f"FAIL {label}: {error}", flush=True)
finally:
if plugin_temp is not None:
plugin_temp.cleanup()
finally:
tcp.shutdown()
udp.shutdown()
return int(failures != 0)
if __name__ == "__main__":
raise SystemExit(main())