#!/usr/bin/env bash set -euo pipefail usage() { cat <<'EOF' Usage: Scripts/burrow-proxy-selftest Run a controlled Burrow proxy packet-runtime test without changing system proxy settings or using the user's Burrow database. The script starts local Trojan and Shadowsocks servers, starts a Burrow daemon against a temporary database/socket, imports a temporary ProxySubscription pointed at those servers, then runs the packet-level proxy TCP, UDP, UDP-over-TCP, and plugin probes through daemon packet streaming. EOF } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}" if [[ -z "${BURROW_BIN:-}" ]]; then (cd "$repo_root" && cargo build -p burrow) elif [[ ! -x "$burrow_bin" ]]; then echo "BURROW_BIN is not executable: $burrow_bin" >&2 exit 2 fi if ! command -v openssl >/dev/null 2>&1; then echo "openssl is required for the local TLS server certificate" >&2 exit 2 fi if ! command -v uv >/dev/null 2>&1; then echo "uv is required for the local Python self-test harness" >&2 exit 2 fi tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-proxy-selftest.XXXXXX")" daemon_pid="" server_pid="" ss_server_pid="" status=0 cleanup() { if [[ -n "$ss_server_pid" ]]; then kill "$ss_server_pid" >/dev/null 2>&1 || true fi if [[ -n "$server_pid" ]]; then kill "$server_pid" >/dev/null 2>&1 || true fi if [[ -n "$daemon_pid" ]]; then kill "$daemon_pid" >/dev/null 2>&1 || true fi if [[ "${BURROW_SELFTEST_KEEP:-}" == "1" || "$status" -ne 0 ]]; then echo "preserving self-test artifacts: $tmpdir" >&2 else rm -rf "$tmpdir" fi } trap 'status=$?; cleanup' EXIT cert="$tmpdir/cert.pem" key="$tmpdir/key.pem" socket="$tmpdir/burrow.sock" port_file="$tmpdir/server.port" ss_port_file="$tmpdir/shadowsocks-server.port" server_result="$tmpdir/server-result.json" ss_server_result="$tmpdir/shadowsocks-server-result.json" payload="$tmpdir/proxy-payload.json" daemon_log="$tmpdir/daemon.log" server_log="$tmpdir/server.log" ss_server_log="$tmpdir/shadowsocks-server.log" openssl req \ -x509 \ -newkey rsa:2048 \ -nodes \ -subj /CN=localhost \ -keyout "$key" \ -out "$cert" \ -days 1 >/dev/null 2>&1 uv run python - "$port_file" "$server_result" "$cert" "$key" >"$server_log" 2>&1 <<'PY' & from __future__ import annotations import hashlib import json import socket import ssl import struct import sys port_file, result_file, cert_file, key_file = sys.argv[1:] password = "secret" def read_exact(sock: ssl.SSLSocket, length: int) -> bytes: chunks: list[bytes] = [] remaining = length while remaining: chunk = sock.recv(remaining) if not chunk: raise EOFError(f"expected {remaining} more bytes") chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def read_socks_addr(sock: ssl.SSLSocket) -> dict[str, object]: atyp = read_exact(sock, 1)[0] if atyp == 1: host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4)) elif atyp == 4: host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16)) elif atyp == 3: length = read_exact(sock, 1)[0] host = read_exact(sock, length).decode("idna") else: raise ValueError(f"unsupported SOCKS address type {atyp}") port = struct.unpack("!H", read_exact(sock, 2))[0] return {"atyp": atyp, "host": host, "port": port} def read_http_headers(sock: ssl.SSLSocket) -> bytes: data = bytearray() while b"\r\n\r\n" not in data: chunk = sock.recv(4096) if not chunk: break data.extend(chunk) if len(data) > 65535: raise ValueError("HTTP request headers exceeded 64 KiB") return bytes(data) ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ctx.load_cert_chain(cert_file, key_file) ctx.set_alpn_protocols(["h2", "http/1.1"]) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", 0)) listener.listen(1) port = listener.getsockname()[1] with open(port_file, "w", encoding="utf-8") as f: f.write(str(port)) raw, peer = listener.accept() raw.settimeout(10) result: dict[str, object] = {"peer": str(peer)} try: with ctx.wrap_socket(raw, server_side=True) as tls: tls.settimeout(10) result["alpn"] = tls.selected_alpn_protocol() got_hash = read_exact(tls, 56) expected_hash = hashlib.sha224(password.encode()).hexdigest().encode() result["password_hash_ok"] = got_hash == expected_hash if not result["password_hash_ok"]: raise ValueError("unexpected Trojan password hash") if read_exact(tls, 2) != b"\r\n": raise ValueError("missing Trojan password delimiter") command = read_exact(tls, 1)[0] result["command"] = command target = read_socks_addr(tls) result["target"] = target if read_exact(tls, 2) != b"\r\n": raise ValueError("missing Trojan header delimiter") request = read_http_headers(tls) result["request_first_line"] = request.splitlines()[0].decode("utf-8", "replace") body = json.dumps( { "ok": True, "target": target, "request_first_line": result["request_first_line"], }, sort_keys=True, ).encode() tls.sendall( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) finally: with open(result_file, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) listener.close() PY server_pid=$! for _ in {1..100}; do if [[ -s "$port_file" ]]; then break fi if ! kill -0 "$server_pid" >/dev/null 2>&1; then echo "local Trojan server exited before listening" >&2 cat "$server_log" >&2 || true exit 1 fi sleep 0.05 done if [[ ! -s "$port_file" ]]; then echo "timed out waiting for local Trojan server" >&2 cat "$server_log" >&2 || true exit 1 fi server_port="$(cat "$port_file")" uv run python - "$ss_port_file" "$ss_server_result" >"$ss_server_log" 2>&1 <<'PY' & from __future__ import annotations import json import base64 import socket import struct import sys port_file, result_file = sys.argv[1:] def read_exact(sock: socket.socket, length: int) -> bytes: chunks: list[bytes] = [] remaining = length while remaining: chunk = sock.recv(remaining) if not chunk: raise EOFError(f"expected {remaining} more bytes") chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def read_socks_addr(sock: socket.socket) -> dict[str, object]: atyp = read_exact(sock, 1)[0] if atyp == 1: host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4)) elif atyp == 4: host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16)) elif atyp == 3: length = read_exact(sock, 1)[0] host = read_exact(sock, length).decode("idna") else: raise ValueError(f"unsupported SOCKS address type {atyp}") port = struct.unpack("!H", read_exact(sock, 2))[0] return {"atyp": atyp, "host": host, "port": port} def parse_socks_addr_packet(data: bytes) -> tuple[dict[str, object], int]: offset = 0 atyp = data[offset] offset += 1 if atyp == 1: host = socket.inet_ntop(socket.AF_INET, data[offset : offset + 4]) offset += 4 elif atyp == 4: host = socket.inet_ntop(socket.AF_INET6, data[offset : offset + 16]) offset += 16 elif atyp == 3: length = data[offset] offset += 1 host = data[offset : offset + length].decode("idna") offset += length else: raise ValueError(f"unsupported SOCKS address type {atyp}") port = struct.unpack("!H", data[offset : offset + 2])[0] offset += 2 return {"atyp": atyp, "host": host, "port": port}, offset def read_uot_addr(sock: socket.socket) -> tuple[dict[str, object], bytes]: raw = bytearray() atyp = read_exact(sock, 1)[0] raw.append(atyp) if atyp == 0: addr = read_exact(sock, 4) raw.extend(addr) host = socket.inet_ntop(socket.AF_INET, addr) elif atyp == 1: addr = read_exact(sock, 16) raw.extend(addr) host = socket.inet_ntop(socket.AF_INET6, addr) elif atyp == 2: length = read_exact(sock, 1)[0] raw.append(length) addr = read_exact(sock, length) raw.extend(addr) host = addr.decode("idna") else: raise ValueError(f"unsupported UDP-over-TCP address type {atyp}") port_bytes = read_exact(sock, 2) raw.extend(port_bytes) port = struct.unpack("!H", port_bytes)[0] return {"atyp": atyp, "host": host, "port": port}, bytes(raw) def read_uot_frame(sock: socket.socket) -> tuple[dict[str, object], bytes, bytes]: target, target_bytes = read_uot_addr(sock) length_bytes = read_exact(sock, 2) payload_len = struct.unpack("!H", length_bytes)[0] payload = read_exact(sock, payload_len) return target, target_bytes + length_bytes + payload, payload def read_uot_request(sock: socket.socket) -> dict[str, object]: version = read_exact(sock, 1)[0] target = read_socks_addr(sock) return {"version": version, "target": target} def handle_uot_connection( listener: socket.socket, result: dict[str, object], prefix: str, expect_request: bool, ) -> None: uot_raw, uot_peer = listener.accept() uot_raw.settimeout(10) try: uot_magic = read_socks_addr(uot_raw) if expect_request: result[f"{prefix}_request"] = read_uot_request(uot_raw) uot_target, uot_frame, uot_payload = read_uot_frame(uot_raw) result[f"{prefix}_peer"] = str(uot_peer) result[f"{prefix}_magic_target"] = uot_magic result[f"{prefix}_target"] = uot_target result[f"{prefix}_payload"] = uot_payload.decode("utf-8", "replace") uot_raw.sendall(uot_frame) finally: uot_raw.close() def read_http_headers(sock: socket.socket) -> bytes: return read_http_headers_with_prefix(sock, b"") def read_http_headers_with_prefix(sock: socket.socket, prefix: bytes) -> bytes: data = bytearray(prefix) while b"\r\n\r\n" not in data: chunk = sock.recv(4096) if not chunk: break data.extend(chunk) if len(data) > 65535: raise ValueError("HTTP request headers exceeded 64 KiB") return bytes(data) def read_obfs_http_headers(sock: socket.socket) -> tuple[bytes, bytes]: data = bytearray() while b"\r\n\r\n" not in data: chunk = sock.recv(4096) if not chunk: break data.extend(chunk) if len(data) > 65535: raise ValueError("simple-obfs HTTP headers exceeded 64 KiB") header_end = bytes(data).find(b"\r\n\r\n") if header_end < 0: raise ValueError("simple-obfs HTTP headers were incomplete") header_end += 4 return bytes(data[:header_end]), bytes(data[header_end:]) def read_simple_obfs_tls_payload(sock: socket.socket) -> tuple[bytes, str | None]: header = read_exact(sock, 5) if header[0] != 22: raise ValueError(f"expected TLS handshake record, got type {header[0]}") record_len = struct.unpack("!H", header[3:5])[0] record = read_exact(sock, record_len) if len(record) < 4 or record[0] != 1: raise ValueError("expected TLS ClientHello handshake") body_len = int.from_bytes(record[1:4], "big") body = record[4 : 4 + body_len] offset = 0 offset += 2 # version offset += 32 # random session_id_len = body[offset] offset += 1 + session_id_len cipher_len = struct.unpack("!H", body[offset : offset + 2])[0] offset += 2 + cipher_len compression_len = body[offset] offset += 1 + compression_len extension_len = struct.unpack("!H", body[offset : offset + 2])[0] offset += 2 end = offset + extension_len ticket_payload: bytes | None = None server_name: str | None = None while offset + 4 <= end: extension_type = struct.unpack("!H", body[offset : offset + 2])[0] length = struct.unpack("!H", body[offset + 2 : offset + 4])[0] offset += 4 extension = body[offset : offset + length] offset += length if extension_type == 0x0023: ticket_payload = extension elif extension_type == 0x0000 and len(extension) >= 5: name_len = struct.unpack("!H", extension[3:5])[0] if len(extension) >= 5 + name_len: server_name = extension[5 : 5 + name_len].decode("idna") if ticket_payload is None: raise ValueError("simple-obfs TLS ClientHello did not include payload extension") return ticket_payload, server_name def read_simple_obfs_tls_application_payload(sock: socket.socket) -> bytes: header = read_exact(sock, 5) if header[:3] != b"\x17\x03\x03": raise ValueError(f"expected TLS application-data record, got {header.hex()}") record_len = struct.unpack("!H", header[3:5])[0] return read_exact(sock, record_len) def read_simple_obfs_tls_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: data = bytearray(prefix) while b"\r\n\r\n" not in data: data.extend(read_simple_obfs_tls_application_payload(sock)) if len(data) > 65535: raise ValueError("simple-obfs TLS plaintext headers exceeded 64 KiB") return bytes(data) def read_client_websocket_frame(sock: socket.socket) -> bytes: header = read_exact(sock, 2) if header[0] & 0x0F != 2: raise ValueError(f"expected binary websocket frame, got opcode {header[0] & 0x0F}") if header[1] & 0x80 == 0: raise ValueError("client websocket frame was not masked") length = header[1] & 0x7F if length == 126: length = struct.unpack("!H", read_exact(sock, 2))[0] elif length == 127: length = struct.unpack("!Q", read_exact(sock, 8))[0] mask = read_exact(sock, 4) payload = bytearray(read_exact(sock, length)) for index, byte in enumerate(payload): payload[index] = byte ^ mask[index % 4] return bytes(payload) def server_websocket_frame(payload: bytes) -> bytes: if len(payload) < 126: return b"\x82" + bytes([len(payload)]) + payload if len(payload) <= 65535: return b"\x82\x7e" + struct.pack("!H", len(payload)) + payload return b"\x82\x7f" + struct.pack("!Q", len(payload)) + payload def read_websocket_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: data = bytearray(prefix) while b"\r\n\r\n" not in data: data.extend(read_client_websocket_frame(sock)) if len(data) > 65535: raise ValueError("websocket plaintext headers exceeded 64 KiB") return bytes(data) def http_header_value(headers: bytes, name: bytes) -> str | None: name = name.lower() + b":" for line in headers.splitlines(): if line.lower().startswith(name): return line.decode("utf-8", "replace").split(":", 1)[1].strip() return None def decode_websocket_early_data(value: str | None) -> bytes: if not value: raise ValueError("missing websocket early-data protocol header") padding = "=" * (-len(value) % 4) return base64.urlsafe_b64decode((value + padding).encode()) def parse_v2ray_mux_client_payload(payload: bytes) -> bytes: open_len = struct.unpack("!H", payload[:2])[0] open_frame = payload[2 : 2 + open_len] if len(open_frame) != open_len or open_frame[:4] != b"\x00\x00\x01\x00": raise ValueError("v2ray mux open frame was invalid") offset = 2 + open_len data_header = payload[offset : offset + 8] if len(data_header) != 8 or data_header[:6] != b"\x00\x04\x00\x00\x02\x01": raise ValueError("v2ray mux data frame header was invalid") data_len = struct.unpack("!H", data_header[6:8])[0] data_start = offset + 8 data = payload[data_start : data_start + data_len] if len(data) != data_len: raise ValueError("v2ray mux data frame payload was incomplete") return data def v2ray_mux_server_data_frame(payload: bytes) -> bytes: if len(payload) > 65535: raise ValueError("v2ray mux payload is too large") return b"\x00\x04\x00\x00\x02\x01" + struct.pack("!H", len(payload)) + payload def parse_v2ray_mux_data_payload(payload: bytes) -> bytes: if len(payload) < 8 or payload[:6] != b"\x00\x04\x00\x00\x02\x01": raise ValueError("v2ray mux data frame header was invalid") data_len = struct.unpack("!H", payload[6:8])[0] data = payload[8 : 8 + data_len] if len(data) != data_len: raise ValueError("v2ray mux data frame payload was incomplete") return data def read_v2ray_mux_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: data = bytearray(prefix) while b"\r\n\r\n" not in data: data.extend(parse_v2ray_mux_data_payload(read_client_websocket_frame(sock))) if len(data) > 65535: raise ValueError("v2ray mux plaintext headers exceeded 64 KiB") return bytes(data) listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listener.bind(("127.0.0.1", 0)) listener.listen(16) port = listener.getsockname()[1] udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) udp.bind(("127.0.0.1", port)) udp.settimeout(10) with open(port_file, "w", encoding="utf-8") as f: f.write(str(port)) raw, peer = listener.accept() raw.settimeout(10) result: dict[str, object] = {"tcp_peer": str(peer)} try: target = read_socks_addr(raw) result["tcp_target"] = target request = read_http_headers(raw) result["tcp_request_first_line"] = request.splitlines()[0].decode("utf-8", "replace") body = json.dumps( { "ok": True, "target": target, "request_first_line": result["tcp_request_first_line"], }, sort_keys=True, ).encode() raw.sendall( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) datagram, udp_peer = udp.recvfrom(65535) udp_target, used = parse_socks_addr_packet(datagram) udp_payload = datagram[used:] result["udp_peer"] = str(udp_peer) result["udp_target"] = udp_target result["udp_payload"] = udp_payload.decode("utf-8", "replace") udp.sendto(datagram[:used] + udp_payload, udp_peer) handle_uot_connection(listener, result, "uot", False) handle_uot_connection(listener, result, "uot_v2", True) obfs_raw, obfs_peer = listener.accept() obfs_raw.settimeout(10) try: obfs_headers, obfs_payload = read_obfs_http_headers(obfs_raw) obfs_target, used = parse_socks_addr_packet(obfs_payload) result["obfs_http_peer"] = str(obfs_peer) result["obfs_http_first_line"] = obfs_headers.splitlines()[0].decode( "utf-8", "replace", ) result["obfs_http_host"] = next( ( line.decode("utf-8", "replace").split(":", 1)[1].strip() for line in obfs_headers.splitlines() if line.lower().startswith(b"host:") ), None, ) result["obfs_http_content_length"] = next( ( line.decode("utf-8", "replace").split(":", 1)[1].strip() for line in obfs_headers.splitlines() if line.lower().startswith(b"content-length:") ), None, ) result["obfs_http_target"] = obfs_target obfs_request = read_http_headers_with_prefix(obfs_raw, obfs_payload[used:]) result["obfs_http_request_first_line"] = obfs_request.splitlines()[0].decode( "utf-8", "replace", ) body = json.dumps( { "ok": True, "target": obfs_target, "request_first_line": result["obfs_http_request_first_line"], }, sort_keys=True, ).encode() obfs_raw.sendall( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) finally: obfs_raw.close() obfs_tls_raw, obfs_tls_peer = listener.accept() obfs_tls_raw.settimeout(10) try: obfs_tls_payload, obfs_tls_server_name = read_simple_obfs_tls_payload(obfs_tls_raw) obfs_tls_target, used = parse_socks_addr_packet(obfs_tls_payload) obfs_tls_request = read_simple_obfs_tls_plaintext_headers( obfs_tls_raw, obfs_tls_payload[used:], ) result["obfs_tls_peer"] = str(obfs_tls_peer) result["obfs_tls_server_name"] = obfs_tls_server_name result["obfs_tls_target"] = obfs_tls_target result["obfs_tls_request_first_line"] = obfs_tls_request.splitlines()[0].decode( "utf-8", "replace", ) body = json.dumps( { "ok": True, "target": obfs_tls_target, "request_first_line": result["obfs_tls_request_first_line"], }, sort_keys=True, ).encode() response = ( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) obfs_tls_raw.sendall(bytes(105) + len(response).to_bytes(2, "big") + response) finally: obfs_tls_raw.close() v2ray_raw, v2ray_peer = listener.accept() v2ray_raw.settimeout(10) try: v2ray_headers = read_http_headers(v2ray_raw) result["v2ray_http_upgrade_peer"] = str(v2ray_peer) result["v2ray_http_upgrade_first_line"] = v2ray_headers.splitlines()[0].decode( "utf-8", "replace", ) result["v2ray_http_upgrade_host"] = next( ( line.decode("utf-8", "replace").split(":", 1)[1].strip() for line in v2ray_headers.splitlines() if line.lower().startswith(b"host:") ), None, ) result["v2ray_http_upgrade_has_websocket_key"] = any( line.lower().startswith(b"sec-websocket-key:") for line in v2ray_headers.splitlines() ) v2ray_raw.sendall( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" ) v2ray_target = read_socks_addr(v2ray_raw) v2ray_request = read_http_headers(v2ray_raw) result["v2ray_http_upgrade_target"] = v2ray_target result["v2ray_http_upgrade_request_first_line"] = ( v2ray_request.splitlines()[0].decode( "utf-8", "replace", ) ) body = json.dumps( { "ok": True, "target": v2ray_target, "request_first_line": result["v2ray_http_upgrade_request_first_line"], }, sort_keys=True, ).encode() v2ray_raw.sendall( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) finally: v2ray_raw.close() v2ray_ws_raw, v2ray_ws_peer = listener.accept() v2ray_ws_raw.settimeout(10) try: v2ray_ws_headers = read_http_headers(v2ray_ws_raw) result["v2ray_websocket_peer"] = str(v2ray_ws_peer) result["v2ray_websocket_first_line"] = ( v2ray_ws_headers.splitlines()[0].decode( "utf-8", "replace", ) ) result["v2ray_websocket_host"] = next( ( line.decode("utf-8", "replace").split(":", 1)[1].strip() for line in v2ray_ws_headers.splitlines() if line.lower().startswith(b"host:") ), None, ) result["v2ray_websocket_has_websocket_key"] = any( line.lower().startswith(b"sec-websocket-key:") for line in v2ray_ws_headers.splitlines() ) v2ray_ws_raw.sendall( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" ) v2ray_ws_payload = read_client_websocket_frame(v2ray_ws_raw) v2ray_ws_target, used = parse_socks_addr_packet(v2ray_ws_payload) v2ray_ws_request = read_websocket_plaintext_headers( v2ray_ws_raw, v2ray_ws_payload[used:], ) result["v2ray_websocket_target"] = v2ray_ws_target result["v2ray_websocket_request_first_line"] = ( v2ray_ws_request.splitlines()[0].decode( "utf-8", "replace", ) ) body = json.dumps( { "ok": True, "target": v2ray_ws_target, "request_first_line": result["v2ray_websocket_request_first_line"], }, sort_keys=True, ).encode() response = ( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) v2ray_ws_raw.sendall(server_websocket_frame(response)) finally: v2ray_ws_raw.close() v2ray_mux_raw, v2ray_mux_peer = listener.accept() v2ray_mux_raw.settimeout(10) try: v2ray_mux_headers = read_http_headers(v2ray_mux_raw) result["v2ray_mux_peer"] = str(v2ray_mux_peer) result["v2ray_mux_first_line"] = ( v2ray_mux_headers.splitlines()[0].decode( "utf-8", "replace", ) ) result["v2ray_mux_host"] = next( ( line.decode("utf-8", "replace").split(":", 1)[1].strip() for line in v2ray_mux_headers.splitlines() if line.lower().startswith(b"host:") ), None, ) result["v2ray_mux_has_websocket_key"] = any( line.lower().startswith(b"sec-websocket-key:") for line in v2ray_mux_headers.splitlines() ) v2ray_mux_raw.sendall( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" ) v2ray_mux_payload = parse_v2ray_mux_client_payload( read_client_websocket_frame(v2ray_mux_raw), ) v2ray_mux_target, used = parse_socks_addr_packet(v2ray_mux_payload) v2ray_mux_request = read_v2ray_mux_plaintext_headers( v2ray_mux_raw, v2ray_mux_payload[used:], ) result["v2ray_mux_target"] = v2ray_mux_target result["v2ray_mux_request_first_line"] = ( v2ray_mux_request.splitlines()[0].decode( "utf-8", "replace", ) ) body = json.dumps( { "ok": True, "target": v2ray_mux_target, "request_first_line": result["v2ray_mux_request_first_line"], }, sort_keys=True, ).encode() response = ( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) v2ray_mux_raw.sendall( server_websocket_frame(v2ray_mux_server_data_frame(response)), ) finally: v2ray_mux_raw.close() gost_ws_raw, gost_ws_peer = listener.accept() gost_ws_raw.settimeout(10) try: gost_ws_headers = read_http_headers(gost_ws_raw) result["gost_websocket_peer"] = str(gost_ws_peer) result["gost_websocket_first_line"] = ( gost_ws_headers.splitlines()[0].decode( "utf-8", "replace", ) ) result["gost_websocket_host"] = next( ( line.decode("utf-8", "replace").split(":", 1)[1].strip() for line in gost_ws_headers.splitlines() if line.lower().startswith(b"host:") ), None, ) result["gost_websocket_has_websocket_key"] = any( line.lower().startswith(b"sec-websocket-key:") for line in gost_ws_headers.splitlines() ) gost_ws_raw.sendall( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" ) gost_ws_payload = read_client_websocket_frame(gost_ws_raw) gost_ws_target, used = parse_socks_addr_packet(gost_ws_payload) gost_ws_request = read_websocket_plaintext_headers( gost_ws_raw, gost_ws_payload[used:], ) result["gost_websocket_target"] = gost_ws_target result["gost_websocket_request_first_line"] = ( gost_ws_request.splitlines()[0].decode( "utf-8", "replace", ) ) body = json.dumps( { "ok": True, "target": gost_ws_target, "request_first_line": result["gost_websocket_request_first_line"], }, sort_keys=True, ).encode() response = ( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) gost_ws_raw.sendall(server_websocket_frame(response)) finally: gost_ws_raw.close() v2ray_ed_raw, v2ray_ed_peer = listener.accept() v2ray_ed_raw.settimeout(10) try: v2ray_ed_headers = read_http_headers(v2ray_ed_raw) result["v2ray_early_data_peer"] = str(v2ray_ed_peer) result["v2ray_early_data_first_line"] = ( v2ray_ed_headers.splitlines()[0].decode( "utf-8", "replace", ) ) result["v2ray_early_data_host"] = http_header_value(v2ray_ed_headers, b"host") result["v2ray_early_data_has_websocket_key"] = ( http_header_value(v2ray_ed_headers, b"sec-websocket-key") is not None ) early_data = decode_websocket_early_data( http_header_value(v2ray_ed_headers, b"sec-websocket-protocol"), ) result["v2ray_early_data_len"] = len(early_data) v2ray_ed_raw.sendall( b"HTTP/1.1 101 Switching Protocols\r\n" b"Upgrade: websocket\r\n" b"Connection: Upgrade\r\n\r\n" ) v2ray_ed_payload = early_data + read_client_websocket_frame(v2ray_ed_raw) v2ray_ed_target, used = parse_socks_addr_packet(v2ray_ed_payload) v2ray_ed_request = read_websocket_plaintext_headers( v2ray_ed_raw, v2ray_ed_payload[used:], ) result["v2ray_early_data_target"] = v2ray_ed_target result["v2ray_early_data_request_first_line"] = ( v2ray_ed_request.splitlines()[0].decode( "utf-8", "replace", ) ) body = json.dumps( { "ok": True, "target": v2ray_ed_target, "request_first_line": result["v2ray_early_data_request_first_line"], }, sort_keys=True, ).encode() response = ( b"HTTP/1.1 200 OK\r\n" + f"Content-Length: {len(body)}\r\n".encode() + b"Connection: close\r\n" + b"Content-Type: application/json\r\n\r\n" + body ) v2ray_ed_raw.sendall(server_websocket_frame(response)) finally: v2ray_ed_raw.close() finally: raw.close() udp.close() with open(result_file, "w", encoding="utf-8") as f: json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) listener.close() PY ss_server_pid=$! for _ in {1..100}; do if [[ -s "$ss_port_file" ]]; then break fi if ! kill -0 "$ss_server_pid" >/dev/null 2>&1; then echo "local Shadowsocks server exited before listening" >&2 cat "$ss_server_log" >&2 || true exit 1 fi sleep 0.05 done if [[ ! -s "$ss_port_file" ]]; then echo "timed out waiting for local Shadowsocks server" >&2 cat "$ss_server_log" >&2 || true exit 1 fi ss_server_port="$(cat "$ss_port_file")" cat >"$payload" <"$daemon_log" 2>&1 ) & daemon_pid=$! for _ in {1..100}; do if [[ -S "$socket" ]]; then break fi if ! kill -0 "$daemon_pid" >/dev/null 2>&1; then echo "Burrow daemon exited before creating socket" >&2 cat "$daemon_log" >&2 || true exit 1 fi sleep 0.05 done if [[ ! -S "$socket" ]]; then echo "timed out waiting for Burrow daemon socket" >&2 cat "$daemon_log" >&2 || true exit 1 fi ( cd "$tmpdir" BURROW_SOCKET_PATH="$socket" "$burrow_bin" network-add 1 3 "$payload" BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 ) wait "$server_pid" server_pid="" echo echo "Local Trojan server observed:" cat "$server_result" echo ( cd "$tmpdir" BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 2 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ --message burrow-shadowsocks-udp-selftest \ --timeout-ms 8000 \ 9.9.9.9:5353 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 3 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ --message burrow-shadowsocks-uot-selftest \ --timeout-ms 8000 \ 9.9.9.9:5354 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 4 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ --message burrow-shadowsocks-uot-v2-selftest \ --timeout-ms 8000 \ 9.9.9.9:5355 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 5 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 6 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 7 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 8 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 9 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 10 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 11 BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ --dns-name selftest.invalid \ --remote 1.1.1.1:80 \ --timeout-ms 8000 ) wait "$ss_server_pid" ss_server_pid="" echo echo "Local Shadowsocks server observed:" cat "$ss_server_result" echo uv run python - "$server_result" "$ss_server_result" <<'PY' from __future__ import annotations import json import sys trojan_path, shadowsocks_path = sys.argv[1:] def load(path: str) -> dict[str, object]: with open(path, "r", encoding="utf-8") as f: return json.load(f) def expect(value: object, expected: object, label: str) -> None: if value != expected: raise AssertionError(f"{label}: expected {expected!r}, got {value!r}") def expect_startswith(value: object, prefix: str, label: str) -> None: if not isinstance(value, str) or not value.startswith(prefix): raise AssertionError(f"{label}: expected prefix {prefix!r}, got {value!r}") def expect_target(result: dict[str, object], key: str, host: str, port: int) -> None: target = result.get(key) if not isinstance(target, dict): raise AssertionError(f"{key}: missing target object") expect(target.get("host"), host, f"{key}.host") expect(target.get("port"), port, f"{key}.port") trojan = load(trojan_path) expect(trojan.get("password_hash_ok"), True, "trojan password hash") expect(trojan.get("command"), 1, "trojan tcp command") expect(trojan.get("request_first_line"), "GET / HTTP/1.1", "trojan request") expect_target(trojan, "target", "selftest.invalid", 80) ss = load(shadowsocks_path) for key in [ "tcp_target", "obfs_http_target", "obfs_tls_target", "v2ray_http_upgrade_target", "v2ray_websocket_target", "v2ray_mux_target", "gost_websocket_target", "v2ray_early_data_target", ]: expect_target(ss, key, "selftest.invalid", 80) expect(ss.get("udp_payload"), "burrow-shadowsocks-udp-selftest", "native UDP payload") expect_target(ss, "udp_target", "9.9.9.9", 5353) expect(ss.get("uot_payload"), "burrow-shadowsocks-uot-selftest", "legacy UOT payload") expect_target(ss, "uot_magic_target", "sp.udp-over-tcp.arpa", 0) expect_target(ss, "uot_target", "9.9.9.9", 5354) expect(ss.get("uot_v2_payload"), "burrow-shadowsocks-uot-v2-selftest", "v2 UOT payload") expect_target(ss, "uot_v2_magic_target", "sp.v2.udp-over-tcp.arpa", 0) expect_target(ss, "uot_v2_target", "9.9.9.9", 5355) expect_startswith(ss.get("obfs_http_host"), "front.example:", "simple-obfs HTTP host") expect(ss.get("obfs_tls_server_name"), "front.example", "simple-obfs TLS SNI") expect(ss.get("v2ray_http_upgrade_has_websocket_key"), False, "v2ray raw upgrade key") expect(ss.get("v2ray_websocket_has_websocket_key"), True, "v2ray websocket key") expect(ss.get("v2ray_mux_has_websocket_key"), True, "v2ray mux websocket key") expect(ss.get("gost_websocket_has_websocket_key"), True, "gost websocket key") expect(ss.get("v2ray_early_data_first_line"), "GET /ed?trace=1 HTTP/1.1", "early-data path") expect(ss.get("v2ray_early_data_len"), 8, "early-data length") print("Proxy self-test assertions passed") PY