burrow/Scripts/burrow-proxy-selftest
2026-06-05 10:33:41 -07:00

1417 lines
46 KiB
Bash
Executable file

#!/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" <<EOF
{
"name": "burrow proxy selftest",
"source": { "url": "selftest://local-proxies" },
"detected_format": "uri-list",
"selected_ordinal": 1,
"selected_name": "local trojan",
"nodes": [
{
"ordinal": 1,
"name": "local trojan",
"protocol": "trojan",
"server": "127.0.0.1",
"port": $server_port,
"warnings": [],
"config": {
"type": "trojan",
"password": "secret",
"sni": "localhost",
"alpn": [],
"alpn_present": true,
"skip_cert_verify": true,
"network": "tcp",
"udp": true,
"client_fingerprint": null,
"fingerprint": null
}
},
{
"ordinal": 2,
"name": "local shadowsocks none",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": null
}
},
{
"ordinal": 3,
"name": "local shadowsocks none uot",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": true,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": null
}
},
{
"ordinal": 4,
"name": "local shadowsocks none uot v2",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": true,
"udp_over_tcp_version": 2,
"client_fingerprint": null,
"plugin": null
}
},
{
"ordinal": 5,
"name": "local shadowsocks none obfs http",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "obfs",
"opts": {
"mode": "http",
"host": "front.example"
}
}
}
},
{
"ordinal": 6,
"name": "local shadowsocks none obfs tls",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "obfs",
"opts": {
"mode": "tls",
"host": "front.example"
}
}
}
},
{
"ordinal": 7,
"name": "local shadowsocks none v2ray http upgrade",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "v2ray-plugin",
"opts": {
"mode": "websocket",
"host": "front.example",
"path": "/upgrade",
"mux": "false",
"v2ray-http-upgrade": "true"
}
}
}
},
{
"ordinal": 8,
"name": "local shadowsocks none v2ray websocket",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "v2ray-plugin",
"opts": {
"mode": "websocket",
"host": "front.example",
"path": "/ws",
"mux": "false"
}
}
}
},
{
"ordinal": 9,
"name": "local shadowsocks none v2ray mux",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "v2ray-plugin",
"opts": {
"mode": "websocket",
"host": "front.example",
"path": "/mux"
}
}
}
},
{
"ordinal": 10,
"name": "local shadowsocks none gost websocket",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "gost-plugin",
"opts": {
"mode": "websocket",
"host": "front.example",
"path": "/gost",
"mux": "false"
}
}
}
},
{
"ordinal": 11,
"name": "local shadowsocks none v2ray early data",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $ss_server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": true,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": {
"name": "v2ray-plugin",
"opts": {
"mode": "websocket",
"host": "front.example",
"path": "/ed?ed=8&trace=1",
"mux": "false"
}
}
}
}
],
"warnings": []
}
EOF
(
cd "$tmpdir"
BURROW_SOCKET_PATH="$socket" "$burrow_bin" daemon >"$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