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:
parent
97c569fb35
commit
d1638726ca
46 changed files with 15079 additions and 456 deletions
434
Scripts/burrow-doctor
Executable file
434
Scripts/burrow-doctor
Executable 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
576
Scripts/burrow-proxy-ladder
Executable 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
272
Scripts/burrow-proxy-selftest
Executable 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue