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' "$?"; }