#!/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 a local Trojan TLS server, starts a Burrow daemon against a temporary database/socket, imports a temporary ProxySubscription pointed at that server, then runs the packet-level proxy-tcp-probe through daemon DNS and TCP 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 [[ ! -x "$burrow_bin" ]]; then (cd "$repo_root" && cargo build -p burrow) fi if ! command -v openssl >/dev/null 2>&1; then echo "openssl is required for the local TLS server certificate" >&2 exit 2 fi tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-proxy-selftest.XXXXXX")" daemon_pid="" server_pid="" status=0 cleanup() { 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" server_result="$tmpdir/server-result.json" payload="$tmpdir/proxy-payload.json" daemon_log="$tmpdir/daemon.log" server_log="$tmpdir/server.log" openssl req \ -x509 \ -newkey rsa:2048 \ -nodes \ -subj /CN=localhost \ -keyout "$key" \ -out "$cert" \ -days 1 >/dev/null 2>&1 python3 - "$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")" 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