tunnel-doctor by daymade/claude-code-skills
npx skills add https://github.com/daymade/claude-code-skills --skill tunnel-doctor诊断并修复 macOS 上 Tailscale 与代理/VPN 工具共存时的冲突,并提供访问 WSL 实例 SSH 的特定指导。
macOS 上的代理/VPN 工具在五个独立的层级上造成冲突。第 1-3 层影响 Tailscale 连接性;第 4 层影响 SSH git 操作;第 5 层影响 VM/容器运行时:
| 层级 | 什么会中断 | 什么仍然有效 | 根本原因 |
|---|---|---|---|
| 1. 路由表 | 所有连接 (SSH, curl, 浏览器) | tailscale ping | tun-excluded-routes 添加了覆盖 Tailscale utun 的 en0 路由 |
| 2. HTTP 环境变量 | curl, Python requests, Node.js fetch | SSH, 浏览器 | 设置了 http_proxy 但 未排除 Tailscale |
广告位招租
在这里展示您的产品或服务
触达数万 AI 开发者,精准高效
NO_PROXY| 3. 系统代理 (浏览器) | 仅浏览器 (HTTP 503) | SSH, curl (无论是否使用代理) | 浏览器使用 VPN 系统代理;DIRECT 规则通过 Wi-Fi 而非 Tailscale utun 路由 |
| 4. SSH ProxyCommand 双重隧道 | git push/pull (间歇性) | ssh -T (小数据) | connect -H 创建了与 Shadowrocket TUN 冗余的 HTTP CONNECT 隧道;落地代理丢弃大/长连接传输 |
| 5. VM/容器代理传播 | docker pull, docker build | 主机 curl, 运行中的容器 | VM 运行时 (OrbStack/Docker Desktop) 自动注入或缓存代理配置;移除代理会使情况更糟 (VM 流量通过 TUN → TLS 超时) |
确定哪种情况适用:
curl 和 SSH 都有效 → 系统代理绕过冲突 (步骤 2C)curl 中 local.<domain> 失败,但直接/无代理请求有效 → 本地自定义域名代理拦截 (步骤 2C-1)localhost → 浏览器无法跟随 → 需要 SSH 隧道 (步骤 2D)make status / 脚本向 localhost 的 curl 请求在代理下失败 → localhost 代理拦截 (步骤 2E)git push/pull 失败并显示 FATAL: failed to begin relaying via HTTP → SSH 双重隧道 (步骤 2F)docker pull 失败并显示 TLS handshake timeout 或 docker build 无法拉取基础镜像 → VM/容器代理传播 (步骤 2G)git clone 失败并显示 Connection closed by 198.18.x.x → TUN DNS 劫持 SSH (步骤 2H)operation not permitted → Tailscale SSH 配置问题 (步骤 4)be-child ssh 退出码为 1 → WSL snap 沙盒问题 (步骤 5)关键区别 :
http_proxy/NO_PROXY 环境变量。如果 SSH 有效但 HTTP 无效 → 第 2 层。curl 使用 http_proxy 环境变量,而非系统代理。浏览器使用系统代理 (由 VPN 设置)。如果 curl 有效但浏览器无效 → 第 3 层。tailscale ping 有效但常规 ping 无效 → 第 1 层 (路由表损坏)。ssh -T git@github.com 有效但 git push 间歇性失败 → 第 4 层 (双重隧道)。curl https://... 有效但 docker pull 超时 → 第 5 层 (VM 代理传播)。198.18.x.x 虚拟 IP → TUN DNS 劫持 (步骤 2H)。对于常见的 macOS 冲突 (环境代理、系统代理例外、直接/代理路径分离、本地 TLS 信任),运行:
python3 scripts/quick_diagnose.py --host local.claude4.dev --url https://local.claude4.dev/health
可选地,对 Tailscale 目标进行路由所有权检查:
python3 scripts/quick_diagnose.py --host <target-host> --url http://<target-host>:<port>/health --tailscale-ip <100.x.x.x>
解读:
direct=PASS + forced_proxy=FAIL = 主机必须绕过代理 (skip-proxy + NO_PROXY)。strict_tls=FAIL + direct=PASS = 路径可达;仅为信任问题 (安装/信任本地 CA)。host in scutil exceptions: no = 浏览器/系统客户端仍可能被代理。检查代理环境变量是否拦截了 Tailscale HTTP 流量:
env | grep -i proxy
错误输出 — 设置了代理但 NO_PROXY 未排除 Tailscale:
http_proxy=http://127.0.0.1:1082
https_proxy=http://127.0.0.1:1082
NO_PROXY=localhost,127.0.0.1 ← 缺少 Tailscale!
修复 — 将 Tailscale MagicDNS 域名 + CIDR 添加到 NO_PROXY:
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*
| 条目 | 覆盖范围 | 原因 |
|---|---|---|
.ts.net | MagicDNS 域名 (host.tailnet.ts.net) | 在 DNS 解析前匹配 |
100.64.0.0/10 | Tailscale IP (100.64.* – 100.127.*) | 精确的 CIDR,无公网 IP 误报 |
192.168.*,10.*,172.16.* | RFC 1918 私有网络 | LAN 永远不应被代理 |
两个层级互补:.ts.net 处理基于域名的访问,100.64.0.0/10 处理直接 IP 访问。
NO_PROXY 语法陷阱 — 参见 references/proxy_conflict_reference.md 了解兼容性矩阵。
验证修复:
# 两者都必须返回 HTTP 200:
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<host>.ts.net:<port>/health -w "HTTP %{http_code}\n"
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<tailscale-ip>:<port>/health -w "HTTP %{http_code}\n"
然后持久化到 shell 配置 (~/.zshrc 或 ~/.bashrc)。
检查代理工具是否劫持了 Tailscale CGNAT 范围:
route -n get <tailscale-ip>
健康输出 — 流量通过 Tailscale 接口:
destination: 100.64.0.0
interface: utun7 # Tailscale 接口 (utunN 可能不同)
错误输出 — 代理劫持了路由:
destination: 100.64.0.0
gateway: 192.168.x.1 # 默认网关
interface: en0 # 物理接口,非 Tailscale
通过完整路由表确认:
netstat -rn | grep 100.64
两条竞争路由表明存在冲突:
100.64/10 192.168.x.1 UGSc en0 ← 代理添加了此路由 (胜出)
100.64/10 link#N UCSI utun7 ← Tailscale 路由 (失败)
根本原因:在 macOS 上,对于相同的前缀长度,UGSc (静态网关) 的优先级高于 UCSI (克隆的静态接口)。
症状:浏览器对 http://<tailscale-ip>:<port> 显示 HTTP 503,但 curl --noproxy '*' 和 curl (使用代理环境变量) 都返回 200。SSH 也有效。
根本原因:浏览器使用 VPN 配置文件 (Shadowrocket/Clash/Surge) 配置的系统代理。代理匹配 IP-CIDR,100.64.0.0/10,DIRECT 并尝试直接连接 — 但“直接”意味着通过 Wi-Fi 接口 (en0),而不是通过 Tailscale 的 utun 接口。代理进程本身没有到 Tailscale IP 的路由,因此连接失败并返回 503。
诊断:
# 使用代理环境变量的 curl 有效 (curl 连接到代理端口,但流量流向不同)
curl -s -o /dev/null -w "%{http_code}" http://<tailscale-ip>:<port>/
# → 200
# 浏览器得到 503,因为它通过 VPN 系统代理,而不是 http_proxy 环境变量
修复 — 在代理工具配置中将 Tailscale CGNAT 范围添加到 skip-proxy:
对于 Shadowrocket,在 [General] 中:
skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, localhost, *.local, captive.apple.com
skip-proxy 告诉系统“对这些地址完全绕过代理”。然后浏览器通过操作系统网络栈直接连接,Tailscale 的路由表会正确处理流量。
为什么 skip-proxy 有效而 tun-excluded-routes 无效:
skip-proxy:仅绕过 HTTP 代理层。流量仍流经 TUN 接口,Tailscale utun 会处理它。安全。tun-excluded-routes:将 CIDR 从 TUN 路由中完全移除。这会创建一个覆盖 Tailscale 的竞争 en0 路由。破坏一切。local.<domain>)症状:https://local.<domain> 在浏览器或默认 curl 中失败,但直接/无代理命令成功:
env -u http_proxy -u https_proxy curl -k -I https://local.<domain>/health
# -> 200
curl -I https://local.<domain>/health
# -> 代理 CONNECT 然后 TLS 重置/失败
根本原因:域名通过系统/shell 代理路由,而非本地直接路径。
修复:
skip-proxy)。NO_PROXY/no_proxy)。# ~/.zshrc
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*,local.<domain>,www.local.<domain>
export no_proxy="$NO_PROXY"
验证:
python3 scripts/quick_diagnose.py --host local.<domain> --url https://local.<domain>/health
预期:
host in NO_PROXY: yeshost in scutil exceptions: yesambient=PASS 和 direct=PASS症状:开发服务器在远程机器上运行 (例如,通过 Tailscale 的 Mac Mini)。你在浏览器中访问 http://<tailscale-ip>:3010。登录/注册有效,但在身份验证后,应用重定向到 http://localhost:3010/ 失败 — 你机器上的 localhost 没有运行开发服务器。
根本原因:应用的 APP_URL (或等效项) 设置为 http://localhost:3010。身份验证库 (Better-Auth, NextAuth 等) 使用此 URL 进行回调重定向。将 APP_URL 更改为 Tailscale IP 会引入 Shadowrocket 代理冲突,并破坏远程机器上的本地开发。
修复 — SSH 本地端口转发。这完全避免了所有三个冲突层级:
# 将本地端口 3010 转发到远程机器的 localhost:3010
ssh -NL 3010:localhost:3010 <tailscale-ip>
# 或者使用 autossh 实现自动重连 (推荐用于长时间会话)
autossh -M 0 -f -N -L 3010:localhost:3010 \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
<tailscale-ip>
现在在浏览器中访问 http://localhost:3010。身份验证重定向到 localhost:3010 → 隧道 → 远程开发服务器 → 正常工作。
为什么这是最佳方法:
.env — APP_URL=http://localhost:3010 在任何地方都有效localhost 始终在 skip-proxy 中安装 autossh:brew install autossh (macOS) 或 apt install autossh (Linux)
终止后台隧道:pkill -f 'autossh.*<tailscale-ip>'
症状:Makefile 目标或脚本中对 localhost 的 curl 请求 (健康检查、预热路由) 在 shell 中全局设置了 http_proxy 时失败或超时。
根本原因:http_proxy=http://127.0.0.1:1082 在 ~/.zshrc 中设置,但 no_proxy 未包含 localhost。所有 curl 命令都将 localhost 请求发送到代理。
修复 — 在脚本中的所有 localhost curl 命令中添加 --noproxy localhost:
# 错误 — 当设置了 http_proxy 时失败
@curl -sf http://localhost:9000/minio/health/live && echo "OK"
# 正确 — 始终为 localhost 绕过代理
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live && echo "OK"
或者,在 ~/.zshrc 中全局设置 no_proxy:
export no_proxy=localhost,127.0.0.1
症状:ssh -T git@github.com 始终成功,但 git push 或 git pull 间歇性失败并显示:
FATAL: failed to begin relaying via HTTP.
Connection closed by UNKNOWN port 65535
小操作 (身份验证、获取元数据) 有效;大数据传输失败。
根本原因:当 Shadowrocket TUN 激活时,它已经通过其 VPN 隧道路由所有 TCP 流量。如果 SSH 配置也使用 ProxyCommand connect -H,数据会流经两个代理层 — 落地代理丢弃大/长连接的 HTTP CONNECT 连接。
诊断:
# 1. 确认 Shadowrocket TUN 处于活动状态
ifconfig | grep '^utun'
# 2. 检查 SSH 配置中的 ProxyCommand
grep -A5 'Host github.com' ~/.ssh/config
# 3. 确认:移除 ProxyCommand 可修复推送
GIT_SSH_COMMAND="ssh -o ProxyCommand=none" git push origin main
修复 — 移除 ProxyCommand 并切换到 ssh.github.com:443。完整 SSH 配置、端口 443 的帮助原因以及 VPN 关闭时的回退选项,请参见 references/proxy_conflict_reference.md § SSH ProxyCommand and Git Operations。
症状:docker pull 或 docker build 失败并显示 net/http: TLS handshake timeout 或来自 auth.docker.io 的 Internal Server Error,而主机 curl 访问相同 URL 正常。
适用于:OrbStack, Docker Desktop,或任何在 macOS 上运行且 Shadowrocket/Clash TUN 处于活动状态的基于 VM 的 Docker 运行时。
根本原因:基于 VM 的 Docker 运行时 (OrbStack, Docker Desktop) 在轻量级 VM 内运行 Docker 守护进程。VM 的出站流量采用与主机进程不同的网络路径:
主机进程 (curl): 进程 → TUN (Shadowrocket) → 落地代理 → 互联网 ✅
VM 进程 (Docker): Docker 守护进程 → VM 网桥 → 主机网络 → TUN → ??? ❌
TUN 正确处理源自主机的流量,但可能丢弃或延迟 VM 桥接的流量 (不同的 TCP 栈、MTU、保活行为)。
三个子问题及其修复:
OrbStack 的 network_proxy: auto 从 shell 环境读取 http_proxy 并将其写入 ~/.orbstack/config/docker.json。关键的是,orbctl config set network_proxy none 不会清理 docker.json — 缓存的代理会持续存在。
诊断:
# OrbStack 配置显示 "none" 但 Docker 仍显示代理
orbctl config get network_proxy # → "none"
docker info | grep -i proxy # → HTTP Proxy: http://127.0.0.1:1082 ← 过时的!
# 真正的真相来源:
cat ~/.orbstack/config/docker.json
# → {"proxies": {"http-proxy": "http://127.0.0.1:1082", ...}} ← 缓存的!
修复 — 不要移除代理。相反,添加精确的 no-proxy 以防止 localhost 拦截,同时保持代理作为 VM 的出站通道:
# 写入修正后的配置 (保留代理,为本地流量添加 no-proxy)
python3 -c "
import json
config = {
'proxies': {
'http-proxy': 'http://127.0.0.1:1082',
'https-proxy': 'http://127.0.0.1:1082',
'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0/24,100.64.0.0/10,host.internal,*.local'
}
}
json.dump(config, open('$HOME/.orbstack/config/docker.json', 'w'), indent=2)
"
# 完全重启 (不仅仅是 docker 引擎)
orbctl stop && sleep 3 && orbctl start
为什么不要移除代理:当 TUN 处于活动状态时,移除 Docker 代理意味着 VM 流量直接通过网桥 → TUN 路径,这会导致 TLS 握手超时。代理提供了一个有效的出站通道,因为 OrbStack 将主机 127.0.0.1 映射到 VM 中。
| Docker 配置 | 流量路径 | 结果 |
|---|---|---|
代理开启,无 no-proxy | Docker → 代理 → TUN → 互联网 | Docker Hub ✅, localhost 探测 ❌ |
| 代理关闭 | Docker → VM 网桥 → 主机 → TUN → 互联网 | TLS 超时 ❌ |
代理开启 + no-proxy | 外部:Docker → 代理 → 互联网 ✅;本地:Docker → 直接 ✅ | 两者都有效 ✅ |
在 Docker 环境中 curl localhost 的部署脚本将通过代理路由。通过在脚本级别添加 NO_PROXY 来修复:
# 在 deploy.sh 或类似脚本中:
_local_bypass="localhost,127.0.0.1,::1"
if [[ -n "${NO_PROXY:-}" ]]; then
export NO_PROXY="${_local_bypass},${NO_PROXY}"
else
export NO_PROXY="${_local_bypass}"
fi
export no_proxy="$NO_PROXY"
# 在探测 URL 中使用 127.0.0.1 而不是 localhost (某些代理实现
# 仅在 no-proxy 中匹配确切的字符串 "localhost",而不是解析后的 IP)
curl http://127.0.0.1:3001/health # ✅ 绕过代理
curl http://localhost:3001/health # ❌ 可能仍通过代理
验证修复:
# Docker 代理检查 (应显示代理 + no-proxy)
docker info | grep -iE "proxy|No Proxy"
# 拉取测试
docker pull --quiet hello-world
# 本地探测测试
curl -s http://127.0.0.1:3001/health
症状:git clone/fetch/push 失败并显示 Connection closed by 198.18.0.x port 443。ssh -T git@github.com 也可能失败。DNS 解析返回 198.18.x.x 地址而不是真实 IP。
根本原因:Shadowrocket TUN 拦截所有 DNS 查询并返回 198.18.0.0/15 范围内的虚拟 IP。然后它通过 TUN 路由到这些虚拟 IP 的流量,以进行协议感知代理。HTTP/HTTPS 有效,因为落地代理理解这些协议,但 SSH-over-443 (GitHub 使用) 被错误处理 — TUN 看到端口 443 流量,期望是 HTTPS,并丢弃 SSH 握手。
诊断:
# DNS 返回虚拟 IP (TUN 劫持)
nslookup ssh.github.com
# → 198.18.0.26 ← Shadowrocket 虚拟 IP,非真实 GitHub IP
# 直接 IP 有效 (绕过 DNS 劫持)
ssh -o HostName=140.82.112.35 -o Port=443 git@github.com
# → "Hi user! You've successfully authenticated"
修复 — 在 SSH 配置中使用直接 IP 以绕过 DNS 劫持:
# ~/.ssh/config
Host github.com
HostName 140.82.112.35 # GitHub SSH 服务器真实 IP (绕过 TUN DNS 劫持)
Port 443
User git
ServerAliveInterval 60
ServerAliveCountMax 3
IdentityFile ~/.ssh/id_ed25519
GitHub SSH 服务器 IP (截至 2026 年,使用 dig +short ssh.github.com @8.8.8.8 验证):
140.82.112.35 (主要)140.82.112.36 (备用)权衡:硬编码的 IP 在 GitHub 更改它们时会失效。监控 ssh -T git@github.com — 如果开始失败,更新 IP。cron 作业可以自动化此过程:
# 每周检查 (添加到 crontab)
0 9 * * 1 dig +short ssh.github.com @8.8.8.8 | head -1 > /tmp/github-ssh-ip.txt
替代方案 (如果你控制 Shadowrocket 规则):将 GitHub SSH IP 添加到 DIRECT 规则,以便 TUN 在不进行协议检查的情况下传递它们:
IP-CIDR,140.82.112.0/24,DIRECT
IP-CIDR,192.30.252.0/22,DIRECT
这更健壮,但需要代理工具配置访问权限。
识别代理工具并应用相应的修复。每个工具的详细说明请参见 references/proxy_conflict_reference.md。
关键原则:不要使用 tun-excluded-routes 来排除 100.64.0.0/10。这会导致代理添加一个覆盖 Tailscale 的 → en0 路由。相反,让流量进入代理 TUN,并使用 DIRECT 规则传递它。
通用修复 — 将此规则添加到任何代理工具:
IP-CIDR,100.64.0.0/10,DIRECT
IP-CIDR,fd7a:115c:a1e0::/48,DIRECT
应用修复后,验证:
route -n get <tailscale-ip>
# 应显示 Tailscale utun 接口,而非 en0
如果 SSH 可以连接但返回 operation not permitted,Tailscale ACL 可能要求每次连接都进行浏览器身份验证。
在 Tailscale ACL admin,确保 SSH 部分使用 "action": "accept":
"ssh": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}
]
注意:"action": "check" 要求每次进行浏览器身份验证。对于非交互式 SSH 访问,更改为 "accept"。
如果 SSH 可以连接且 ACL 通过,但在 tailscaled 日志中失败并显示 be-child ssh 退出码 1,则 snap 安装的 Tailscale 具有沙盒限制,阻止 SSH shell 执行。
诊断 — 检查 WSL tailscaled 日志:
# 对于 snap 安装:
sudo journalctl -u snap.tailscale.tailscaled -n 30 --no-pager
# 对于 apt 安装:
sudo journalctl -u tailscaled -n 30 --no-pager
查找:
access granted to user@example.com as ssh-user "username"
starting non-pty command: [/snap/tailscale/.../tailscaled be-child ssh ...]
Wait: code=1
修复 — 用 apt 安装替换 snap 安装:
# 移除 snap 版本
sudo snap remove tailscale
# 安装 apt 版本
curl -fsSL https://tailscale.com/install.sh | sh
# 启用 SSH 启动
sudo tailscale up --ssh
重要:新安装可能会分配不同的 Tailscale IP。使用 tailscale status --self 检查。
运行完整的连接性测试:
# 1. 检查路由是否正确
route -n get <tailscale-ip>
# 2. 测试 TCP 连接性
nc -z -w 5 <tailscale-ip> 22
# 3. 测试 SSH
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no <user>@<tailscale-ip> 'echo SSH_OK && hostname && whoami'
所有三项都必须通过。如果步骤 1 失败,重新审视步骤 3。如果步骤 2 失败,检查 WSL sshd 或防火墙。如果步骤 3 失败,重新审视步骤 4-5。
通过 Tailscale 和代理工具进行远程开发的主动设置指南。在遇到问题之前遵循这些步骤。
ssh <tailscale-ip> 'echo ok'任何对 localhost 进行 curl 的 Makefile 目标都必须使用 --noproxy localhost。这是必需的,因为 http_proxy 通常在 ~/.zshrc 中全局设置 (在中国常见),并且 Make 会继承 shell 环境变量。
## ── 健康检查 ─────────────────────────────────────
status: ## 健康检查仪表板
@echo "=== 开发基础设施 ==="
@docker exec my-postgres pg_isready -U postgres 2>/dev/null && echo "PostgreSQL: OK" || echo "PostgreSQL: FAIL"
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live >/dev/null 2>&1 && echo "MinIO: OK" || echo "MinIO: FAIL"
@curl --noproxy localhost -sf http://localhost:3001/api/status >/dev/null 2>&1 && echo "API: OK" || echo "API: FAIL"
## ── 路由预热 ──────────────────────────────────────
warmup: ## 预编译关键路由 (在开发服务器就绪后运行)
@echo "正在预热开发服务器路由..."
@echo -n " /api/health → " && curl --noproxy localhost -s -o /dev/null -w '%{http_code} (%{time_total}s)\n' http://localhost:3010/api/health
@echo -n " / → " && curl --noproxy localhost -s -o /dev/null -w '%{http_code} (%{time_total}s)\n' http://localhost:3010/
@echo "预热完成。"
规则:
curl http://localhost 调用必须包含 --noproxy localhostdocker exec) 不受 http_proxy 影响 — 无需修复redis-cli, pg_isready 通过 TCP 直接连接 — 无需修复为通过 Tailscale SSH 隧道进行远程开发添加这些目标:
## ── 远程开发 ────────────────────────────────
REMOTE_HOST ?= <tailscale-ip>
TUNNEL_FORWARD ?= -L 3010:localhost:3010
tunnel: ## 到远程机器的 SSH 隧道 (前台)
ssh -N $(TUNNEL_FORWARD) $(REMOTE_HOST)
tunnel-bg: ## 到远程机器的 SSH 隧道 (后台,自动重连)
autossh -M 0 -f -N $(TUNNEL_FORWARD) \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
$(REMOTE_HOST)
@echo "隧道在后台运行。终止命令:pkill -f 'autossh.*$(REMOTE_HOST)'"
设计决策:
| 选择 | 理由 |
|---|---|
?= (条件赋值) | 允许覆盖:make tunnel REMOTE_HOST=100.x.x.x |
TUNNEL_FORWARD 作为变量 | 支持多端口:make tunnel TUNNEL_FORWARD="-L 3010:localhost:3010 -L 9000:localhost:9000" |
autossh -M 0 | 禁用 autossh 自己的监控端口;依赖 ServerAliveInterval 代替 (通过 NAT 更可靠) |
ExitOnForwardFailure=yes | 如果端口已被绑定,立即失败,而不是在没有隧道的情况下静默运行 |
终止提示使用 autossh.*$(REMOTE_HOST) | 精确模式 — 不会意外终止其他 SSH 会话 |
安装 autossh:brew install autossh (macOS) 或 apt install autossh (Linux/WSL)
当项目需要多个服务时 (开发服务器 + 对象存储 + API 网关):
# 在一个隧道中转发多个端口
make tunnel TUNNEL_FORWARD="-L 3010:localhost:3010 -L 9000:localhost:9000 -L 3001:localhost:3001"
# 或者在 Makefile 中定义项目特定的默认值
TUNNEL_FORWARD ?= -L 3010:localhost:3010 -L 9000:localhost:9000
每个 -L 标志都是独立的。如果一个端口已在本地绑定,ExitOnForwardFailure=yes 将中止整个隧道 — 首先修复端口冲突。
SSH 非登录 shell 不加载 ~/.zshrc,因此 nvm/Homebrew 工具和代理环境变量不可用。在所有远程命令前加上 source ~/.zshrc 2>/dev/null;。详细信息和示例请参见 references/proxy_conflict_reference.md § SSH Non-Login Shell Pitfall。
对于运行远程命令的 Makefile 目标:
REMOTE_CMD = ssh $(REMOTE_HOST) 'source ~/.zshrc 2>/dev/null; $(1)'
remote-status: ## 检查远程开发服务器状态
$(call REMOTE_CMD,curl --noproxy localhost -sf http://localhost:3010/api/health && echo "OK" || echo "FAIL")
# 1. 克隆仓库并安装依赖
ssh <tailscale-ip>
cd /path/to/project
git clone git@github.com:user/repo.git && cd repo
pnpm install # 如果在中国,添加 --registry https://registry.npmmirror.com
# 2. 从本地机器复制 .env (在本地运行)
scp .env <tailscale-ip>:/path/to/project/repo/.env
# 3. 启动 Docker 基础设施
make up && make status
# 4. 运行数据库迁移
bun run db:migrate
# 5. 启动开发服务器
bun run dev
# 1. 启动隧道
make tunnel-bg
#
Diagnose and fix conflicts when Tailscale coexists with proxy/VPN tools on macOS, with specific guidance for SSH access to WSL instances.
Proxy/VPN 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/container runtimes:
| Layer | What breaks | What still works | Root cause |
|---|---|---|---|
| 1. Route table | Everything (SSH, curl, browser) | tailscale ping | tun-excluded-routes adds en0 route overriding Tailscale utun |
| 2. HTTP env vars | curl, Python requests, Node.js fetch | SSH, browser | http_proxy set without NO_PROXY for Tailscale |
| 3. System proxy (browser) | Browser only (HTTP 503) | SSH, curl (both with/without proxy) | Browser uses VPN system proxy; DIRECT rule routes via Wi-Fi, not Tailscale utun |
| 4. SSH ProxyCommand double tunnel | git push/pull (intermittent) | ssh -T (small data) | connect -H creates HTTP CONNECT tunnel redundant with Shadowrocket TUN; landing proxy drops large/long-lived transfers |
| 5. VM/Container proxy propagation | docker pull, docker build | Host curl, running containers | VM runtime (OrbStack/Docker Desktop) auto-injects or caches proxy config; removing proxy makes it worse (VM traffic via TUN → TLS timeout) |
Determine which scenario applies:
curl and SSH both work → System proxy bypass conflict (Step 2C)local.<domain> fails in browser/default curl, but direct/no-proxy request works → Local vanity domain proxy interception (Step 2C-1)localhost → browser can't follow → SSH tunnel needed (Step 2D)make status / scripts curl to localhost fail with proxy → localhost proxy interception (Step 2E)git push/pull fails with FATAL: failed to begin relaying via HTTP → SSH double tunnel (Step 2F)Key distinctions :
http_proxy/NO_PROXY env vars. If SSH works but HTTP doesn't → Layer 2.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.tailscale ping works but regular ping doesn't → Layer 1 (route table corrupted).ssh -T git@github.com works but git push fails intermittently → Layer 4 (double tunnel).curl https://... works but times out → Layer 5 (VM proxy propagation).For common macOS conflicts (env proxy, system proxy exceptions, direct/proxy path split, local TLS trust), run:
python3 scripts/quick_diagnose.py --host local.claude4.dev --url https://local.claude4.dev/health
Optional route ownership check for a Tailscale destination:
python3 scripts/quick_diagnose.py --host <target-host> --url http://<target-host>:<port>/health --tailscale-ip <100.x.x.x>
Interpretation:
direct=PASS + forced_proxy=FAIL = host must bypass proxy (skip-proxy + NO_PROXY).strict_tls=FAIL + direct=PASS = path is reachable; trust issue only (install/trust local CA).host in scutil exceptions: no = browser/system clients still likely proxied.Check if proxy env vars are intercepting Tailscale HTTP traffic:
env | grep -i proxy
Broken output — proxy is set but NO_PROXY doesn't exclude Tailscale:
http_proxy=http://127.0.0.1:1082
https_proxy=http://127.0.0.1:1082
NO_PROXY=localhost,127.0.0.1 ← Missing Tailscale!
Fix — add Tailscale MagicDNS domain + CIDR to NO_PROXY:
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*
| Entry | Covers | Why |
|---|---|---|
.ts.net | MagicDNS domains (host.tailnet.ts.net) | Matched before DNS resolution |
100.64.0.0/10 | Tailscale IPs (100.64.* – 100.127.*) | Precise CIDR, no public IP false positives |
192.168.*,10.*,172.16.* | RFC 1918 private networks | LAN should never be proxied |
Two layers complement each other : .ts.net handles domain-based access, 100.64.0.0/10 handles direct IP access.
NO_PROXY syntax pitfalls — see references/proxy_conflict_reference.md for the compatibility matrix.
Verify the fix:
# Both must return HTTP 200:
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<host>.ts.net:<port>/health -w "HTTP %{http_code}\n"
NO_PROXY="...(new value)..." curl -s --connect-timeout 5 http://<tailscale-ip>:<port>/health -w "HTTP %{http_code}\n"
Then persist in shell config (~/.zshrc or ~/.bashrc).
Check if a proxy tool hijacked the Tailscale CGNAT range:
route -n get <tailscale-ip>
Healthy output — traffic goes through Tailscale interface:
destination: 100.64.0.0
interface: utun7 # Tailscale interface (utunN varies)
Broken output — proxy hijacked the route:
destination: 100.64.0.0
gateway: 192.168.x.1 # Default gateway
interface: en0 # Physical interface, NOT Tailscale
Confirm with full route table:
netstat -rn | grep 100.64
Two competing routes indicate a conflict:
100.64/10 192.168.x.1 UGSc en0 ← Proxy added this (wins)
100.64/10 link#N UCSI utun7 ← Tailscale route (loses)
Root cause : On macOS, UGSc (Static Gateway) takes priority over UCSI (Cloned Static Interface) for the same prefix length.
Symptom : Browser shows HTTP 503 for http://<tailscale-ip>:<port>, but both curl --noproxy '*' and curl (with proxy env var) return 200. SSH also works.
Root cause : The browser uses the system proxy configured by the VPN profile (Shadowrocket/Clash/Surge). The proxy matches IP-CIDR,100.64.0.0/10,DIRECT and tries to connect directly — but "directly" means via the Wi-Fi interface (en0), NOT through Tailscale's utun interface. The proxy process itself doesn't have a route to Tailscale IPs, so the connection fails with 503.
Diagnosis :
# curl with proxy env var works (curl connects to proxy port, but traffic flows differently)
curl -s -o /dev/null -w "%{http_code}" http://<tailscale-ip>:<port>/
# → 200
# Browser gets 503 because it goes through the VPN system proxy, not http_proxy env var
Fix — add Tailscale CGNAT range to skip-proxy in the proxy tool config:
For Shadowrocket, in [General]:
skip-proxy = 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, localhost, *.local, captive.apple.com
skip-proxy tells the system "bypass the proxy entirely for these addresses." The browser then connects directly through the OS network stack, where Tailscale's routing table correctly handles the traffic.
Whyskip-proxy works but tun-excluded-routes doesn't:
skip-proxy: Bypasses the HTTP proxy layer only. Traffic still flows through the TUN interface and Tailscale utun handles it. Safe.tun-excluded-routes: Removes the CIDR from the TUN routing entirely. This creates a competing en0 route that overrides Tailscale. Breaks everything.local.<domain>)Symptom : https://local.<domain> fails in browser or default curl, but succeeds with direct/no-proxy command:
env -u http_proxy -u https_proxy curl -k -I https://local.<domain>/health
# -> 200
curl -I https://local.<domain>/health
# -> proxy CONNECT then TLS reset/failure
Root cause : The domain is routed through system/shell proxy instead of local direct path.
Fix :
skip-proxy for Shadowrocket).NO_PROXY/no_proxy).# ~/.zshrc
export NO_PROXY=localhost,127.0.0.1,.ts.net,100.64.0.0/10,192.168.*,10.*,172.16.*,local.<domain>,www.local.<domain>
export no_proxy="$NO_PROXY"
Verification :
python3 scripts/quick_diagnose.py --host local.<domain> --url https://local.<domain>/health
Expected:
host in NO_PROXY: yeshost in scutil exceptions: yesambient=PASS and direct=PASSSymptom : Dev server runs on a remote machine (e.g., Mac Mini via Tailscale). You access http://<tailscale-ip>:3010 in the browser. Login/signup works, but after auth, the app redirects to http://localhost:3010/ which fails — localhost on your machine isn't running the dev server.
Root cause : The app's APP_URL (or equivalent) is set to http://localhost:3010. Auth libraries (Better-Auth, NextAuth, etc.) use this URL for callback redirects. Changing APP_URL to the Tailscale IP introduces Shadowrocket proxy conflicts and breaks local development on the remote machine.
Fix — SSH local port forwarding. This avoids all three conflict layers entirely:
# Forward local port 3010 to remote machine's localhost:3010
ssh -NL 3010:localhost:3010 <tailscale-ip>
# Or with autossh for auto-reconnect (recommended for long sessions)
autossh -M 0 -f -N -L 3010:localhost:3010 \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
<tailscale-ip>
Now access http://localhost:3010 in the browser. Auth redirects to localhost:3010 → tunnel → remote dev server → works correctly.
Why this is the best approach :
.env changes needed — APP_URL=http://localhost:3010 works everywherelocalhost is always in skip-proxyInstall autossh : brew install autossh (macOS) or apt install autossh (Linux)
Kill background tunnel : pkill -f 'autossh.*<tailscale-ip>'
Symptom : Makefile targets or scripts that curl localhost (health checks, warmup routes) fail or timeout when http_proxy is set globally in the shell.
Root cause : http_proxy=http://127.0.0.1:1082 is set in ~/.zshrc but no_proxy doesn't include localhost. All curl commands send localhost requests through the proxy.
Fix — add --noproxy localhost to all localhost curl commands in scripts:
# WRONG — fails when http_proxy is set
@curl -sf http://localhost:9000/minio/health/live && echo "OK"
# CORRECT — always bypasses proxy for localhost
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live && echo "OK"
Alternatively, set no_proxy globally in ~/.zshrc:
export no_proxy=localhost,127.0.0.1
Symptom : ssh -T git@github.com succeeds consistently, but git push or git pull fails intermittently with:
FATAL: failed to begin relaying via HTTP.
Connection closed by UNKNOWN port 65535
Small operations (auth, fetch metadata) work; large data transfers fail.
Root cause : When Shadowrocket TUN is active, it already routes all TCP traffic through its VPN tunnel. If SSH config also uses ProxyCommand connect -H, data flows through two proxy layers — the landing proxy drops large/long-lived HTTP CONNECT connections.
Diagnosis :
# 1. Confirm Shadowrocket TUN is active
ifconfig | grep '^utun'
# 2. Check SSH config for ProxyCommand
grep -A5 'Host github.com' ~/.ssh/config
# 3. Confirm: removing ProxyCommand fixes push
GIT_SSH_COMMAND="ssh -o ProxyCommand=none" git push origin main
Fix — remove ProxyCommand and switch to ssh.github.com:443. See references/proxy_conflict_reference.md § SSH ProxyCommand and Git Operations for the full SSH config, why port 443 helps, and fallback options when VPN is off.
Symptom : docker pull or docker build fails with net/http: TLS handshake timeout or Internal Server Error from auth.docker.io, while host curl to the same URLs works fine.
Applies to : OrbStack, Docker Desktop, or any VM-based Docker runtime on macOS with Shadowrocket/Clash TUN active.
Root cause : VM-based Docker runtimes (OrbStack, Docker Desktop) run the Docker daemon inside a lightweight VM. The VM's outbound traffic takes a different network path than host processes:
Host process (curl): Process → TUN (Shadowrocket) → landing proxy → internet ✅
VM process (Docker): Docker daemon → VM bridge → host network → TUN → ??? ❌
The TUN handles host-originated traffic correctly but may drop or delay VM-bridged traffic (different TCP stack, MTU, keepalive behavior).
Three sub-problems and their fixes :
OrbStack's network_proxy: auto reads http_proxy from the shell environment and writes it to ~/.orbstack/config/docker.json. Crucially , orbctl config set network_proxy none does NOT clean up docker.json — the cached proxy persists.
Diagnosis :
# OrbStack config says "none" but Docker still shows proxy
orbctl config get network_proxy # → "none"
docker info | grep -i proxy # → HTTP Proxy: http://127.0.0.1:1082 ← stale!
# The real source of truth:
cat ~/.orbstack/config/docker.json
# → {"proxies": {"http-proxy": "http://127.0.0.1:1082", ...}} ← cached!
Fix — DON'T remove the proxy. Instead, add precise no-proxy to prevent localhost interception while keeping the proxy as the VM's outbound channel:
# Write corrected config (keeps proxy, adds no-proxy for local traffic)
python3 -c "
import json
config = {
'proxies': {
'http-proxy': 'http://127.0.0.1:1082',
'https-proxy': 'http://127.0.0.1:1082',
'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0/24,100.64.0.0/10,host.internal,*.local'
}
}
json.dump(config, open('$HOME/.orbstack/config/docker.json', 'w'), indent=2)
"
# Full restart (not just docker engine)
orbctl stop && sleep 3 && orbctl start
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 because OrbStack maps host 127.0.0.1 into the VM.
| Docker config | Traffic path | Result |
|---|---|---|
Proxy ON, no no-proxy | Docker → proxy → TUN → internet | Docker Hub ✅, localhost probes ❌ |
| Proxy OFF | Docker → VM bridge → host → TUN → internet | TLS timeout ❌ |
Proxy ON +no-proxy | External: Docker → proxy → internet ✅; Local: Docker → direct ✅ | Both work ✅ |
Deploy scripts that curl localhost inside the Docker environment will route through the proxy. Fix by adding NO_PROXY at the script level:
# In deploy.sh or similar scripts:
_local_bypass="localhost,127.0.0.1,::1"
if [[ -n "${NO_PROXY:-}" ]]; then
export NO_PROXY="${_local_bypass},${NO_PROXY}"
else
export NO_PROXY="${_local_bypass}"
fi
export no_proxy="$NO_PROXY"
# Use 127.0.0.1 instead of localhost in probe URLs (some proxy implementations
# only match exact string "localhost" in no-proxy, not the resolved IP)
curl http://127.0.0.1:3001/health # ✅ bypasses proxy
curl http://localhost:3001/health # ❌ may still go through proxy
Verify the fix :
# Docker proxy check (should show proxy + no-proxy)
docker info | grep -iE "proxy|No Proxy"
# Pull test
docker pull --quiet hello-world
# Local probe test
curl -s http://127.0.0.1:3001/health
Symptom : git clone/fetch/push 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.
Root cause : Shadowrocket TUN intercepts all DNS queries and returns virtual IPs in the 198.18.0.0/15 range. It then routes traffic to these virtual IPs through the TUN for protocol-aware proxying. HTTP/HTTPS 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.
Diagnosis :
# DNS returns virtual IP (TUN hijack)
nslookup ssh.github.com
# → 198.18.0.26 ← Shadowrocket virtual IP, NOT real GitHub IP
# Direct IP works (bypasses DNS hijack)
ssh -o HostName=140.82.112.35 -o Port=443 git@github.com
# → "Hi user! You've successfully authenticated"
Fix — use direct IP in SSH config to bypass DNS hijack:
# ~/.ssh/config
Host github.com
HostName 140.82.112.35 # GitHub SSH server real IP (bypasses TUN DNS hijack)
Port 443
User git
ServerAliveInterval 60
ServerAliveCountMax 3
IdentityFile ~/.ssh/id_ed25519
GitHub SSH server IPs (as of 2026, verify with dig +short ssh.github.com @8.8.8.8):
140.82.112.35 (primary)140.82.112.36 (alternate)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:
# Weekly check (add to crontab)
0 9 * * 1 dig +short ssh.github.com @8.8.8.8 | head -1 > /tmp/github-ssh-ip.txt
Alternative (if you control Shadowrocket rules): Add GitHub SSH IPs to DIRECT rule so TUN passes them through without protocol inspection:
IP-CIDR,140.82.112.0/24,DIRECT
IP-CIDR,192.30.252.0/22,DIRECT
This is more robust but requires proxy tool config access.
Identify the proxy tool and apply the appropriate fix. See references/proxy_conflict_reference.md for detailed instructions per tool.
Key principle : Do NOT use tun-excluded-routes to exclude 100.64.0.0/10. 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.
Universal fix — add this rule to any proxy tool:
IP-CIDR,100.64.0.0/10,DIRECT
IP-CIDR,fd7a:115c:a1e0::/48,DIRECT
After applying fixes, verify:
route -n get <tailscale-ip>
# Should show Tailscale utun interface, NOT en0
If SSH connects but returns operation not permitted, the Tailscale ACL may require browser authentication for each connection.
At Tailscale ACL admin, ensure the SSH section uses "action": "accept":
"ssh": [
{
"action": "accept",
"src": ["autogroup:member"],
"dst": ["autogroup:self"],
"users": ["autogroup:nonroot", "root"]
}
]
Note : "action": "check" requires browser authentication each time. Change to "accept" for non-interactive SSH access.
If 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.
Diagnosis — check WSL tailscaled logs:
# For snap installs:
sudo journalctl -u snap.tailscale.tailscaled -n 30 --no-pager
# For apt installs:
sudo journalctl -u tailscaled -n 30 --no-pager
Look for:
access granted to user@example.com as ssh-user "username"
starting non-pty command: [/snap/tailscale/.../tailscaled be-child ssh ...]
Wait: code=1
Fix — replace snap with apt installation:
# Remove snap version
sudo snap remove tailscale
# Install apt version
curl -fsSL https://tailscale.com/install.sh | sh
# Start with SSH enabled
sudo tailscale up --ssh
Important : The new installation may assign a different Tailscale IP. Check with tailscale status --self.
Run a complete connectivity test:
# 1. Check route is correct
route -n get <tailscale-ip>
# 2. Test TCP connectivity
nc -z -w 5 <tailscale-ip> 22
# 3. Test SSH
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no <user>@<tailscale-ip> 'echo SSH_OK && hostname && whoami'
All three must pass. If step 1 fails, revisit Step 3. If step 2 fails, check WSL sshd or firewall. If step 3 fails, revisit Steps 4-5.
Proactive setup guide for remote development over Tailscale with proxy tools. Follow these steps before encountering problems.
ssh <tailscale-ip> 'echo ok'Any Makefile target that curls localhost must use --noproxy localhost. This is required because http_proxy is often set globally in ~/.zshrc (common in China), and Make inherits shell environment variables.
## ── Health Checks ─────────────────────────────────────
status: ## Health check dashboard
@echo "=== Dev Infrastructure ==="
@docker exec my-postgres pg_isready -U postgres 2>/dev/null && echo "PostgreSQL: OK" || echo "PostgreSQL: FAIL"
@curl --noproxy localhost -sf http://localhost:9000/minio/health/live >/dev/null 2>&1 && echo "MinIO: OK" || echo "MinIO: FAIL"
@curl --noproxy localhost -sf http://localhost:3001/api/status >/dev/null 2>&1 && echo "API: OK" || echo "API: FAIL"
## ── Route Warmup ──────────────────────────────────────
warmup: ## Pre-compile key routes (run after dev server is ready)
@echo "Warming up dev server routes..."
@echo -n " /api/health → " && curl --noproxy localhost -s -o /dev/null -w '%{http_code} (%{time_total}s)\n' http://localhost:3010/api/health
@echo -n " / → " && curl --noproxy localhost -s -o /dev/null -w '%{http_code} (%{time_total}s)\n' http://localhost:3010/
@echo "Warmup complete."
Rules :
curl http://localhost call MUST include --noproxy localhostdocker exec) are unaffected by http_proxy — no fix neededredis-cli, pg_isready connect via TCP directly — no fix neededAdd these targets for remote development via Tailscale SSH tunnels:
## ── Remote Development ────────────────────────────────
REMOTE_HOST ?= <tailscale-ip>
TUNNEL_FORWARD ?= -L 3010:localhost:3010
tunnel: ## SSH tunnel to remote machine (foreground)
ssh -N $(TUNNEL_FORWARD) $(REMOTE_HOST)
tunnel-bg: ## SSH tunnel to remote machine (background, auto-reconnect)
autossh -M 0 -f -N $(TUNNEL_FORWARD) \
-o "ServerAliveInterval=30" \
-o "ServerAliveCountMax=3" \
-o "ExitOnForwardFailure=yes" \
$(REMOTE_HOST)
@echo "Tunnel running in background. Kill with: pkill -f 'autossh.*$(REMOTE_HOST)'"
Design decisions :
| Choice | Rationale |
|---|---|
?= (conditional assign) | Allows override: make tunnel REMOTE_HOST=100.x.x.x |
TUNNEL_FORWARD as variable | Supports multi-port: make tunnel TUNNEL_FORWARD="-L 3010:localhost:3010 -L 9000:localhost:9000" |
autossh -M 0 | Disables autossh's own monitoring port; relies on ServerAliveInterval instead (more reliable through NAT) |
ExitOnForwardFailure=yes |
Install autossh : brew install autossh (macOS) or apt install autossh (Linux/WSL)
When the project requires multiple services (dev server + object storage + API gateway):
# Forward multiple ports in one tunnel
make tunnel TUNNEL_FORWARD="-L 3010:localhost:3010 -L 9000:localhost:9000 -L 3001:localhost:3001"
# Or define a project-specific default in Makefile
TUNNEL_FORWARD ?= -L 3010:localhost:3010 -L 9000:localhost:9000
Each -L flag is independent. If one port is already bound locally, ExitOnForwardFailure=yes will abort the entire tunnel — fix the port conflict first.
SSH non-login shells don't load ~/.zshrc, so nvm/Homebrew tools and proxy env vars are unavailable. Prefix all remote commands with source ~/.zshrc 2>/dev/null;. See references/proxy_conflict_reference.md § SSH Non-Login Shell Pitfall for details and examples.
For Makefile targets that run remote commands:
REMOTE_CMD = ssh $(REMOTE_HOST) 'source ~/.zshrc 2>/dev/null; $(1)'
remote-status: ## Check remote dev server status
$(call REMOTE_CMD,curl --noproxy localhost -sf http://localhost:3010/api/health && echo "OK" || echo "FAIL")
# 1. Clone repo and install dependencies
ssh <tailscale-ip>
cd /path/to/project
git clone git@github.com:user/repo.git && cd repo
pnpm install # Add --registry https://registry.npmmirror.com if in China
# 2. Copy .env from local machine (run on local)
scp .env <tailscale-ip>:/path/to/project/repo/.env
# 3. Start Docker infrastructure
make up && make status
# 4. Run database migrations
bun run db:migrate
# 5. Start dev server
bun run dev
# 1. Start tunnel
make tunnel-bg
# 2. Open browser
open http://localhost:3010
# 3. Auth, coding, testing — everything works as if local
# 4. When done, kill tunnel
pkill -f 'autossh.*<tailscale-ip>'
Browser → localhost:3010 → SSH tunnel → Remote localhost:3010 → Dev server
↓
Auth redirects to localhost:3010
↓
Browser follows redirect → same tunnel → works
The key insight: APP_URL=http://localhost: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.
Before starting remote development, verify:
tailscale statusssh <tailscale-ip> 'echo ok'[Rule] has IP-CIDR,100.64.0.0/10,DIRECTskip-proxy includes 100.64.0.0/10tun-excluded-routes does NOT include 100.64.0.0/10NO_PROXY includes .ts.net,100.64.0.0/10Weekly Installs
49
Repository
GitHub Stars
636
First Seen
Feb 7, 2026
Security Audits
Gen Agent Trust HubFailSocketPassSnykWarn
Installed on
codex41
gemini-cli40
opencode39
github-copilot38
cursor36
amp34
Clui CC - Claude代码桌面悬浮窗 | macOS本地AI编程助手 | 支持语音输入和多标签会话
535 周安装
Coinw合约API技能:市场数据、交易下单、止盈止损、仓位查询、账户资产
Objective-C Blocks与GCD并发编程指南:语法、用法与最佳实践
Objective-C ARC 模式详解:内存管理、循环引用与最佳实践
Blender 3D AI 助手技能:通过 Claude 与 BlenderMCP 插件实现自动化 3D 建模与渲染
context-engineering 技能:AI 上下文工程插件,提升 Claude 模型性能与开发效率
1 周安装
Claude Code Expert - 基于 Claude 的 AI 代码助手插件,提升编程效率与代码质量
1 周安装
docker pull fails with TLS handshake timeout or docker build can't fetch base images → VM/container proxy propagation (Step 2G)git clone fails with Connection closed by 198.18.x.x → TUN DNS hijack for SSH (Step 2H)operation not permitted → Tailscale SSH config issue (Step 4)be-child ssh exits code 1 → WSL snap sandbox issue (Step 5)docker pull198.18.x.x virtual IPs → TUN DNS hijack (Step 2H).| Fails immediately if port is already bound, instead of silently running without tunnel |
Kill hint uses autossh.*$(REMOTE_HOST) | Precise pattern — won't accidentally kill other SSH sessions |
autossh installed: which autossh--noproxy localhostssh <ip> 'source ~/.zshrc 2>/dev/null; curl --noproxy localhost -sf http://localhost:3010/'make tunnel-bg && curl -sf http://localhost:3010/