mirror of
https://github.com/shadowsocks/shadowsocks-libev.git
synced 2026-09-25 18:33:56 +00:00
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.
276 lines
12 KiB
Python
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())
|