transcript-fixer: - Add common_words.py safety system (blocks common Chinese words from dictionary) - Add --audit command to scan existing dictionary for risky rules - Add --force flag to override safety checks explicitly - Fix substring corruption (产线数据→产线束据, 现金流→现现金流) - Unified position-aware replacement with _already_corrected() check - 69 tests covering all production false positive scenarios tunnel-doctor: - Add Step 5A: Tailscale SSH proxy silent failure on WSL - Add Step 5B: App Store vs Standalone Tailscale on macOS - Add Go net/http NO_PROXY CIDR incompatibility warning - Add utun interface identification (MTU 1280=Tailscale, 4064=Shadowrocket) - Fix "Four→Five Conflict Layers" inconsistency in reference doc - Add complete working Shadowrocket config reference Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
523 lines
17 KiB
Python
Executable File
523 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Quick tunnel/proxy conflict diagnostics for macOS.
|
|
|
|
This script detects the most common local and Tailscale networking conflicts:
|
|
1) Shell proxy env + NO_PROXY mismatch
|
|
2) System proxy exceptions mismatch
|
|
3) Proxy path failure vs direct path success
|
|
4) Local TLS trust issues
|
|
5) Route ownership conflicts for a Tailscale IP (optional)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
|
|
def run(cmd: List[str], env: Optional[Dict[str, str]] = None) -> Tuple[int, str, str]:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
return proc.returncode, proc.stdout.strip(), proc.stderr.strip()
|
|
|
|
|
|
def split_csv(value: str) -> List[str]:
|
|
if not value:
|
|
return []
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
|
|
|
|
def match_proxy_pattern(host: str, pattern: str) -> bool:
|
|
p = pattern.strip().lower()
|
|
h = host.strip().lower()
|
|
if not p or not h:
|
|
return False
|
|
|
|
# CIDR / wildcard IP patterns are not domain checks.
|
|
if "/" in p and not p.startswith("*."):
|
|
return False
|
|
if re.match(r"^\d+\.\d+\.\d+\.\*$", p):
|
|
return False
|
|
|
|
if p == h:
|
|
return True
|
|
if p.startswith("*."):
|
|
suffix = p[1:] # keep leading dot
|
|
return h.endswith(suffix)
|
|
if p.startswith("."):
|
|
return h.endswith(p)
|
|
return False
|
|
|
|
|
|
def has_host_bypass(host: str, patterns: List[str]) -> bool:
|
|
return any(match_proxy_pattern(host, item) for item in patterns)
|
|
|
|
|
|
def parse_scutil_proxy() -> Dict[str, object]:
|
|
code, stdout, _stderr = run(["scutil", "--proxy"])
|
|
if code != 0:
|
|
return {"raw": "", "exceptions": [], "http_enabled": False, "https_enabled": False}
|
|
|
|
raw = stdout
|
|
exceptions: List[str] = []
|
|
for line in raw.splitlines():
|
|
m = re.search(r"\d+\s*:\s*(.+)$", line)
|
|
if m and "ExceptionsList" not in line:
|
|
exceptions.append(m.group(1).strip())
|
|
|
|
http_enabled = bool(re.search(r"HTTPEnable\s*:\s*1", raw))
|
|
https_enabled = bool(re.search(r"HTTPSEnable\s*:\s*1", raw))
|
|
|
|
return {
|
|
"raw": raw,
|
|
"exceptions": exceptions,
|
|
"http_enabled": http_enabled,
|
|
"https_enabled": https_enabled,
|
|
}
|
|
|
|
|
|
def resolve_host(host: str) -> List[str]:
|
|
ips = []
|
|
try:
|
|
infos = socket.getaddrinfo(host, None)
|
|
for item in infos:
|
|
ip = item[4][0]
|
|
if ip not in ips:
|
|
ips.append(ip)
|
|
except socket.gaierror:
|
|
pass
|
|
return ips
|
|
|
|
|
|
def curl_status(url: str, timeout: int, mode: str, proxy_url: Optional[str] = None) -> Dict[str, object]:
|
|
cmd = [
|
|
"curl",
|
|
"-k",
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"--max-time",
|
|
str(timeout),
|
|
url,
|
|
]
|
|
|
|
env = os.environ.copy()
|
|
if mode == "direct":
|
|
for key in (
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"all_proxy",
|
|
"ALL_PROXY",
|
|
):
|
|
env.pop(key, None)
|
|
elif mode == "forced_proxy" and proxy_url:
|
|
cmd.extend(["--proxy", proxy_url])
|
|
|
|
code, stdout, stderr = run(cmd, env=env)
|
|
http_code = stdout if stdout else "000"
|
|
ok = code == 0 and http_code.isdigit() and http_code != "000"
|
|
|
|
return {
|
|
"ok": ok,
|
|
"http_code": http_code,
|
|
"exit_code": code,
|
|
"stderr": stderr,
|
|
"command": " ".join(shlex.quote(x) for x in cmd),
|
|
}
|
|
|
|
|
|
def strict_tls_check(url: str, timeout: int) -> Dict[str, object]:
|
|
cmd = [
|
|
"curl",
|
|
"-sS",
|
|
"-o",
|
|
"/dev/null",
|
|
"-w",
|
|
"%{http_code}",
|
|
"--max-time",
|
|
str(timeout),
|
|
url,
|
|
]
|
|
env = os.environ.copy()
|
|
for key in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
|
|
env.pop(key, None)
|
|
code, stdout, stderr = run(cmd, env=env)
|
|
cert_issue = "certificate" in stderr.lower() or "ssl" in stderr.lower()
|
|
return {
|
|
"ok": code == 0 and stdout != "000",
|
|
"http_code": stdout if stdout else "000",
|
|
"exit_code": code,
|
|
"stderr": stderr,
|
|
"cert_issue": cert_issue,
|
|
}
|
|
|
|
|
|
def find_tailscale_utun() -> Optional[str]:
|
|
"""Find which utun interface belongs to Tailscale (has a 100.x.x.x IP)."""
|
|
code, stdout, _ = run(["ifconfig"])
|
|
if code != 0:
|
|
return None
|
|
current_iface = ""
|
|
for line in stdout.splitlines():
|
|
# Interface header line (e.g., "utun7: flags=...")
|
|
m = re.match(r"^(\w+):", line)
|
|
if m:
|
|
current_iface = m.group(1)
|
|
# Look for Tailscale CGNAT IP on a utun interface
|
|
if current_iface.startswith("utun") and "inet 100." in line:
|
|
return current_iface
|
|
return None
|
|
|
|
|
|
def get_iface_mtu(iface: str) -> Optional[int]:
|
|
"""Get MTU of a network interface."""
|
|
code, stdout, _ = run(["ifconfig", iface])
|
|
if code != 0:
|
|
return None
|
|
m = re.search(r"mtu\s+(\d+)", stdout)
|
|
return int(m.group(1)) if m else None
|
|
|
|
|
|
def route_check(tailscale_ip: str) -> Dict[str, object]:
|
|
code, stdout, stderr = run(["route", "-n", "get", tailscale_ip])
|
|
if code != 0:
|
|
return {"ok": False, "interface": "", "gateway": "", "raw": stderr or stdout}
|
|
|
|
interface = ""
|
|
gateway = ""
|
|
for line in stdout.splitlines():
|
|
line = line.strip()
|
|
if line.startswith("interface:"):
|
|
interface = line.split(":", 1)[1].strip()
|
|
if line.startswith("gateway:"):
|
|
gateway = line.split(":", 1)[1].strip()
|
|
|
|
# Identify which utun is Tailscale's and whether the route points to it
|
|
tailscale_utun = find_tailscale_utun()
|
|
route_mtu = get_iface_mtu(interface) if interface else None
|
|
is_tailscale_iface = (interface == tailscale_utun) if tailscale_utun else None
|
|
wrong_utun = (
|
|
interface.startswith("utun")
|
|
and tailscale_utun is not None
|
|
and interface != tailscale_utun
|
|
)
|
|
|
|
return {
|
|
"ok": True,
|
|
"interface": interface,
|
|
"gateway": gateway,
|
|
"tailscale_utun": tailscale_utun or "",
|
|
"route_iface_mtu": route_mtu,
|
|
"is_tailscale_iface": is_tailscale_iface,
|
|
"wrong_utun": wrong_utun,
|
|
"raw": stdout,
|
|
}
|
|
|
|
|
|
def pick_proxy_url() -> Optional[str]:
|
|
for key in ("http_proxy", "HTTP_PROXY", "https_proxy", "HTTPS_PROXY"):
|
|
value = os.environ.get(key)
|
|
if value:
|
|
return value
|
|
return None
|
|
|
|
|
|
def build_report(
|
|
host: str,
|
|
url: str,
|
|
timeout: int,
|
|
tailscale_ip: Optional[str],
|
|
) -> Dict[str, object]:
|
|
no_proxy = os.environ.get("NO_PROXY", "")
|
|
no_proxy_lc = os.environ.get("no_proxy", "")
|
|
no_proxy_entries = split_csv(no_proxy if no_proxy else no_proxy_lc)
|
|
|
|
scutil_info = parse_scutil_proxy()
|
|
scutil_exceptions = scutil_info["exceptions"]
|
|
|
|
proxy_url = pick_proxy_url()
|
|
direct = curl_status(url, timeout, mode="direct")
|
|
ambient = curl_status(url, timeout, mode="ambient")
|
|
forced_proxy = curl_status(url, timeout, mode="forced_proxy", proxy_url=proxy_url) if proxy_url else None
|
|
strict_tls = strict_tls_check(url, timeout) if url.startswith("https://") else None
|
|
|
|
host_ips = resolve_host(host)
|
|
host_in_no_proxy = has_host_bypass(host, no_proxy_entries)
|
|
host_in_scutil_exceptions = has_host_bypass(host, scutil_exceptions)
|
|
|
|
findings: List[Dict[str, str]] = []
|
|
|
|
if not host_ips:
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Host resolution failed",
|
|
"detail": f"{host} could not be resolved. Check DNS/hosts first.",
|
|
"fix": f"Add a hosts entry if this is local: 127.0.0.1 {host}",
|
|
}
|
|
)
|
|
|
|
if proxy_url and not host_in_no_proxy:
|
|
findings.append(
|
|
{
|
|
"level": "warn",
|
|
"title": "NO_PROXY missing target host",
|
|
"detail": f"Proxy is enabled ({proxy_url}) but NO_PROXY does not match {host}.",
|
|
"fix": (
|
|
"Add host to NO_PROXY/no_proxy, e.g. "
|
|
f"NO_PROXY=...,{host}"
|
|
),
|
|
}
|
|
)
|
|
|
|
if (scutil_info["http_enabled"] or scutil_info["https_enabled"]) and not host_in_scutil_exceptions:
|
|
findings.append(
|
|
{
|
|
"level": "warn",
|
|
"title": "System proxy exception missing target host",
|
|
"detail": f"scutil active exceptions do not include {host}.",
|
|
"fix": (
|
|
"Add host to proxy app skip/bypass list (Shadowrocket/Clash/Surge), "
|
|
"then reload profile."
|
|
),
|
|
}
|
|
)
|
|
|
|
if direct["ok"] and forced_proxy and not forced_proxy["ok"]:
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Proxy path is broken for target host",
|
|
"detail": (
|
|
"Direct access works, but forced proxy tunnel fails. "
|
|
"Traffic must bypass proxy for this host."
|
|
),
|
|
"fix": (
|
|
f"Add {host} to both NO_PROXY and proxy app skip-proxy/DIRECT rules."
|
|
),
|
|
}
|
|
)
|
|
|
|
if not ambient["ok"] and direct["ok"]:
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Ambient shell path fails while direct path works",
|
|
"detail": (
|
|
"Current shell env/proxy settings break default access."
|
|
),
|
|
"fix": (
|
|
"Use NO_PROXY for this host, or temporarily unset proxy env for local verification."
|
|
),
|
|
}
|
|
)
|
|
|
|
if strict_tls and not strict_tls["ok"] and direct["ok"] and strict_tls["cert_issue"]:
|
|
findings.append(
|
|
{
|
|
"level": "warn",
|
|
"title": "TLS trust issue detected",
|
|
"detail": "Network path is reachable, but strict TLS validation failed.",
|
|
"fix": "Trust local CA certificate (for local/internal TLS) or use a valid public cert.",
|
|
}
|
|
)
|
|
|
|
route_info = route_check(tailscale_ip) if tailscale_ip else None
|
|
if route_info and route_info["ok"]:
|
|
iface = str(route_info["interface"])
|
|
ts_utun = str(route_info.get("tailscale_utun", ""))
|
|
route_mtu = route_info.get("route_iface_mtu")
|
|
wrong_utun = route_info.get("wrong_utun", False)
|
|
|
|
if iface.startswith("en"):
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Possible route hijack for Tailscale destination",
|
|
"detail": f"route -n get {tailscale_ip} resolved to {iface}, not utun*.",
|
|
"fix": (
|
|
"Check proxy TUN excluded-routes. Do not exclude 100.64.0.0/10 from TUN route table."
|
|
),
|
|
}
|
|
)
|
|
elif wrong_utun:
|
|
mtu_hint = f" (MTU {route_mtu})" if route_mtu else ""
|
|
findings.append(
|
|
{
|
|
"level": "error",
|
|
"title": "Route points to wrong utun interface",
|
|
"detail": (
|
|
f"route -n get {tailscale_ip} resolved to {iface}{mtu_hint}, "
|
|
f"but Tailscale is on {ts_utun}. "
|
|
f"Likely hitting Shadowrocket/VPN TUN (MTU 4064) instead of Tailscale (MTU 1280)."
|
|
),
|
|
"fix": (
|
|
"Check proxy TUN excluded-routes and rule ordering. "
|
|
"Ensure IP-CIDR,100.64.0.0/10,DIRECT is in proxy rules."
|
|
),
|
|
}
|
|
)
|
|
|
|
summary = {
|
|
"host": host,
|
|
"url": url,
|
|
"host_ips": host_ips,
|
|
"proxy_url": proxy_url or "",
|
|
"env_no_proxy": no_proxy if no_proxy else no_proxy_lc,
|
|
"host_in_no_proxy": host_in_no_proxy,
|
|
"scutil_http_enabled": scutil_info["http_enabled"],
|
|
"scutil_https_enabled": scutil_info["https_enabled"],
|
|
"host_in_scutil_exceptions": host_in_scutil_exceptions,
|
|
"connectivity": {
|
|
"ambient": ambient,
|
|
"direct": direct,
|
|
"forced_proxy": forced_proxy,
|
|
"strict_tls": strict_tls,
|
|
},
|
|
"tailscale_route": route_info,
|
|
"findings": findings,
|
|
}
|
|
return summary
|
|
|
|
|
|
def print_human(report: Dict[str, object]) -> int:
|
|
print("=== Tunnel Doctor Quick Diagnose ===")
|
|
print(f"Host: {report['host']}")
|
|
print(f"URL: {report['url']}")
|
|
ips = report["host_ips"]
|
|
print(f"Resolved IPs: {', '.join(ips) if ips else 'N/A'}")
|
|
print("")
|
|
|
|
print("Proxy Context")
|
|
print(f"- proxy env: {report['proxy_url'] or '(not set)'}")
|
|
print(f"- host in NO_PROXY: {'yes' if report['host_in_no_proxy'] else 'no'}")
|
|
print(
|
|
"- system proxy enabled: "
|
|
f"HTTP={'yes' if report['scutil_http_enabled'] else 'no'} "
|
|
f"HTTPS={'yes' if report['scutil_https_enabled'] else 'no'}"
|
|
)
|
|
print(f"- host in scutil exceptions: {'yes' if report['host_in_scutil_exceptions'] else 'no'}")
|
|
print("")
|
|
|
|
conn = report["connectivity"]
|
|
print("Connectivity Checks")
|
|
for key in ("ambient", "direct", "forced_proxy", "strict_tls"):
|
|
value = conn.get(key)
|
|
if not value:
|
|
continue
|
|
ok = "PASS" if value.get("ok") else "FAIL"
|
|
print(
|
|
f"- {key:12s}: {ok} "
|
|
f"(http={value.get('http_code', '000')}, exit={value.get('exit_code', 'n/a')})"
|
|
)
|
|
stderr = value.get("stderr")
|
|
if stderr:
|
|
print(f" stderr: {stderr}")
|
|
print("")
|
|
|
|
route = report.get("tailscale_route")
|
|
if route:
|
|
if route.get("ok"):
|
|
print("Tailscale Route Check")
|
|
print(f"- route interface: {route.get('interface') or 'N/A'}")
|
|
route_mtu = route.get("route_iface_mtu")
|
|
if route_mtu:
|
|
print(f" route iface MTU: {route_mtu}")
|
|
print(f"- gateway: {route.get('gateway') or 'N/A'}")
|
|
ts_utun = route.get("tailscale_utun")
|
|
if ts_utun:
|
|
print(f"- tailscale utun: {ts_utun}")
|
|
is_ts = route.get("is_tailscale_iface")
|
|
if is_ts is True:
|
|
print(" route → Tailscale utun: YES (correct)")
|
|
elif is_ts is False:
|
|
print(" route → Tailscale utun: NO (MISMATCH — see findings)")
|
|
else:
|
|
print("- tailscale utun: (not detected — is Tailscale running?)")
|
|
print("")
|
|
else:
|
|
print("Tailscale Route Check")
|
|
print(f"- failed: {route.get('raw', '')}")
|
|
print("")
|
|
|
|
findings = report["findings"]
|
|
if not findings:
|
|
print("Result: no high-confidence conflict found.")
|
|
print("If browser still fails, verify proxy app profile mode/rule order and reload profile.")
|
|
return 0
|
|
|
|
print("Findings")
|
|
severity_order = {"error": 0, "warn": 1, "info": 2}
|
|
findings_sorted = sorted(findings, key=lambda x: severity_order.get(str(x.get("level")), 99))
|
|
for idx, item in enumerate(findings_sorted, start=1):
|
|
print(f"{idx}. [{item['level'].upper()}] {item['title']}")
|
|
print(f" Detail: {item['detail']}")
|
|
print(f" Fix: {item['fix']}")
|
|
|
|
return 1 if any(x["level"] == "error" for x in findings) else 0
|
|
|
|
|
|
def main() -> int:
|
|
if sys.platform != "darwin":
|
|
print("This script is designed for macOS only.", file=sys.stderr)
|
|
return 2
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description="Quick diagnostics for Tailscale + proxy conflicts on macOS."
|
|
)
|
|
parser.add_argument("--host", default="local.claude4.dev", help="Target host to diagnose.")
|
|
parser.add_argument(
|
|
"--url",
|
|
default="",
|
|
help="Full URL to test. Default: https://<host>/health",
|
|
)
|
|
parser.add_argument(
|
|
"--timeout",
|
|
type=int,
|
|
default=8,
|
|
help="curl timeout seconds (default: 8).",
|
|
)
|
|
parser.add_argument(
|
|
"--tailscale-ip",
|
|
default="",
|
|
help="Optional Tailscale IP for route ownership check (e.g. 100.101.102.103).",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
action="store_true",
|
|
help="Output JSON report.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
url = args.url.strip() or f"https://{args.host}/health"
|
|
report = build_report(
|
|
host=args.host.strip(),
|
|
url=url,
|
|
timeout=max(args.timeout, 1),
|
|
tailscale_ip=args.tailscale_ip.strip() or None,
|
|
)
|
|
|
|
if args.json:
|
|
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
return 0
|
|
return print_human(report)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|