[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M6F3QXVXW0AEMC8BV5DR4PFK":3,"skill-same:01M6F3QXVXW0AEMC8BV5DR4PFK":73},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":23,"publishedAt":44,"linkOnly":45,"markdownContent":46,"markdownBody":47,"frontMatter":48,"refName":50,"commitSha":51,"skillDirectory":52,"forksCount":53,"repositoryHtmlUrl":54,"repositoryHomepageUrl":55,"ownerHtmlUrl":56,"ownerBlogUrl":6,"ownerDescription":57,"updatedAt":58,"install":59,"resources":72},"01M6F3QXVXW0AEMC8BV5DR4PFK","tunnel-doctor",null,"Diagnoses and fixes conflicts between Tailscale and proxy\u002FVPN tools (Shadowrocket, Clash, Surge) on macOS. Covers five conflict layers - (1) route hijacking, (2) HTTP proxy env var interception, (3) system proxy bypass, (4) SSH ProxyCommand double tunneling, and (5) VM\u002Fcontainer runtime proxy propagation (OrbStack\u002FDocker). Includes SOP for remote development via SSH tunnels with proxy-safe Makefile patterns. Use when Tailscale ping works but SSH\u002FHTTP times out, when browser returns 503 but curl works, when git push fails with \"failed to begin relaying via HTTP\", when Docker pull times out behind TUN\u002FVPN, when setting up Tailscale SSH to WSL instances, or when bootstrapping remote dev environments over Tailscale.","ComeOnOliver\u002Fskillshub","ComeOnOliver","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F43215937?v=4","01M6E5JNFECJAAVFE0W7SMWWD2","Haoran Shu","01M6E5JNNDQS7AJZDYFQ8TPZCK",67,"MIT","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fdaymade\u002Fclaude-code-skills\u002Ftunnel-doctor\u002FSKILL.md",[18],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000011","运维与部署","SCENARIO","使用场景",[24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43],"tunnel doctor","doctor","tunnel","edit","remote","surge","env","conflict","instances","conflicts","use","macos","read","git push","system","makefile","dev","vm","git","safe","2026-11-10 10:12:30",false,"---\nname: tunnel-doctor\ndescription: Diagnoses and fixes conflicts between Tailscale and proxy\u002FVPN tools (Shadowrocket, Clash, Surge) on macOS. Covers five conflict layers - (1) route hijacking, (2) HTTP proxy env var interception, (3) system proxy bypass, (4) SSH ProxyCommand double tunneling, and (5) VM\u002Fcontainer runtime proxy propagation (OrbStack\u002FDocker). Includes SOP for remote development via SSH tunnels with proxy-safe Makefile patterns. Use when Tailscale ping works but SSH\u002FHTTP times out, when browser returns 503 but curl works, when git push fails with \"failed to begin relaying via HTTP\", when Docker pull times out behind TUN\u002FVPN, when setting up Tailscale SSH to WSL instances, or when bootstrapping remote dev environments over Tailscale.\nallowed-tools: Read, Grep, Edit, Bash\n---\n\n# Tunnel Doctor\n\nDiagnose and fix conflicts when Tailscale coexists with proxy\u002FVPN tools on macOS, with specific guidance for SSH access to WSL instances.\n\n## Five Conflict Layers\n\nProxy\u002FVPN tools on macOS create conflicts at five independent layers. Layers 1-3 affect Tailscale connectivity; Layer 4 affects SSH git operations; Layer 5 affects VM\u002Fcontainer runtimes:\n\n| Layer | What breaks | What still works | Root cause |\n|-------|-------------|------------------|------------|\n| 1. Route table | Everything (SSH, curl, browser) | `tailscale ping` | `tun-excluded-routes` adds `en0` route overriding Tailscale utun |\n| 2. HTTP env vars | `curl`, Python requests, Node.js fetch | SSH, browser | `http_proxy` set without `NO_PROXY` for Tailscale |\n| 3. System proxy (browser) | Browser only (HTTP 503) | SSH, `curl` (both with\u002Fwithout proxy) | Browser uses VPN system proxy; DIRECT rule routes via Wi-Fi, not Tailscale utun |\n| 4. SSH ProxyCommand double tunnel | `git push\u002Fpull` (intermittent) | `ssh -T` (small data) | `connect -H` creates HTTP CONNECT tunnel redundant with Shadowrocket TUN; landing proxy drops large\u002Flong-lived transfers |\n| 5. VM\u002FContainer proxy propagation | `docker pull`, `docker build` | Host `curl`, running containers | VM runtime (OrbStack\u002FDocker Desktop) auto-injects or caches proxy config; removing proxy makes it worse (VM traffic via TUN → TLS timeout) |\n\n## Diagnostic Workflow\n\n### Step 1: Identify the Symptom\n\nDetermine which scenario applies:\n\n- **Browser returns HTTP 503, but `curl` and SSH both work** → System proxy bypass conflict (Step 2C)\n- **`local.\u003Cdomain>` fails in browser\u002Fdefault `curl`, but direct\u002Fno-proxy request works** → Local vanity domain proxy interception (Step 2C-1)\n- **Tailscale ping works, SSH works, but curl\u002FHTTP times out** → HTTP proxy env var conflict (Step 2A)\n- **Tailscale ping works, SSH\u002FTCP times out** → Route conflict (Step 2B)\n- **Remote dev server auth redirects to `localhost` → browser can't follow** → SSH tunnel needed (Step 2D)\n- **`make status` \u002F scripts curl to localhost fail with proxy** → localhost proxy interception (Step 2E)\n- **`git push\u002Fpull` fails with `FATAL: failed to begin relaying via HTTP`** → SSH double tunnel (Step 2F)\n- **`docker build` `RUN apk\u002Fapt` fails with `Connection refused` instantly** → OrbStack transparent proxy + TUN conflict (Step 2G-1, fix: `--network host`)\n- **`docker pull` fails with `TLS handshake timeout`** → VM proxy misconfiguration (Step 2G-2, fix: `docker.json` with `host.internal`)\n- **Container healthcheck `(unhealthy)` but app runs fine** → Lowercase proxy env var leak (Step 2G-4, fix: clear `http_proxy`+`HTTP_PROXY`)\n- **`docker build` can't fetch base images** → VM\u002Fcontainer proxy propagation (Step 2G)\n- **`git clone` fails with `Connection closed by 198.18.x.x`** → TUN DNS hijack for SSH (Step 2H)\n- **SSH connects but `operation not permitted`** → Tailscale SSH config issue (Step 4)\n- **SSH connects but `be-child ssh` exits code 1** → WSL snap sandbox issue (Step 5)\n- **TCP port 22 reachable (`nc -z` succeeds) but SSH fails with `kex_exchange_identification: Connection closed`** → Tailscale SSH proxy intercept on WSL (Step 5A)\n- **`tailscale ssh` returns \"not available on App Store builds\"** → Wrong Tailscale distribution on macOS (Step 5B)\n\n**Key distinctions**:\n- SSH does NOT use `http_proxy`\u002F`NO_PROXY` env vars. If SSH works but HTTP doesn't → Layer 2.\n- `curl` uses `http_proxy` env var, NOT the system proxy. Browser uses system proxy (set by VPN). If `curl` works but browser doesn't → Layer 3.\n- If `tailscale ping` works but regular `ping` doesn't → Layer 1 (route table corrupted).\n- If `ssh -T git@github.com` works but `git push` fails intermittently → Layer 4 (double tunnel).\n- If host `curl https:\u002F\u002F...` works but `docker pull` times out → Layer 5 (VM proxy propagation).\n- If `docker pull` works but `docker build` `RUN apk add` fails instantly with `Connection refused` → OrbStack transparent proxy broken by TUN (Step 2G-1).\n- If container healthcheck shows `(unhealthy)` but app works → lowercase `http_proxy` leaked into container (Step 2G-4).\n- If DNS resolves to `198.18.x.x` virtual IPs → TUN DNS hijack (Step 2H).\n- If `nc -z` succeeds on port 22 but SSH gets no banner (`kex_exchange_identification`) → Tailscale SSH proxy intercept (Step 5A). Confirm with `tcpdump -i any port 22` on the remote — 0 packets means Tailscale intercepts above the kernel.\n- If `tailscale ssh` fails with \"not available on App Store builds\" → install Standalone Tailscale (Step 5B).\n\n### Fast Path: Run Automated Checks\n\nFor common macOS conflicts (env proxy, system proxy exceptions, direct\u002Fproxy path split, local TLS trust), run:\n\n```bash\npython3 scripts\u002Fquick_diagnose.py --host local.claude4.dev --url https:\u002F\u002Flocal.claude4.dev\u002Fhealth\n```\n\nOptional route ownership check for a Tailscale destination:\n\n```bash\npython3 scripts\u002Fquick_diagnose.py --host \u003Ctarget-host> --url http:\u002F\u002F\u003Ctarget-host>:\u003Cport>\u002Fhealth --tailscale-ip \u003C100.x.x.x>\n```\n\nInterpretation:\n- `direct=PASS` + `forced_proxy=FAIL` = host must bypass proxy (`skip-proxy` + `NO_PROXY`).\n- `strict_tls=FAIL` + `direct=PASS` = path is reachable; trust issue only (install\u002Ftrust local CA).\n- `host in scutil exceptions: no` = browser\u002Fsystem clients still likely proxied.\n\n### Step 2A: Fix HTTP Proxy Environment Variables\n\nCheck if proxy env vars are intercepting Tailscale HTTP traffic:\n\n```bash\nenv | grep -i proxy\n```\n\n**Broken output** — proxy is set but `NO_PROXY` doesn't exclude Tailscale:\n```\nhttp_proxy=http:\u002F\u002F127.0.0.1:1082\nhttps_proxy=http:\u002F\u002F127.0.0.1:1082\nNO_PROXY=localhost,127.0.0.1          ← Missing Tailscale!\n```\n\n**Fix** — add Tailscale MagicDNS domain + CIDR to `NO_PROXY`:\n\n```bash\nexport NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0\u002F10,192.168.*,10.*,172.16.*\n```\n\n| Entry | Covers | Why |\n|-------|--------|-----|\n| `.ts.net` | MagicDNS domains (`host.tailnet.ts.net`) | Matched before DNS resolution |\n| `100.64.0.0\u002F10` | Tailscale IPs (`100.64.*` – `100.127.*`) | Precise CIDR, no public IP false positives |\n| `192.168.*,10.*,172.16.*` | RFC 1918 private networks | LAN should never be proxied |\n\n**Two layers complement each other**: `.ts.net` handles domain-based access, `100.64.0.0\u002F10` handles direct IP access.\n\n**NO_PROXY syntax pitfalls** — see [references\u002Fproxy_conflict_reference.md](references\u002Fproxy_conflict_reference.md) for the compatibility matrix.\n\n**Go `net\u002Fhttp` CIDR caveat**: Go's standard `net\u002Fhttp` does NOT support CIDR notation in `NO_PROXY`. Setting `NO_PROXY=100.64.0.0\u002F10` 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., `workstation-4090-wsl`) instead of raw IPs, or add explicit hostnames to `NO_PROXY`:\n\n```bash\n# WRONG for Go programs — CIDR is silently ignored\nNO_PROXY=100.64.0.0\u002F10 go-program http:\u002F\u002F100.101.102.103:8002\u002Fhealth  # → goes through proxy\n\n# CORRECT — use hostname (matched as suffix) or explicit IP\nexport NO_PROXY=localhost,127.0.0.1,.ts.net,workstation-4090-wsl,100.101.102.103,192.168.*,10.*,172.16.*\n```\n\nThis is especially relevant when accessing Tailscale services from Go-based tools (e.g., custom CLIs, Go test suites hitting remote APIs).\n\nVerify the fix:\n\n```bash\n# Both must return HTTP 200:\nNO_PROXY=\"...(new value)...\" curl -s --connect-timeout 5 http:\u002F\u002F\u003Chost>.ts.net:\u003Cport>\u002Fhealth -w \"HTTP %{http_code}\\n\"\nNO_PROXY=\"...(new value)...\" curl -s --connect-timeout 5 http:\u002F\u002F\u003Ctailscale-ip>:\u003Cport>\u002Fhealth -w \"HTTP %{http_code}\\n\"\n```\n\nThen persist in shell config (`~\u002F.zshrc` or `~\u002F.bashrc`).\n\n### Step 2B: Detect Route Conflicts\n\nCheck if a proxy tool hijacked the Tailscale CGNAT range:\n\n```bash\nroute -n get \u003Ctailscale-ip>\n```\n\n**Healthy output** — traffic goes through Tailscale interface:\n```\ndestination: 100.64.0.0\ninterface: utun7    # Tailscale interface (utunN varies)\n```\n\n**Broken output** — proxy hijacked the route:\n```\ndestination: 100.64.0.0\ngateway: 192.168.x.1    # Default gateway\ninterface: en0           # Physical interface, NOT Tailscale\n```\n\n**Important**: Not all `utun` interfaces are Tailscale's. Verify which utun belongs to Tailscale before concluding the route is correct:\n\n```bash\n# Find Tailscale's utun interface (has a 100.x.x.x IP)\nifconfig | grep -A2 'inet 100\\.'\n```\n\nQuick indicators by MTU:\n- **MTU 1280** → typically Tailscale\n- **MTU 4064** → typically Shadowrocket TUN\n\nIf `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`.\n\nConfirm with full route table:\n\n```bash\nnetstat -rn | grep 100.64\n```\n\nTwo competing routes indicate a conflict:\n```\n100.64\u002F10  192.168.x.1   UGSc  en0       ← Proxy added this (wins)\n100.64\u002F10  link#N        UCSI  utun7     ← Tailscale route (loses)\n```\n\n**Root cause**: On macOS, `UGSc` (Static Gateway) takes priority over `UCSI` (Cloned Static Interface) for the same prefix length.\n\n### Step 2C: Fix System Proxy Bypass (Browser 503)\n\n**Symptom**: Browser shows HTTP 503 for `http:\u002F\u002F\u003Ctailscale-ip>:\u003Cport>`, but both `curl --noproxy '*'` and `curl` (with proxy env var) return 200. SSH also works.\n\n**Root cause**: The browser uses the system proxy configured by the VPN profile (Shadowrocket\u002FClash\u002FSurge). The proxy matches `IP-CIDR,100.64.0.0\u002F10,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.\n\n**Diagnosis**:\n\n```bash\n# curl with proxy env var works (curl connects to proxy port, but traffic flows differently)\ncurl -s -o \u002Fdev\u002Fnull -w \"%{http_code}\" http:\u002F\u002F\u003Ctailscale-ip>:\u003Cport>\u002F\n# → 200\n\n# Browser gets 503 because it goes through the VPN system proxy, not http_proxy env var\n```\n\n**Fix** — add Tailscale CGNAT range to `skip-proxy` in the proxy tool config:\n\nFor Shadowrocket, in `[General]`:\n```\nskip-proxy = 192.168.0.0\u002F16, 10.0.0.0\u002F8, 172.16.0.0\u002F12, 100.64.0.0\u002F10, localhost, *.local, captive.apple.com\n```\n\n`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.\n\n**Why `skip-proxy` works but `tun-excluded-routes` doesn't**:\n- `skip-proxy`: Bypasses the HTTP proxy layer only. Traffic still flows through the TUN interface and Tailscale utun handles it. Safe.\n- `tun-excluded-routes`: Removes the CIDR from the TUN routing entirely. This creates a competing `en0` route that overrides Tailscale. Breaks everything.\n\n#### Step 2C-1: Fix Local Vanity Domain Interception (`local.\u003Cdomain>`)\n\n**Symptom**: `https:\u002F\u002Flocal.\u003Cdomain>` fails in browser or default `curl`, but succeeds with direct\u002Fno-proxy command:\n\n```bash\nenv -u http_proxy -u https_proxy curl -k -I https:\u002F\u002Flocal.\u003Cdomain>\u002Fhealth\n# -> 200\ncurl -I https:\u002F\u002Flocal.\u003Cdomain>\u002Fhealth\n# -> proxy CONNECT then TLS reset\u002Ffailure\n```\n\n**Root cause**: The domain is routed through system\u002Fshell proxy instead of local direct path.\n\n**Fix**:\n1. Add domain to proxy app bypass list (`skip-proxy` for Shadowrocket).\n2. Add domain to shell bypass list (`NO_PROXY`\u002F`no_proxy`).\n3. If local TLS uses internal CA, trust the local root certificate.\n\n```bash\n# ~\u002F.zshrc\nexport NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0\u002F10,192.168.*,10.*,172.16.*,local.\u003Cdomain>,www.local.\u003Cdomain>\nexport no_proxy=\"$NO_PROXY\"\n```\n\n**Verification**:\n\n```bash\npython3 scripts\u002Fquick_diagnose.py --host local.\u003Cdomain> --url https:\u002F\u002Flocal.\u003Cdomain>\u002Fhealth\n```\n\nExpected:\n- `host in NO_PROXY: yes`\n- `host in scutil exceptions: yes`\n- `ambient=PASS` and `direct=PASS`\n\n### Step 2D: Fix Auth Redirect for Remote Dev (SSH Tunnel)\n\n**Symptom**: Dev server runs on a remote machine (e.g., Mac Mini via Tailscale). You access `http:\u002F\u002F\u003Ctailscale-ip>:3010` in the browser. Login\u002Fsignup works, but after auth, the app redirects to `http:\u002F\u002Flocalhost:3010\u002F` which fails — `localhost` on your machine isn't running the dev server.\n\n**Root cause**: The app's `APP_URL` (or equivalent) is set to `http:\u002F\u002Flocalhost: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.\n\n**Fix** — SSH local port forwarding. This avoids all three conflict layers entirely:\n\n```bash\n# Forward local port 3010 to remote machine's localhost:3010\nssh -NL 3010:localhost:3010 \u003Ctailscale-ip>\n\n# Or with autossh for auto-reconnect (recommended for long sessions)\nautossh -M 0 -f -N -L 3010:localhost:3010 \\\n    -o \"ServerAliveInterval=30\" \\\n    -o \"ServerAliveCountMax=3\" \\\n    -o \"ExitOnForwardFailure=yes\" \\\n    \u003Ctailscale-ip>\n```\n\nNow access `http:\u002F\u002Flocalhost:3010` in the browser. Auth redirects to `localhost:3010` → tunnel → remote dev server → works correctly.\n\n**Why this is the best approach**:\n- No `.env` changes needed — `APP_URL=http:\u002F\u002Flocalhost:3010` works everywhere\n- No Shadowrocket conflicts — `localhost` is always in `skip-proxy`\n- No code changes — same behavior as local development\n- Industry standard — VS Code Remote SSH, GitHub Codespaces use the same pattern\n\n**Install autossh**: `brew install autossh` (macOS) or `apt install autossh` (Linux)\n\n**Kill background tunnel**: `pkill -f 'autossh.*\u003Ctailscale-ip>'`\n\n### Step 2E: Fix localhost Proxy Interception in Scripts\n\n**Symptom**: Makefile targets or scripts that `curl` localhost (health checks, warmup routes) fail or timeout when `http_proxy` is set globally in the shell.\n\n**Root cause**: `http_proxy=http:\u002F\u002F127.0.0.1:1082` is set in `~\u002F.zshrc` but `no_proxy` doesn't include `localhost`. All curl commands send localhost requests through the proxy.\n\n**Fix** — add `--noproxy localhost` to all localhost curl commands in scripts:\n\n```makefile\n# WRONG — fails when http_proxy is set\n@curl -sf http:\u002F\u002Flocalhost:9000\u002Fminio\u002Fhealth\u002Flive && echo \"OK\"\n\n# CORRECT — always bypasses proxy for localhost\n@curl --noproxy localhost -sf http:\u002F\u002Flocalhost:9000\u002Fminio\u002Fhealth\u002Flive && echo \"OK\"\n```\n\nAlternatively, set `no_proxy` globally in `~\u002F.zshrc`:\n\n```bash\nexport no_proxy=localhost,127.0.0.1\n```\n\n### Step 2F: Fix SSH ProxyCommand Double Tunnel (git push\u002Fpull failures)\n\n**Symptom**: `ssh -T git@github.com` succeeds consistently, but `git push` or `git pull` fails intermittently with:\n\n```\nFATAL: failed to begin relaying via HTTP.\nConnection closed by UNKNOWN port 65535\n```\n\nSmall operations (auth, fetch metadata) work; large data transfers fail.\n\n**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\u002Flong-lived HTTP CONNECT connections.\n\n**Diagnosis**:\n\n```bash\n# 1. Confirm Shadowrocket TUN is active\nifconfig | grep '^utun'\n\n# 2. Check SSH config for ProxyCommand\ngrep -A5 'Host github.com' ~\u002F.ssh\u002Fconfig\n\n# 3. Confirm: removing ProxyCommand fixes push\nGIT_SSH_COMMAND=\"ssh -o ProxyCommand=none\" git push origin main\n```\n\n**Fix** — remove ProxyCommand and switch to `ssh.github.com:443`. See [references\u002Fproxy_conflict_reference.md § SSH ProxyCommand and Git Operations](references\u002Fproxy_conflict_reference.md) for the full SSH config, why port 443 helps, and fallback options when VPN is off.\n\n### Step 2G: Fix VM\u002FContainer Runtime Proxy Propagation (Docker pull\u002Fbuild failures)\n\n**Symptom**: `docker pull` or `docker build` fails with `net\u002Fhttp: TLS handshake timeout`, `Connection refused` from Alpine\u002FDebian repos, or `Internal Server Error` from `auth.docker.io`, while host `curl` to the same URLs works fine.\n\n**Applies to**: OrbStack, Docker Desktop, or any VM-based Docker runtime on macOS with Shadowrocket\u002FClash TUN active.\n\n**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:\n\n```\nHost process (curl):   Process → TUN (Shadowrocket) → landing proxy → internet ✅\nVM process (Docker):   Docker daemon → VM bridge → host network → TUN → ??? ❌\n```\n\nThe TUN handles host-originated traffic correctly but may drop or delay VM-bridged traffic (different TCP stack, MTU, keepalive behavior).\n\n**Critical distinction: `docker pull` vs `docker build` use different proxy paths**:\n\n| Operation | Proxy source | What controls it |\n|-----------|-------------|------------------|\n| `docker pull` | Docker daemon config | `~\u002F.orbstack\u002Fconfig\u002Fdocker.json` or `docker info` |\n| `docker build` (`RUN apt\u002Fapk`) | Build container env | `--build-arg http_proxy=...` or `--network host` |\n| `docker run` | Container env | `-e http_proxy=...` or inherited from daemon |\n\nFixing `docker.json` alone will NOT fix `docker build` — the `RUN` commands inside the build container don't inherit daemon proxy settings.\n\n**Diagnosis** — identify which sub-problem:\n\n```bash\n# 1. Can the Docker daemon pull images?\ndocker pull --quiet alpine:latest 2>&1\n\n# 2. Can a RUN command inside a build reach the internet?\ndocker build --no-cache - \u003C\u003C'EOF' 2>&1\nFROM alpine:latest\nRUN apk update && echo \"APK OK\"\nEOF\n\n# 3. Can a running container reach the internet?\ndocker run --rm alpine:latest sh -c \"apk update 2>&1 | head -3\"\n```\n\n**Four sub-problems and their fixes**:\n\n#### 2G-1: `docker build` fails but host works (most common with OrbStack + Shadowrocket)\n\n**Symptom**: `RUN apk add` or `RUN apt-get install` inside `docker build` fails with `Connection refused` instantly (\u003C 0.2s), even though host `curl` to the same URL works.\n\n**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.\n\n**Diagnosis**:\n\n```bash\n# Verify: inside the container, HTTPS goes to 127.0.0.1 (broken transparent proxy)\ndocker run --rm alpine:latest sh -c \"wget -q --timeout=5 -O \u002Fdev\u002Fnull https:\u002F\u002Fdl-cdn.alpinelinux.org\u002F 2>&1\"\n# → \"wget: can't connect to remote host (127.0.0.1): Connection refused\"\n#                                        ^^^^^^^^^^^^ This is the smoking gun\n\n# Verify: --network host bypasses the VM bridge and works\ndocker run --rm --network host alpine:latest sh -c \"apk update 2>&1 | head -3\"\n# → \"v3.23.x ... OK: 27431 distinct packages available\"  ← Works!\n```\n\n**Fix** — use `--network host` for docker build:\n\n```bash\ndocker build --network host -f Dockerfile -t myimage .\n```\n\nThis bypasses OrbStack's VM network bridge entirely. The build container uses the host's network stack directly, where Shadowrocket TUN correctly handles traffic.\n\n**Trade-off**: `--network host` disables build-time network isolation. For CI\u002FCD, prefer fixing the proxy config (2G-2). For local development, `--network host` is the pragmatic fix.\n\n**Permanent fix** — if all your builds need this, add to `~\u002F.docker\u002Fdaemon.json` or use a shell alias:\n\n```bash\n# Shell alias (add to ~\u002F.zshrc)\nalias docker-build='docker build --network host'\n```\n\n#### 2G-2: OrbStack auto-detects and caches proxy config\n\nOrbStack's `network_proxy: auto` reads `http_proxy` from the shell environment and configures the Docker daemon. The config is stored in `~\u002F.orbstack\u002Fconfig\u002Fdocker.json`.\n\n**Key behaviors**:\n- `network_proxy: auto` — OrbStack reads host env, creates transparent proxy in VM\n- `network_proxy: none` — Disables transparent proxy, but VM bridge traffic still routes through TUN (may timeout)\n- `docker.json` — Controls `docker pull` proxy, NOT `docker build` RUN commands\n\n**Diagnosis**:\n\n```bash\n# Check all three layers\necho \"=== OrbStack config ===\"\norbctl config get network_proxy\n\necho \"=== docker.json (daemon proxy) ===\"\ncat ~\u002F.orbstack\u002Fconfig\u002Fdocker.json\n\necho \"=== Docker info (effective proxy) ===\"\ndocker info | grep -iE \"proxy|No Proxy\"\n```\n\n**Fix** — configure `docker.json` with `host.internal` (OrbStack resolves this to the host IP):\n\n```bash\npython3 -c \"\nimport json, os\nconfig = {\n    'proxies': {\n        'http-proxy': 'http:\u002F\u002Fhost.internal:1082',\n        'https-proxy': 'http:\u002F\u002Fhost.internal:1082',\n        'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0\u002F24,100.64.0.0\u002F10,host.internal,*.local'\n    }\n}\npath = os.path.expanduser('~\u002F.orbstack\u002Fconfig\u002Fdocker.json')\njson.dump(config, open(path, 'w'), indent=2)\nprint('Written:', path)\n\"\n\n# Full restart required\norbctl stop && sleep 3 && orbctl start\n```\n\n**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).\n\n**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.\n\n#### 2G-3: Removing proxy makes Docker worse (counter-intuitive)\n\n| Docker config | Traffic path | Result |\n|---------------|-------------|--------|\n| Proxy ON (`127.0.0.1`), no `no-proxy` | Docker → VM proxy → ??? | `docker pull` may work, localhost probes ❌ |\n| Proxy ON (`host.internal`), + `no-proxy` | External: Docker → host proxy → internet; Local: direct | **Both work ✅** |\n| Proxy OFF (`network_proxy: none`) | Docker → VM bridge → host → TUN → internet | TLS timeout ❌ |\n| **`--network host` (build only)** | **Build container → host network → TUN → internet** | **Build works ✅** |\n\n**Decision tree**:\n- `docker pull` broken → Fix `docker.json` with `host.internal` proxy (2G-2)\n- `docker build` broken → Use `--network host` (2G-1) OR pass `--build-arg http_proxy=http:\u002F\u002Fhost.internal:1082`\n- Both broken → Fix both: `docker.json` + `--network host`\n\n#### 2G-4: Deploy scripts and container healthchecks probe localhost through proxy\n\nDeploy scripts that `curl localhost` inside containers or Docker healthchecks that use `wget http:\u002F\u002Flocalhost` will route through the proxy if env vars leak into the container.\n\n**Common symptoms**:\n- Container healthcheck shows `(unhealthy)` but the app inside is running fine\n- `wget: can't connect to remote host (127.0.0.1): Connection refused` in healthcheck logs (proxy port, not app port)\n\n**Root cause**: Docker inherits uppercase AND lowercase proxy env vars from the host. Many tools only clear uppercase (`HTTP_PROXY=`) but forget lowercase (`http_proxy=http:\u002F\u002F127.0.0.1:1082`). The healthcheck `wget` uses lowercase.\n\n**Fix in docker-compose.yml** — clear BOTH cases:\n\n```yaml\nenvironment:\n  # Must clear both uppercase and lowercase — wget\u002Fcurl check different vars\n  - HTTP_PROXY=\n  - HTTPS_PROXY=\n  - http_proxy=\n  - https_proxy=\n  - NO_PROXY=*\n  - no_proxy=*\n```\n\n**Fix in deploy scripts**:\n\n```bash\n_local_bypass=\"localhost,127.0.0.1,::1\"\nexport NO_PROXY=\"${_local_bypass}${NO_PROXY:+,${NO_PROXY}}\"\nexport no_proxy=\"$NO_PROXY\"\n\n# Use 127.0.0.1 instead of localhost in probe URLs (some proxy implementations\n# only match exact string \"localhost\" in no-proxy, not the resolved IP)\ncurl http:\u002F\u002F127.0.0.1:3001\u002Fhealth   # ✅ bypasses proxy\ncurl http:\u002F\u002Flocalhost:3001\u002Fhealth    # ❌ may still go through proxy\n```\n\n**Verify the fix**:\n\n```bash\n# Docker proxy check (should show proxy + no-proxy)\ndocker info | grep -iE \"proxy|No Proxy\"\n\n# Pull test\ndocker pull --quiet hello-world\n\n# Build test (the real verification)\ndocker build --network host --no-cache - \u003C\u003C'EOF'\nFROM alpine:latest\nRUN apk update && echo \"BUILD OK\"\nEOF\n\n# Container env check (no proxy leak)\ndocker exec \u003Ccontainer> env | grep -i proxy\n# Expected: all empty or not set\n```\n\n### Step 2H: Fix TUN DNS Hijack for SSH\u002FGit (198.18.x.x virtual IPs)\n\n**Symptom**: `git clone\u002Ffetch\u002Fpush` fails with `Connection closed by 198.18.0.x port 443`. `ssh -T git@github.com` may also fail. DNS resolution returns `198.18.x.x` addresses instead of real IPs.\n\n**Root cause**: Shadowrocket TUN intercepts all DNS queries and returns virtual IPs in the `198.18.0.0\u002F15` range. It then routes traffic to these virtual IPs through the TUN for protocol-aware proxying. HTTP\u002FHTTPS works because the landing proxy understands these protocols, but SSH-over-443 (used by GitHub) gets mishandled — the TUN sees port 443 traffic, expects HTTPS, and drops the SSH handshake.\n\n**Diagnosis**:\n\n```bash\n# DNS returns virtual IP (TUN hijack)\nnslookup ssh.github.com\n# → 198.18.0.26  ← Shadowrocket virtual IP, NOT real GitHub IP\n\n# Direct IP works (bypasses DNS hijack)\nssh -o HostName=140.82.112.35 -o Port=443 git@github.com\n# → \"Hi user! You've successfully authenticated\"\n```\n\n**Fix** — use direct IP in SSH config to bypass DNS hijack:\n\n```bash\n# ~\u002F.ssh\u002Fconfig\nHost github.com\n    HostName 140.82.112.35    # GitHub SSH server real IP (bypasses TUN DNS hijack)\n    Port 443\n    User git\n    ServerAliveInterval 60\n    ServerAliveCountMax 3\n    IdentityFile ~\u002F.ssh\u002Fid_ed25519\n```\n\n**GitHub SSH server IPs** (as of 2026, verify with `dig +short ssh.github.com @8.8.8.8`):\n- `140.82.112.35` (primary)\n- `140.82.112.36` (alternate)\n\n**Trade-off**: Hardcoded IPs break if GitHub changes them. Monitor `ssh -T git@github.com` — if it starts failing, update the IP. A cron job can automate this:\n\n```bash\n# Weekly check (add to crontab)\n0 9 * * 1 dig +short ssh.github.com @8.8.8.8 | head -1 > \u002Ftmp\u002Fgithub-ssh-ip.txt\n```\n\n**Alternative** (if you control Shadowrocket rules): Add GitHub SSH IPs to DIRECT rule so TUN passes them through without protocol inspection:\n\n```\nIP-CIDR,140.82.112.0\u002F24,DIRECT\nIP-CIDR,192.30.252.0\u002F22,DIRECT\n```\n\nThis is more robust but requires proxy tool config access.\n\n### Step 3: Fix Proxy Tool Configuration\n\nIdentify the proxy tool and apply the appropriate fix. See [references\u002Fproxy_conflict_reference.md](references\u002Fproxy_conflict_reference.md) for detailed instructions per tool.\n\n**Key principle**: Do NOT use `tun-excluded-routes` to exclude `100.64.0.0\u002F10`. This causes the proxy to add a `→ en0` route that overrides Tailscale. Instead, let the traffic enter the proxy TUN and use a DIRECT rule to pass it through.\n\n**Universal fix** — add this rule to any proxy tool:\n```\nIP-CIDR,100.64.0.0\u002F10,DIRECT\nIP-CIDR,fd7a:115c:a1e0::\u002F48,DIRECT\n```\n\nAfter applying fixes, verify:\n\n```bash\nroute -n get \u003Ctailscale-ip>\n# Should show Tailscale utun interface, NOT en0\n```\n\n### Step 4: Configure Tailscale SSH ACL\n\nIf SSH connects but returns `operation not permitted`, the Tailscale ACL may require browser authentication for each connection.\n\nAt [Tailscale ACL admin](https:\u002F\u002Flogin.tailscale.com\u002Fadmin\u002Facls), ensure the SSH section uses `\"action\": \"accept\"`:\n\n```json\n\"ssh\": [\n    {\n        \"action\": \"accept\",\n        \"src\": [\"autogroup:member\"],\n        \"dst\": [\"autogroup:self\"],\n        \"users\": [\"autogroup:nonroot\", \"root\"]\n    }\n]\n```\n\n**Note**: `\"action\": \"check\"` requires browser authentication each time. Change to `\"accept\"` for non-interactive SSH access.\n\n### Step 5: Fix WSL Tailscale Installation\n\nIf SSH connects and ACL passes but fails with `be-child ssh` exit code 1 in tailscaled logs, the snap-installed Tailscale has sandbox restrictions preventing SSH shell execution.\n\n**Diagnosis** — check WSL tailscaled logs:\n\n```bash\n# For snap installs:\nsudo journalctl -u snap.tailscale.tailscaled -n 30 --no-pager\n\n# For apt installs:\nsudo journalctl -u tailscaled -n 30 --no-pager\n```\n\nLook for:\n```\naccess granted to user@example.com as ssh-user \"username\"\nstarting non-pty command: [\u002Fsnap\u002Ftailscale\u002F...\u002Ftailscaled be-child ssh ...]\nWait: code=1\n```\n\n**Fix** — replace snap with apt installation:\n\n```bash\n# Remove snap version\nsudo snap remove tailscale\n\n# Install apt version\ncurl -fsSL https:\u002F\u002Ftailscale.com\u002Finstall.sh | sh\n\n# Start with SSH enabled\nsudo tailscale up --ssh\n```\n\n**Important**: The new installation may assign a different Tailscale IP. Check with `tailscale status --self`.\n\n### Step 5A: Fix Tailscale SSH Proxy Silent Failure on WSL\n\n**Symptom**: TCP port 22 is reachable (`nc -z -w 5 \u003Cip> 22` succeeds), but SSH fails immediately with:\n\n```\nkex_exchange_identification: Connection closed by remote host\n```\n\nNo SSH banner is ever received. This happens even with apt-installed Tailscale (not snap).\n\n**Root cause**: When `tailscale up --ssh` is enabled on WSL, Tailscale intercepts port 22 connections at the application layer (above the kernel network stack). If Tailscale's built-in SSH proxy malfunctions, it accepts the TCP connection but immediately closes it before sending the SSH banner.\n\n**Key diagnostic** — on the WSL instance:\n\n```bash\n# This will show 0 packets even during active SSH attempts\nsudo tcpdump -i any port 22 -c 5 -w \u002Fdev\u002Fnull 2>&1\n```\n\nZero packets means Tailscale is intercepting connections before they reach the kernel network stack. The kernel's `sshd` never sees the connection.\n\n**Distinction from Step 5**: Step 5 covers snap sandbox issues where `be-child ssh` fails. This is a different problem — Tailscale's SSH proxy itself silently fails, regardless of installation method.\n\n**Fix** — disable Tailscale's SSH proxy and use regular sshd:\n\n```bash\n# On the WSL instance:\nsudo tailscale up --ssh=false\n\n# Verify sshd is running\nsudo service ssh status\n# If not running:\nsudo service ssh start\n\n# Verify from the client machine:\nssh -o ConnectTimeout=10 \u003Cuser>@\u003Ctailscale-ip> 'echo SSH_OK'\n```\n\nAfter disabling Tailscale SSH, connections go through the kernel network stack to `sshd` as normal. The Tailscale ACL `\"action\": \"accept\"` in Step 4 is no longer relevant — authentication is handled by `sshd` using SSH keys or passwords.\n\n**When to keep `--ssh` enabled**: Only if you specifically need Tailscale's SSH features (ACL-based access control, no SSH key management). If standard sshd works, prefer `--ssh=false` for reliability.\n\n### Step 5B: Fix App Store Tailscale on macOS (Missing `tailscale ssh`)\n\n**Symptom**: Running `tailscale ssh` returns:\n\n```\nThe 'tailscale ssh' subcommand is not available on macOS builds\ndistributed through the App Store or TestFlight.\n```\n\n**Root cause**: The App Store version of Tailscale for macOS is sandboxed and does not include the `tailscale ssh` subcommand.\n\n**Fix** — install the Standalone version:\n\n1. Uninstall the App Store version (delete from \u002FApplications)\n2. Download the Standalone build from https:\u002F\u002Fpkgs.tailscale.com\u002Fstable\u002F#macos\n3. Install to \u002FApplications\n\n**Post-install CLI setup**: The standalone `tailscale` CLI binary is embedded inside the app bundle. Add an alias to your shell config:\n\n```bash\n# ~\u002F.zshrc\nalias tailscale=\"\u002FApplications\u002FTailscale.app\u002FContents\u002FMacOS\u002FTailscale\"\n```\n\nVerify:\n\n```bash\nsource ~\u002F.zshrc\ntailscale version\ntailscale ssh \u003Cuser>@\u003Chostname>   # Should work now\n```\n\n### Step 6: Verify End-to-End\n\nRun a complete connectivity test:\n\n```bash\n# 1. Check route is correct (must show Tailscale's utun, not en0 or Shadowrocket's utun)\nroute -n get \u003Ctailscale-ip>\n# Also confirm which utun is Tailscale's:\nifconfig | grep -A2 'inet 100\\.'\n\n# 2. Test TCP connectivity\nnc -z -w 5 \u003Ctailscale-ip> 22\n\n# 3. Test SSH\nssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no \u003Cuser>@\u003Ctailscale-ip> 'echo SSH_OK && hostname && whoami'\n```\n\nAll three must pass. If step 1 fails, revisit Step 3. If step 1 shows wrong utun (e.g., Shadowrocket's utun with MTU 4064 instead of Tailscale's with MTU 1280), that is also a route conflict. If step 2 passes but step 3 fails with `kex_exchange_identification`, revisit Step 5A (Tailscale SSH proxy intercept). If step 2 fails, check WSL sshd or firewall. If step 3 fails with other errors, revisit Steps 4-5.\n\n## SOP: Remote Development via Tailscale\n\nProactive setup guide for remote development over Tailscale with proxy tools. Follow these steps **before** encountering problems.\n\n### Prerequisites\n\n- Tailscale installed and running on both machines\n- Proxy tool (Shadowrocket\u002FClash\u002FSurge) configured with Tailscale compatibility (see Step 3 above)\n- SSH access working: `ssh \u003Ctailscale-ip> 'echo ok'`\n\n### 1. Proxy-Safe Makefile Pattern\n\nAny Makefile target that curls `localhost` must use `--noproxy localhost`. This is required because `http_proxy` is often set globally in `~\u002F.zshrc` (common in China), and Make inherits shell environment variables.\n\n```makefile\n## ── Health Checks ─────────────────────────────────────\n\nstatus:                ## Health check dashboard\n\t@echo \"=== Dev Infrastructure ===\"\n\t@docker exec my-postgres pg_isready -U postgres 2>\u002Fdev\u002Fnull && echo \"PostgreSQL: OK\" || echo \"PostgreSQL: FAIL\"\n\t@curl --noproxy localhost -sf http:\u002F\u002Flocalhost:9000\u002Fminio\u002Fhealth\u002Flive >\u002Fdev\u002Fnull 2>&1 && echo \"MinIO: OK\" || echo \"MinIO: FAIL\"\n\t@curl --noproxy localhost -sf http:\u002F\u002Flocalhost:3001\u002Fapi\u002Fstatus >\u002Fdev\u002Fnull 2>&1 && echo \"API: OK\" || echo \"API: FAIL\"\n\n## ── Route Warmup ──────────────────────────────────────\n\nwarmup:                ## Pre-compile key routes (run after dev server is ready)\n\t@echo \"Warming up dev server routes...\"\n\t@echo -n \"  \u002Fapi\u002Fhealth → \" && curl --noproxy localhost -s -o \u002Fdev\u002Fnull -w '%{http_code} (%{time_total}s)\\n' http:\u002F\u002Flocalhost:3010\u002Fapi\u002Fhealth\n\t@echo -n \"  \u002F            → \" && curl --noproxy localhost -s -o \u002Fdev\u002Fnull -w '%{http_code} (%{time_total}s)\\n' http:\u002F\u002Flocalhost:3010\u002F\n\t@echo \"Warmup complete.\"\n```\n\n**Rules**:\n- Every `curl http:\u002F\u002Flocalhost` call MUST include `--noproxy localhost`\n- Docker commands (`docker exec`) are unaffected by `http_proxy` — no fix needed\n- `redis-cli`, `pg_isready` connect via TCP directly — no fix needed\n\n### 2. SSH Tunnel Makefile Targets\n\nAdd these targets for remote development via Tailscale SSH tunnels:\n\n```makefile\n## ── Remote Development ────────────────────────────────\n\nREMOTE_HOST    ?= \u003Ctailscale-ip>\nTUNNEL_FORWARD ?= -L 3010:localhost:3010\n\ntunnel:                ## SSH tunnel to remote machine (foreground)\n\tssh -N $(TUNNEL_FORWARD) $(REMOTE_HOST)\n\ntunnel-bg:             ## SSH tunnel to remote machine (background, auto-reconnect)\n\tautossh -M 0 -f -N $(TUNNEL_FORWARD) \\\n\t\t-o \"ServerAliveInterval=30\" \\\n\t\t-o \"ServerAliveCountMax=3\" \\\n\t\t-o \"ExitOnForwardFailure=yes\" \\\n\t\t$(REMOTE_HOST)\n\t@echo \"Tunnel running in background. Kill with: pkill -f 'autossh.*$(REMOTE_HOST)'\"\n```\n\n**Design decisions**:\n\n| Choice | Rationale |\n|--------|-----------|\n| `?=` (conditional assign) | Allows override: `make tunnel REMOTE_HOST=100.x.x.x` |\n| `TUNNEL_FORWARD` as variable | Supports multi-port: `make tunnel TUNNEL_FORWARD=\"-L 3010:localhost:3010 -L 9000:localhost:9000\"` |\n| `autossh -M 0` | Disables autossh's own monitoring port; relies on `ServerAliveInterval` instead (more reliable through NAT) |\n| `ExitOnForwardFailure=yes` | Fails immediately if port is already bound, instead of silently running without tunnel |\n| Kill hint uses `autossh.*$(REMOTE_HOST)` | Precise pattern — won't accidentally kill other SSH sessions |\n\n**Install autossh**: `brew install autossh` (macOS) or `apt install autossh` (Linux\u002FWSL)\n\n### 3. Multi-Port Tunnels\n\nWhen the project requires multiple services (dev server + object storage + API gateway):\n\n```bash\n# Forward multiple ports in one tunnel\nmake tunnel TUNNEL_FORWARD=\"-L 3010:localhost:3010 -L 9000:localhost:9000 -L 3001:localhost:3001\"\n\n# Or define a project-specific default in Makefile\nTUNNEL_FORWARD ?= -L 3010:localhost:3010 -L 9000:localhost:9000\n```\n\nEach `-L` flag is independent. If one port is already bound locally, `ExitOnForwardFailure=yes` will abort the entire tunnel — fix the port conflict first.\n\n### 4. SSH Non-Login Shell Setup\n\n**This is a frequent source of \"it works interactively but fails in scripts\" bugs.** SSH non-login shells don't load `~\u002F.zshrc` (or `~\u002F.bashrc` on Linux), so tools installed via nvm, Homebrew, uv, cargo, or any shell-level manager won't be in `$PATH`. Proxy env vars set in `~\u002F.zshrc` also won't be loaded.\n\nThis affects **all** remote commands run via `ssh user@host \"command\"`, including CI\u002FCD pipelines, cron-triggered SSH, and Makefile remote targets. Prefix all remote commands with `source ~\u002F.zshrc 2>\u002Fdev\u002Fnull;` (macOS) or `source ~\u002F.bashrc 2>\u002Fdev\u002Fnull;` (Linux\u002FWSL).\n\n**Common failure**: `ssh user@host \"uv run ...\"` or `ssh user@host \"node ...\"` returns `command not found` even though the command works in an interactive SSH session.\n\nSee [references\u002Fproxy_conflict_reference.md § SSH Non-Login Shell Pitfall](references\u002Fproxy_conflict_reference.md) for details and examples.\n\nFor Makefile targets that run remote commands:\n\n```makefile\nREMOTE_CMD = ssh $(REMOTE_HOST) 'source ~\u002F.zshrc 2>\u002Fdev\u002Fnull; $(1)'\n\nremote-status:         ## Check remote dev server status\n\t$(call REMOTE_CMD,curl --noproxy localhost -sf http:\u002F\u002Flocalhost:3010\u002Fapi\u002Fhealth && echo \"OK\" || echo \"FAIL\")\n```\n\n### 5. End-to-End Workflow\n\n#### First-time setup (remote machine)\n\n```bash\n# 1. Clone repo and install dependencies\nssh \u003Ctailscale-ip>\ncd \u002Fpath\u002Fto\u002Fproject\ngit clone git@github.com:user\u002Frepo.git && cd repo\npnpm install  # Add --registry https:\u002F\u002Fregistry.npmmirror.com if in China\n\n# 2. Copy .env from local machine (run on local)\nscp .env \u003Ctailscale-ip>:\u002Fpath\u002Fto\u002Fproject\u002Frepo\u002F.env\n\n# 3. Start Docker infrastructure\nmake up && make status\n\n# 4. Run database migrations\nbun run db:migrate\n\n# 5. Start dev server\nbun run dev\n```\n\n#### Daily workflow (local machine)\n\n```bash\n# 1. Start tunnel\nmake tunnel-bg\n\n# 2. Open browser\nopen http:\u002F\u002Flocalhost:3010\n\n# 3. Auth, coding, testing — everything works as if local\n\n# 4. When done, kill tunnel\npkill -f 'autossh.*\u003Ctailscale-ip>'\n```\n\n#### Why this works\n\n```\nBrowser → localhost:3010 → SSH tunnel → Remote localhost:3010 → Dev server\n                                     ↓\n                              Auth redirects to localhost:3010\n                                     ↓\n                              Browser follows redirect → same tunnel → works\n```\n\nThe key insight: `APP_URL=http:\u002F\u002Flocalhost:3010` in `.env` is correct for **both** local and remote development. The SSH tunnel makes the remote server's localhost accessible as the local machine's localhost. Auth callback redirects to `localhost:3010` always resolve correctly.\n\n### 6. Checklist\n\nBefore starting remote development, verify:\n\n- [ ] Tailscale connected: `tailscale status`\n- [ ] SSH works: `ssh \u003Ctailscale-ip> 'echo ok'`\n- [ ] Proxy tool configured: `[Rule]` has `IP-CIDR,100.64.0.0\u002F10,DIRECT`\n- [ ] `skip-proxy` includes `100.64.0.0\u002F10`\n- [ ] `tun-excluded-routes` does NOT include `100.64.0.0\u002F10`\n- [ ] `NO_PROXY` includes `.ts.net,100.64.0.0\u002F10`\n- [ ] `autossh` installed: `which autossh`\n- [ ] Makefile curl commands have `--noproxy localhost`\n- [ ] Remote dev server running: `ssh \u003Cip> 'source ~\u002F.zshrc 2>\u002Fdev\u002Fnull; curl --noproxy localhost -sf http:\u002F\u002Flocalhost:3010\u002F'`\n- [ ] Tunnel works: `make tunnel-bg && curl -sf http:\u002F\u002Flocalhost:3010\u002F`\n\n## References\n\n- [references\u002Fproxy_conflict_reference.md](references\u002Fproxy_conflict_reference.md) — Per-tool configuration (Shadowrocket, Clash, Surge), NO_PROXY syntax, SSH ProxyCommand, and conflict architecture\n\n","# Tunnel Doctor\n\nDiagnose and fix conflicts when Tailscale coexists with proxy\u002FVPN tools on macOS, with specific guidance for SSH access to WSL instances.\n\n## Five Conflict Layers\n\nProxy\u002FVPN tools on macOS create conflicts at five independent layers. Layers 1-3 affect Tailscale connectivity; Layer 4 affects SSH git operations; Layer 5 affects VM\u002Fcontainer runtimes:\n\n| Layer | What breaks | What still works | Root cause |\n|-------|-------------|------------------|------------|\n| 1. Route table | Everything (SSH, curl, browser) | `tailscale ping` | `tun-excluded-routes` adds `en0` route overriding Tailscale utun |\n| 2. HTTP env vars | `curl`, Python requests, Node.js fetch | SSH, browser | `http_proxy` set without `NO_PROXY` for Tailscale |\n| 3. System proxy (browser) | Browser only (HTTP 503) | SSH, `curl` (both with\u002Fwithout proxy) | Browser uses VPN system proxy; DIRECT rule routes via Wi-Fi, not Tailscale utun |\n| 4. SSH ProxyCommand double tunnel | `git push\u002Fpull` (intermittent) | `ssh -T` (small data) | `connect -H` creates HTTP CONNECT tunnel redundant with Shadowrocket TUN; landing proxy drops large\u002Flong-lived transfers |\n| 5. VM\u002FContainer proxy propagation | `docker pull`, `docker build` | Host `curl`, running containers | VM runtime (OrbStack\u002FDocker Desktop) auto-injects or caches proxy config; removing proxy makes it worse (VM traffic via TUN → TLS timeout) |\n\n## Diagnostic Workflow\n\n### Step 1: Identify the Symptom\n\nDetermine which scenario applies:\n\n- **Browser returns HTTP 503, but `curl` and SSH both work** → System proxy bypass conflict (Step 2C)\n- **`local.\u003Cdomain>` fails in browser\u002Fdefault `curl`, but direct\u002Fno-proxy request works** → Local vanity domain proxy interception (Step 2C-1)\n- **Tailscale ping works, SSH works, but curl\u002FHTTP times out** → HTTP proxy env var conflict (Step 2A)\n- **Tailscale ping works, SSH\u002FTCP times out** → Route conflict (Step 2B)\n- **Remote dev server auth redirects to `localhost` → browser can't follow** → SSH tunnel needed (Step 2D)\n- **`make status` \u002F scripts curl to localhost fail with proxy** → localhost proxy interception (Step 2E)\n- **`git push\u002Fpull` fails with `FATAL: failed to begin relaying via HTTP`** → SSH double tunnel (Step 2F)\n- **`docker build` `RUN apk\u002Fapt` fails with `Connection refused` instantly** → OrbStack transparent proxy + TUN conflict (Step 2G-1, fix: `--network host`)\n- **`docker pull` fails with `TLS handshake timeout`** → VM proxy misconfiguration (Step 2G-2, fix: `docker.json` with `host.internal`)\n- **Container healthcheck `(unhealthy)` but app runs fine** → Lowercase proxy env var leak (Step 2G-4, fix: clear `http_proxy`+`HTTP_PROXY`)\n- **`docker build` can't fetch base images** → VM\u002Fcontainer proxy propagation (Step 2G)\n- **`git clone` fails with `Connection closed by 198.18.x.x`** → TUN DNS hijack for SSH (Step 2H)\n- **SSH connects but `operation not permitted`** → Tailscale SSH config issue (Step 4)\n- **SSH connects but `be-child ssh` exits code 1** → WSL snap sandbox issue (Step 5)\n- **TCP port 22 reachable (`nc -z` succeeds) but SSH fails with `kex_exchange_identification: Connection closed`** → Tailscale SSH proxy intercept on WSL (Step 5A)\n- **`tailscale ssh` returns \"not available on App Store builds\"** → Wrong Tailscale distribution on macOS (Step 5B)\n\n**Key distinctions**:\n- SSH does NOT use `http_proxy`\u002F`NO_PROXY` env vars. If SSH works but HTTP doesn't → Layer 2.\n- `curl` uses `http_proxy` env var, NOT the system proxy. Browser uses system proxy (set by VPN). If `curl` works but browser doesn't → Layer 3.\n- If `tailscale ping` works but regular `ping` doesn't → Layer 1 (route table corrupted).\n- If `ssh -T git@github.com` works but `git push` fails intermittently → Layer 4 (double tunnel).\n- If host `curl https:\u002F\u002F...` works but `docker pull` times out → Layer 5 (VM proxy propagation).\n- If `docker pull` works but `docker build` `RUN apk add` fails instantly with `Connection refused` → OrbStack transparent proxy broken by TUN (Step 2G-1).\n- If container healthcheck shows `(unhealthy)` but app works → lowercase `http_proxy` leaked into container (Step 2G-4).\n- If DNS resolves to `198.18.x.x` virtual IPs → TUN DNS hijack (Step 2H).\n- If `nc -z` succeeds on port 22 but SSH gets no banner (`kex_exchange_identification`) → Tailscale SSH proxy intercept (Step 5A). Confirm with `tcpdump -i any port 22` on the remote — 0 packets means Tailscale intercepts above the kernel.\n- If `tailscale ssh` fails with \"not available on App Store builds\" → install Standalone Tailscale (Step 5B).\n\n### Fast Path: Run Automated Checks\n\nFor common macOS conflicts (env proxy, system proxy exceptions, direct\u002Fproxy path split, local TLS trust), run:\n\n```bash\npython3 scripts\u002Fquick_diagnose.py --host local.claude4.dev --url https:\u002F\u002Flocal.claude4.dev\u002Fhealth\n```\n\nOptional route ownership check for a Tailscale destination:\n\n```bash\npython3 scripts\u002Fquick_diagnose.py --host \u003Ctarget-host> --url http:\u002F\u002F\u003Ctarget-host>:\u003Cport>\u002Fhealth --tailscale-ip \u003C100.x.x.x>\n```\n\nInterpretation:\n- `direct=PASS` + `forced_proxy=FAIL` = host must bypass proxy (`skip-proxy` + `NO_PROXY`).\n- `strict_tls=FAIL` + `direct=PASS` = path is reachable; trust issue only (install\u002Ftrust local CA).\n- `host in scutil exceptions: no` = browser\u002Fsystem clients still likely proxied.\n\n### Step 2A: Fix HTTP Proxy Environment Variables\n\nCheck if proxy env vars are intercepting Tailscale HTTP traffic:\n\n```bash\nenv | grep -i proxy\n```\n\n**Broken output** — proxy is set but `NO_PROXY` doesn't exclude Tailscale:\n```\nhttp_proxy=http:\u002F\u002F127.0.0.1:1082\nhttps_proxy=http:\u002F\u002F127.0.0.1:1082\nNO_PROXY=localhost,127.0.0.1          ← Missing Tailscale!\n```\n\n**Fix** — add Tailscale MagicDNS domain + CIDR to `NO_PROXY`:\n\n```bash\nexport NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0\u002F10,192.168.*,10.*,172.16.*\n```\n\n| Entry | Covers | Why |\n|-------|--------|-----|\n| `.ts.net` | MagicDNS domains (`host.tailnet.ts.net`) | Matched before DNS resolution |\n| `100.64.0.0\u002F10` | Tailscale IPs (`100.64.*` – `100.127.*`) | Precise CIDR, no public IP false positives |\n| `192.168.*,10.*,172.16.*` | RFC 1918 private networks | LAN should never be proxied |\n\n**Two layers complement each other**: `.ts.net` handles domain-based access, `100.64.0.0\u002F10` handles direct IP access.\n\n**NO_PROXY syntax pitfalls** — see [references\u002Fproxy_conflict_reference.md](references\u002Fproxy_conflict_reference.md) for the compatibility matrix.\n\n**Go `net\u002Fhttp` CIDR caveat**: Go's standard `net\u002Fhttp` does NOT support CIDR notation in `NO_PROXY`. Setting `NO_PROXY=100.64.0.0\u002F10` 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., `workstation-4090-wsl`) instead of raw IPs, or add explicit hostnames to `NO_PROXY`:\n\n```bash\n# WRONG for Go programs — CIDR is silently ignored\nNO_PROXY=100.64.0.0\u002F10 go-program http:\u002F\u002F100.101.102.103:8002\u002Fhealth  # → goes through proxy\n\n# CORRECT — use hostname (matched as suffix) or explicit IP\nexport NO_PROXY=localhost,127.0.0.1,.ts.net,workstation-4090-wsl,100.101.102.103,192.168.*,10.*,172.16.*\n```\n\nThis is especially relevant when accessing Tailscale services from Go-based tools (e.g., custom CLIs, Go test suites hitting remote APIs).\n\nVerify the fix:\n\n```bash\n# Both must return HTTP 200:\nNO_PROXY=\"...(new value)...\" curl -s --connect-timeout 5 http:\u002F\u002F\u003Chost>.ts.net:\u003Cport>\u002Fhealth -w \"HTTP %{http_code}\\n\"\nNO_PROXY=\"...(new value)...\" curl -s --connect-timeout 5 http:\u002F\u002F\u003Ctailscale-ip>:\u003Cport>\u002Fhealth -w \"HTTP %{http_code}\\n\"\n```\n\nThen persist in shell config (`~\u002F.zshrc` or `~\u002F.bashrc`).\n\n### Step 2B: Detect Route Conflicts\n\nCheck if a proxy tool hijacked the Tailscale CGNAT range:\n\n```bash\nroute -n get \u003Ctailscale-ip>\n```\n\n**Healthy output** — traffic goes through Tailscale interface:\n```\ndestination: 100.64.0.0\ninterface: utun7    # Tailscale interface (utunN varies)\n```\n\n**Broken output** — proxy hijacked the route:\n```\ndestination: 100.64.0.0\ngateway: 192.168.x.1    # Default gateway\ninterface: en0           # Physical interface, NOT Tailscale\n```\n\n**Important**: Not all `utun` interfaces are Tailscale's. Verify which utun belongs to Tailscale before concluding the route is correct:\n\n```bash\n# Find Tailscale's utun interface (has a 100.x.x.x IP)\nifconfig | grep -A2 'inet 100\\.'\n```\n\nQuick indicators by MTU:\n- **MTU 1280** → typically Tailscale\n- **MTU 4064** → typically Shadowrocket TUN\n\nIf `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`.\n\nConfirm with full route table:\n\n```bash\nnetstat -rn | grep 100.64\n```\n\nTwo competing routes indicate a conflict:\n```\n100.64\u002F10  192.168.x.1   UGSc  en0       ← Proxy added this (wins)\n100.64\u002F10  link#N        UCSI  utun7     ← Tailscale route (loses)\n```\n\n**Root cause**: On macOS, `UGSc` (Static Gateway) takes priority over `UCSI` (Cloned Static Interface) for the same prefix length.\n\n### Step 2C: Fix System Proxy Bypass (Browser 503)\n\n**Symptom**: Browser shows HTTP 503 for `http:\u002F\u002F\u003Ctailscale-ip>:\u003Cport>`, but both `curl --noproxy '*'` and `curl` (with proxy env var) return 200. SSH also works.\n\n**Root cause**: The browser uses the system proxy configured by the VPN profile (Shadowrocket\u002FClash\u002FSurge). The proxy matches `IP-CIDR,100.64.0.0\u002F10,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.\n\n**Diagnosis**:\n\n```bash\n# curl with proxy env var works (curl connects to proxy port, but traffic flows differently)\ncurl -s -o \u002Fdev\u002Fnull -w \"%{http_code}\" http:\u002F\u002F\u003Ctailscale-ip>:\u003Cport>\u002F\n# → 200\n\n# Browser gets 503 because it goes through the VPN system proxy, not http_proxy env var\n```\n\n**Fix** — add Tailscale CGNAT range to `skip-proxy` in the proxy tool config:\n\nFor Shadowrocket, in `[General]`:\n```\nskip-proxy = 192.168.0.0\u002F16, 10.0.0.0\u002F8, 172.16.0.0\u002F12, 100.64.0.0\u002F10, localhost, *.local, captive.apple.com\n```\n\n`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.\n\n**Why `skip-proxy` works but `tun-excluded-routes` doesn't**:\n- `skip-proxy`: Bypasses the HTTP proxy layer only. Traffic still flows through the TUN interface and Tailscale utun handles it. Safe.\n- `tun-excluded-routes`: Removes the CIDR from the TUN routing entirely. This creates a competing `en0` route that overrides Tailscale. Breaks everything.\n\n#### Step 2C-1: Fix Local Vanity Domain Interception (`local.\u003Cdomain>`)\n\n**Symptom**: `https:\u002F\u002Flocal.\u003Cdomain>` fails in browser or default `curl`, but succeeds with direct\u002Fno-proxy command:\n\n```bash\nenv -u http_proxy -u https_proxy curl -k -I https:\u002F\u002Flocal.\u003Cdomain>\u002Fhealth\n# -> 200\ncurl -I https:\u002F\u002Flocal.\u003Cdomain>\u002Fhealth\n# -> proxy CONNECT then TLS reset\u002Ffailure\n```\n\n**Root cause**: The domain is routed through system\u002Fshell proxy instead of local direct path.\n\n**Fix**:\n1. Add domain to proxy app bypass list (`skip-proxy` for Shadowrocket).\n2. Add domain to shell bypass list (`NO_PROXY`\u002F`no_proxy`).\n3. If local TLS uses internal CA, trust the local root certificate.\n\n```bash\n# ~\u002F.zshrc\nexport NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0\u002F10,192.168.*,10.*,172.16.*,local.\u003Cdomain>,www.local.\u003Cdomain>\nexport no_proxy=\"$NO_PROXY\"\n```\n\n**Verification**:\n\n```bash\npython3 scripts\u002Fquick_diagnose.py --host local.\u003Cdomain> --url https:\u002F\u002Flocal.\u003Cdomain>\u002Fhealth\n```\n\nExpected:\n- `host in NO_PROXY: yes`\n- `host in scutil exceptions: yes`\n- `ambient=PASS` and `direct=PASS`\n\n### Step 2D: Fix Auth Redirect for Remote Dev (SSH Tunnel)\n\n**Symptom**: Dev server runs on a remote machine (e.g., Mac Mini via Tailscale). You access `http:\u002F\u002F\u003Ctailscale-ip>:3010` in the browser. Login\u002Fsignup works, but after auth, the app redirects to `http:\u002F\u002Flocalhost:3010\u002F` which fails — `localhost` on your machine isn't running the dev server.\n\n**Root cause**: The app's `APP_URL` (or equivalent) is set to `http:\u002F\u002Flocalhost: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.\n\n**Fix** — SSH local port forwarding. This avoids all three conflict layers entirely:\n\n```bash\n# Forward local port 3010 to remote machine's localhost:3010\nssh -NL 3010:localhost:3010 \u003Ctailscale-ip>\n\n# Or with autossh for auto-reconnect (recommended for long sessions)\nautossh -M 0 -f -N -L 3010:localhost:3010 \\\n    -o \"ServerAliveInterval=30\" \\\n    -o \"ServerAliveCountMax=3\" \\\n    -o \"ExitOnForwardFailure=yes\" \\\n    \u003Ctailscale-ip>\n```\n\nNow access `http:\u002F\u002Flocalhost:3010` in the browser. Auth redirects to `localhost:3010` → tunnel → remote dev server → works correctly.\n\n**Why this is the best approach**:\n- No `.env` changes needed — `APP_URL=http:\u002F\u002Flocalhost:3010` works everywhere\n- No Shadowrocket conflicts — `localhost` is always in `skip-proxy`\n- No code changes — same behavior as local development\n- Industry standard — VS Code Remote SSH, GitHub Codespaces use the same pattern\n\n**Install autossh**: `brew install autossh` (macOS) or `apt install autossh` (Linux)\n\n**Kill background tunnel**: `pkill -f 'autossh.*\u003Ctailscale-ip>'`\n\n### Step 2E: Fix localhost Proxy Interception in Scripts\n\n**Symptom**: Makefile targets or scripts that `curl` localhost (health checks, warmup routes) fail or timeout when `http_proxy` is set globally in the shell.\n\n**Root cause**: `http_proxy=http:\u002F\u002F127.0.0.1:1082` is set in `~\u002F.zshrc` but `no_proxy` doesn't include `localhost`. All curl commands send localhost requests through the proxy.\n\n**Fix** — add `--noproxy localhost` to all localhost curl commands in scripts:\n\n```makefile\n# WRONG — fails when http_proxy is set\n@curl -sf http:\u002F\u002Flocalhost:9000\u002Fminio\u002Fhealth\u002Flive && echo \"OK\"\n\n# CORRECT — always bypasses proxy for localhost\n@curl --noproxy localhost -sf http:\u002F\u002Flocalhost:9000\u002Fminio\u002Fhealth\u002Flive && echo \"OK\"\n```\n\nAlternatively, set `no_proxy` globally in `~\u002F.zshrc`:\n\n```bash\nexport no_proxy=localhost,127.0.0.1\n```\n\n### Step 2F: Fix SSH ProxyCommand Double Tunnel (git push\u002Fpull failures)\n\n**Symptom**: `ssh -T git@github.com` succeeds consistently, but `git push` or `git pull` fails intermittently with:\n\n```\nFATAL: failed to begin relaying via HTTP.\nConnection closed by UNKNOWN port 65535\n```\n\nSmall operations (auth, fetch metadata) work; large data transfers fail.\n\n**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\u002Flong-lived HTTP CONNECT connections.\n\n**Diagnosis**:\n\n```bash\n# 1. Confirm Shadowrocket TUN is active\nifconfig | grep '^utun'\n\n# 2. Check SSH config for ProxyCommand\ngrep -A5 'Host github.com' ~\u002F.ssh\u002Fconfig\n\n# 3. Confirm: removing ProxyCommand fixes push\nGIT_SSH_COMMAND=\"ssh -o ProxyCommand=none\" git push origin main\n```\n\n**Fix** — remove ProxyCommand and switch to `ssh.github.com:443`. See [references\u002Fproxy_conflict_reference.md § SSH ProxyCommand and Git Operations](references\u002Fproxy_conflict_reference.md) for the full SSH config, why port 443 helps, and fallback options when VPN is off.\n\n### Step 2G: Fix VM\u002FContainer Runtime Proxy Propagation (Docker pull\u002Fbuild failures)\n\n**Symptom**: `docker pull` or `docker build` fails with `net\u002Fhttp: TLS handshake timeout`, `Connection refused` from Alpine\u002FDebian repos, or `Internal Server Error` from `auth.docker.io`, while host `curl` to the same URLs works fine.\n\n**Applies to**: OrbStack, Docker Desktop, or any VM-based Docker runtime on macOS with Shadowrocket\u002FClash TUN active.\n\n**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:\n\n```\nHost process (curl):   Process → TUN (Shadowrocket) → landing proxy → internet ✅\nVM process (Docker):   Docker daemon → VM bridge → host network → TUN → ??? ❌\n```\n\nThe TUN handles host-originated traffic correctly but may drop or delay VM-bridged traffic (different TCP stack, MTU, keepalive behavior).\n\n**Critical distinction: `docker pull` vs `docker build` use different proxy paths**:\n\n| Operation | Proxy source | What controls it |\n|-----------|-------------|------------------|\n| `docker pull` | Docker daemon config | `~\u002F.orbstack\u002Fconfig\u002Fdocker.json` or `docker info` |\n| `docker build` (`RUN apt\u002Fapk`) | Build container env | `--build-arg http_proxy=...` or `--network host` |\n| `docker run` | Container env | `-e http_proxy=...` or inherited from daemon |\n\nFixing `docker.json` alone will NOT fix `docker build` — the `RUN` commands inside the build container don't inherit daemon proxy settings.\n\n**Diagnosis** — identify which sub-problem:\n\n```bash\n# 1. Can the Docker daemon pull images?\ndocker pull --quiet alpine:latest 2>&1\n\n# 2. Can a RUN command inside a build reach the internet?\ndocker build --no-cache - \u003C\u003C'EOF' 2>&1\nFROM alpine:latest\nRUN apk update && echo \"APK OK\"\nEOF\n\n# 3. Can a running container reach the internet?\ndocker run --rm alpine:latest sh -c \"apk update 2>&1 | head -3\"\n```\n\n**Four sub-problems and their fixes**:\n\n#### 2G-1: `docker build` fails but host works (most common with OrbStack + Shadowrocket)\n\n**Symptom**: `RUN apk add` or `RUN apt-get install` inside `docker build` fails with `Connection refused` instantly (\u003C 0.2s), even though host `curl` to the same URL works.\n\n**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.\n\n**Diagnosis**:\n\n```bash\n# Verify: inside the container, HTTPS goes to 127.0.0.1 (broken transparent proxy)\ndocker run --rm alpine:latest sh -c \"wget -q --timeout=5 -O \u002Fdev\u002Fnull https:\u002F\u002Fdl-cdn.alpinelinux.org\u002F 2>&1\"\n# → \"wget: can't connect to remote host (127.0.0.1): Connection refused\"\n#                                        ^^^^^^^^^^^^ This is the smoking gun\n\n# Verify: --network host bypasses the VM bridge and works\ndocker run --rm --network host alpine:latest sh -c \"apk update 2>&1 | head -3\"\n# → \"v3.23.x ... OK: 27431 distinct packages available\"  ← Works!\n```\n\n**Fix** — use `--network host` for docker build:\n\n```bash\ndocker build --network host -f Dockerfile -t myimage .\n```\n\nThis bypasses OrbStack's VM network bridge entirely. The build container uses the host's network stack directly, where Shadowrocket TUN correctly handles traffic.\n\n**Trade-off**: `--network host` disables build-time network isolation. For CI\u002FCD, prefer fixing the proxy config (2G-2). For local development, `--network host` is the pragmatic fix.\n\n**Permanent fix** — if all your builds need this, add to `~\u002F.docker\u002Fdaemon.json` or use a shell alias:\n\n```bash\n# Shell alias (add to ~\u002F.zshrc)\nalias docker-build='docker build --network host'\n```\n\n#### 2G-2: OrbStack auto-detects and caches proxy config\n\nOrbStack's `network_proxy: auto` reads `http_proxy` from the shell environment and configures the Docker daemon. The config is stored in `~\u002F.orbstack\u002Fconfig\u002Fdocker.json`.\n\n**Key behaviors**:\n- `network_proxy: auto` — OrbStack reads host env, creates transparent proxy in VM\n- `network_proxy: none` — Disables transparent proxy, but VM bridge traffic still routes through TUN (may timeout)\n- `docker.json` — Controls `docker pull` proxy, NOT `docker build` RUN commands\n\n**Diagnosis**:\n\n```bash\n# Check all three layers\necho \"=== OrbStack config ===\"\norbctl config get network_proxy\n\necho \"=== docker.json (daemon proxy) ===\"\ncat ~\u002F.orbstack\u002Fconfig\u002Fdocker.json\n\necho \"=== Docker info (effective proxy) ===\"\ndocker info | grep -iE \"proxy|No Proxy\"\n```\n\n**Fix** — configure `docker.json` with `host.internal` (OrbStack resolves this to the host IP):\n\n```bash\npython3 -c \"\nimport json, os\nconfig = {\n    'proxies': {\n        'http-proxy': 'http:\u002F\u002Fhost.internal:1082',\n        'https-proxy': 'http:\u002F\u002Fhost.internal:1082',\n        'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0\u002F24,100.64.0.0\u002F10,host.internal,*.local'\n    }\n}\npath = os.path.expanduser('~\u002F.orbstack\u002Fconfig\u002Fdocker.json')\njson.dump(config, open(path, 'w'), indent=2)\nprint('Written:', path)\n\"\n\n# Full restart required\norbctl stop && sleep 3 && orbctl start\n```\n\n**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).\n\n**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.\n\n#### 2G-3: Removing proxy makes Docker worse (counter-intuitive)\n\n| Docker config | Traffic path | Result |\n|---------------|-------------|--------|\n| Proxy ON (`127.0.0.1`), no `no-proxy` | Docker → VM proxy → ??? | `docker pull` may work, localhost probes ❌ |\n| Proxy ON (`host.internal`), + `no-proxy` | External: Docker → host proxy → internet; Local: direct | **Both work ✅** |\n| Proxy OFF (`network_proxy: none`) | Docker → VM bridge → host → TUN → internet | TLS timeout ❌ |\n| **`--network host` (build only)** | **Build container → host network → TUN → internet** | **Build works ✅** |\n\n**Decision tree**:\n- `docker pull` broken → Fix `docker.json` with `host.internal` proxy (2G-2)\n- `docker build` broken → Use `--network host` (2G-1) OR pass `--build-arg http_proxy=http:\u002F\u002Fhost.internal:1082`\n- Both broken → Fix both: `docker.json` + `--network host`\n\n#### 2G-4: Deploy scripts and container healthchecks probe localhost through proxy\n\nDeploy scripts that `curl localhost` inside containers or Docker healthchecks that use `wget http:\u002F\u002Flocalhost` will route through the proxy if env vars leak into the container.\n\n**Common symptoms**:\n- Container healthcheck shows `(unhealthy)` but the app inside is running fine\n- `wget: can't connect to remote host (127.0.0.1): Connection refused` in healthcheck logs (proxy port, not app port)\n\n**Root cause**: Docker inherits uppercase AND lowercase proxy env vars from the host. Many tools only clear uppercase (`HTTP_PROXY=`) but forget lowercase (`http_proxy=http:\u002F\u002F127.0.0.1:1082`). The healthcheck `wget` uses lowercase.\n\n**Fix in docker-compose.yml** — clear BOTH cases:\n\n```yaml\nenvironment:\n  # Must clear both uppercase and lowercase — wget\u002Fcurl check different vars\n  - HTTP_PROXY=\n  - HTTPS_PROXY=\n  - http_proxy=\n  - https_proxy=\n  - NO_PROXY=*\n  - no_proxy=*\n```\n\n**Fix in deploy scripts**:\n\n```bash\n_local_bypass=\"localhost,127.0.0.1,::1\"\nexport NO_PROXY=\"${_local_bypass}${NO_PROXY:+,${NO_PROXY}}\"\nexport no_proxy=\"$NO_PROXY\"\n\n# Use 127.0.0.1 instead of localhost in probe URLs (some proxy implementations\n# only match exact string \"localhost\" in no-proxy, not the resolved IP)\ncurl http:\u002F\u002F127.0.0.1:3001\u002Fhealth   # ✅ bypasses proxy\ncurl http:\u002F\u002Flocalhost:3001\u002Fhealth    # ❌ may still go through proxy\n```\n\n**Verify the fix**:\n\n```bash\n# Docker proxy check (should show proxy + no-proxy)\ndocker info | grep -iE \"proxy|No Proxy\"\n\n# Pull test\ndocker pull --quiet hello-world\n\n# Build test (the real verification)\ndocker build --network host --no-cache - \u003C\u003C'EOF'\nFROM alpine:latest\nRUN apk update && echo \"BUILD OK\"\nEOF\n\n# Container env check (no proxy leak)\ndocker exec \u003Ccontainer> env | grep -i proxy\n# Expected: all empty or not set\n```\n\n### Step 2H: Fix TUN DNS Hijack for SSH\u002FGit (198.18.x.x virtual IPs)\n\n**Symptom**: `git clone\u002Ffetch\u002Fpush` fails with `Connection closed by 198.18.0.x port 443`. `ssh -T git@github.com` may also fail. DNS resolution returns `198.18.x.x` addresses instead of real IPs.\n\n**Root cause**: Shadowrocket TUN intercepts all DNS queries and returns virtual IPs in the `198.18.0.0\u002F15` range. It then routes traffic to these virtual IPs through the TUN for protocol-aware proxying. HTTP\u002FHTTPS works because the landing proxy understands these protocols, but SSH-over-443 (used by GitHub) gets mishandled — the TUN sees port 443 traffic, expects HTTPS, and drops the SSH handshake.\n\n**Diagnosis**:\n\n```bash\n# DNS returns virtual IP (TUN hijack)\nnslookup ssh.github.com\n# → 198.18.0.26  ← Shadowrocket virtual IP, NOT real GitHub IP\n\n# Direct IP works (bypasses DNS hijack)\nssh -o HostName=140.82.112.35 -o Port=443 git@github.com\n# → \"Hi user! You've successfully authenticated\"\n```\n\n**Fix** — use direct IP in SSH config to bypass DNS hijack:\n\n```bash\n# ~\u002F.ssh\u002Fconfig\nHost github.com\n    HostName 140.82.112.35    # GitHub SSH server real IP (bypasses TUN DNS hijack)\n    Port 443\n    User git\n    ServerAliveInterval 60\n    ServerAliveCountMax 3\n    IdentityFile ~\u002F.ssh\u002Fid_ed25519\n```\n\n**GitHub SSH server IPs** (as of 2026, verify with `dig +short ssh.github.com @8.8.8.8`):\n- `140.82.112.35` (primary)\n- `140.82.112.36` (alternate)\n\n**Trade-off**: Hardcoded IPs break if GitHub changes them. Monitor `ssh -T git@github.com` — if it starts failing, update the IP. A cron job can automate this:\n\n```bash\n# Weekly check (add to crontab)\n0 9 * * 1 dig +short ssh.github.com @8.8.8.8 | head -1 > \u002Ftmp\u002Fgithub-ssh-ip.txt\n```\n\n**Alternative** (if you control Shadowrocket rules): Add GitHub SSH IPs to DIRECT rule so TUN passes them through without protocol inspection:\n\n```\nIP-CIDR,140.82.112.0\u002F24,DIRECT\nIP-CIDR,192.30.252.0\u002F22,DIRECT\n```\n\nThis is more robust but requires proxy tool config access.\n\n### Step 3: Fix Proxy Tool Configuration\n\nIdentify the proxy tool and apply the appropriate fix. See [references\u002Fproxy_conflict_reference.md](references\u002Fproxy_conflict_reference.md) for detailed instructions per tool.\n\n**Key principle**: Do NOT use `tun-excluded-routes` to exclude `100.64.0.0\u002F10`. This causes the proxy to add a `→ en0` route that overrides Tailscale. Instead, let the traffic enter the proxy TUN and use a DIRECT rule to pass it through.\n\n**Universal fix** — add this rule to any proxy tool:\n```\nIP-CIDR,100.64.0.0\u002F10,DIRECT\nIP-CIDR,fd7a:115c:a1e0::\u002F48,DIRECT\n```\n\nAfter applying fixes, verify:\n\n```bash\nroute -n get \u003Ctailscale-ip>\n# Should show Tailscale utun interface, NOT en0\n```\n\n### Step 4: Configure Tailscale SSH ACL\n\nIf SSH connects but returns `operation not permitted`, the Tailscale ACL may require browser authentication for each connection.\n\nAt [Tailscale ACL admin](https:\u002F\u002Flogin.tailscale.com\u002Fadmin\u002Facls), ensure the SSH section uses `\"action\": \"accept\"`:\n\n```json\n\"ssh\": [\n    {\n        \"action\": \"accept\",\n        \"src\": [\"autogroup:member\"],\n        \"dst\": [\"autogroup:self\"],\n        \"users\": [\"autogroup:nonroot\", \"root\"]\n    }\n]\n```\n\n**Note**: `\"action\": \"check\"` requires browser authentication each time. Change to `\"accept\"` for non-interactive SSH access.\n\n### Step 5: Fix WSL Tailscale Installation\n\nIf SSH connects and ACL passes but fails with `be-child ssh` exit code 1 in tailscaled logs, the snap-installed Tailscale has sandbox restrictions preventing SSH shell execution.\n\n**Diagnosis** — check WSL tailscaled logs:\n\n```bash\n# For snap installs:\nsudo journalctl -u snap.tailscale.tailscaled -n 30 --no-pager\n\n# For apt installs:\nsudo journalctl -u tailscaled -n 30 --no-pager\n```\n\nLook for:\n```\naccess granted to user@example.com as ssh-user \"username\"\nstarting non-pty command: [\u002Fsnap\u002Ftailscale\u002F...\u002Ftailscaled be-child ssh ...]\nWait: code=1\n```\n\n**Fix** — replace snap with apt installation:\n\n```bash\n# Remove snap version\nsudo snap remove tailscale\n\n# Install apt version\ncurl -fsSL https:\u002F\u002Ftailscale.com\u002Finstall.sh | sh\n\n# Start with SSH enabled\nsudo tailscale up --ssh\n```\n\n**Important**: The new installation may assign a different Tailscale IP. Check with `tailscale status --self`.\n\n### Step 5A: Fix Tailscale SSH Proxy Silent Failure on WSL\n\n**Symptom**: TCP port 22 is reachable (`nc -z -w 5 \u003Cip> 22` succeeds), but SSH fails immediately with:\n\n```\nkex_exchange_identification: Connection closed by remote host\n```\n\nNo SSH banner is ever received. This happens even with apt-installed Tailscale (not snap).\n\n**Root cause**: When `tailscale up --ssh` is enabled on WSL, Tailscale intercepts port 22 connections at the application layer (above the kernel network stack). If Tailscale's built-in SSH proxy malfunctions, it accepts the TCP connection but immediately closes it before sending the SSH banner.\n\n**Key diagnostic** — on the WSL instance:\n\n```bash\n# This will show 0 packets even during active SSH attempts\nsudo tcpdump -i any port 22 -c 5 -w \u002Fdev\u002Fnull 2>&1\n```\n\nZero packets means Tailscale is intercepting connections before they reach the kernel network stack. The kernel's `sshd` never sees the connection.\n\n**Distinction from Step 5**: Step 5 covers snap sandbox issues where `be-child ssh` fails. This is a different problem — Tailscale's SSH proxy itself silently fails, regardless of installation method.\n\n**Fix** — disable Tailscale's SSH proxy and use regular sshd:\n\n```bash\n# On the WSL instance:\nsudo tailscale up --ssh=false\n\n# Verify sshd is running\nsudo service ssh status\n# If not running:\nsudo service ssh start\n\n# Verify from the client machine:\nssh -o ConnectTimeout=10 \u003Cuser>@\u003Ctailscale-ip> 'echo SSH_OK'\n```\n\nAfter disabling Tailscale SSH, connections go through the kernel network stack to `sshd` as normal. The Tailscale ACL `\"action\": \"accept\"` in Step 4 is no longer relevant — authentication is handled by `sshd` using SSH keys or passwords.\n\n**When to keep `--ssh` enabled**: Only if you specifically need Tailscale's SSH features (ACL-based access control, no SSH key management). If standard sshd works, prefer `--ssh=false` for reliability.\n\n### Step 5B: Fix App Store Tailscale on macOS (Missing `tailscale ssh`)\n\n**Symptom**: Running `tailscale ssh` returns:\n\n```\nThe 'tailscale ssh' subcommand is not available on macOS builds\ndistributed through the App Store or TestFlight.\n```\n\n**Root cause**: The App Store version of Tailscale for macOS is sandboxed and does not include the `tailscale ssh` subcommand.\n\n**Fix** — install the Standalone version:\n\n1. Uninstall the App Store version (delete from \u002FApplications)\n2. Download the Standalone build from https:\u002F\u002Fpkgs.tailscale.com\u002Fstable\u002F#macos\n3. Install to \u002FApplications\n\n**Post-install CLI setup**: The standalone `tailscale` CLI binary is embedded inside the app bundle. Add an alias to your shell config:\n\n```bash\n# ~\u002F.zshrc\nalias tailscale=\"\u002FApplications\u002FTailscale.app\u002FContents\u002FMacOS\u002FTailscale\"\n```\n\nVerify:\n\n```bash\nsource ~\u002F.zshrc\ntailscale version\ntailscale ssh \u003Cuser>@\u003Chostname>   # Should work now\n```\n\n### Step 6: Verify End-to-End\n\nRun a complete connectivity test:\n\n```bash\n# 1. Check route is correct (must show Tailscale's utun, not en0 or Shadowrocket's utun)\nroute -n get \u003Ctailscale-ip>\n# Also confirm which utun is Tailscale's:\nifconfig | grep -A2 'inet 100\\.'\n\n# 2. Test TCP connectivity\nnc -z -w 5 \u003Ctailscale-ip> 22\n\n# 3. Test SSH\nssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no \u003Cuser>@\u003Ctailscale-ip> 'echo SSH_OK && hostname && whoami'\n```\n\nAll three must pass. If step 1 fails, revisit Step 3. If step 1 shows wrong utun (e.g., Shadowrocket's utun with MTU 4064 instead of Tailscale's with MTU 1280), that is also a route conflict. If step 2 passes but step 3 fails with `kex_exchange_identification`, revisit Step 5A (Tailscale SSH proxy intercept). If step 2 fails, check WSL sshd or firewall. If step 3 fails with other errors, revisit Steps 4-5.\n\n## SOP: Remote Development via Tailscale\n\nProactive setup guide for remote development over Tailscale with proxy tools. Follow these steps **before** encountering problems.\n\n### Prerequisites\n\n- Tailscale installed and running on both machines\n- Proxy tool (Shadowrocket\u002FClash\u002FSurge) configured with Tailscale compatibility (see Step 3 above)\n- SSH access working: `ssh \u003Ctailscale-ip> 'echo ok'`\n\n### 1. Proxy-Safe Makefile Pattern\n\nAny Makefile target that curls `localhost` must use `--noproxy localhost`. This is required because `http_proxy` is often set globally in `~\u002F.zshrc` (common in China), and Make inherits shell environment variables.\n\n```makefile\n## ── Health Checks ─────────────────────────────────────\n\nstatus:                ## Health check dashboard\n\t@echo \"=== Dev Infrastructure ===\"\n\t@docker exec my-postgres pg_isready -U postgres 2>\u002Fdev\u002Fnull && echo \"PostgreSQL: OK\" || echo \"PostgreSQL: FAIL\"\n\t@curl --noproxy localhost -sf http:\u002F\u002Flocalhost:9000\u002Fminio\u002Fhealth\u002Flive >\u002Fdev\u002Fnull 2>&1 && echo \"MinIO: OK\" || echo \"MinIO: FAIL\"\n\t@curl --noproxy localhost -sf http:\u002F\u002Flocalhost:3001\u002Fapi\u002Fstatus >\u002Fdev\u002Fnull 2>&1 && echo \"API: OK\" || echo \"API: FAIL\"\n\n## ── Route Warmup ──────────────────────────────────────\n\nwarmup:                ## Pre-compile key routes (run after dev server is ready)\n\t@echo \"Warming up dev server routes...\"\n\t@echo -n \"  \u002Fapi\u002Fhealth → \" && curl --noproxy localhost -s -o \u002Fdev\u002Fnull -w '%{http_code} (%{time_total}s)\\n' http:\u002F\u002Flocalhost:3010\u002Fapi\u002Fhealth\n\t@echo -n \"  \u002F            → \" && curl --noproxy localhost -s -o \u002Fdev\u002Fnull -w '%{http_code} (%{time_total}s)\\n' http:\u002F\u002Flocalhost:3010\u002F\n\t@echo \"Warmup complete.\"\n```\n\n**Rules**:\n- Every `curl http:\u002F\u002Flocalhost` call MUST include `--noproxy localhost`\n- Docker commands (`docker exec`) are unaffected by `http_proxy` — no fix needed\n- `redis-cli`, `pg_isready` connect via TCP directly — no fix needed\n\n### 2. SSH Tunnel Makefile Targets\n\nAdd these targets for remote development via Tailscale SSH tunnels:\n\n```makefile\n## ── Remote Development ────────────────────────────────\n\nREMOTE_HOST    ?= \u003Ctailscale-ip>\nTUNNEL_FORWARD ?= -L 3010:localhost:3010\n\ntunnel:                ## SSH tunnel to remote machine (foreground)\n\tssh -N $(TUNNEL_FORWARD) $(REMOTE_HOST)\n\ntunnel-bg:             ## SSH tunnel to remote machine (background, auto-reconnect)\n\tautossh -M 0 -f -N $(TUNNEL_FORWARD) \\\n\t\t-o \"ServerAliveInterval=30\" \\\n\t\t-o \"ServerAliveCountMax=3\" \\\n\t\t-o \"ExitOnForwardFailure=yes\" \\\n\t\t$(REMOTE_HOST)\n\t@echo \"Tunnel running in background. Kill with: pkill -f 'autossh.*$(REMOTE_HOST)'\"\n```\n\n**Design decisions**:\n\n| Choice | Rationale |\n|--------|-----------|\n| `?=` (conditional assign) | Allows override: `make tunnel REMOTE_HOST=100.x.x.x` |\n| `TUNNEL_FORWARD` as variable | Supports multi-port: `make tunnel TUNNEL_FORWARD=\"-L 3010:localhost:3010 -L 9000:localhost:9000\"` |\n| `autossh -M 0` | Disables autossh's own monitoring port; relies on `ServerAliveInterval` instead (more reliable through NAT) |\n| `ExitOnForwardFailure=yes` | Fails immediately if port is already bound, instead of silently running without tunnel |\n| Kill hint uses `autossh.*$(REMOTE_HOST)` | Precise pattern — won't accidentally kill other SSH sessions |\n\n**Install autossh**: `brew install autossh` (macOS) or `apt install autossh` (Linux\u002FWSL)\n\n### 3. Multi-Port Tunnels\n\nWhen the project requires multiple services (dev server + object storage + API gateway):\n\n```bash\n# Forward multiple ports in one tunnel\nmake tunnel TUNNEL_FORWARD=\"-L 3010:localhost:3010 -L 9000:localhost:9000 -L 3001:localhost:3001\"\n\n# Or define a project-specific default in Makefile\nTUNNEL_FORWARD ?= -L 3010:localhost:3010 -L 9000:localhost:9000\n```\n\nEach `-L` flag is independent. If one port is already bound locally, `ExitOnForwardFailure=yes` will abort the entire tunnel — fix the port conflict first.\n\n### 4. SSH Non-Login Shell Setup\n\n**This is a frequent source of \"it works interactively but fails in scripts\" bugs.** SSH non-login shells don't load `~\u002F.zshrc` (or `~\u002F.bashrc` on Linux), so tools installed via nvm, Homebrew, uv, cargo, or any shell-level manager won't be in `$PATH`. Proxy env vars set in `~\u002F.zshrc` also won't be loaded.\n\nThis affects **all** remote commands run via `ssh user@host \"command\"`, including CI\u002FCD pipelines, cron-triggered SSH, and Makefile remote targets. Prefix all remote commands with `source ~\u002F.zshrc 2>\u002Fdev\u002Fnull;` (macOS) or `source ~\u002F.bashrc 2>\u002Fdev\u002Fnull;` (Linux\u002FWSL).\n\n**Common failure**: `ssh user@host \"uv run ...\"` or `ssh user@host \"node ...\"` returns `command not found` even though the command works in an interactive SSH session.\n\nSee [references\u002Fproxy_conflict_reference.md § SSH Non-Login Shell Pitfall](references\u002Fproxy_conflict_reference.md) for details and examples.\n\nFor Makefile targets that run remote commands:\n\n```makefile\nREMOTE_CMD = ssh $(REMOTE_HOST) 'source ~\u002F.zshrc 2>\u002Fdev\u002Fnull; $(1)'\n\nremote-status:         ## Check remote dev server status\n\t$(call REMOTE_CMD,curl --noproxy localhost -sf http:\u002F\u002Flocalhost:3010\u002Fapi\u002Fhealth && echo \"OK\" || echo \"FAIL\")\n```\n\n### 5. End-to-End Workflow\n\n#### First-time setup (remote machine)\n\n```bash\n# 1. Clone repo and install dependencies\nssh \u003Ctailscale-ip>\ncd \u002Fpath\u002Fto\u002Fproject\ngit clone git@github.com:user\u002Frepo.git && cd repo\npnpm install  # Add --registry https:\u002F\u002Fregistry.npmmirror.com if in China\n\n# 2. Copy .env from local machine (run on local)\nscp .env \u003Ctailscale-ip>:\u002Fpath\u002Fto\u002Fproject\u002Frepo\u002F.env\n\n# 3. Start Docker infrastructure\nmake up && make status\n\n# 4. Run database migrations\nbun run db:migrate\n\n# 5. Start dev server\nbun run dev\n```\n\n#### Daily workflow (local machine)\n\n```bash\n# 1. Start tunnel\nmake tunnel-bg\n\n# 2. Open browser\nopen http:\u002F\u002Flocalhost:3010\n\n# 3. Auth, coding, testing — everything works as if local\n\n# 4. When done, kill tunnel\npkill -f 'autossh.*\u003Ctailscale-ip>'\n```\n\n#### Why this works\n\n```\nBrowser → localhost:3010 → SSH tunnel → Remote localhost:3010 → Dev server\n                                     ↓\n                              Auth redirects to localhost:3010\n                                     ↓\n                              Browser follows redirect → same tunnel → works\n```\n\nThe key insight: `APP_URL=http:\u002F\u002Flocalhost:3010` in `.env` is correct for **both** local and remote development. The SSH tunnel makes the remote server's localhost accessible as the local machine's localhost. Auth callback redirects to `localhost:3010` always resolve correctly.\n\n### 6. Checklist\n\nBefore starting remote development, verify:\n\n- [ ] Tailscale connected: `tailscale status`\n- [ ] SSH works: `ssh \u003Ctailscale-ip> 'echo ok'`\n- [ ] Proxy tool configured: `[Rule]` has `IP-CIDR,100.64.0.0\u002F10,DIRECT`\n- [ ] `skip-proxy` includes `100.64.0.0\u002F10`\n- [ ] `tun-excluded-routes` does NOT include `100.64.0.0\u002F10`\n- [ ] `NO_PROXY` includes `.ts.net,100.64.0.0\u002F10`\n- [ ] `autossh` installed: `which autossh`\n- [ ] Makefile curl commands have `--noproxy localhost`\n- [ ] Remote dev server running: `ssh \u003Cip> 'source ~\u002F.zshrc 2>\u002Fdev\u002Fnull; curl --noproxy localhost -sf http:\u002F\u002Flocalhost:3010\u002F'`\n- [ ] Tunnel works: `make tunnel-bg && curl -sf http:\u002F\u002Flocalhost:3010\u002F`\n\n## References\n\n- [references\u002Fproxy_conflict_reference.md](references\u002Fproxy_conflict_reference.md) — Per-tool configuration (Shadowrocket, Clash, Surge), NO_PROXY syntax, SSH ProxyCommand, and conflict architecture\n\n",{"name":5,"description":7,"allowed-tools":49},"Read, Grep, Edit, Bash","main","def8531e65114c0fca8fb8551c1871ee0eed705c","skills\u002Fdaymade\u002Fclaude-code-skills\u002Ftunnel-doctor",24,"https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub","https:\u002F\u002Fskillshub.wtf","https:\u002F\u002Fgithub.com\u002FComeOnOliver","AI & Blockchain Engineer\r\n","2026-06-24 22:07:19",{"source":8,"skillName":5,"commands":60,"prompt":70,"zipAvailable":71},[61,64,67],{"tool":62,"command":63},"npx","npx skills add ComeOnOliver\u002Fskillshub --skill tunnel-doctor",{"tool":65,"command":66},"bunx","bunx skills add ComeOnOliver\u002Fskillshub --skill tunnel-doctor",{"tool":68,"command":69},"pnpm","pnpm dlx skills add ComeOnOliver\u002Fskillshub --skill tunnel-doctor","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fdaymade\u002Fclaude-code-skills\u002Ftunnel-doctor\u002FSKILL.md",true,[],[74,86,96,105,111,118],{"skillNo":75,"name":76,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":77,"categories":78,"keywords":82,"publishedAt":44},"01M6FKJN13GMG4KY57J3QD8GY5","model-hierarchy-skill","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzscole\u002Fmodel-hierarchy-skill\u002Fmodel-hierarchy-skill\u002FSKILL.md",[79],{"categoryNo":80,"categoryName":81,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[83,84,85],"model","model hierarchy","model hierarchy skill",{"skillNo":87,"name":88,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":89,"categories":90,"keywords":92,"publishedAt":44},"01M6FKJJJ6GCAMR4PPPRD12M5X","zoom-mcp","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fzoom-mcp\u002FSKILL.md",[91],{"categoryNo":80,"categoryName":81,"taxonomyType":21,"taxonomyTypeDesc":22},[93,94,95],"MCP","zoom","zoom mcp",{"skillNo":97,"name":98,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":99,"categories":100,"keywords":101,"publishedAt":44},"01M6FKJE3KYT3KPEZMEEHYKA6K","zoom-apps-sdk","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fzoom-apps-sdk\u002FSKILL.md",[],[102,103,104,94],"sdk","zoom apps sdk","apps",{"skillNo":106,"name":107,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":108,"categories":109,"keywords":110,"publishedAt":44},"01M6FKJ9EDH9Q5VWDF8HAJRSP0","windows","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fwindows\u002FSKILL.md",[],[107],{"skillNo":112,"name":113,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":114,"categories":115,"keywords":117,"publishedAt":44},"01M6FKJ666BEEFC3AE9GJ72Q93","whiteboard","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fwhiteboard\u002FSKILL.md",[116],{"categoryNo":80,"categoryName":81,"taxonomyType":21,"taxonomyTypeDesc":22},[113],{"skillNo":119,"name":120,"nameZh":6,"description":6,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":121,"categories":122,"keywords":123,"publishedAt":44},"01M6FKJ21WFCDGTJQ2Q91T9DVW","websockets","https:\u002F\u002Fgithub.com\u002FComeOnOliver\u002Fskillshub\u002Fblob\u002Fmain\u002Fskills\u002Fzoom\u002Fskills\u002Fwebsockets\u002FSKILL.md",[],[120]]