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