Files
claude-code-skills-reference/tunnel-doctor/references/proxy_conflict_reference.md
daymade a496c91cae fix: prevent dictionary false positives + add tunnel-doctor WSL/Go findings
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>
2026-03-21 15:56:38 +08:00

18 KiB
Raw Blame History

Proxy Tool Fix Reference

Detailed instructions for making each proxy tool coexist with Tailscale on macOS.

Contents

  • Shadowrocket (macOS ARM)
  • Clash / ClashX Pro
  • Surge
  • NO_PROXY Environment Variable
  • General Principles

Shadowrocket (macOS ARM)

The Problem

Shadowrocket's tun-excluded-routes adds a system route 100.64/10 → default gateway (en0) for each excluded CIDR. This route has higher priority (UGSc) than Tailscale's route (UCSI), hijacking all Tailscale traffic.

The Fix (Three Settings)

Three Shadowrocket settings work together to handle Tailscale traffic correctly:

1. [Rule] — Add DIRECT rule (handles TUN-level routing)

IP-CIDR,100.64.0.0/10,DIRECT

This lets Tailscale traffic enter the Shadowrocket TUN interface, where the DIRECT rule passes it through without proxying. The system route table remains clean.

2. skip-proxy — Add Tailscale CGNAT range (fixes browser 503)

In [General], add 100.64.0.0/10 to skip-proxy:

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

Why this is needed: Browsers (Chrome, Safari) use the system proxy set by the VPN profile, not http_proxy env vars. Without skip-proxy, the browser sends Tailscale requests to Shadowrocket's proxy process. The DIRECT rule tells the proxy to connect "directly" — but the proxy connects via Wi-Fi (en0), not Tailscale's utun, resulting in HTTP 503.

With skip-proxy, the system bypasses the proxy entirely for these IPs. The browser connects through the normal OS network stack where Tailscale's routing works correctly.

3. tun-excluded-routes — Do NOT add 100.64.0.0/10

Never add 100.64.0.0/10 to tun-excluded-routes. This breaks Tailscale completely:

  • Shadowrocket adds 100.64/10 → en0 (UGSc) to the system route table
  • This overrides Tailscale's 100.64/10 → utun (UCSI) route
  • Result: tailscale ping works (Tailscale-layer), but SSH, ping, curl, browser all fail (OS-layer)
  • Reverting and restarting Shadowrocket VPN restores the routes

Config API

Shadowrocket exposes a config editor API when the Edit Plain Text view is open:

# Read current config
NO_PROXY="<shadowrocket-ip>" curl -s "http://<shadowrocket-ip>:8080/api/read"

# Save updated config (replaces editor buffer)
NO_PROXY="<shadowrocket-ip>" curl -s -X POST "http://<shadowrocket-ip>:8080/api/save" --data-binary @config.txt

Detect Shadowrocket IP: The device IP changes with DHCP. Do not hardcode it. Detect it before use:

# If you know the device is on the same subnet
# Check common ports or use mDNS
curl --noproxy '*' -s --connect-timeout 2 "http://192.168.31.110:8080/api/read" | head -1

Port conflict warning: Shadowrocket's config API listens on port 8080 by default, which may conflict with other services (e.g., whisper.cpp server, development proxies). If the API returns unexpected content (HTML, JSON from another service), verify what is actually listening on the port:

lsof -nP -iTCP:8080 | head -5

If another service owns port 8080, you need to either stop that service or access the Shadowrocket API from a different device on the same network.

Critical: Use --data-binary, NOT -d. The -d flag URL-encodes the content, corrupting #, =, & and other characters in the config. This destroys the entire configuration — all rules, settings, and proxy groups are lost. The user must restore from backup.

# CORRECT — preserves raw content
curl -s -X POST "http://<ip>:8080/api/save" --data-binary @config.txt

# WRONG — URL-encodes special chars, destroys config
curl -s -X POST "http://<ip>:8080/api/save" -d @config.txt

Important: The API save only writes to the editor buffer. The user must click Save in the Shadowrocket UI to persist changes. After saving, the VPN connection must be restarted for route changes to take effect.

Example tun-excluded-routes (correct)

tun-excluded-routes = 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 255.255.255.255/32

Note: 100.64.0.0/10 is intentionally absent.

Complete Working Reference Config for Tailscale Compatibility

This is a validated reference showing the correct relationship between skip-proxy, tun-excluded-routes, and [Rule] for Tailscale coexistence:

[General]
# skip-proxy: bypass the HTTP proxy for these destinations (fixes browser 503)
# 100.64.0.0/10 MUST be here for browser access to Tailscale IPs
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

