Tunnel Doctor
MUST be used first for tunnel / proxy-path connectivity failures or slow throughput (网络故障 / 连不上 /…
by daymade · MIT
The skill
MUST be used first for tunnel / proxy-path connectivity failures or slow throughput (网络故障 / 连不上 / 代理或隧道下超时). Diagnoses Tailscale, proxy, TUN, DNS and SSH-tunnel problems on macOS and Windows/WSL, e.g. Tailscale ping works but SSH fails, curl works but browser gets 503, Git/Docker fails behind the proxy, or downloads crawl despite a reachable proxy. Not for application-layer bugs (use debugging-network-issues).
Tunnel Doctor
Diagnose and fix conflicts when Tailscale coexists with proxy/VPN tools on macOS, with specific guidance for SSH access to WSL instances.
Methodology base: the general diagnostic discipline this skill builds on — evidence over assumption, falsification over confirmation, layered isolation, counter-review — lives in the debugging-network-issues skill. This skill is the macOS Tailscale⨯proxy domain layer on top of it; reach for the base skill when the symptom is not a known Tailscale/proxy conflict.
Ownership boundary: this skill owns concrete local network-path operations:
Tailscale, route/DNS/proxy interception, VM/WSL propagation, TUN forwarding
planes, and proxy node/exit/chain capacity. Use debugging-network-issues as the
general method when the boundary is still unknown or the symptom belongs to
SSE/CDN/application protocol behavior. This keeps one concrete operator instead
of forcing the user to choose between two overlapping proxy doctors.
Target and task scope
Before remote probes or changes, establish whether the target is a designated test machine, an explicitly requested colleague's working computer, or unknown. Use the owner's existing machine-purpose ledger and current task; do not log into a colleague's machine to classify it. Saved SSH access and previous successful tests do not grant ongoing use. Use designated test machines for routine testing; their unavailability does not authorize another person's computer.
Keep an explicitly requested one-off test on a colleague's computer within its stated result and necessary verification. Stop there on completion, cancellation, or a need for an unapproved environment change. Move later performance, migration, and regression tests to designated test machines. A test failure does not authorize disabling that person's proxy/VPN, quitting apps, changing routes, or installing a background task. Proceed with disruptive repair there only when the user has explicitly requested that repair and its interruption. Existing authorization for owner test-machine maintenance remains valid; do not ask again for its routine steps.
Apply this boundary even when invoked directly, through another Skill, after compaction, or through a peer handoff. After access is stopped, use existing records rather than another SSH probe to verify that it stopped.
Disruptive network changes
Before any authorized VPN disconnect, app termination, route cutover, or service restart, read network_change_recovery.md. Validate prerequisites before interruption, arm bounded recovery before the first disruptive action, and verify both the repaired path and the user's original network use. A successful stop command, a detached process, or a restart placed at the end of a script is not recovery evidence.
Conflict Layers
Proxy/VPN tools on macOS create conflicts at several independent layers. Layers 1-3 affect Tailscale connectivity; Layer 4 affects SSH git operations; Layer 5 affects VM/container runtimes. TUN-state failure modes beyond this table — SSH/git connection drops, resolver stall, DIRECT split-brain — are covered in Steps 2H–2J:
| Layer | What breaks | What still works | Root cause |
|---|---|---|---|
| 1. Route table | Everything (SSH, curl, browser) | tailscale ping |
tun-excluded-routes adds en0 route overriding Tailscale utun |
| 2. HTTP env vars | curl, Python requests, Node.js fetch |
SSH, browser | http_proxy set without NO_PROXY for Tailscale |
| 3. System proxy (browser) | Browser only (HTTP 503) | SSH, curl (both with/without proxy) |
Browser uses VPN system proxy; DIRECT rule routes via Wi-Fi, not Tailscale utun |
| 4. SSH ProxyCommand double tunnel | git push/pull (intermittent) |
ssh -T (small data) |
connect -H creates HTTP CONNECT tunnel redundant with Shadowrocket TUN; landing proxy drops large/long-lived transfers |
| 5. VM/Container proxy propagation | docker pull, docker build |
Host curl, running containers |
VM runtime (OrbStack/Docker Desktop) auto-injects or caches proxy config; removing proxy makes it worse (VM traffic via TUN → TLS timeout) |
Diagnostic Workflow
Step 1: Identify the Symptom
Name the actual consuming command or service and its required protocols before
choosing a repair. When an authorized directly reachable peer can reach the slow
target, and authenticated SSH plus a representative transfer through that peer
already work better, treat the gateway as an integration candidate. First inspect
the consumer's effective SSH/inventory arguments and test whether its normal entry
can use that path; a successful handwritten ssh -J does not prove integration.
If the gateway carries the required workload, connect it through the existing managed transport and replay the original operation without per-request jump arguments. Verify target identity, host-key trust, content integrity, elapsed time and the declared failure/recovery behavior. Do this before further work on a native relay that is not required by the user's result. Keep exact hosts, ports and live route state in the owning infrastructure configuration.
Keep unmet protocols explicit: an SSH gateway does not establish UDP or arbitrary application connectivity. Continue the relevant network repair when those are required; do not replace that acceptance with SSH success. The executing agent must judge protocol fit and user intent; no bundled checker establishes either.
Determine which scenario applies:
- Public gateway returns 502 while its backend responds normally, or the dedicated SSH tunnel key is rejected as
correct key but not from a permitted host→ read SSH tunnel source restrictions before changing TUN/firewall rules or treating a running process as recovery. - Browser returns HTTP 503, but
curland SSH both work → System proxy bypass conflict (Step 2C) local.<domain>fails in browser/defaultcurl, but direct/no-proxy request works → Local vanity domain proxy interception (Step 2C-1)- Tailscale ping works, SSH works, but curl/HTTP times out → HTTP proxy env var conflict (Step 2A)
- Tailscale ping works, SSH/TCP times out → Check route ownership (Step 2B); a matching symptom alone does not prove a route conflict.
- Tailscale IPv4 SSH times out, or an alternate peer address triggers host-key verification → Read same-peer IPv4/IPv6 SSH recovery before declaring the host offline or changing trust/network state.
- Remote dev server auth redirects to
localhost→ browser can't follow → SSH tunnel needed (Step 2D) make status/ scripts curl to localhost fail with proxy → localhost proxy interception (Step 2E)git push/pullfails withFATAL: failed to begin relaying via HTTP→ SSH double tunnel (Step 2F)docker buildRUN apk/aptfails withConnection refusedinstantly → OrbStack transparent proxy + TUN conflict (Step 2G-1, fix:--network host)docker pullfails withTLS handshake timeout→ VM proxy misconfiguration (Step 2G-2, fix:docker.jsonwithhost.internal)- Container healthcheck
(unhealthy)but app runs fine → Lowercase proxy env var leak (Step 2G-4, fix: clearhttp_proxy+HTTP_PROXY) docker buildcan't fetch base images → VM/container proxy propagation (Step 2G)docker pullworks butdocker buildstill dies fetching base-image tokens (TLS/i-o timeout) on a WSL2 + Docker Desktop host, or every runbook proxy port on a Windows+v2rayN host silently died after a v2rayN upgrade → buildkit direct-dials past the DD proxy override / v2rayN ≥7.17 LAN-port split (references/windows_host_tun_wsl_cascade.md §2026-08-23)git clonefails withConnection closed by 198.18.x.x→ two different mechanisms produce this; Step 2H separates them- Every domestic/DIRECT-rule site fails at once (TLS
unexpected EOFmid-handshake, proxy-port CONNECT returns 503, Node CLIs reportUNKNOWN_CERTIFICATE_VERIFICATION_ERROR) while proxied overseas sites keep working → TUN DIRECT split-brain (Step 2J) - Everything on macOS fails at once — domestic AND overseas, and even Step 2J's real-IP probe fails → do not declare an outage yet; run the physical-interface discriminator in "TUN full-stall vs genuine outage" (above Step 2A)
- SSH connects but
operation not permitted→ Tailscale SSH config issue (Step 4) - SSH connects but
be-child sshexits code 1 → WSL snap sandbox issue (Step 5) - TCP port 22 reachable (
nc -zsucceeds) but SSH fails withkex_exchange_identification: Connection closed→ Tailscale SSH proxy intercept on WSL (Step 5A) - Same
kex_exchange_identification/Connection closed by UNKNOWN port 65535, but there is no Tailscale — you are behind a TUN and the host is a public cloud VM whose HTTPS still works → could be your TUN or the destination's firewall allowlist; they are indistinguishable from the client. Step 2K settles it out-of-band tailscale sshreturns "not available on App Store builds" → Wrong Tailscale distribution on macOS (Step 5B)- Any tool using system DNS (
ssh,curl,git) hangs ~60s before resolving, butnslookupreturns instantly → Stalled resolver ingetaddrinfochain (Step 2I) - Windows+WSL host: everything offline at once (domestic AND overseas), WSL dead too, "even Tailscale won't come online" — and/or the user tried several recovery actions (switched NICs, toggled TUN) and can't tell which one fixed it → Windows host TUN cascade + event-log forensics (Step 5C)
- Tailscale, DNS, route ownership, and proxy reachability all pass, but large transfers remain slow and switching the active proxy node changes throughput → proxy node / exit / chain capacity workflow in references/proxy_node_chain_throughput.md
- You want one app's bulk traffic (BaiduNetdisk downloads, Feishu/Lark uploads) off the metered proxy — or out of the status-bar counter entirely → not a conflict; a splitting task. Use the existing local traffic-splitting workflow if one is installed and verified; this skill does not bundle a splitter, target list, replay helper or subscription-wipe guard. For mechanism diagnosis, read references/proxy_conflict_reference.md § "Per-app traffic splitting on macOS" — which of the three mechanisms (DIRECT rule /
tun-excluded-routes/always-real-ip) can work for the app depends on whether it connects to literal IPs or the system resolver, and the DNS-layer keys are inert on the macOS Catalyst build.
Key distinctions:
- SSH does NOT use
http_proxy/NO_PROXYenv vars. If SSH works but HTTP doesn't → Layer 2. curluseshttp_proxyenv var, NOT the system proxy. Browser uses system proxy (set by VPN). Ifcurlworks but browser doesn't → Layer 3.- If
tailscale pingworks but regularpingdoesn't → Layer 1 (route table corrupted). - If
ssh -T git@github.comworks butgit pushfails intermittently → Layer 4 (double tunnel). - If host
curl https://...works butdocker pulltimes out → Layer 5 (VM proxy propagation). - If
docker pullworks butdocker buildRUN apk addfails instantly withConnection refused→ OrbStack transparent proxy broken by TUN (Step 2G-1). - If container healthcheck shows
(unhealthy)but app works → lowercasehttp_proxyleaked into container (Step 2G-4). - If DNS resolves to
198.18.x.xvirtual IPs → a TUN is active, which is not by itself a diagnosis: under TUN every hostname resolves this way, on success as well as failure. Go to Step 2H to find out which mechanism you have. - If connections to fake-IP-resolved domains die but the same host works via
curl --resolve <host>:443:<real-ip>(real IP fromdig @<public-resolver>) → the TUN tool's DIRECT forwarding state is broken, not your network and not the destination (Step 2J). - If
nc -zsucceeds on port 22 but SSH gets no banner (kex_exchange_identification) → Tailscale SSH proxy intercept (Step 5A). Confirm withtcpdump -i any port 22on the remote — 0 packets means Tailscale intercepts above the kernel. - If
nc -zalso succeeds on a port the destination does not serve (probe 65001) → under a TUN the local stack answers for the destination, so nonc -zresult on that host means anything. Never read a port probe you have not calibrated this way. This supersedes the plainnc -zreading in the bullet above whenever a TUN — not just Tailscale — is in the path (Step 2K). - If the destination's own
journalctl -u sshshows zero entries for your attempts → something beforesshdis eating them, but not necessarily the network: a host-local packet filter drops them just as silently.tcpdumpon the host separates "never arrived" from "arrived and was dropped locally"; only the first implicates your TUN. Reach the machine through the cloud provider's guest-agent channel to ask, since that path does not use the port you cannot reach. Check the journal, notauth.log: on Ubuntu 24.04sshdwrites nothing toauth.log, so an empty file there is not evidence (Step 2K). - A cloud host enforces an inbound port twice — provider security group and host firewall (
ufw/firewalld/nftables). Adding your IP to one leaves the other blocking, with no signal that you only did half the job (Step 2K). - If
tailscale sshfails with "not available on App Store builds" → install Standalone Tailscale (Step 5B). - If
nslookup <host>is fast (<0.1s) butdscacheutil -q host -a name <host>takes 60s+ → a supplemental resolver inscutil --dnsis dead (Step 2I). - If
ping <resolver-ip>succeeds butdig @<resolver-ip>times out → daemon dead,utuninterface zombied. ICMP is answered by the interface; the actual port-53 service is gone (Step 2I). - If
ssh -vvvhangs immediately afterdebug2: resolving "<host>" port <port>and never reachesdebug1: connect to address→ DNS resolution stage, not network connect stage. This is Step 2I, not Step 2B/2H.
Diagnosis Discipline (Read Before Committing to a Hypothesis)
When symptoms point at a component (proxy, VPN, route table, DNS), don't commit to a hypothesis from circumstantial evidence — verify with that component's own health endpoint first. Each component has a one-line health check faster and more reliable than ruling out neighbors:
| Suspected component | Authoritative health check (run this first) |
|---|---|
| HTTP proxy (Shadowrocket / Clash / Surge) | curl -x http://127.0.0.1:<port> -m 10 https://api.github.com returns 200 |
| Tailscale daemon | tailscale status returns peer list (not connection error) |
| A specific DNS resolver | dig @<nameserver-ip> +tries=1 +timeout=3 example.com <100ms |
| Routing for an IP | route -n get <ip> shows expected interface |
| Per-resolver bisection (when DNS is suspect) | The for ns in ...; do dig @$ns ... loop in Step 2I |
| An agent CLI complaining "can't connect / keeps reconnecting" (Codex etc.) | The client's own runtime log, bucketed by error shape and by hour — for Codex: logs table in the newest ~/.codex/logs_*.sqlite. Errors clustered in time windows = episodic transport (keep reading here); a single failing process_uuid among clean ones = per-process cause (e.g. stale token), not the network at all |
Why this matters: A symptom that matches the description of Step 2X does not, by itself, prove component X is the problem. Multiple layers can produce overlapping symptoms (a 60-second hang during git push could be proxy node death, fakeip route corruption, or DNS resolver stall — all plausible from the user-visible symptom alone). Reaching for the most specific verification first avoids committing to a wrong layer and chasing it down a dead end.
If the failing operation involves DNS at all, run the per-nameserver bisection from Step 2I before suspecting proxy or routing. It rules in/out the largest single class of macOS-on-China-network failures in under 15 seconds.
TUN Measurement Contamination (what your probes lie about while a TUN proxy is up)
When a proxy tool runs in TUN / global mode (Shadowrocket, Clash, Surge), it intercepts traffic at the routing layer and fabricates parts of the network stack locally. Several everyday diagnostic commands then return fabricated or misrouted numbers — trusting them sends the whole investigation the wrong way. Know what each probe actually measures under TUN:
| Probe | What it looks like | What it actually is under TUN | Trust? |
|---|---|---|---|
nc -z <node-ip> <port> / raw TCP connect showing 0.00s |
"node reachable, instant" | TUN completes the TCP handshake locally before tunneling. 0.00s to an overseas host is physically impossible (light alone is tens of ms each way) — you connected to the TUN, not the node. |
❌ |
ping <host> with near-zero loss / sub-ms RTT |
"link healthy" | TUN can answer ICMP locally; loss and RTT are fabricated and uncorrelated with TCP. (Separately: ICMP ≠ TCP even with no TUN.) | ❌ |
curl … -w '%{remote_ip}' |
"connected to peer X" | Always the local TUN endpoint (127.0.0.1 / loopback), never the real remote peer. |
❌ |
IP-geo lookup via a foreign service (an ip-api-style endpoint) |
"my egress / home IP is …" | A foreign-domain request gets routed through the proxy, so it reports the exit IP, not your real local/home IP. | ❌ for "what is my real local IP" |
| IPv4-vs-IPv6 path choice, HTTP/3 / QUIC speedup | varies | TUN typically does not forward UDP/443, so QUIC never leaves. The comparison is meaningless. | ❌ |
| A DIRECT-rule / domestic domain succeeding through your proxy config, cited as evidence the tunnel/exit node is alive | "the proxy has at least one working path, so the tunnel is probably fine" | DIRECT-matched traffic never enters the tunnel — it exits locally via your real last mile, identically to the proxy not running at all. Its success says nothing about the proxied plane. (Observed 2026-09-22: an overseas API endpoint that "worked through the proxy" during a collection-health check turned out to hit a DIRECT bypass rule; a no-proxy-at-all retest succeeded identically, proving the tunnel itself had never been exercised.) | ❌ for "is the tunnel/exit node healthy" |
What you can trust under TUN:
time_appconnect/time_starttransferfromcurl(application-layer handshake / TTFB) — these complete only after the tunneled connection actually establishes, so they reflect the real end-to-end path.- An in-region / domestic IP-geo source for "what is my real local ISP" — an in-region domain hits the proxy's DIRECT rule and exits your real last mile (the foreign source gets tunneled and lies; see table). The same DIRECT-hit property is not evidence for the tunnel's own health — see the table row above; check
proxyBypassHosts/DIRECT rule lists before crediting any single host's success to the tunnel. - The proxy/TUN config decoded from disk + the tool's own GUI — the authoritative source of which node/route is actually active. Cross-check a file parse against the GUI; do not infer the active node from a network probe.
Counter-move: before citing any latency / reachability number while a TUN is up, ask "would this number be physically possible if the packet really traversed to the destination?" A 0.00s connect or a 0.2ms ping to another continent is the tell that you measured the TUN, not the network. Switch to time_appconnect, or temporarily disable the TUN to get a clean baseline (raw probes become meaningful again once it is off).
TUN full-stall vs genuine outage (when EVERYTHING fails, prove the wire is dead before standing down)
The contamination table has a systemic limit: every probe that goes through the TUN shares one fate. That includes "real-IP" probes (dig @<resolver> + curl --resolve) — the TUN intercepts UDP/53 too. When the TUN's forwarder stalls completely, domain probes, real-IP probes and proxied probes all fail together, and the picture is indistinguishable from a genuine physical outage. A watchdog or investigator that reads "all probes dead" as "the network is down" will stand down through a recoverable TUN stall — or worse, blame the router (observed 2026-09-10: a proxy health daemon recorded 13 "genuine network outage, standing down" windows in one day; the layer was never proven either way until a physical-path probe was added).
The discriminator is a probe that bypasses the TUN entirely — bind the physical interface (curl --interface = IP_BOUND_IF, scoped routes take the packet straight out the physical NIC):
# 1) Find the physical interface that actually carries the LAN. Do NOT use
# `route -n get default` here — under TUN it answers with the utun itself.
for i in en0 en1 en2 en3; do
addr=$(ipconfig getifaddr $i 2>/dev/null) && echo "$i $addr"
done
ping -c 1 -t 2 <gateway-ip> # sanity: is the LAN itself alive at all
# 2) Probe bare-IP targets through that interface — two independent ones.
# Both verified answering on a healthy link 2026-09-10 (AliDNS 443 → 404,
# DNSPod 80 → 404). Targets rot: 114.114.114.114 and 180.76.76.76 returned
# 000 on a healthy wire that same day — re-verify a target before trusting it.
for u in "https://223.5.5.5/" "http://119.29.29.29/"; do
curl -s --noproxy '*' --interface en0 -k --connect-timeout 3 --max-time 6 \
-o /dev/null -w "$u: %{http_code}\n" "$u"
done
- Any non-
000answer (even 404) → the physical path (NIC → AP → gateway → ISP) is alive and the TUN is the suspect: reconnect the tunnel under the recovery contract (the disconnect/connect mechanism lives in Step 2J) — do not stand down, do not reboot the router. 000from both targets → a genuine outage is earned. A single target's 000 proves only that target: enterprise/campus egress whitelists and resolver-side changes each produce a lone 000 on a healthy wire.
Calibrate before trusting: on a healthy link each answer arrives in ~0.1s; a deliberately wrong interface must fail — --interface en9 returns 000 with a nonzero exit (45 when the interface doesn't exist, 7/28 when it exists but is down or unrouted — the 000, not the exit number, is the signal).
Fast Path: Run Automated Checks
For common macOS conflicts (env proxy, system proxy exceptions, direct/proxy path split, local TLS trust), run:
python3 scripts/quick_diagnose.py --host local.example.com --url https://local.example.com/health
Optional route ownership check for a Tailscale destination:
python3 scripts/quick_diagnose.py --host <target-host> --url http://<target-host>:<port>/health --tailscale-ip <100.x.x.x>
Interpretation:
direct=PASS+ambient=FAIL+forced_proxy=FAILsupports investigating the shell proxy path.direct=PASS+system_proxy=FAILis a warning until the affected browser or system client fails too; only then consider a host-specific bypass.forced_proxyuses the target URL's proxy environment (https_proxyfor HTTPS,http_proxyfor HTTP, thenall_proxy); a failed forced probe alone is not a reason to change a working ambient path.direct=FAILdoes not support a bypass recommendation. A non-000HTTP response (including 403) proves a response arrived, not that the application accepted the request.strict_tls=FAIL+direct=PASS= path is reachable; trust issue only (install/trust local CA).host in scutil exceptions: nois context, not a fault by itself. The script'sdirectprobe bypasses curl proxies but may still traverse a system TUN; it does not prove an independent physical path.
Step 2A: Fix HTTP Proxy Environment Variables
Check if proxy env vars are intercepting Tailscale HTTP traffic:
env | grep -i proxy
Broken output — proxy is set but NO_PROXY doesn't exclude Tailscale:
http_proxy=http://127.0.0.1:1082
https_proxy=http://127.0.0.1:1082
NO_PROXY=localhost,127.0.0.1 ← Missing Tailscale!
Fix — add Tailscale MagicDNS domain + CIDR to NO_PROXY:
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*
| Entry | Covers | Why |
|---|---|---|
.ts.net |
MagicDNS domains (host.tailnet.ts.net) |
Matched before DNS resolution |
100.64.0.0/10 |
Tailscale IPs (100.64.* – 100.127.*) |
Precise CIDR, no public IP false positives |
192.168.*,10.*,172.16.* |
RFC 1918 private networks | LAN should never be proxied |
Two layers complement each other: .ts.net handles domain-based access, 100.64.0.0/10 handles direct IP access.
NO_PROXY syntax pitfalls — see references/proxy_conflict_reference.md for the compatibility matrix.
Go net/http CIDR caveat: Go's standard net/http does NOT support CIDR notation in NO_PROXY. Setting NO_PROXY=100.64.0.0/10 works for curl and Python, but Go programs (including Tailscale-adjacent tooling) will still send traffic through the proxy. The fix is to use MagicDNS hostnames (e.g., my-wsl-box) instead of raw IPs, or add explicit hostnames to NO_PROXY:
# WRONG for Go programs — CIDR is silently ignored
NO_PROXY=100.64.0.0/10 go-program http://100.101.102.103:8002/health # → goes through proxy
# CORRECT — use hostname (matched as suffix) or explicit IP
export NO_PROXY=localhost,127.0.0.1,.ts.net,my-wsl-box,100.101.102.103,192.168.*,10.*,172.16.*
This is especially relevant when accessing Tailscale services from Go-based tools (e.g., custom CLIs, Go test suites hitting remote APIs).
Verify the fix:
# Both must return HTTP 200:
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<host>.ts.net:<port>/health -w "HTTP %{http_code}\n"
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<tailscale-ip>:<port>/health -w "HTTP %{http_code}\n"
Then persist in shell config (~/.zshrc or ~/.bashrc).
Step 2B: Detect Route Conflicts
Check if a proxy tool hijacked the Tailscale CGNAT range:
route -n get <tailscale-ip>
Healthy output — traffic goes through Tailscale interface:
destination: 100.64.0.0
interface: utun7 # Tailscale interface (utunN varies)
Broken output — proxy hijacked the route:
destination: 100.64.0.0
gateway: 192.168.x.1 # Default gateway
interface: en0 # Physical interface, NOT Tailscale
Important: Not all utun interfaces are Tailscale's. Verify which utun belongs to Tailscale before concluding the route is correct:
# Find Tailscale's utun interface (has a 100.x.x.x IP)
ifconfig | grep -A2 'inet 100\.'
Quick indicators by MTU:
- MTU 1280 → typically Tailscale
- MTU 4064 → typically Shadowrocket TUN
If route -n get shows traffic going to a utun with MTU 4064, it is hitting Shadowrocket's TUN, not Tailscale — this is still a route conflict even though the interface name starts with utun.
Confirm with full route table:
netstat -rn | grep 100.64
Two competing routes indicate a conflict:
100.64/10 192.168.x.1 UGSc en0 ← Proxy added this (wins)
100.64/10 link#N UCSI utun7 ← Tailscale route (loses)
Root cause: On macOS, UGSc (Static Gateway) takes priority over UCSI (Cloned Static Interface) for the same prefix length.
Step 2C: Fix System Proxy Bypass (Browser 503)
Symptom: Browser shows HTTP 503 for http://<tailscale-ip>:<port>, but both curl --noproxy '*' and curl (with proxy env var) return 200. SSH also works.
Root cause: The browser uses the system proxy configured by the VPN profile (Shadowrocket/Clash/Surge). The proxy matches IP-CIDR,100.64.0.0/10,DIRECT and tries to connect directly — but "directly" means via the Wi-Fi interface (en0), NOT through Tailscale's utun interface. The proxy process itself doesn't have a route to Tailscale IPs, so the connection fails with 503.
Diagnosis:
# curl with proxy env var works (curl connects to proxy port, but traffic flows differently)
curl -s -o /dev/null -w "%{http_code}" http://<tailscale-ip>:<port>/
# → 200
# Browser gets 503 because it goes through the VPN system proxy, not http_proxy env var
Fix — add Tailscale CGNAT range to skip-proxy in the proxy tool config:
For Shadowrocket, in [General]:
skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, localhost, *.local, captive.apple.com
skip-proxy tells the system "bypass the proxy entirely for these addresses." The browser then connects directly through the OS network stack, where Tailscale's routing table correctly handles the traffic.
Why skip-proxy works but tun-excluded-routes doesn't:
skip-proxy: Bypasses the HTTP proxy layer only. Traffic still flows through the TUN interface and Tailscale utun handles it. Safe.tun-excluded-routes: Removes the CIDR from the TUN routing entirely. This creates a competingen0route that overrides Tailscale. Breaks everything.
Step 2C-1: Fix Local Vanity Domain Interception (local.<domain>)
Symptom: https://local.<domain> fails in browser or default curl, but succeeds with direct/no-proxy command:
env -u http_proxy -u https_proxy curl -k -I https://local.<domain>/health
# -> 200
curl -I https://local.<domain>/health
# -> proxy CONNECT then TLS reset/failure
Root cause: The domain is routed through system/shell proxy instead of local direct path.
Fix:
- Add domain to proxy app bypass list (
skip-proxyfor Shadowrocket). - Add domain to shell bypass list (
NO_PROXY/no_proxy). - If local TLS uses internal CA, trust the local root certificate.
# ~/.zshrc
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*,local.<domain>,www.local.<domain>
export no_proxy="$NO_PROXY"
Verification:
python3 scripts/quick_diagnose.py --host local.<domain> --url https://local.<domain>/health
Expected:
host in NO_PROXY: yeshost in scutil exceptions: yesambient=PASSanddirect=PASS
Step 2D: Fix Auth Redirect for Remote Dev (SSH Tunnel)
Symptom: Dev server runs on a remote machine (e.g., Mac Mini via Tailscale). You access http://<tailscale-ip>:3010 in the browser. Login/signup works, but after auth, the app redirects to http://localhost:3010/ which fails — localhost on your machine isn't running the dev server.
Root cause: The app's APP_URL (or equivalent) is set to http://localhost:3010. Auth libraries (Better-Auth, NextAuth, etc.) use this URL for callback redirects. Changing APP_URL to the Tailscale IP introduces Shadowrocket proxy conflicts and breaks local development on the remote machine.
Fix — SSH local port forwarding. This avoids all three conflict layers entirely:
# Forward local port 3010 to remote machine's localhost:3010
ssh -NL 3010:localhost:3010 <tailscale-ip>
# Or with autossh for auto-reconnect (recommended for long sessions)
autossh -M 0 -f -N -L 3010:localhost:3010 \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
<tailscale-ip>
Now access http://localhost:3010 in the browser. Auth redirects to localhost:3010 → tunnel → remote dev server → works correctly.
Why this is the best approach:
- No
.envchanges needed —APP_URL=http://localhost:3010works everywhere - No Shadowrocket conflicts —
localhostis always inskip-proxy - No code changes — same behavior as local development
- Industry standard — VS Code Remote SSH, GitHub Codespaces use the same pattern
Install autossh: brew install autossh (macOS) or apt install autossh (Linux)
Kill background tunnel: pkill -f 'autossh.*<tailscale-ip>'
Step 2E: Fix localhost Proxy Interception in Scripts
Symptom: Makefile targets or scripts that curl localhost (health checks, warmup routes) fail or timeout when http_proxy is set globally in the shell.
Root cause: http_proxy=http://127.0.0.1:1082 is set in ~/.zshrc but no_proxy doesn't include localhost. All curl commands send localhost requests through the proxy.
Fix — add --noproxy localhost to all localhost curl commands in scripts:
# WRONG — fails when http_proxy is set
@curl -sf http://localhost:9000/minio/health/live && echo "OK"
# CORRECT — always bypasses proxy for localhost
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live && echo "OK"
Alternatively, set no_proxy globally in ~/.zshrc:
export no_proxy=localhost,127.0.0.1
Step 2F: Fix SSH ProxyCommand Double Tunnel (git push/pull failures)
Symptom: ssh -T git@github.com succeeds consistently, but git push or git pull fails intermittently with:
FATAL: failed to begin relaying via HTTP.
Connection closed by UNKNOWN port 65535
Small operations (auth, fetch metadata) work; large data transfers fail.
Root cause: When Shadowrocket TUN is active, it already routes all TCP traffic through its VPN tunnel. If SSH config also uses ProxyCommand connect -H, data flows through two proxy layers — the landing proxy drops large/long-lived HTTP CONNECT connections.
Diagnosis:
# 1. Confirm Shadowrocket TUN is active
ifconfig | grep '^utun'
# 2. Check SSH config for ProxyCommand
grep -A5 'Host github.com' ~/.ssh/config
# 3. Confirm: removing ProxyCommand fixes push
GIT_SSH_COMMAND="ssh -o ProxyCommand=none" git push origin main
Fix — remove ProxyCommand and switch to ssh.github.com:443. See references/proxy_conflict_reference.md § SSH ProxyCommand and Git Operations for the full SSH config, why port 443 helps, and fallback options when VPN is off.
Step 2G: Fix VM/Container Runtime Proxy Propagation (Docker pull/build failures)
Symptom: docker pull or docker build fails with net/http: TLS handshake timeout, Connection refused from Alpine/Debian repos, or Internal Server Error from auth.docker.io, while host curl to the same URLs works fine.
Applies to: OrbStack, Docker Desktop, or any VM-based Docker runtime on macOS with Shadowrocket/Clash TUN active.
Root cause: VM-based Docker runtimes (OrbStack, Docker Desktop) run the Docker daemon inside a lightweight VM. The VM's outbound traffic takes a different network path than host processes:
Host process (curl): Process → TUN (Shadowrocket) → landing proxy → internet ✅
VM process (Docker): Docker daemon → VM bridge → host network → TUN → ??? ❌
The TUN handles host-originated traffic correctly but may drop or delay VM-bridged traffic (different TCP stack, MTU, keepalive behavior).
Critical distinction: docker pull vs docker build use different proxy paths:
| Operation | Proxy source | What controls it |
|---|---|---|
docker pull |
Docker daemon config | ~/.orbstack/config/docker.json or docker info |
docker build (RUN apt/apk) |
Build container env | --build-arg http_proxy=... or --network host |
docker run |
Container env | -e http_proxy=... or inherited from daemon |
Fixing docker.json alone will NOT fix docker build — the RUN commands inside the build container don't inherit daemon proxy settings.
Diagnosis — identify which sub-problem:
# 1. Can the Docker daemon pull images?
docker pull --quiet alpine:latest 2>&1
# 2. Can a RUN command inside a build reach the internet?
docker build --no-cache - <<'EOF' 2>&1
FROM alpine:latest
RUN apk update && echo "APK OK"
EOF
# 3. Can a running container reach the internet?
docker run --rm alpine:latest sh -c "apk update 2>&1 | head -3"
Four sub-problems and their fixes:
2G-1: docker build fails but host works (most common with OrbStack + Shadowrocket)
Symptom: RUN apk add or RUN apt-get install inside docker build fails with Connection refused instantly (< 0.2s), even though host curl to the same URL works.
Root cause: OrbStack's network_proxy: auto creates a transparent proxy inside the VM that intercepts all HTTPS traffic. When Shadowrocket TUN is also active, the transparent proxy's upstream connection breaks — it redirects HTTPS to 127.0.0.1 inside the VM, which has nothing listening.
Diagnosis:
# Verify: inside the container, HTTPS goes to 127.0.0.1 (broken transparent proxy)
docker run --rm alpine:latest sh -c "wget -q --timeout=5 -O /dev/null https://dl-cdn.alpinelinux.org/ 2>&1"
# → "wget: can't connect to remote host (127.0.0.1): Connection refused"
# ^^^^^^^^^^^^ This is the smoking gun
# Verify: --network host bypasses the VM bridge and works
docker run --rm --network host alpine:latest sh -c "apk update 2>&1 | head -3"
# → "v3.23.x ... OK: 27431 distinct packages available" ← Works!
Fix — use --network host for docker build:
docker build --network host -f Dockerfile -t myimage .
This bypasses OrbStack's VM network bridge entirely. The build container uses the host's network stack directly, where Shadowrocket TUN correctly handles traffic.
Trade-off: --network host disables build-time network isolation. For CI/CD, prefer fixing the proxy config (2G-2). For local development, --network host is the pragmatic fix.
Permanent fix — if all your builds need this, add to ~/.docker/daemon.json or use a shell alias:
# Shell alias (add to ~/.zshrc)
alias docker-build='docker build --network host'
2G-2: OrbStack auto-detects and caches proxy config
OrbStack's network_proxy: auto reads http_proxy from the shell environment and configures the Docker daemon. The config is stored in ~/.orbstack/config/docker.json.
Key behaviors:
network_proxy: auto— OrbStack reads host env, creates transparent proxy in VMnetwork_proxy: none— Disables transparent proxy, but VM bridge traffic still routes through TUN (may timeout)docker.json— Controlsdocker pullproxy, NOTdocker buildRUN commands
Diagnosis:
# Check all three layers
echo "=== OrbStack config ==="
orbctl config get network_proxy
echo "=== docker.json (daemon proxy) ==="
cat ~/.orbstack/config/docker.json
echo "=== Docker info (effective proxy) ==="
docker info | grep -iE "proxy|No Proxy"
Fix — configure docker.json with host.internal (OrbStack resolves this to the host IP):
python3 -c "
import json, os
config = {
'proxies': {
'http-proxy': 'http://host.internal:1082',
'https-proxy': 'http://host.internal:1082',
'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0/24,100.64.0.0/10,host.internal,*.local'
}
}
path = os.path.expanduser('~/.orbstack/config/docker.json')
json.dump(config, open(path, 'w'), indent=2)
print('Written:', path)
"
# Full restart required
orbctl stop && sleep 3 && orbctl start
Important: Use host.internal (OrbStack-specific), NOT 127.0.0.1 (points to VM loopback) and NOT host.docker.internal (may not resolve in all contexts).
Why NOT remove the proxy: When TUN is active, removing the Docker proxy means VM traffic goes directly through the bridge → TUN path, which causes TLS handshake timeouts. The proxy provides a working outbound channel.
2G-3: Removing proxy makes Docker worse (counter-intuitive)
| Docker config | Traffic path | Result |
|---|---|---|
Proxy ON (127.0.0.1), no no-proxy |
Docker → VM proxy → ??? | docker pull may work |
Use this skill
Connect your agent to the Skill Store MCP server and ask for it by ref: daymade/tunnel-doctor-68609262086.
Details
- Publisher
- daymade
- Category
- Software Development › DevOps & cloud
- License
- MIT
- Updated
- 2026-10-19
References
More from daymade
- App Evolver Improves an existing application using its code, product contract, authorized work history and co…
- Auto Repo Setup Diagnoses and repairs repository setup and guarded Git workflows for Claude Code or Codex — envir…
- Bilibili Source Fetches real, citable Bilibili (B站) video data — stats, metadata, tags, and full danmaku text — v…
- Cli Demo Generator Generates professional animated CLI demos as GIFs using VHS terminal recordings.