Add proxy subscription runtime support

Add daemon RPCs, Apple and GTK import flows, packet proxy runtime support, diagnostics, and BEPs for proxy subscription handling.

Redact subscription URL secrets from fetch errors before they reach logs or UI surfaces.
This commit is contained in:
JettChenT 2026-06-01 15:23:17 +08:00
parent 97c569fb35
commit d1638726ca
46 changed files with 15079 additions and 456 deletions

434
Scripts/burrow-doctor Executable file
View file

@ -0,0 +1,434 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/burrow-doctor [--last 10m]
Print a single stdout diagnostics report for the CURRENT macOS networking state.
Run this while Burrow is the active VPN but routing/websites are broken, then
redirect stdout to a file before switching to a working VPN:
Scripts/burrow-doctor --last 10m > burrow-broken.txt
The report focuses on live VPN/proxy/DNS/route state, Burrow NetworkExtension
state, socket ownership, the selected Burrow node, and recent Burrow logs.
It intentionally does not redact local output, because broken proxy state often
depends on exact subscription and node configuration.
EOF
}
last="10m"
while [[ $# -gt 0 ]]; do
case "$1" in
--last)
[[ $# -ge 2 ]] || { echo "--last requires a value" >&2; exit 2; }
last="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
group_id="${BURROW_APP_GROUP_ID:-2KPBQHRJ2D.com.tianruichen.burrow}"
group_dir="${BURROW_APP_GROUP_DIR:-$HOME/Library/Group Containers/$group_id}"
db_path="${BURROW_DB_PATH:-$group_dir/burrow.db}"
runtime_log="${BURROW_RUNTIME_LOG:-$group_dir/burrow-runtime.log}"
bundle_id="${BURROW_BUNDLE_ID:-com.tianruichen.burrow}"
extension_process="${BURROW_EXTENSION_PROCESS:-BurrowNetworkExtension}"
section() {
printf '\n## %s\n\n' "$1"
}
cmd() {
printf '$'
printf ' %q' "$@"
printf '\n'
}
run() {
local title="$1"
shift
section "$title"
cmd "$@"
printf '\n'
"$@" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?"
}
run_zsh() {
local title="$1"
local script="$2"
section "$title"
printf '$ %s\n\n' "$script"
/bin/zsh -lc "$script" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?"
}
run_python() {
if command -v uv >/dev/null 2>&1; then
uv run python "$@"
elif command -v python3 >/dev/null 2>&1; then
python3 "$@"
else
return 127
fi
}
capture() {
"$@" 2>&1 || true
}
nc_list="$(capture scutil --nc list)"
proxy_state="$(capture scutil --proxy)"
dns_state="$(capture scutil --dns)"
inet_routes="$(capture netstat -rn -f inet)"
proxy_listeners="$(capture /bin/zsh -lc "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN")"
cat <<EOF
# Burrow Doctor
Generated: $(date)
Repo: $repo_root
Log window: $last
App group: $group_id
DB path: $db_path
Runtime log: $runtime_log
Purpose: snapshot CURRENT networking state while Burrow is active but not
routing requests, so this report can be inspected later after switching to a
working VPN.
EOF
section "Quick Findings"
if printf '%s\n' "$nc_list" | grep -Eiq '\(Connected\).+Burrow'; then
echo "- Burrow is currently connected according to scutil."
else
echo "- Burrow is NOT currently connected according to scutil. If this was not captured during the broken active-Burrow window, rerun it while Burrow is active."
fi
other_connected="$(printf '%s\n' "$nc_list" | grep -Ei '\(Connected\)' | grep -Eiv 'Burrow|MT65xx' || true)"
if [[ -n "$other_connected" ]]; then
echo "- Another VPN-like service is connected and may supersede or interfere with Burrow:"
printf '%s\n' "$other_connected" | sed 's/^/ /'
fi
if printf '%s\n' "$proxy_state" | grep -Eq 'HTTPProxy : 127\.0\.0\.1|SOCKSProxy : 127\.0\.0\.1|HTTPSProxy : 127\.0\.0\.1'; then
echo "- System proxy settings point to localhost. See Local Proxy Listeners for the owning process."
fi
if printf '%s\n%s\n' "$dns_state" "$inet_routes" | grep -Eq '198\.18\.|198\.19\.'; then
echo "- Current DNS/routes include 198.18.0.0/15 fake-IP style addresses."
fi
if printf '%s\n' "$inet_routes" | grep -Eq '^default[[:space:]].*utun'; then
echo "- IPv4 default route currently points at a utun interface."
fi
if [[ -n "$proxy_listeners" ]]; then
echo "- Common localhost proxy ports are listening:"
printf '%s\n' "$proxy_listeners" | sed 's/^/ /'
fi
if [[ -f "$runtime_log" ]] && tail -400 "$runtime_log" | grep -q 'panicked'; then
echo "- Burrow runtime log contains recent task panics. See Burrow Runtime Log File."
fi
if [[ -f "$runtime_log" ]] && tail -400 "$runtime_log" | grep -q 'CryptoProvider'; then
echo "- Burrow runtime log mentions rustls CryptoProvider initialization failures."
fi
if printf '%s\n' "$dns_state" | grep -A8 '^resolver #1' | grep -Eq 'if_index[[:space:]]*:.*utun' \
&& [[ -f "$runtime_log" ]] \
&& tail -400 "$runtime_log" | grep -q 'proxy udp session failed'; then
echo "- Primary DNS is on the tunnel while Burrow proxy UDP sessions are failing; name resolution may be blocked before HTTP/TCP requests can start."
fi
if ! printf '%s\n' "$dns_state" | awk '
/^DNS configuration$/ { global = 1; next }
/^DNS configuration \(for scoped queries\)/ { global = 0; next }
global && /nameserver\[[0-9]+\]/ { found = 1 }
END { exit found ? 0 : 1 }
'; then
echo "- No global DNS nameserver is configured. If only scoped physical DNS remains, ordinary hostname lookups may fail under the full-tunnel route."
fi
section "Selected Burrow Network"
if [[ -f "$db_path" ]]; then
run_python - "$db_path" <<'PY'
from __future__ import annotations
import json
import sqlite3
import sys
db_path = sys.argv[1]
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = list(conn.execute("select id,type,idx,payload,interface_id from network order by idx"))
if not rows:
print("No rows in network table.")
raise SystemExit
summaries = []
for row in rows:
payload_blob = row["payload"]
payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
try:
payload = json.loads(payload_text) if payload_text else {}
except Exception as exc:
summaries.append({
"id": row["id"],
"type": row["type"],
"idx": row["idx"],
"payload_parse_error": str(exc),
})
continue
summary = {
"id": row["id"],
"type": row["type"],
"idx": row["idx"],
"interface_id": row["interface_id"],
"name": payload.get("name"),
"selected_ordinal": payload.get("selected_ordinal"),
"selected_name": payload.get("selected_name"),
"source": payload.get("source"),
"node_count": len(payload.get("nodes") or []),
}
selected_node = None
for node in payload.get("nodes") or []:
if node.get("name") == payload.get("selected_name") or node.get("ordinal") == payload.get("selected_ordinal"):
selected_node = node
break
summary["selected_node"] = selected_node
summaries.append(summary)
print(json.dumps(summaries, ensure_ascii=False, indent=2))
PY
else
echo "DB not found: $db_path"
fi
run "VPN Configuration List" scutil --nc list
run "System Proxy State" scutil --proxy
run_zsh "Local Proxy Listeners" "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN || true"
run "DNS State" scutil --dns
run "IPv4 Routes" netstat -rn -f inet
run "IPv6 Routes" netstat -rn -f inet6
run "Interfaces" ifconfig
run_zsh "Burrow And VPN Processes" "ps aux | egrep -i 'Burrow|BurrowNetworkExtension|nesessionmanager|neagent|Surge|Tailscale|Clash|mihomo|sing-box' | egrep -v 'egrep|burrow-doctor'"
run_zsh "Burrow Socket Owners" "lsof -U | egrep 'burrow.*sock|Burrow|BurrowNetworkExtension|NetworkExtension' || true"
run "App Group Directory" ls -la "$group_dir"
section "Selected Proxy Server Connectivity"
if [[ -f "$db_path" ]]; then
run_python - "$db_path" <<'PY'
from __future__ import annotations
import json
import socket
import sqlite3
import subprocess
import sys
import time
db_path = sys.argv[1]
def choose_node(payload: dict) -> dict | None:
nodes = payload.get("nodes") or []
selected_name = payload.get("selected_name")
selected_ordinal = payload.get("selected_ordinal")
for node in nodes:
if selected_name is not None and node.get("name") == selected_name:
return node
if selected_ordinal is not None and node.get("ordinal") == selected_ordinal:
return node
return nodes[0] if nodes else None
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"select payload from network where type = 'ProxySubscription' order by idx limit 1"
).fetchone()
if row is None:
print("No ProxySubscription network in database.")
raise SystemExit
payload_blob = row["payload"]
payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
payload = json.loads(payload_text)
node = choose_node(payload)
if not node:
print("ProxySubscription has no nodes.")
raise SystemExit
server = node.get("server")
port = int(node.get("port"))
print(f"selected_proxy_server={server}:{port}")
addresses = []
try:
for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(server, port, type=socket.SOCK_STREAM):
host, resolved_port = sockaddr[:2]
key = (host, resolved_port)
if key not in addresses:
addresses.append(key)
except Exception as exc:
print(f"resolve_error={type(exc).__name__}: {exc}")
raise SystemExit
print("resolved_addresses=" + ",".join(f"{host}:{resolved_port}" for host, resolved_port in addresses))
for host, _ in addresses:
print(f"\n$ route -n get {host}")
try:
result = subprocess.run(
["route", "-n", "get", host],
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
timeout=5,
)
print(result.stdout.rstrip())
print(f"[exit={result.returncode}]")
except Exception as exc:
print(f"route_probe_error={type(exc).__name__}: {exc}")
for host, resolved_port in addresses:
started = time.monotonic()
try:
with socket.create_connection((host, resolved_port), timeout=5):
elapsed_ms = (time.monotonic() - started) * 1000
print(f"tcp_connect_ok address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f}")
except Exception as exc:
elapsed_ms = (time.monotonic() - started) * 1000
print(f"tcp_connect_error address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}")
PY
else
echo "DB not found: $db_path"
fi
run_zsh "Route Probes" "for host in 1.1.1.1 8.8.8.8 google.com; do echo; echo \"\$ route -n get \$host\"; route -n get \"\$host\" 2>&1 || true; done"
run_zsh "DNS Probes" "if command -v dig >/dev/null 2>&1; then dig +time=3 +tries=1 google.com A; echo; dig +time=3 +tries=1 @1.1.1.1 google.com A; else dscacheutil -q host -a name google.com; fi"
run_zsh "Scoped Physical DNS Probes" "if command -v dig >/dev/null 2>&1; then servers=\$(scutil --dns | awk 'BEGIN { scoped=0; current=\"\" } /^DNS configuration \\(for scoped queries\\)/ { scoped=1; next } scoped && /^resolver #/ { current=\"\"; next } scoped && /nameserver\\[[0-9]+\\]/ { current=\$3 } scoped && /if_index/ && \$0 !~ /utun/ && current != \"\" { print current; current=\"\" }'); if [[ -z \"\$servers\" ]]; then echo 'No scoped non-utun DNS servers found.'; else for server in \$servers; do echo; echo \"\$ dig +time=3 +tries=1 @\$server google.com A\"; dig +time=3 +tries=1 @\"\$server\" google.com A; done; fi; else echo 'dig not found'; fi"
run_zsh "TCP Probes" "for target in '1.1.1.1 53' '1.1.1.1 443' 'google.com 443'; do set -- \$target; echo; echo \"\$ nc -vz -w 5 \$1 \$2\"; nc -vz -w 5 \"\$1\" \"\$2\" 2>&1 || true; done"
run_zsh "HTTP Probes" "for url in 'http://www.gstatic.com/generate_204' 'https://www.google.com/generate_204' 'https://www.cloudflare.com/cdn-cgi/trace'; do echo; echo \"\$ curl -4 -I --connect-timeout 5 --max-time 10 \$url\"; curl -4 -I --connect-timeout 5 --max-time 10 \"\$url\" 2>&1 || true; done"
run_zsh "Burrow Controlled Proxy Self-Test" "if [[ -x '$repo_root/Scripts/burrow-proxy-selftest' ]]; then '$repo_root/Scripts/burrow-proxy-selftest'; else echo 'Scripts/burrow-proxy-selftest not found or not executable'; fi"
run_zsh "Burrow Direct Provider Proxy Ladder" "if [[ -x '$repo_root/Scripts/burrow-proxy-ladder' ]]; then '$repo_root/Scripts/burrow-proxy-ladder' --db '$db_path' --resolve-server doh-all --target google.com:80 --http-host google.com --skip-udp --timeout 8; else echo 'Scripts/burrow-proxy-ladder not found or not executable'; fi"
run_zsh "Burrow Daemon Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-tcp-probe --dns-name google.com --remote 1.1.1.1:80 --timeout-ms 12000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi"
run_zsh "Burrow Daemon HTTPS Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-https-probe --dns-name news.ycombinator.com --remote 1.1.1.1:443 --timeout-ms 20000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi"
section "Proxy Subscription Endpoint Status"
if [[ -f "$db_path" ]]; then
run_python - "$db_path" <<'PY'
from __future__ import annotations
import json
import sqlite3
import sys
import urllib.error
import urllib.request
db_path = sys.argv[1]
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"select payload from network where type = 'ProxySubscription' order by idx limit 1"
).fetchone()
if row is None:
print("No ProxySubscription network in database.")
raise SystemExit
payload_blob = row["payload"]
payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
payload = json.loads(payload_text)
url = (payload.get("source") or {}).get("url")
if not url:
print("ProxySubscription has no source URL.")
raise SystemExit
print("source_url_redacted=yes")
request = urllib.request.Request(url, headers={"User-Agent": "mihomo/1.18.3"})
try:
with urllib.request.urlopen(request, timeout=20) as response:
body = response.read(1024 * 1024)
print(f"status={response.status}")
for key, value in response.headers.items():
lower = key.lower()
if (
lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"}
or "expire" in lower
or "subscription" in lower
):
print(f"header_{key}={value}")
print(f"body_bytes={len(body)}")
except urllib.error.HTTPError as exc:
print(f"status={exc.code}")
print(f"reason={exc.reason}")
for key, value in exc.headers.items():
lower = key.lower()
if (
lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"}
or "expire" in lower
or "subscription" in lower
):
print(f"header_{key}={value}")
body = exc.read(1000).decode("utf-8", errors="replace").strip()
if body:
print(f"body_prefix={body[:200]}")
except Exception as exc:
print(f"fetch_error={type(exc).__name__}: {exc}")
PY
else
echo "DB not found: $db_path"
fi
section "Burrow Runtime Log File"
if [[ -f "$runtime_log" ]]; then
cmd tail -400 "$runtime_log"
printf '\n'
tail -400 "$runtime_log" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?"
else
echo "Runtime log not found: $runtime_log"
fi
section "Recent Burrow NetworkExtension Logs"
cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\""
printf '\n'
{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\"" 2>&1 \
|| printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; }
section "Recent Burrow Extension Logs"
cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\""
printf '\n'
{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\"" 2>&1 \
|| printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; }
section "Recent Burrow Errors"
cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")"
printf '\n'
{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")" 2>&1 \
|| printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; }

576
Scripts/burrow-proxy-ladder Executable file
View file

@ -0,0 +1,576 @@
#!/usr/bin/env python3
"""Probe a Burrow proxy subscription node without enabling system proxying.
This script intentionally bypasses the Burrow daemon, Network Extension,
routes, DNS settings, and browsers. It loads the selected proxy node from the
Burrow SQLite database and speaks the provider protocol directly.
"""
from __future__ import annotations
import argparse
import hashlib
import ipaddress
import json
import os
import socket
import sqlite3
import ssl
import struct
import sys
import time
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import Any
DEFAULT_APP_GROUP = "2KPBQHRJ2D.com.tianruichen.burrow"
DEFAULT_DB = (
Path.home()
/ "Library"
/ "Group Containers"
/ DEFAULT_APP_GROUP
/ "burrow.db"
)
DEFAULT_TARGET = "1.1.1.1:80"
DEFAULT_HTTP_HOST = "one.one.one.one"
DEFAULT_DNS_SERVER = "1.1.1.1:53"
DEFAULT_DNS_NAME = "google.com"
DEFAULT_TIMEOUT = 5.0
DOH_PROVIDERS = {
"doh-google": "https://dns.google/resolve",
"doh-cloudflare": "https://cloudflare-dns.com/dns-query",
}
TROJAN_CMD_CONNECT = 0x01
TROJAN_CMD_UDP_ASSOCIATE = 0x03
@dataclass(frozen=True)
class Endpoint:
host: str
port: int
@dataclass(frozen=True)
class ProbeResult:
ok: bool
detail: str
def parse_endpoint(value: str, default_port: int | None = None) -> Endpoint:
text = value.strip()
if not text:
raise ValueError("endpoint must not be empty")
if text.startswith("["):
end = text.find("]")
if end == -1:
raise ValueError(f"invalid bracketed IPv6 endpoint: {value}")
host = text[1:end]
rest = text[end + 1 :]
if rest.startswith(":"):
port = int(rest[1:])
elif default_port is not None:
port = default_port
else:
raise ValueError(f"endpoint is missing a port: {value}")
return Endpoint(host, port)
if text.count(":") == 1:
host, port_text = text.rsplit(":", 1)
return Endpoint(host, int(port_text))
try:
ipaddress.ip_address(text)
except ValueError:
if ":" in text:
if default_port is None:
raise ValueError(f"IPv6 endpoint is missing a port: {value}")
return Endpoint(text, default_port)
if default_port is None:
raise ValueError(f"endpoint is missing a port: {value}")
return Endpoint(text, default_port)
if default_port is None:
raise ValueError(f"endpoint is missing a port: {value}")
return Endpoint(text, default_port)
def encode_socks_addr(endpoint: Endpoint) -> bytes:
try:
ip = ipaddress.ip_address(endpoint.host)
except ValueError:
host = endpoint.host.encode("idna")
if len(host) > 255:
raise ValueError(f"SOCKS domain is too long: {endpoint.host}")
return bytes([3, len(host)]) + host + struct.pack("!H", endpoint.port)
if ip.version == 4:
return bytes([1]) + ip.packed + struct.pack("!H", endpoint.port)
return bytes([4]) + ip.packed + struct.pack("!H", endpoint.port)
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) -> Endpoint:
atyp = read_exact(sock, 1)[0]
if atyp == 1:
host = str(ipaddress.IPv4Address(read_exact(sock, 4)))
elif atyp == 4:
host = str(ipaddress.IPv6Address(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 in response: {atyp}")
port = struct.unpack("!H", read_exact(sock, 2))[0]
return Endpoint(host, port)
def trojan_password_hash(password: str) -> bytes:
return hashlib.sha224(password.encode()).hexdigest().encode()
def trojan_header(password: str, command: int, target: Endpoint) -> bytes:
return (
trojan_password_hash(password)
+ b"\r\n"
+ bytes([command])
+ encode_socks_addr(target)
+ b"\r\n"
)
def trojan_udp_frame(target: Endpoint, payload: bytes) -> bytes:
return encode_socks_addr(target) + struct.pack("!H", len(payload)) + b"\r\n" + payload
def build_dns_query(name: str) -> tuple[int, bytes]:
query_id = int(time.time() * 1000) & 0xFFFF
labels = [label for label in name.rstrip(".").split(".") if label]
qname = b"".join(bytes([len(label)]) + label.encode("ascii") for label in labels) + b"\x00"
header = struct.pack("!HHHHHH", query_id, 0x0100, 1, 0, 0, 0)
question = qname + struct.pack("!HH", 1, 1)
return query_id, header + question
def parse_dns_response(query_id: int, payload: bytes) -> str:
if len(payload) < 12:
return f"truncated DNS response: {len(payload)} bytes"
response_id, flags, questions, answers, authority, additional = struct.unpack(
"!HHHHHH", payload[:12]
)
rcode = flags & 0x000F
qr = bool(flags & 0x8000)
id_note = "matching id" if response_id == query_id else f"id mismatch {response_id:#06x}"
return (
f"{len(payload)} bytes, qr={qr}, rcode={rcode}, answers={answers}, "
f"authority={authority}, additional={additional}, questions={questions}, {id_note}"
)
def load_payload(db_path: Path, network_id: int | None) -> tuple[int, dict[str, Any]]:
if not db_path.exists():
raise FileNotFoundError(f"Burrow database not found: {db_path}")
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
try:
if network_id is None:
row = conn.execute(
"select id,payload from network where type = 'ProxySubscription' order by idx limit 1"
).fetchone()
else:
row = conn.execute(
"select id,payload from network where id = ? and type = 'ProxySubscription'",
(network_id,),
).fetchone()
finally:
conn.close()
if row is None:
selector = "first ProxySubscription" if network_id is None else f"ProxySubscription id={network_id}"
raise LookupError(f"could not find {selector} in {db_path}")
payload_blob = row["payload"]
if isinstance(payload_blob, bytes):
payload_text = payload_blob.decode("utf-8", errors="replace")
else:
payload_text = payload_blob or "{}"
return int(row["id"]), json.loads(payload_text)
def choose_node(
payload: dict[str, Any],
node_name: str | None = None,
node_ordinal: int | None = None,
) -> dict[str, Any]:
nodes = payload.get("nodes") or []
if node_name is not None:
for node in nodes:
if node.get("name") == node_name:
return node
raise LookupError(f"ProxySubscription payload has no node named {node_name!r}")
if node_ordinal is not None:
for node in nodes:
if node.get("ordinal") == node_ordinal:
return node
raise LookupError(f"ProxySubscription payload has no node with ordinal {node_ordinal}")
selected_name = payload.get("selected_name")
selected_ordinal = payload.get("selected_ordinal")
for node in nodes:
if selected_name is not None and node.get("name") == selected_name:
return node
if selected_ordinal is not None and node.get("ordinal") == selected_ordinal:
return node
if nodes:
return nodes[0]
raise LookupError("ProxySubscription payload has no compatible nodes")
def resolved_stream_addresses(endpoint: Endpoint) -> list[tuple[int, tuple[Any, ...]]]:
addresses: list[tuple[int, tuple[Any, ...]]] = []
seen: set[tuple[Any, ...]] = set()
for family, _, _, _, sockaddr in socket.getaddrinfo(
endpoint.host, endpoint.port, type=socket.SOCK_STREAM
):
key = (family, sockaddr)
if key not in seen:
seen.add(key)
addresses.append((family, sockaddr))
return addresses
def resolve_doh(host: str, port: int, provider: str, timeout: float) -> list[Endpoint]:
base = DOH_PROVIDERS[provider]
url = base + "?" + urllib.parse.urlencode({"name": host, "type": "A"})
request = urllib.request.Request(
url,
headers={"Accept": "application/dns-json", "User-Agent": "burrow-proxy-ladder"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
body = json.load(response)
endpoints: list[Endpoint] = []
for answer in body.get("Answer") or []:
if answer.get("type") != 1:
continue
address = str(answer.get("data") or "")
try:
ipaddress.IPv4Address(address)
except ValueError:
continue
endpoint = Endpoint(address, port)
if endpoint not in endpoints:
endpoints.append(endpoint)
return endpoints
def resolve_doh_all(host: str, port: int, timeout: float) -> list[Endpoint]:
endpoints: list[Endpoint] = []
errors: list[str] = []
for provider in DOH_PROVIDERS:
try:
provider_endpoints = resolve_doh(host, port, provider, timeout)
except BaseException as exc:
errors.append(f"{provider}={type(exc).__name__}: {exc}")
continue
print(
f"{provider}_answers="
+ ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in provider_endpoints)
)
for endpoint in provider_endpoints:
if endpoint not in endpoints:
endpoints.append(endpoint)
if errors:
print("doh_warnings=" + "; ".join(errors))
return endpoints
def is_fake_ip(host: str) -> bool:
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
return ip in ipaddress.ip_network("198.18.0.0/15")
def server_hostname(server: str, sni: str | None) -> str | None:
value = sni or server
if not value:
return None
return value
def tls_context(config: dict[str, Any]) -> ssl.SSLContext:
skip_verify = bool(config.get("skip_cert_verify"))
if skip_verify:
context = ssl._create_unverified_context()
else:
context = ssl.create_default_context()
alpn_present = bool(config.get("alpn_present"))
alpn = config.get("alpn") or []
if not alpn_present:
alpn = ["h2", "http/1.1"]
if alpn:
context.set_alpn_protocols([str(value) for value in alpn])
return context
def connect_tls(
connect_endpoint: Endpoint,
tls_server_name: str,
node: dict[str, Any],
timeout: float,
) -> ssl.SSLSocket:
config = node["config"]
context = tls_context(config)
sni = server_hostname(tls_server_name, config.get("sni"))
last_error: BaseException | None = None
for _family, sockaddr in resolved_stream_addresses(connect_endpoint):
started = time.monotonic()
raw_sock: socket.socket | None = None
try:
raw_sock = socket.create_connection(sockaddr, timeout=timeout)
raw_sock.settimeout(timeout)
tls_sock = context.wrap_socket(raw_sock, server_hostname=sni)
tls_sock.settimeout(timeout)
elapsed_ms = (time.monotonic() - started) * 1000
peer = sockaddr[0] if isinstance(sockaddr, tuple) else str(sockaddr)
print(
f" tls_connect_ok address={peer}:{connect_endpoint.port} "
f"elapsed_ms={elapsed_ms:.1f} alpn={tls_sock.selected_alpn_protocol()!r}"
)
return tls_sock
except BaseException as exc:
last_error = exc
elapsed_ms = (time.monotonic() - started) * 1000
peer = sockaddr[0] if isinstance(sockaddr, tuple) else str(sockaddr)
print(
f" tls_connect_error address={peer}:{connect_endpoint.port} "
f"elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}"
)
if raw_sock is not None:
raw_sock.close()
assert last_error is not None
raise last_error
def probe_trojan_tcp(
connect_endpoint: Endpoint,
tls_server_name: str,
node: dict[str, Any],
target: Endpoint,
http_host: str,
timeout: float,
) -> ProbeResult:
request = (
f"GET / HTTP/1.1\r\n"
f"Host: {http_host}\r\n"
f"Connection: close\r\n"
f"User-Agent: burrow-proxy-ladder\r\n\r\n"
).encode()
try:
with connect_tls(connect_endpoint, tls_server_name, node, timeout) as sock:
sock.sendall(trojan_header(node["config"]["password"], TROJAN_CMD_CONNECT, target))
sock.sendall(request)
data = sock.recv(2048)
except BaseException as exc:
return ProbeResult(False, f"{type(exc).__name__}: {exc}")
if not data:
return ProbeResult(False, "proxy returned EOF before any HTTP response bytes")
first_line = data.splitlines()[0].decode("utf-8", errors="replace")
return ProbeResult(True, f"received {len(data)} bytes; first_line={first_line!r}")
def probe_trojan_udp_dns(
connect_endpoint: Endpoint,
tls_server_name: str,
node: dict[str, Any],
dns_server: Endpoint,
dns_name: str,
timeout: float,
) -> ProbeResult:
if node["config"].get("udp") is False:
return ProbeResult(False, "selected Trojan node has udp=false")
query_id, query = build_dns_query(dns_name)
try:
with connect_tls(connect_endpoint, tls_server_name, node, timeout) as sock:
sock.sendall(
trojan_header(node["config"]["password"], TROJAN_CMD_UDP_ASSOCIATE, dns_server)
)
sock.sendall(trojan_udp_frame(dns_server, query))
source = read_socks_addr(sock)
payload_len = struct.unpack("!H", read_exact(sock, 2))[0]
delimiter = read_exact(sock, 2)
if delimiter != b"\r\n":
return ProbeResult(False, f"invalid Trojan UDP delimiter: {delimiter!r}")
payload = read_exact(sock, payload_len)
except BaseException as exc:
return ProbeResult(False, f"{type(exc).__name__}: {exc}")
dns_summary = parse_dns_response(query_id, payload)
return ProbeResult(
True,
f"received UDP frame from {source.host}:{source.port}; DNS response {dns_summary}",
)
def print_node_summary(network_id: int, payload: dict[str, Any], node: dict[str, Any]) -> None:
config = node.get("config") or {}
print(f"network_id={network_id}")
print(f"subscription={payload.get('name')!r}")
print(f"selected_node={node.get('name')!r}")
print(f"protocol={node.get('protocol')!r}")
print(f"server={node.get('server')}:{node.get('port')}")
print(f"sni={config.get('sni')!r}")
print(f"skip_cert_verify={bool(config.get('skip_cert_verify'))}")
print(f"alpn_present={bool(config.get('alpn_present'))} alpn={config.get('alpn') or []!r}")
print(f"udp={config.get('udp', True)!r}")
def main() -> int:
parser = argparse.ArgumentParser(
description=(
"Directly probe the selected Burrow proxy subscription node without "
"using the daemon, Network Extension, system proxy, routes, or DNS settings."
)
)
parser.add_argument("--db", type=Path, default=Path(os.environ.get("BURROW_DB", DEFAULT_DB)))
parser.add_argument("--network-id", type=int)
parser.add_argument("--node-name", help="Probe this subscription node instead of the selected node.")
parser.add_argument("--node-ordinal", type=int, help="Probe this subscription ordinal instead of the selected node.")
parser.add_argument(
"--server-address",
help=(
"Override the proxy connect address, as host or host:port. "
"This bypasses system DNS for the proxy hostname while preserving SNI."
),
)
parser.add_argument(
"--resolve-server",
choices=["system", "doh-google", "doh-cloudflare", "doh-all"],
default="system",
help="How to resolve the proxy server when --server-address is not provided.",
)
parser.add_argument(
"--all-server-addresses",
action="store_true",
help="Probe every resolved proxy server address and pass if any address works.",
)
parser.add_argument("--target", default=DEFAULT_TARGET, help="TCP HTTP target, host:port")
parser.add_argument("--http-host", default=DEFAULT_HTTP_HOST)
parser.add_argument("--dns-server", default=DEFAULT_DNS_SERVER, help="UDP DNS target, host:port")
parser.add_argument("--dns-name", default=DEFAULT_DNS_NAME)
parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
parser.add_argument("--skip-udp", action="store_true")
parser.add_argument("--require-udp", action="store_true")
args = parser.parse_args()
print("Burrow proxy ladder: direct provider/protocol probe")
print("bypasses=daemon,NetworkExtension,system_proxy,routes,system_dns,browser")
print(f"db={args.db}")
try:
if args.node_name is not None and args.node_ordinal is not None:
raise ValueError("--node-name and --node-ordinal are mutually exclusive")
network_id, payload = load_payload(args.db, args.network_id)
node = choose_node(payload, args.node_name, args.node_ordinal)
print_node_summary(network_id, payload, node)
if node.get("protocol") != "trojan":
print(f"unsupported protocol for this direct probe: {node.get('protocol')!r}")
return 2
server = Endpoint(str(node["server"]), int(node["port"]))
if args.server_address:
connect_endpoints = [parse_endpoint(args.server_address, default_port=server.port)]
elif args.resolve_server == "system":
system_addresses = resolved_stream_addresses(server)
connect_endpoints = [
Endpoint(str(sockaddr[0]), int(sockaddr[1])) for _family, sockaddr in system_addresses
]
if not args.all_server_addresses and connect_endpoints:
connect_endpoints = connect_endpoints[:1]
if not connect_endpoints:
connect_endpoints = [server]
elif args.resolve_server == "doh-all":
connect_endpoints = resolve_doh_all(server.host, server.port, args.timeout)
if not args.all_server_addresses and connect_endpoints:
connect_endpoints = connect_endpoints[:1]
else:
connect_endpoints = resolve_doh(server.host, server.port, args.resolve_server, args.timeout)
print(
f"{args.resolve_server}_answers="
+ ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in connect_endpoints)
)
if not args.all_server_addresses and connect_endpoints:
connect_endpoints = connect_endpoints[:1]
if not connect_endpoints:
raise LookupError("proxy server resolution returned no usable A records")
target = parse_endpoint(args.target)
dns_server = parse_endpoint(args.dns_server)
except BaseException as exc:
print(f"setup_error={type(exc).__name__}: {exc}", file=sys.stderr)
return 2
print(
"connect_endpoints="
+ ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in connect_endpoints)
)
if any(endpoint != server for endpoint in connect_endpoints):
print(f"tls_server_name_source={server.host!r} sni_preserved=True")
if any(is_fake_ip(endpoint.host) for endpoint in connect_endpoints):
print(
"warning=proxy hostname resolved into 198.18.0.0/15; "
"rerun with --resolve-server doh-all or --server-address REAL_IP to avoid fake-IP DNS"
)
any_tcp_ok = False
any_required_udp_ok = False
for idx, connect_endpoint in enumerate(connect_endpoints, start=1):
if len(connect_endpoints) > 1:
print(f"\n=== proxy server candidate {idx}/{len(connect_endpoints)} ===")
print(f"connect_endpoint={connect_endpoint.host}:{connect_endpoint.port}")
print("\n[1/2] Trojan TCP CONNECT probe")
print(f"target={target.host}:{target.port} http_host={args.http_host!r}")
tcp_result = probe_trojan_tcp(
connect_endpoint, server.host, node, target, args.http_host, args.timeout
)
print(("ok: " if tcp_result.ok else "fail: ") + tcp_result.detail)
any_tcp_ok = any_tcp_ok or tcp_result.ok
udp_result = ProbeResult(True, "skipped")
if not args.skip_udp:
print("\n[2/2] Trojan UDP DNS probe")
print(f"dns_server={dns_server.host}:{dns_server.port} dns_name={args.dns_name!r}")
udp_result = probe_trojan_udp_dns(
connect_endpoint, server.host, node, dns_server, args.dns_name, args.timeout
)
print(("ok: " if udp_result.ok else "fail: ") + udp_result.detail)
else:
print("\n[2/2] Trojan UDP DNS probe")
print("skipped by --skip-udp")
any_required_udp_ok = any_required_udp_ok or udp_result.ok
if tcp_result.ok and (not args.require_udp or udp_result.ok):
break
if not any_tcp_ok:
return 1
if args.require_udp and not any_required_udp_ok:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

272
Scripts/burrow-proxy-selftest Executable file
View file

@ -0,0 +1,272 @@
#!/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" <<EOF
{
"name": "burrow proxy selftest",
"source": { "url": "selftest://local-trojan" },
"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
}
}
],
"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