# tun-excluded-routes: CIDRs excluded from TUN routing (sent directly via physical interface)
# 100.64.0.0/10 must NOT be here — including it creates an en0 route that overrides Tailscale
tun-excluded-routes = 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.88.99.0/24, 192.168.0.0/16, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 255.255.255.255/32

[Rule]
# Tailscale traffic enters TUN but is passed through without proxying
IP-CIDR,100.64.0.0/10,DIRECT
# ... other rules ...

Key points:

  • skip-proxy — YES, include 100.64.0.0/10 (browser bypass)
  • tun-excluded-routes — NO, never include 100.64.0.0/10 (would hijack routing)
  • [Rule] — YES, include IP-CIDR,100.64.0.0/10,DIRECT (TUN passthrough)

Clash / ClashX Pro

The Fix

Add Tailscale CIDRs to the rules section before MATCH:

rules:
  - IP-CIDR,100.64.0.0/10,DIRECT
  - IP-CIDR,fd7a:115c:a1e0::/48,DIRECT
  # ... other rules ...
  - MATCH,PROXY

For Clash with TUN mode, also add to tun.excluded-routes (if TUN mode doesn't create conflicting system routes on macOS):

tun:
  enable: true
  # Only if this doesn't create conflicting system routes:
  # excluded-routes:
  #   - 100.64.0.0/10

Test with route -n get 100.x.x.x after applying to confirm no en0 hijack.

Surge

The Fix

Add to the [Rule] section:

IP-CIDR,100.64.0.0/10,DIRECT
IP-CIDR,fd7a:115c:a1e0::/48,DIRECT

In Surge's TUN Excluded Routes (if available), the same caveat applies as Shadowrocket: excluding 100.64.0.0/10 may add an en0 route. Test with route -n get to confirm.

Surge also supports skip-proxy and always-real-ip. Adding skip-proxy is required to fix browser 503 (same mechanism as Shadowrocket):

[General]
skip-proxy = 100.64.0.0/10, fd7a:115c:a1e0::/48
always-real-ip = *.ts.net

NO_PROXY Environment Variable

The Problem

Even when system routes are correct (Tailscale utun interface wins), HTTP clients like curl, Python requests, and Node.js fetch respect http_proxy/https_proxy env vars. If NO_PROXY doesn't exclude Tailscale addresses, HTTP traffic is sent to the proxy process, which may fail to reach 100.x addresses.

This is a different conflict layer from route hijacking — routes are fine, but the application bypasses them by sending traffic to the local proxy port.

The Fix

export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*

NO_PROXY Syntax Pitfalls

Syntax curl Python requests Go net/http Node.js Meaning
.ts.net Domain suffix match (correct)
*.ts.net varies Glob — curl and Go do NOT support this
100.64.0.0/10 7.86+ 2.25+ native CIDR notation — Go silently ignores it
100.* Too broad — covers public IPs 100.0-63.* and 100.128-255.*
workstation-name Exact hostname match (safest for Go)

Go net/http warning: Go's proxy bypass logic (httpproxy.Config.ProxyFunc) does not implement CIDR matching. NO_PROXY=100.64.0.0/10 is silently ignored — Go programs will still route traffic through the proxy. Use MagicDNS hostnames (e.g., workstation-4090-wsl) or explicit IPs (e.g., 100.101.102.103) instead of CIDR ranges when Go programs need to bypass the proxy.

Key rule: Always use .ts.net (leading dot, no asterisk) for domain suffix matching. This is the most portable syntax across all HTTP clients.

Why Not 100.*?

100.0.0.0/8 includes public IP space:

  • 100.0.0.0 100.63.255.255public IPs
  • 100.64.0.0 100.127.255.255 — CGNAT (Tailscale uses this)
  • 100.128.0.0 100.255.255.255public IPs

Using 100.* in NO_PROXY would bypass the proxy for services on public 100.x IPs — potentially breaking access to GFW-blocked services that happen to use those addresses.

MagicDNS Recommendation

Prefer accessing Tailscale devices by MagicDNS name (e.g., my-server or my-server.tailnet.ts.net) rather than raw IPs. This makes .ts.net in NO_PROXY the primary bypass mechanism, with 100.64.0.0/10 as a fallback for direct IP usage.

Check MagicDNS status:

tailscale dns status

SSH ProxyCommand and Git Operations

The Problem

Many developers in China configure SSH with ProxyCommand connect -H 127.0.0.1:<port> to tunnel SSH through their HTTP proxy. This works fine for interactive SSH and small operations. But when Shadowrocket (or Clash/Surge) runs in TUN mode, this creates a double tunnel:

  1. connect -H creates an HTTP CONNECT tunnel to the local proxy port
  2. Shadowrocket TUN captures the same traffic at the system level

The landing proxy sees a long-lived HTTP CONNECT connection and may drop it during large data transfers (git push, git clone of large repos).

Data Flow Comparison

Double tunnel (broken):
SSH → connect -H (HTTP CONNECT tunnel) → Shadowrocket local port 1082
                                          → Shadowrocket TUN → landing proxy → GitHub

Single tunnel (correct):
SSH → system network stack → Shadowrocket TUN → landing proxy → GitHub

The HTTP CONNECT tunnel adds protocol framing overhead. The landing proxy (落地代理) sees a long-lived HTTP CONNECT connection and may apply aggressive timeouts or buffer limits, dropping the connection during large transfers.

Detecting TUN Mode

# If utun interfaces exist (other than Tailscale's), a VPN TUN is active
ifconfig | grep '^utun'

If Shadowrocket/Clash/Surge TUN is active, ProxyCommand connect -H is redundant.

The Fix — SSH over Port 443 without ProxyCommand

# 1. Add ssh.github.com host key
ssh-keyscan -p 443 ssh.github.com >> ~/.ssh/known_hosts

# 2. Update ~/.ssh/config
Host github.com
    HostName ssh.github.com
    Port 443
    User git
    # No ProxyCommand — Shadowrocket TUN handles routing at the system level.
    # Port 443 gets longer timeouts from landing proxies than port 22.
    ServerAliveInterval 60
    ServerAliveCountMax 3
    IdentityFile ~/.ssh/id_ed25519

Why Port 443

HTTP proxies (and landing proxies) are optimized for port 443 traffic:

  • Longer connection timeouts: HTTPS connections are expected to be long-lived (WebSocket, streaming, large file downloads)
  • Larger buffer limits: Proxies allocate more resources for 443 traffic
  • No protocol inspection: Port 22 may trigger deep packet inspection on some proxies; 443 is treated as opaque TLS

GitHub officially supports SSH on port 443 via ssh.github.com — it's the same service, same authentication, different port.

Fallback When VPN Is Off

Without Shadowrocket TUN, SSH can't reach GitHub directly from China. Options:

  1. Keep old config as comment — manually uncomment ProxyCommand when needed
  2. Use Match directive — conditionally apply ProxyCommand (advanced):
Host github.com
    HostName ssh.github.com
    Port 443
    User git
    ServerAliveInterval 60
    ServerAliveCountMax 3
    IdentityFile ~/.ssh/id_ed25519

# Uncomment when Shadowrocket is off:
#   ProxyCommand /opt/homebrew/bin/connect -H 127.0.0.1:1082 %h %p

Verification

# Auth test
ssh -T git@github.com
# → Hi username! You've successfully authenticated...

# Verbose — confirm ssh.github.com:443
ssh -v -T git@github.com 2>&1 | grep 'Connecting to'
# → Connecting to ssh.github.com [20.205.243.160] port 443.

# Large transfer test
cd /path/to/repo && git push origin main

Performance Trade-off

Connection setup is slightly slower (~6s vs ~2s) because TUN routing has more network hops than a direct HTTP CONNECT tunnel. Actual data transfer speed is the same (bottlenecked by bandwidth, not connection setup).

General Principles

Five Conflict Layers

Proxy tools create conflicts at five independent layers on macOS. Layers 1-3 affect Tailscale connectivity; Layer 4 affects SSH git operations; Layer 5 affects VM/container runtimes:

Layer Setting What it controls Symptom when wrong
1. Route table tun-excluded-routes OS-level IP routing Everything broken (SSH, curl, browser). tailscale ping works but ping doesn't
2. HTTP env vars http_proxy / NO_PROXY CLI tools (curl, wget, Python, Node.js) curl times out, SSH works, browser works
3. System proxy skip-proxy Browser and system HTTP clients Browser 503, curl works (both with/without proxy), SSH works
4. SSH ProxyCommand ProxyCommand connect -H SSH git operations (push/pull/clone) ssh -T works, git push fails intermittently with failed to begin relaying via HTTP
5. VM/Container proxy Docker/OrbStack proxy config docker pull, docker build Host curl works, docker pull times out (TLS handshake timeout)

Each layer is independent. A fix at one layer doesn't help the others. You may need fixes at multiple layers simultaneously.

Why tun-excluded-routes Breaks Tailscale

On macOS, when a VPN tool excludes a CIDR from its TUN interface, it typically adds a system route pointing that CIDR to the default gateway via en0. For 100.64.0.0/10:

100.64/10  192.168.x.1  UGSc  en0       ← VPN tool adds this
100.64/10  link#N       UCSI  utun7     ← Tailscale's route

macOS route priority: UGSc > UCSI for same prefix length. Result: Tailscale traffic goes to the router, which has no route to 100.x addresses.

Why skip-proxy Is Needed for Browsers

Even with correct routes and a DIRECT rule, browsers can still get 503. The flow:

  1. Browser sends request to Shadowrocket's system proxy (set by VPN profile)
  2. Shadowrocket matches IP-CIDR,100.64.0.0/10,DIRECT
  3. Shadowrocket tries to connect "directly" — but from its own process context, via Wi-Fi (en0)
  4. 100.x.x.x is unreachable via en0 → 503

curl works because it uses the http_proxy env var (or no proxy with --noproxy), going through the OS network stack where Tailscale routing works. Browsers don't use http_proxy — they use the system proxy.

Adding 100.64.0.0/10 to skip-proxy makes the system bypass the proxy entirely for those IPs. The browser connects directly through the OS network stack → Tailscale utun handles routing → connection succeeds.

The Correct Approach

For full Tailscale compatibility with proxy tools, apply all four fixes:

  1. [Rule]: IP-CIDR,100.64.0.0/10,DIRECT — handles TUN-level traffic
  2. skip-proxy: Add 100.64.0.0/10 — fixes browser access
  3. NO_PROXY env var: Add 100.64.0.0/10,.ts.net — fixes CLI HTTP tools
  4. SSH ~/.ssh/config: Remove ProxyCommand, use ssh.github.com:443 — fixes git push/pull

Critical anti-pattern: Do NOT add 100.64.0.0/10 to tun-excluded-routes — this breaks everything (see "Why tun-excluded-routes Breaks Tailscale" above).

Quick Verification

After any fix, always verify:

# Route should go through Tailscale utun, not en0
route -n get <tailscale-ip>

# Should show only one 100.64/10 route (Tailscale's)
netstat -rn | grep 100.64

# SSH must work
ssh -o ConnectTimeout=5 <user>@<tailscale-ip> 'echo ok'

# curl must work (with and without proxy)
curl --noproxy '*' -s -o /dev/null -w "%{http_code}" http://<tailscale-ip>:<port>/
curl -s -o /dev/null -w "%{http_code}" http://<tailscale-ip>:<port>/

# Browser must work (open in Chrome, no 503)

SSH Non-Login Shell Pitfall

When SSHing to a remote macOS machine, non-login shells don't load ~/.zshrc. Tools installed via nvm, Homebrew, or other shell-level managers won't be in $PATH. Proxy env vars set in ~/.zshrc also won't be loaded.

# FAILS — non-login shell, nvm/proxy not loaded
ssh <tailscale-ip> 'node --version'
# → command not found

# WORKS — explicitly source shell config
ssh <tailscale-ip> 'source ~/.zshrc 2>/dev/null; node --version'
# → v22.18.0

Note: bash -lc loads .bash_profile but NOT .zshrc. On macOS (default shell is zsh), always use source ~/.zshrc or zsh -ic for interactive shell initialization.

localhost Proxy Interception in Scripts

When http_proxy is set globally (common in China), any script or Makefile that curls localhost will fail unless it bypasses the proxy. This affects health checks, warmup scripts, and test harnesses.

Fix: Add --noproxy localhost to every localhost curl call in Makefiles and scripts:

# Health check that works regardless of proxy settings
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live && echo "OK"

Or set no_proxy in ~/.zshrc alongside http_proxy:

export http_proxy=http://127.0.0.1:1082
export https_proxy=http://127.0.0.1:1082
export no_proxy=localhost,127.0.0.1   # Always add this alongside proxy vars

Emergency Rollback

If a proxy config change breaks Tailscale connectivity:

# Revert the config change and restart Shadowrocket VPN
# This restores the original routes

# Or manually delete a conflicting route:
sudo route delete -net 100.64.0.0/10 <gateway-ip>

Important: Manually deleting a bad en0 route with sudo route delete is only a temporary fix. Shadowrocket will re-add the route when the VPN connection is next reconnected or toggled. The only permanent fix is modifying the Shadowrocket configuration to remove 100.64.0.0/10 from tun-excluded-routes (it should never be there).

If tun-excluded-routes was modified, reverting it and restarting Shadowrocket will restore Tailscale's routing immediately.