mcpField
T
tunnel-doctor

tunnel-doctor

github.com/daymade云与 DevOps1.5k683 次安装今天更新
代码仓库

关于 tunnel-doctor

tunnel-doctor 用于诊断和修复 macOS 上 Tailscale 与代理或 VPN 工具的冲突,也涵盖 SSH 访问 WSL。它按症状提供基于证据的排查流程,并限制远程访问和高影响网络变更的范围。

主要功能

  1. 01诊断路由、DNS 和代理冲突
  2. 02排查 SSH 与 Git 连接问题
  3. 03处理虚拟机和容器代理故障
  4. 04检查 TUN 转发与代理容量
  5. 05规范远程访问范围
  6. 06高影响变更前规划恢复

适用场景

  1. 01排查 Tailscale ping 成功但连接异常
  2. 02诊断 SSH 上 Git 传输间歇失败
  3. 03调查 Docker 或 WSL 的代理故障
  4. 04规划经授权的 VPN 或路由修复

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/daymade/claude-code-skills --skill tunnel-doctor

安装后在 Claude Code 中输入/tunnel-doctor确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
dns_resolver_chain_stall.md
network_change_recovery.md
node-liveness-verification.md
provider_account_suspension.md
proxy_conflict_reference.md
proxy_node_chain_throughput.md
single-host-dns-migration.md
ssh_tunnel_source_restriction.md
tailscale_address_family_ssh.md
windows_host_tun_wsl_cascade.md
scripts/
quick_diagnose.py
tests/
test_quick_diagnose.py

详细介绍

Tunnel Doctor

诊断并修复 macOS 上 Tailscale 与代理/VPN 工具共存时的冲突,并针对通过 SSH 访问 WSL 实例提供具体指导。

方法论基础: 本技能基于通用诊断原则:以证据而非假设为准,以证伪而非证实为先,分层隔离,并进行交叉审查。这些原则位于 debugging-network-issues 技能中。本技能是在此基础上的 macOS Tailscale⨯代理领域层;如果问题并非已知的 Tailscale/代理冲突,请使用基础技能。

职责边界: 本技能负责具体的本地网络路径操作: Tailscale、路由/DNS/代理拦截、VM/WSL 传播、TUN 转发 平面,以及代理节点/出口/链路容量。若边界仍不明确,或问题属于 SSE/CDN/应用协议行为,请使用 debugging-network-issues 作为 通用方法。这样可由一个具体操作员负责,而不必让用户在两个职责重叠的代理诊断技能之间作选择。

目标和任务范围

在进行远程探测或更改之前,确认目标是指定的测试机器、用户明确要求使用的同事工作电脑,还是未知设备。使用所有者现有的机器用途记录和当前任务进行判断;不要登录同事的机器来分类。已保存的 SSH 访问方式和以往的成功测试,并不意味着可以持续使用。日常测试应使用指定的测试机器;这些机器不可用并不构成使用他人电脑的授权。

对于用户明确要求在同事电脑上进行的一次性测试,仅限于所述结果和必要的验证。完成、取消或需要进行未经批准的环境更改时,应立即停止。后续的性能、迁移和回归测试应转移到指定的测试机器。测试失败并不意味着可以关闭该同事的代理/VPN、退出应用、更改路由或安装后台任务。只有当用户明确要求在该电脑上进行会造成中断的修复时,才可继续操作。对所有者测试机器维护的现有授权仍然有效;其常规步骤无需再次询问。

即使直接调用、通过其他 Skill 调用、压缩上下文后调用,或通过同级移交调用,也必须遵守此边界。停止访问后,应使用现有记录确认访问已停止,不要再通过 SSH 探测。

破坏性网络更改

在进行任何已获授权的 VPN 断开、应用终止、路由切换或服务重启之前,请阅读 network_change_recovery.md。中断前验证前置条件,在首次执行破坏性操作前启用有时限的恢复机制,并验证修复后的路径以及用户原有的网络使用是否正常。成功执行停止命令、进程已脱离,或在脚本末尾安排重启,都不能作为恢复已就绪的证据。

冲突层

macOS 上的代理/VPN 工具会在多个相互独立的层面造成冲突。第 1–3 层会影响 Tailscale 连接;第 4 层会影响 SSH git 操作;第 5 层会影响 VM/容器运行时。除下表所列情况外,TUN 状态故障模式——SSH/git 连接中断、解析器停滞、DIRECT 裂脑——请参阅步骤 2H–2J:

层故障表现仍可正常工作根本原因
1. 路由表所有操作(SSH、curl、浏览器)tailscale pingtun-excluded-routes 添加 en0 路由,覆盖 Tailscale utun
2. HTTP 环境变量curl、Python requests、Node.js fetchSSH、浏览器设置了 http_proxy,但未为 Tailscale 设置 NO_PROXY
3. 系统代理(浏览器)仅浏览器(HTTP 503)SSH、curl(无论是否使用代理)浏览器使用 VPN 系统代理;DIRECT 规则通过 Wi-Fi 而非 Tailscale utun 路由
4. SSH ProxyCommand 双重隧道git push/pull(间歇性)ssh -T(小数据量)connect -H 创建的 HTTP CONNECT 隧道与 Shadowrocket TUN 重复;落地代理会丢弃大型或长时间传输
5. VM/容器代理传播docker pull、docker build主机 curl、正在运行的容器VM 运行时(OrbStack/Docker Desktop)会自动注入或缓存代理配置;移除代理会使情况更糟(VM 流量经 TUN → TLS 超时)

诊断工作流

第 1 步:识别症状

在选择修复方案之前,先明确实际使用该连接的命令或服务,以及它所需的协议。如果有一个经授权且可直接访问的对等端能够连接到速度缓慢的目标,并且通过该对等端进行的已认证 SSH 和有代表性的传输已经表现更好,则将该网关视为集成候选方案。首先检查使用方实际生效的 SSH/清单参数,并测试其常规入口能否使用该路径;手写的 ssh -J 成功并不能证明已经完成集成。

如果网关能够承载所需工作负载,就通过现有的托管传输连接它,并在不添加逐请求跳转参数的情况下重放原始操作。验证目标身份、主机密钥信任、内容完整性、耗时,以及声明的故障/恢复行为。在继续处理用户结果并不需要的原生中继之前,先完成这些验证。将确切的主机、端口和实时路由状态保留在负责管理它们的基础设施配置中。

明确标出尚未满足的协议:SSH 网关无法证明 UDP 或任意应用连接性。如果用户需要这些能力,就继续进行相关网络修复;不能用 SSH 成功来替代相应的验收标准。执行任务的 Agent 必须判断协议是否适用以及用户意图;没有任何捆绑的检查器能够证明这两点。

确定适用的场景:

  • 公共网关返回 502,但其后端响应正常;或者专用 SSH 隧道密钥被拒绝,并提示 correct key but not from a permitted host → 在更改 TUN/防火墙规则,或将正在运行的进程视为恢复之前,请阅读 SSH 隧道来源限制。
  • 浏览器返回 HTTP 503,但 curl 和 SSH 均可正常工作 → 系统代理绕过冲突(步骤 2C)
  • 浏览器/默认 curl 无法访问 local.<domain>,但直接请求/不使用代理时可以访问 → 本地虚拟域名遭代理拦截(步骤 2C-1)
  • Tailscale ping 和 SSH 正常,但 curl/HTTP 超时 → HTTP 代理环境变量冲突(步骤 2A)
  • Tailscale ping 正常,但 SSH/TCP 超时 → 检查路由归属(步骤 2B);仅凭症状相似并不能证明存在路由冲突。
  • Tailscale IPv4 SSH 超时,或者备用对等端地址触发主机密钥验证 → 在判定主机离线或更改信任/网络状态之前,请阅读 同一对等端 IPv4/IPv6 SSH 恢复。
  • 远程开发服务认证重定向到 localhost → 浏览器无法跟随 → 需要 SSH 隧道(步骤 2D)
  • make status/脚本通过 curl 访问 localhost 时因代理而失败 → localhost 代理拦截(步骤 2E)
  • git push/pull 因 FATAL: failed to begin relaying via HTTP 而失败 → SSH 双重隧道(步骤 2F)
  • docker build 中的 RUN apk/apt 立即因 Connection refused 失败 → OrbStack 透明代理与 TUN 冲突(步骤 2G-1,修复方法:--network host)
  • docker pull 因 TLS handshake timeout 而失败 → VM 代理配置错误(步骤 2G-2,修复方法:在 docker.json 中使用 host.internal)
  • 容器健康检查显示 (unhealthy),但应用运行正常 → 小写代理环境变量泄漏(步骤 2G-4,修复方法:清除 http_proxy+HTTP_PROXY)
  • docker build 无法获取基础镜像 → VM/容器代理传递问题(步骤 2G)
  • 在 WSL2 + Docker Desktop 主机上,docker pull 正常,但 docker build 获取基础镜像令牌时仍失败(TLS/i-o 超时);或者在 Windows+v2rayN 主机上,v2rayN 升级后所有运行手册中的代理端口都悄然失效 → buildkit 绕过 DD 代理覆盖项直接拨号/v2rayN ≥7.17 LAN 端口拆分(references/windows_host_tun_wsl_cascade.md §2026-08-23)
  • git clone 因 Connection closed by 198.18.x.x 而失败 → 这可能由两种不同机制导致;步骤 2H 会区分它们
  • 所有国内/DIRECT 规则网站同时失败(TLS 握手过程中出现 unexpected EOF,代理端口 CONNECT 返回 503,Node CLI 报告 UNKNOWN_CERTIFICATE_VERIFICATION_ERROR),而代理访问的海外网站仍正常工作 → TUN DIRECT 分流脑裂(步骤 2J)
  • DNS/委派迁移后单个主机名无法访问,而其他网站正常 → 在诊断转发平面故障或重启共享隧道之前,请阅读 单主机 DNS 迁移。
  • macOS 上所有连接同时失败——国内和海外都无法访问,甚至步骤 2J 的真实 IP 探测也失败 → 先别判定为故障;运行“ TUN 完全停滞与真实故障”中的物理接口判别步骤(步骤 2A 之前)
  • SSH 已连接,但出现 operation not permitted → Tailscale SSH 配置问题(步骤 4)
  • SSH 已连接,但 be-child ssh 以代码 1 退出 → WSL snap 沙盒问题(步骤 5)
  • TCP 端口 22 可访问(nc -z 成功),但 SSH 因 kex_exchange_identification: Connection closed 而失败 → WSL 上的 Tailscale SSH 代理拦截(步骤 5A)
  • 同样出现 kex_exchange_identification/Connection closed by UNKNOWN port 65535,但没有 Tailscale——你处于 TUN 后方,且主机是 HTTPS 仍可正常工作的公共云 VM → 可能是你的 TUN,也可能是目标端的防火墙允许列表;从客户端无法区分。步骤 2K 会通过带外方式查明原因
  • 同一代理提供商的所有节点都以相同方式卡住(TCP 已连接,TLS appconnect=0.000),且重启本地代理应用和数据包隧道扩展都无效 → 这是提供商级事件,而非你的隧道问题;请通过提供商的控制平面确认,并切换到其他提供商的节点(步骤 2L)
  • 同一代理提供商所有测试节点都迅速因 TLS unrecognized name 而失败,但订阅刷新仍正常 → 请先检查提供商账户控制面板中的流量限制、账单或暂停状态;该症状本身不能确定原因。在重建隧道或重启代理进程之前,请阅读 提供商账户检查。
  • tailscale ssh 返回“not available on App Store builds” → macOS 上的 Tailscale 发行版本错误(步骤 5B)
  • 任何使用系统 DNS 的工具(ssh、curl、git)在解析前都会挂起约 60 秒,但 nslookup 会立即返回 → getaddrinfo 调用链中的解析器卡住(步骤 2I)
  • Windows+WSL 主机:所有连接同时离线(国内和海外都无法访问),WSL 也已失效,“连 Tailscale 都无法上线”——和/或用户尝试了多种恢复操作(切换网卡、切换 TUN),却无法判断是哪种操作修复了问题 → Windows 主机 TUN 级联故障与事件日志取证(步骤 5C)
  • Tailscale、DNS、路由归属和代理可达性均正常,但大流量传输仍然很慢,且切换活动代理节点会改变吞吐量 → 请参阅 references/proxy_node_chain_throughput.md 中的代理节点/出口/链路容量工作流
  • 你想让某个应用的大流量(百度网盘下载、飞书/Lark 上传)不经过计量代理——或者不计入状态栏计数器 → 这不是冲突,而是流量分流任务。如果已安装并验证现有的本地流量分流工作流,请使用它;此技能不捆绑分流器、目标列表、重放助手或订阅清除保护。若要诊断机制,请阅读 references/proxy_conflict_reference.md §“macOS 上的按应用流量分流”——应用适用三种机制(DIRECT 规则/tun-excluded-routes/always-real-ip)中的哪一种,取决于它连接的是字面 IP 还是系统解析器;而在 macOS Catalyst 版本中,DNS 层密钥不起作用。 关键区别:
  • SSH 不使用 http_proxy/NO_PROXY 环境变量。如果 SSH 正常而 HTTP 不通 → 第 2 层。
  • curl 使用 http_proxy 环境变量,而非系统代理。浏览器使用系统代理(由 VPN 设置)。如果 curl 正常而浏览器不通 → 第 3 层。
  • 如果 tailscale ping 正常而普通 ping 不通 → 第 1 层(路由表损坏)。
  • 如果 ssh -T [email protected] 正常,但 git push 间歇性失败 → 第 4 层(双重隧道)。
  • 如果主机上的 curl https://... 正常,但 docker pull 超时 → 第 5 层(VM 代理传播)。
  • 如果 docker pull 正常,但 docker build 中的 RUN apk add 立即因 Connection refused 失败 → OrbStack 透明代理被 TUN 弄坏(步骤 2G-1)。
  • 如果容器健康检查显示 (unhealthy),但应用正常 → 小写的 http_proxy 泄漏到容器中(步骤 2G-4)。
  • 如果 DNS 解析到 198.18.x.x 虚拟 IP → 有 TUN 正在运行,但这本身不能作为诊断依据:在 TUN 下,每个主机名都会以这种方式解析,无论成功还是失败。前往步骤 2H,确认你使用的是哪种机制。
  • 如果虚假 IP 连接失败,但使用新鲜 IP 的 curl --resolve 成功,则应怀疑主机名依赖的解析/转发,但这并不能证明原因。多域名 DIRECT 失败模式请参阅步骤 2J;单个近期迁移的域名请参阅单主机 DNS 迁移。
  • 如果 nc -z 在端口 22 上成功,但 SSH 没有收到 banner(kex_exchange_identification)→ Tailscale SSH 代理拦截(步骤 5A)。在远程端运行 tcpdump -i any port 22 确认——0 个数据包意味着 Tailscale 在内核之上进行了拦截。
  • 如果对目标未提供服务的端口(探测 65001)运行 nc -z 也成功 → 在 TUN 下,本地网络栈会代表目标作出响应,因此在该主机上,任何 nc -z 结果都没有判断意义。绝不要解读未经这种方式校准的端口探测结果。当路径中存在 TUN 时(不只是 Tailscale),此规则优先于上一条中对普通 nc -z 结果的解读(步骤 2K)。
  • 如果目标自身的 journalctl -u ssh 中没有你尝试连接的任何记录 → 有东西在 sshd 之前拦截了连接,但不一定是网络:主机本地的数据包过滤器也会同样静默地丢弃连接。在主机上运行 tcpdump,即可区分“从未到达”和“已到达但在本地被丢弃”;只有前者能说明问题出在你的 TUN。通过云服务提供商的 guest-agent 通道访问机器并进行检查,因为该路径不使用你无法连接的端口。检查 journal,而不是 auth.log:在 Ubuntu 24.04 上,sshd 不会向 auth.log 写入内容,因此该文件为空并不能作为证据(步骤 2K)。
  • 云主机会通过两处规则限制入站端口——服务提供商安全组和主机防火墙(ufw/firewalld/nftables)。只将你的 IP 添加到其中一处,另一处仍会阻止连接,而且没有任何提示表明你只完成了一半(步骤 2K)。
  • 如果 tailscale ssh 因“not available on App Store builds”而失败 → 安装 Standalone Tailscale(步骤 5B)。
  • 如果 nslookup <host> 很快(<0.1s),但 dscacheutil -q host -a name <host> 耗时 60s 以上 → scutil --dns 中的补充解析器已失效(步骤 2I)。
  • 如果 ping <resolver-ip> 成功,但 dig @<resolver-ip> 超时 → 守护进程已失效,utun 接口已成为僵尸。接口会响应 ICMP,但实际的端口 53 服务已消失(步骤 2I)。
  • 如果 ssh -vvv 在 debug2: resolving "<host>" port <port> 之后立即卡住,且始终没有出现 debug1: connect to address → 这是 DNS 解析阶段的问题,而非网络连接阶段。这属于步骤 2I,而非步骤 2B/2H。
诊断纪律(确定假设前请先阅读)

当症状指向某个组件(代理、VPN、路由表、DNS)时,不要仅凭间接证据就认定假设成立——先通过该组件自身的健康检查端点进行验证。 每个组件都有一条健康检查命令,比排查其他组件更快、更可靠:

疑似组件权威健康检查(先运行此命令)
HTTP 代理(Shadowrocket / Clash / Surge)curl -x http://127.0.0.1:<port> -m 10 https://api.github.com 返回 200
Tailscale 守护进程tailscale status 返回对等节点列表(而非连接错误)
特定 DNS 解析器dig @<nameserver-ip> +tries=1 +timeout=3 example.com 响应时间 <100ms
某个 IP 的路由route -n get <ip> 显示预期的网络接口
逐个解析器二分排查(怀疑 DNS 时)第 2I 步中的 for ns in ...; do dig @$ns ... 循环
同一代理提供商的所有节点均不可用,但订阅刷新正常提供商账户控制面板(账号是否已停用、流量是否受限、账单状态)——在进行任何本地修复操作之前
Agent CLI 报告“无法连接/不断重连”(如 Codex)客户端自身的运行时日志,按错误类型和小时分组——对于 Codex:查看最新的 ~/.codex/logs_*.sqlite 中的 logs 表。错误集中在某些时间段内,说明是间歇性传输问题(请继续阅读);只有一个 process_uuid 失败而其他进程正常,说明是进程级问题(例如令牌过期),与网络无关

为何这很重要:症状符合第 2X 步的描述,本身并不能证明组件 X 就是问题所在。多个层级可能产生相似的症状(git push 卡住 60 秒,可能是代理节点失效、fakeip 路由损坏,也可能是 DNS 解析器停滞——仅凭用户可见的症状,这些原因都说得通)。优先进行最有针对性的验证,可以避免认定错误层级并一路追查到死胡同。

如果失败的操作涉及 DNS,在怀疑代理或路由之前,先按第 2I 步对各个名称服务器进行二分排查。这能在 15 秒内排查 macOS 在中国网络环境下最常见的一大类故障。

TUN 测量污染(TUN 代理运行时,探测结果为何会误导你)

当代理工具运行在 TUN/全局模式(Shadowrocket、Clash、Surge)时,它会在路由层拦截流量,并在本地伪造网络堆栈的部分内容。因此,一些日常诊断命令会返回伪造或路由错误的数据——相信这些数据会把整个排查引向错误方向。请了解各项探测在 TUN 模式下实际测量的是什么:

探测方法表面表现TUN 下的实际情况可信?
nc -z <node-ip> <port> / 原始 TCP 连接显示 0.00s“节点可达,瞬间连接”TUN 会在隧道建立之前在本地完成 TCP 握手。连接海外主机只需 0.00s 在物理上不可能(光传播单程就需要几十毫秒)——你连接的是 TUN,而不是节点。❌
ping <host> 显示几乎没有丢包 / RTT 低于 1 毫秒“链路正常”TUN 可以在本地响应 ICMP;丢包率和 RTT 都是伪造的,与 TCP 无关。(另外,即使没有 TUN,ICMP 也不等于 TCP。)❌
curl … -w '%{remote_ip}'“已连接到对端 X”始终显示本地 TUN 端点(127.0.0.1 / 环回地址),而不是实际远程对端。❌
通过境外服务(类似 ip-api 的端点)查询 IP 地理位置“我的出口 / 家庭 IP 是……”发往境外域名的请求会通过代理路由,因此报告的是出口 IP,而不是你真实的本地 / 家庭 IP。用于确认“我的真实本地 IP 是什么”时 ❌
IPv4 与 IPv6 路径选择、HTTP/3 / QUIC 提速因情况而异TUN 通常不会转发 UDP/443,因此 QUIC 根本不会发出。这样的比较没有意义。❌
通过代理配置访问匹配 DIRECT 规则的流量 / 国内域名成功,并将其作为隧道 / 出口节点正常运行的证据“代理至少有一条可用路径,所以隧道大概没问题”匹配 DIRECT 的流量根本不会进入隧道——它会像代理完全没有运行时一样,通过你真实的最后一公里链路在本地出站。这种成功无法说明代理流量是否正常。(2026-09-22 的观察:在一次采集健康检查中,一个“通过代理访问成功”的海外 API 端点,后来发现命中了 DIRECT 绕过规则;完全不使用代理重新测试也同样成功,证明根本没有测试隧道。)用于确认“隧道 / 出口节点是否正常”时 ❌
不带 -I 标志的 networkQuality“我的真实下行 / 上行带宽”默认测试使用拥有默认路由的路径——在 TUN 下就是隧道,而不是物理线路(2026-10-23 已验证:切换节点后,同一测试的结果从 64.7 升至 472.6 Mbps)。适合判断“我的隧道是否很慢”,但不能作为物理线路的基准。它是否实际经过节点,取决于该工具针对测试端点的规则;切换节点后数值发生变化,就证明测试走了隧道。用于测量隧道带宽时 ✅ / 用作物理线路基准时 ❌
使用 curl ifconfig.me / ipify 返回的出口 IP 来判断当前使用的是哪个节点“出口 IP 1.2.3.4 意味着我们使用的是节点 X”出口 IP 属于出口,而不是入口节点:住宅 IP 产品使用的出口 IP 来自 ISP 地址,与节点地址毫无关联(2026-10-23 的测量:一个 Comcast 住宅出口属于“静态住宅”产品,而非其接入的数据中心节点;完整地址未公开)。根据出口 IP 判断当前节点,会把成功与失败错误地归因于错误的订阅。用于判断节点归属时 ❌
在 TUN 下可以信任的内容:
  • curl 提供的 time_appconnect / time_starttransfer(应用层握手 / TTFB)——这些指标只有在隧道连接实际建立后才会完成,因此反映的是真实的端到端路径。
  • 将 networkQuality -s -I <physical-if> 的容量数值作为线路基准(-I = “将测试绑定到接口”,详见其自身的帮助信息)——它与 curl 的 --interface 一样绕过隧道。容量数据可以信任;不要比较不同绑定模式下的延迟 / 响应性子指标——在正常主机上,绑定接口的测试报告的空闲延迟反而高于通过隧道的测试。
  • 用于判断“我真实的本地 ISP 是什么”的区域内 / 国内 IP 地理位置查询源——区域内域名会命中代理的 DIRECT 规则,并通过你真实的最后一公里网络出口(境外来源会经过隧道,结果不真实;见表格)。命中 DIRECT 规则这一点不能证明隧道本身运行正常——见上方表格中的行;在将任何单个主机的成功归功于隧道之前,请检查 proxyBypassHosts / DIRECT 规则列表。
  • 从磁盘解码的代理 / TUN 配置,以及工具自身的 GUI——这是确认当前实际启用节点 / 路由的权威来源。请将文件解析结果与 GUI 交叉核对;不要根据网络探测结果推断当前启用的节点。

应对措施:TUN 运行时,在引用任何延迟 / 可达性数据之前,先问自己:“如果数据包真的经过网络到达目的地,这个数值在物理上可能吗?” 连接耗时 0.00s 或到另一大洲的 ping 耗时 0.2ms,说明你测到的是 TUN,而不是网络。改用 time_appconnect,或暂时禁用 TUN,以获取干净的基准数据(关闭 TUN 后,原始探测才会再次有意义)。

TUN 完全卡死与真实断网(所有探测都失败时,先证明线路已断再停止恢复操作)

污染表有一个系统性局限:所有经过 TUN 的探测都会受到同一影响。这也包括“真实 IP”探测(dig @<resolver> + curl --resolve)——TUN 也会拦截 UDP/53。当 TUN 的转发器完全卡死时,域名探测、真实 IP 探测和代理探测会同时失败,结果无法与真实的物理断网区分。如果看守进程或调查人员把“所有探测都失败”解读为“网络已断”,就会在本可恢复的 TUN 卡死期间停止恢复操作,甚至更糟,还会归咎于路由器(观察记录:2026-09-10,一款代理健康监控守护进程在一天内记录了 13 次“真实网络断网,停止恢复操作”的时段;在加入物理路径探测之前,始终未能确定故障发生在哪一层)。

判别方法是使用一个完全绕过 TUN 的探测——绑定物理接口(curl --interface = IP_BOUND_IF,作用域路由会让数据包直接从物理网卡发出):

# 1) Find the physical interface that actually carries the LAN. Do NOT use
#    `route -n get default` here — under TUN it answers with the utun itself.
for i in en0 en1 en2 en3; do
  addr=$(ipconfig getifaddr $i 2>/dev/null) && echo "$i $addr"
done
ping -c 1 -t 2 <gateway-ip>   # sanity: is the LAN itself alive at all

# 2) Probe bare-IP targets through that interface — two independent ones.
#    Both verified answering on a healthy link 2026-09-10 (AliDNS 443 → 404,
#    DNSPod 80 → 404). Targets rot: 114.114.114.114 and 180.76.76.76 returned
#    000 on a healthy wire that same day — re-verify a target before trusting it.
for u in "https://223.5.5.5/" "http://119.29.29.29/"; do
  curl -s --noproxy '*' --interface en0 -k --connect-timeout 3 --max-time 6 \
    -o /dev/null -w "$u: %{http_code}\n" "$u"
done
  • 返回任何非 000 的结果(即使是 404)→ 物理路径(网卡 → AP → 网关 → ISP)正常,TUN 是疑似故障源:按照**恢复约定**重新连接隧道(断开 / 连接机制位于步骤 2J)——不要停止恢复操作,也不要重启路由器。
  • 两个目标都返回 000 → 足以判定是真实断网。单个目标返回 000 只能证明该目标不可达:企业 / 校园出口白名单以及解析器端的变更,都可能导致线路正常时只有一个目标返回 000。

请先校准,再信任结果:网络正常时,每个响应约在 0.1s 内到达;刻意使用错误的接口必须失败——--interface en9 会返回 000,且退出码非零(接口不存在时为 45;接口存在但已关闭或没有路由时为 7/28——判断信号是 000,而不是退出码)。

快速操作:运行自动化检查

对于常见的 macOS 冲突(环境代理、系统代理例外、直连/代理路径分流、本地 TLS 信任),请运行:

python3 scripts/quick_diagnose.py --host local.example.com --url https://local.example.com/health

可选:检查到 Tailscale 目标的路由归属:

python3 scripts/quick_diagnose.py --host <target-host> --url http://<target-host>:<port>/health --tailscale-ip <100.x.x.x>

解读:

  • direct=PASS + ambient=FAIL + forced_proxy=FAIL 表明应排查 shell 代理路径。direct=PASS + system_proxy=FAIL 是警告,但在受影响的浏览器或系统客户端也出现故障之前,不应据此采取行动;只有届时才考虑针对特定主机设置绕过规则。
  • forced_proxy 使用目标 URL 对应的代理环境变量(HTTPS 使用 https_proxy,HTTP 使用 http_proxy,然后使用 all_proxy);仅强制代理探测失败,不足以成为更改正常环境代理路径的理由。
  • direct=FAIL 不支持建议设置绕过规则。非 000 的 HTTP 响应(包括 403)只能证明收到了响应,不能证明应用接受了请求。
  • strict_tls=FAIL + direct=PASS = 路径可达;仅存在信任问题(安装/信任本地 CA)。
  • host in scutil exceptions: no 只是背景信息,本身不代表故障。脚本的 direct 探测会绕过 curl 代理,但仍可能经过系统 TUN;这不能证明存在独立的物理路径。
步骤 2A:修复 HTTP 代理环境变量

检查代理环境变量是否拦截了 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          ← Missing 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.netMagicDNS 域名(host.tailnet.ts.net)在 DNS 解析前匹配
100.64.0.0/10Tailscale 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。

Go net/http 的 CIDR 限制:Go 标准库的 net/http 不支持在 NO_PROXY 中使用 CIDR 表示法。设置 NO_PROXY=100.64.0.0/10 对 curl 和 Python 有效,但 Go 程序(包括与 Tailscale 相关的工具)仍会通过代理发送流量。解决方法是使用 MagicDNS 主机名(例如 my-wsl-box)而不是原始 IP,或将明确的主机名添加到 NO_PROXY:

# WRONG for Go programs — CIDR is silently ignored
NO_PROXY=100.64.0.0/10 go-program http://100.101.102.103:8002/health  # → goes through proxy

# CORRECT — use hostname (matched as suffix) or explicit IP
export NO_PROXY=localhost,127.0.0.1,.ts.net,my-wsl-box,100.101.102.103,192.168.*,10.*,172.16.*

从 Go 编写的工具(例如自定义 CLI、用于访问远程 API 的 Go 测试套件)访问 Tailscale 服务时,这一点尤其重要。

验证修复结果:

# 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"

然后将设置写入 shell 配置文件(~/.zshrc 或 ~/.bashrc),使其持久生效。

步骤 2B:检测路由冲突

检查代理工具是否劫持了 Tailscale CGNAT 网段:

route -n get <tailscale-ip>

正常输出——流量通过 Tailscale 接口传输:

destination: 100.64.0.0
interface: utun7    # Tailscale interface (utunN varies)

异常输出——代理劫持了路由:

destination: 100.64.0.0
gateway: 192.168.x.1    # Default gateway
interface: en0           # Physical interface, NOT Tailscale

重要:并非所有 utun 接口都属于 Tailscale。在判断路由是否正确之前,请确认哪个 utun 接口属于 Tailscale:

# Find Tailscale's utun interface (has a 100.x.x.x IP)
ifconfig | grep -A2 'inet 100\.'

根据 MTU 快速判断:

  • MTU 1280 → 通常是 Tailscale
  • MTU 4064 → 通常是 Shadowrocket TUN

如果 route -n get 显示流量发往 MTU 为 4064 的 utun 接口,则流量进入的是 Shadowrocket 的 TUN,而不是 Tailscale——即使接口名称以 utun 开头,这仍属于路由冲突。

使用完整路由表进行确认:

netstat -rn | grep 100.64

两条相互竞争的路由表示存在冲突:

100.64/10  192.168.x.1   UGSc  en0       ← Proxy added this (wins)
100.64/10  link#N        UCSI  utun7     ← Tailscale route (loses)

根本原因:在 macOS 上,对于前缀长度相同的路由,UGSc(静态网关)的优先级高于 UCSI(克隆静态接口)。

步骤 2C:修复系统代理绕过问题(浏览器 503)

症状:浏览器访问 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 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

修复——在代理工具配置的 skip-proxy 中添加 Tailscale CGNAT 范围:

对于 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 会告知系统“对这些地址完全绕过代理”。随后,浏览器会通过 OS 网络栈直接连接,Tailscale 的路由表会在其中正确处理流量。

为什么 skip-proxy 有效,而 tun-excluded-routes 无效:

  • skip-proxy:仅绕过 HTTP 代理层。流量仍会经过 TUN 接口,并由 Tailscale utun 处理。安全。
  • tun-excluded-routes:从 TUN 路由中完全移除该 CIDR。这会创建一条与 Tailscale 冲突的 en0 路由,并覆盖 Tailscale。导致所有内容无法正常工作。
步骤 2C-1:修复本地 Vanity 域名拦截问题(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
# -> proxy CONNECT then TLS reset/failure

根本原因:该域名通过系统/命令行代理路由,而非本地直连路径。

修复:

  1. 将域名添加到代理应用的绕过列表中(Shadowrocket 使用 skip-proxy)。
  2. 将域名添加到命令行绕过列表中(NO_PROXY/no_proxy)。
  3. 如果本地 TLS 使用内部 CA,则信任本地根证书。
# ~/.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: yes
  • host in scutil exceptions: yes
  • ambient=PASS 和 direct=PASS
步骤 2D:修复远程开发的身份验证重定向(SSH 隧道)

症状:开发服务器运行在远程机器上(例如,通过 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 本地端口转发。这样可以完全避开所有三个冲突层:

# 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>

现在,在浏览器中访问 http://localhost:3010。身份验证重定向到 localhost:3010 → 隧道 → 远程开发服务器 → 正常工作。

为什么这是最佳方案:

  • 无需更改 .env——APP_URL=http://localhost:3010 在任何地方都能使用
  • 不会与 Shadowrocket 冲突——localhost 始终位于 skip-proxy 中
  • 无需更改代码——行为与本地开发相同
  • 行业标准做法——VS Code Remote SSH、GitHub Codespaces 都采用相同模式

安装 autossh:brew install autossh(macOS)或 apt install autossh(Linux)

终止后台隧道:pkill -f 'autossh.*<tailscale-ip>'

步骤 2E:修复脚本中的 localhost 代理拦截问题

症状:当 shell 中全局设置了 http_proxy 时,用 curl 访问 localhost 的 Makefile 目标或脚本(健康检查、预热路由)会失败或超时。

根本原因:http_proxy=http://127.0.0.1:1082 已设置在 ~/.zshrc 中,但 no_proxy 未包含 localhost。所有 curl 命令都会通过代理发送 localhost 请求。

修复——在脚本中所有访问 localhost 的 curl 命令里添加 --noproxy localhost:

# 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"

或者,在 ~/.zshrc 中全局设置 no_proxy:

export no_proxy=localhost,127.0.0.1
步骤 2F:修复 SSH ProxyCommand 双重隧道(git push/pull 失败)

症状:ssh -T [email protected] 始终能成功,但 git push 或 git pull 间歇性失败,并显示:

FATAL: failed to begin relaying via HTTP.
Connection closed by UNKNOWN port 65535

小型操作(身份验证、获取元数据)可以正常进行;大规模数据传输则会失败。

根本原因:Shadowrocket TUN 启用时,已经会将所有 TCP 流量通过其 VPN 隧道转发。如果 SSH 配置也使用 ProxyCommand connect -H,数据就会经过两层代理——入口代理会丢弃大型或持续时间较长的 HTTP CONNECT 连接。

诊断:

# 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

修复——移除 ProxyCommand,并改用 ssh.github.com:443。完整的 SSH 配置、端口 443 的优势,以及 VPN 关闭时的备用选项,请参阅 references/proxy_conflict_reference.md § SSH ProxyCommand and Git Operations。

步骤 2G:修复 VM/容器运行时的代理传播(Docker 拉取/构建失败)

症状:docker pull 或 docker build 因 net/http: TLS handshake timeout、Alpine/Debian 源返回 Connection refused,或 auth.docker.io 返回 Internal Server Error 而失败,但主机上的 curl 访问相同 URL 时正常。

适用范围:在 macOS 上启用 Shadowrocket/Clash TUN 时使用 OrbStack、Docker Desktop 或任何基于 VM 的 Docker 运行时。

根本原因:基于 VM 的 Docker 运行时(OrbStack、Docker Desktop)会在轻量级 VM 中运行 Docker 守护进程。VM 的出站流量与主机进程走的是不同网络路径:

Host process (curl):   Process → TUN (Shadowrocket) → landing proxy → internet ✅
VM process (Docker):   Docker daemon → VM bridge → host network → TUN → ??? ❌

TUN 能正确处理源自主机的流量,但可能会丢弃或延迟经 VM 桥接的流量(TCP 协议栈、MTU、keepalive 行为均不同)。

重要区别:docker pull 和 docker build 使用不同的代理路径:

操作代理来源控制方式
docker pullDocker 守护进程配置~/.orbstack/config/docker.json 或 docker info
docker build(RUN apt/apk)构建容器环境变量--build-arg http_proxy=... 或 --network host
docker run容器环境变量-e http_proxy=... 或从守护进程继承

仅修复 docker.json 并不能修复 docker build——构建容器内的 RUN 命令不会继承守护进程的代理设置。

诊断——确定具体是哪类问题:

# 1. Can the Docker daemon pull images?
docker pull --quiet alpine:latest 2>&1

# 2. Can a RUN command inside a build reach the internet?
docker build --no-cache - <<'EOF' 2>&1
FROM alpine:latest
RUN apk update && echo "APK OK"
EOF

# 3. Can a running container reach the internet?
docker run --rm alpine:latest sh -c "apk update 2>&1 | head -3"

四类问题及其修复方法:

2G-1:docker build 失败,但主机正常(OrbStack + Shadowrocket 最常见)

症状:在 docker build 内执行 RUN apk add 或 RUN apt-get install 时立即失败(< 0.2s),并显示 Connection refused,尽管主机上的 curl 访问相同 URL 时正常。

根本原因:OrbStack 的 network_proxy: auto 会在 VM 内创建一个透明代理,拦截所有 HTTPS 流量。当 Shadowrocket TUN 也处于启用状态时,透明代理的上游连接会中断——它会将 HTTPS 重定向到 VM 内的 127.0.0.1,但该地址上没有服务在监听。

诊断:

# Verify: inside the container, HTTPS goes to 127.0.0.1 (broken transparent proxy)
docker run --rm alpine:latest sh -c "wget -q --timeout=5 -O /dev/null https://dl-cdn.alpinelinux.org/ 2>&1"
# → "wget: can't connect to remote host (127.0.0.1): Connection refused"
#                                        ^^^^^^^^^^^^ This is the smoking gun

# Verify: --network host bypasses the VM bridge and works
docker run --rm --network host alpine:latest sh -c "apk update 2>&1 | head -3"
# → "v3.23.x ... OK: 27431 distinct packages available"  ← Works!

修复——对 docker build 使用 --network host:

docker build --network host -f Dockerfile -t myimage .

这会完全绕过 OrbStack 的 VM 网络桥接。构建容器会直接使用主机的网络协议栈,由 Shadowrocket TUN 正确处理流量。

权衡:--network host 会禁用构建期间的网络隔离。对于 CI/CD,建议修复代理配置(2G-2)。对于本地开发,--network host 是实用的修复方法。

永久修复——如果所有构建都需要此设置,可将其添加到 ~/.docker/daemon.json,或使用 shell 别名:

# Shell alias (add to ~/.zshrc)
alias docker-build='docker build --network host'
2G-2:OrbStack 自动检测并缓存代理配置

OrbStack 的 network_proxy: auto 会读取 shell 环境中的 http_proxy,并配置 Docker 守护进程。配置存储在 ~/.orbstack/config/docker.json 中。

主要行为:

  • network_proxy: auto — OrbStack 读取主机环境变量,并在 VM 中创建透明代理
  • network_proxy: none — 禁用透明代理,但 VM 网桥流量仍会经由 TUN 路由(可能超时)
  • docker.json — 控制 docker pull 代理,而非 docker build 的 RUN 命令

诊断:

# Check all three layers
echo "=== OrbStack config ==="
orbctl config get network_proxy

echo "=== docker.json (daemon proxy) ==="
cat ~/.orbstack/config/docker.json

echo "=== Docker info (effective proxy) ==="
docker info | grep -iE "proxy|No Proxy"

修复 — 配置 docker.json 使用 host.internal(OrbStack 会将其解析为主机 IP):

python3 -c "
import json, os
config = {
    'proxies': {
        'http-proxy': 'http://host.internal:1082',
        'https-proxy': 'http://host.internal:1082',
        'no-proxy': 'localhost,127.0.0.1,::1,192.168.128.0/24,100.64.0.0/10,host.internal,*.local'
    }
}
path = os.path.expanduser('~/.orbstack/config/docker.json')
json.dump(config, open(path, 'w'), indent=2)
print('Written:', path)
"

# Full restart required
orbctl stop && sleep 3 && orbctl start

重要:使用 host.internal(OrbStack 专用),不要使用 127.0.0.1(指向 VM 的回环地址),也不要使用 host.docker.internal(在某些情况下可能无法解析)。

为什么不要移除代理:TUN 处于活动状态时,移除 Docker 代理意味着 VM 流量会直接经由网桥 → TUN 路径,这会导致 TLS 握手超时。代理提供了一个可用的出站通道。

2G-3:移除代理会让 Docker 更糟(有悖直觉)
Docker 配置流量路径结果
代理开启(127.0.0.1),无 no-proxyDocker → VM 代理 → ???docker pull 可能正常,本地主机探测 ❌
代理开启(host.internal),并设置 no-proxy外部:Docker → 主机代理 → 互联网;本地:直接连接两者均正常 ✅
代理关闭(network_proxy: none)Docker → VM 网桥 → 主机 → TUN → 互联网TLS 超时 ❌
仅构建时使用 --network host构建容器 → 主机网络 → TUN → 互联网构建正常 ✅

决策树:

  • docker pull 出错 → 修复 docker.json,使用 host.internal 代理(2G-2)
  • docker build 出错 → 使用 --network host(2G-1)或传入 --build-arg http_proxy=http://host.internal:1082
  • 两者都出错 → 两项都修复:docker.json + --network host
2G-4:部署脚本和容器健康检查会通过代理探测本地主机

如果容器内的部署脚本执行 curl localhost,或 Docker 健康检查使用 wget http://localhost,那么当环境变量泄漏到容器中时,这些请求会经由代理路由。

常见症状:

  • 容器健康检查显示 (unhealthy),但容器中的应用运行正常
  • 健康检查日志中出现 wget: can't connect to remote host (127.0.0.1): Connection refused(指向代理端口,而非应用端口)

根本原因:Docker 会从主机继承大写和小写的代理环境变量。许多工具只清除大写变量(HTTP_PROXY=),却忘记清除小写变量(http_proxy=http://127.0.0.1:1082)。健康检查中的 wget 使用的是小写变量。

在 docker-compose.yml 中修复 — 同时清除大小写两种变量:

environment:
  # Must clear both uppercase and lowercase — wget/curl check different vars
  - HTTP_PROXY=
  - HTTPS_PROXY=
  - http_proxy=
  - https_proxy=
  - NO_PROXY=*
  - no_proxy=*

在部署脚本中修复:

_local_bypass="localhost,127.0.0.1,::1"
export NO_PROXY="${_local_bypass}${NO_PROXY:+,${NO_PROXY}}"
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

验证修复:

# Docker proxy check (should show proxy + no-proxy)
docker info | grep -iE "proxy|No Proxy"

# Pull test
docker pull --quiet hello-world

# Build test (the real verification)
docker build --network host --no-cache - <<'EOF'
FROM alpine:latest
RUN apk update && echo "BUILD OK"
EOF

# Container env check (no proxy leak)
docker exec <container> env | grep -i proxy
# Expected: all empty or not set
步骤 2H:通过 TUN 的 SSH/Git 失败(Connection closed by 198.18.x.x)

症状:git clone/fetch/push 失败,提示 Connection closed by 198.18.0.x port 443。ssh -T [email protected] 也可能失败。DNS 解析返回 198.18.x.x 地址,而非真实 IP。

首先,关于此症状的警告:错误信息中的 198.18.x.x 并不能证明失败是由 fake-IP 导致的。在 TUN 模式下,每个主机名都会解析为一个 fake IP,因此无论连接因该原因还是其他原因失败,错误中都会出现这个地址。它只能说明 TUN 处于活动状态,仅此而已。有两种截然不同的机制会产生完全相同的错误行,但它们需要采用相反的修复方法:

(A) 协议感知处理不当(B) 偶发的转发路径不稳定
说明TUN 检测到 443 端口,预期使用 HTTPS,因此丢弃 SSH 握手隧道的转发路径会间歇性地丢弃连接,与协议、端口或目标无关
预测SSH-over-443 必定失败;通过同一代理的 HTTPS 正常SSH 和 HTTPS 的失败率相近,且会在特定时间段内集中发生
正确的修复方法让 SSH 绕过 TUN 路由重试 — 下一次连接通常会成功
先区分是哪种情况,再采取行动——通过 HTTPS 向无关主机发送纯 HTTPS 请求,沿用同一代理路径,并进行足够多次测试,以便得出失败率,而不是凭个别情况下结论:
# Same proxy, plain HTTPS, unrelated destination — does it also fail?
for i in $(seq 1 40); do
  curl -s -o /dev/null -x http://127.0.0.1:<proxy-port> \
       -w '%{http_code}\n' -m 10 https://www.google.com/generate_204
done | sort | uniq -c
  • 有些 HTTPS 请求也会失败(000),且失败率与 SSH 相近 → (B)。问题并非特定于协议或端口。改道 SSH 无济于事,因为丢弃连接的环节位于协议之前。
  • 多次测试中 HTTPS 始终 100% 正常,而 SSH 稳定失败 → (A),此时适用下文的 DIRECT 规则修复方案。

以下是 (B) 的一个实测数据点,让失败率有具体依据,而非空口推测:一台运行 Shadowrocket TUN 模式的 macOS 主机上,ssh -T [email protected] 在网络状况良好的时段 20 次全部成功,在状况不佳的时段约有 15–20% 的尝试失败;与此同时,在同一时段,通过显式代理向无关主机发送纯 HTTPS 请求,失败率也相近。同一天,git push 连续失败两次,第三次成功,期间未作任何改动。这台机器属于 (B),因此,关于 443 端口的说法经不起实测。这只是一台主机的情况,并非普遍证伪——运行上面的测试,看看你的情况属于哪一种。

(B) 的修复方案——重试,并将其自动化。 失败是间歇性的,因此最省事且正确的应对方式就是重试。在 git 的传输层实现透明重试,而不是靠自己记住重新运行命令:

git config --global core.sshCommand /path/to/ssh-retry-wrapper.sh

这个包装脚本必须正确处理两件事:

  • 只重试发生在密钥交换完成之前的失败。 匹配诸如 kex_exchange_identification、Connection closed|reset by <host> port <n> 和 Connection timed out during banner exchange 这样的 stderr 特征。如果密钥交换未完成,ssh 就没有运行远程命令,因此 git 收到的协议字节数为零,重新建立连接对它来说是不可见的。传输中途断开时不会出现这些特征,必须原样传递——此时重试会导致问题。
  • 不要改用 ConnectionAttempts,它在这里不起作用。 它只会重试 TCP 连接阶段,而在 TUN 模式下,无论目标端如何处理,握手都会在本地完成,因此连接总会“成功”,失败则发生在后续的 banner 交换阶段。使用一个接受连接后立即关闭的监听器进行实测:ConnectionAttempts=1、3 和 5 最终都只产生了一次接受连接的记录。

⚠️ 注意不要叠加重试。如果包装脚本或 cron 任务已经在重试推送,再在其下方添加传输层重试,就会使重试次数相乘(5 × 3 = 15 次连接),而外层日志记录的连接尝试次数会少于实际次数。

(C)“SSH 通道完全断开”——这是一种应当警惕的判断,而非故障机制。 连续一段时间 SSH 接连失败(ssh.github.com:443 出现 banner 超时,22 端口也报 Connection closed),但 HTTPS 100% 正常,看起来像是出现了新机制。其实并非如此:在不同时间段重复测量后,同一台主机的 ssh -T [email protected] 在 25 次尝试中有 75–84% 成功——这一直都是机制 (B) 在状况不佳时段的表现。同一时间段内连续失败 N 次,并不等于 N 个独立样本:转发稳定性会随时间变化,状况不佳的时段经常会出现连续 5 次失败。在得出“通道已断开”的结论之前,过几分钟再测一次。

需要采样多少次,取决于拒绝的表现形式,而不是失败次数。明确的策略代码(例如 API 返回 KEYLESS_ACCESS_NOT_AVAILABLE)意味着端点正在声明一条规则——在同一会话内采样几次即可确定。挑战页面、速率限制或连接重置则反映的是状态——它会随时间和 IP 信誉变化,因此,同一时段内连续失败只能算一个样本,只有跨时段重新测量才能区分“已断开”和“状况不佳”。

以下两点无论如何都要记住:

  • nc -vz github.com 22 显示 succeeded 并不能证明任何问题——在 TUN 环境下,本地协议栈会对任意端口的目标地址作出响应。只有真实握手才可信(ssh -T)。
  • 当重试包装器确实用尽所有尝试后,可通过一次性 HTTPS 绕过方式操作,且不会更改共享远程配置:git push https://github.com/<owner>/<repo>.git HEAD:refs/heads/main(如果直接尝试因 SSL_ERROR_SYSCALL 失败,则添加前缀 HTTPS_PROXY=http://127.0.0.1:<proxy-port>)。

修复方法(A)——添加 DIRECT 规则(需要访问代理工具配置),让 TUN 不经协议检查,直接转发此流量:

IP-CIDR,140.82.112.0/24,DIRECT
IP-CIDR,192.30.252.0/22,DIRECT

此方法可平滑降级:如果没有帮助,你仍会回到原来的状态。

不建议:在 ~/.ssh/config 中硬编码 GitHub 的 IP。 此技能的早期版本曾建议使用 HostName 140.82.112.35。有三个理由不应这样做:

  1. IP 会过时,而且过时后会导致严重故障。 GitHub 会轮换这些地址;硬编码的 IP 失效后会显示为 connection refused,看起来像是服务中断。需要用每周运行的 cron 任务维护 IP,说明修复方式找错了地方。
  2. 它可能无法绕过声称要绕过的问题。 代理工具按规则路由,而不是根据目标地址是否为假 IP 来路由——因此,除非存在匹配的 DIRECT 规则,否则字面 IP 会像主机名一样走相同的转发路径,也会有相同的不稳定性。
  3. 它会引入新的 known_hosts 查找键。 known_hosts 按连接字符串而非主机密钥索引,因此 [140.82.112.35]:443 未被记录,会触发 TOFU 提示——这要么会阻塞无人值守自动化,要么会迫使你放宽 StrictHostKeyChecking。为了不确定的收益而付出这样的实际代价,并不值得。

如果你已经应用了硬编码 IP 的修复并且情况有所改善,请先运行上面的 HTTPS 测试,再判断是否是 IP 起了作用:偶发故障也会在异常时段结束后自行“好转”。

步骤 2I:修复 getaddrinfo 链中停滞的 DNS 解析器

症状:ssh、curl(不带 -x)、git 以及其他使用系统 DNS 的工具,在解析前会挂起约 60 秒。ssh -vvv 会在以下内容后立即卡住:

debug2: resolving "<host>" port <port>
debug3: resolve_host: lookup <host>:<port>

……并且始终不会显示 debug1: connect to address。等待后最终会成功——但每次新建连接都会遇到同样的延迟。nslookup <host> 会立即返回(约 10ms),但 dscacheutil -q host -a name <host> 需要 60 秒以上。

根本原因:macOS 的 getaddrinfo 会查询 scutil --dns 中所有 domain 筛选条件匹配(或完全没有筛选条件)的条目。如果某个解析器的名称服务器不可达,但其接口仍在路由表中,getaddrinfo 就会等待完整的 UDP 重试超时时间(通常为 30–60 秒),然后才切换到下一个解析器。现实中最常见的诱因是隧道守护进程(Tailscale、Cisco AnyConnect、Pulse Secure)崩溃时未清理其 utun 和 DNS 注入配置。

为什么 nslookup 会误导你:nslookup 只读取 /etc/resolv.conf(一个名称服务器)。dscacheutil 和 getaddrinfo 则通过 DirectoryService 工作,并查询 scutil --dns 中的整个解析器链。这两者的结果不一致,就是关键线索。

“ping 正常但 DNS 不通”的陷阱:即使端口 53 不通,ping <resolver-ip> 也可能在不到 1ms 内响应,因为 utun 接口仍声明拥有该 IP,并在本地回复 ICMP。不要根据 ping 推断解析器是否正常。应测试实际服务:dig @<ip> +tries=1 +timeout=3 example.com。

诊断:按名称服务器二分排查

在 15 秒内找出失效的解析器:

# 1. Read every resolver's nameserver, interface, and matching scope
scutil --dns | grep -E "^resolver|nameserver|domain :|search domain|if_index"

# 2. Time each nameserver in isolation (3-second cap)
for ns in <each_unique_nameserver_from_step_1>; do
  printf "  %s: " "$ns"
  /usr/bin/time -p dig @$ns +tries=1 +timeout=3 +short example.com 2>&1 | tr '\n' ' '
  echo
done

正常的名称服务器会在不到 0.1 秒内响应。失效的名称服务器则会在恰好 3.01 秒后返回 connection timed out; no servers could be reached。

对于 IPv6 解析器,运行相同的 dig @<ipv6> 测试——Tailscale 和多个 VPN 会同时注入 v4 和 v6 地址,任一侧失效都会产生相同症状。

读取解析器属性——确定故障波及范围

每个 scutil --dns 解析器都有决定其处理哪些查询的属性:

属性匹配范围此解析器失效时的卡顿范围
domain : foo.com仅匹配 *.foo.com 查询范围有限——只有 foo.com 查询会卡住
search domain : foo所有查询(会追加搜索后缀)范围不受限——所有查询都会卡住
完全没有 domain 字段所有查询(默认参与)范围不受限——所有查询都会卡住

带有 domain 过滤条件的失效解析器虽然令人烦恼,但影响范围有限。没有 domain 过滤条件的失效解析器(VPN 注入的 DNS 中很常见,例如 Tailscale 的 100.100.100.100)会导致所有系统查询瘫痪,直到你修复它。

确认可疑组件

二分排查找出失效的名称服务器后,确定是哪个应用注入了它(if_index 中的接口名称是最有力的线索——utun* 接口通常可追溯到 VPN 守护进程)。

具体来说,对于 Tailscale:

tailscale status
# Healthy: lists peers
# Dead:    failed to connect to local Tailscale service; is Tailscale running?

“failed to connect” 错误表示守护进程已退出,但它注入的网络配置(utun 接口和 DNS 解析器条目)尚未清理。这种情况同样适用于任何 VPN/隧道工具。

修复

在应用层面重启相关应用,以运行其清理钩子并移除残留接口:

Tailscale(App Store 版和独立版 macOS 构建):

osascript -e 'quit app "Tailscale"' && sleep 3 && open -a Tailscale

对于其他 VPN/隧道工具,优先正常退出应用(菜单栏 → 退出,或 osascript -e 'quit app "<name>"'),而不是使用 kill -9。强制终止会跳过清理步骤,可能导致同样的失效接口状态。只有在应用拒绝正常退出时,才升级使用 pkill -9 <name>。

为什么“重启应用”优于“清空 DNS 缓存”:sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder 会清空缓存结果,但 scutil --dns 中的解析器链是根据网络配置重建的,而不是根据缓存重建的。清空缓存后,失效解析器仍然存在。必须由最初注册该解析器的应用来修复。

端到端验证

DNS 解析器修复很容易只验证一半。在确认系统路径已恢复之前,以下检查必须全部通过:

# 1. The owning daemon is back (not just its UI)
tailscale status | head -3

# 2. The previously-dead nameserver responds fast
dig @<previously-dead-ns> +tries=1 +timeout=3 +short example.com
# Expected: <0.1s, returns IP

# 3. macOS system path is unblocked (proves getaddrinfo recovered)
/usr/bin/time -p dscacheutil -q host -a name example.com
# Expected: <0.1s, returns IP

# 4. The original failing command works WITHOUT any workaround
ssh -o "ProxyCommand=none" -T [email protected]
# Expected: "Hi <user>! You've successfully authenticated..."

最初失败的操作是决定性检查。如果你在诊断过程中应用了变通方案(例如通过 ProxyCommand 将 DNS 查询交给 SOCKS5 代理、添加 /etc/hosts 条目或使用硬编码 IP),那么禁用变通方案(ProxyCommand=none)后运行原始命令,才是确认系统 DNS 路径确实已修复、而不只是绕过问题的唯一方法。

请参阅 references/dns_resolver_chain_stall.md,了解 macOS 解析器顺序、IPv4 与 IPv6 的分流,以及一个逐步演示所有诊断命令及其实际输出的完整示例。

步骤 2J:修复 TUN DIRECT 脑裂(域名连接失效,真实 IP 连接正常)

症状:TUN 工具通过 DIRECT 路由的所有网站(国内网站、云服务商 API、自有 API 域名)同时失效,而通过代理访问的海外网站仍可正常使用。根据客户端不同,故障会呈现出几种不同的症状:

  • curl 直连:握手过程中出现 SSL routines::unexpected eof while reading
  • curl -x http://127.0.0.1:<proxy-port>:CONNECT tunnel failed, response 503
  • 基于 Node 的 CLI(Claude Code、npm 等):不断重试并出现 UNKNOWN_CERTIFICATE_VERIFICATION_ERROR——fake-IP↔域名表映射错误,因此 TUN 将你的 SNI 转发到错误的后端,该后端提供了其他网站的证书
  • 如果你运行了代理健康监控程序,它会一直报告健康——见下方警告

根本原因:TUN 工具(Shadowrocket / Clash / Surge 的 fake-IP 模式)有两个独立的转发平面:代理流量(海外流量,经节点转发)和 DIRECT 流量(根据域名规则匹配,由工具自己的网络栈转发)。DIRECT 平面的状态可能单独出问题——例如隧道内 DNS 失败、网络变更后 fake-IP 表过时,或内部状态损坏——而代理平面仍完全正常。解析到 fake-IP 且域名匹配 DIRECT 规则的连接,都会在工具内部失效。

诊断:

# 1. Confirm fake-IP DNS is in effect (domain resolves into 198.18.0.0/15)
dig +short <any-direct-rule-domain>
# → 198.18.x.x

# 2. Get the real IP from a public resolver (UDP/53 usually bypasses the broken path)
dig +short @223.5.5.5 <domain>   # or @1.1.1.1 / @8.8.8.8
# → real address

# 3. Connect by real IP with correct SNI — bypasses the fake-IP table
curl -sS -o /dev/null -w '%{http_code}\n' --resolve <domain>:443:<real-ip> https://<domain>/
# → normal HTTP status: this fresh-IP path works; establish failure scope before attribution

如果步骤 3 也失败,则不是脑裂——但这也不能证明发生了中断:步骤 3 仍经由 TUN(UDP/53 也会被拦截)。运行“ TUN 完全卡死还是实际中断”(步骤 2A 之前)中的绑定网络接口的物理网络探测;只有该探测两次都返回 000,才能判定为“本地网络确实中断”。

修复——仅在网络维护及其中断已获授权时,按照恢复约定重启指定隧道,然后在仍有过期 fake-IP 条目时刷新操作系统 DNS 缓存。根据当前配置确认服务;切勿用 Tailscale 控制隧道替代,也不要以终止代理应用作为捷径。对于 Shadowrocket,shadowrocket://disconnect 和 shadowrocket://connect 仅请求状态切换;每次操作后都要验证 VPN 的实际状态。不要用 && 串联它们:若两者之间发生故障,仍必须执行已准备好的恢复操作。对于其他客户端,使用相应的 API 或 GUI。

仅当确认隧道已连接后,才可在授权的维护范围内运行 sudo killall -HUP mDNSResponder;刷新 DNS 缓存无法恢复已断开的 VPN。

逐项验证所需的转发平面——修复一个平面后,另一个平面可能仍处于故障状态(或因此发生故障):

curl -sS -o /dev/null -w 'domestic direct: %{http_code}\n' --max-time 10 https://<domestic-site>/
curl -sS -o /dev/null -w 'own DIRECT-rule domain: %{http_code}\n' --max-time 12 https://<your-api-domain>/health
curl -sS -o /dev/null -w 'cloud API: %{http_code}\n' --max-time 10 https://<cloud-provider-endpoint>/
curl -x http://127.0.0.1:<proxy-port> -sS -o /dev/null -w 'overseas via proxy: %{http_code}\n' --max-time 10 https://www.google.com/generate_204

健康监控盲区:如果你运行了自动代理健康检查程序,请检查它实际探测的内容。如果监控程序只通过代理测试海外端点,它验证的只有代理平面——在导致本节内容的问题事件中,DIRECT 平面完全故障期间,监控程序连续 2 小时以上每 5 分钟报告一次“健康”。绿色健康检查结果只能证明它探测的路径正常;请探测该工具转发的每个平面。

步骤 2K:判断故障源于你的隧道还是目标主机的防火墙

症状:SSH 连接到公共主机时出现 kex_exchange_identification: Connection closed by <ip> 或 Connection closed by UNKNOWN port 65535,而到同一主机的 HTTPS 仍可正常使用。nc -z <ip> 22 报告端口开放。以下方法针对 SSH 编写;对于其他被拦截的服务,请将相关端口、日志路径和服务单元替换为该服务对应的值。 为什么这需要单独列为一个步骤:它看起来和步骤 2H 完全一样,而步骤 2H 的修复方法(重启 TUN、刷新 DNS)对此无效,因为原因可能根本不在本地。它看起来也和步骤 5A 完全一样——但 5A 专门针对 WSL 上的 Tailscale,而这一步针对的是路径中没有 Tailscale 的 TUN。这两者都未涵盖的第三种机制,是目标端自己的防火墙悄悄丢弃了你的连接——云主机强制执行的两个层级任一层都可能出现这种情况(机器外部的提供商安全组,以及机器内部的 ufw/firewalld/nftables)。从客户端来看,被允许列表丢弃的连接和 TUN 转发故障无法区分:两者都会表现为“连接已建立,但在协议标识信息出现之前就断开”。使用 TUN 时,每多做一次本地探测,得到的误导性证据只会更多,不会更少。

首先:别再相信 nc -z。 使用 TUN 时,本地网络栈会代替目标端完成 TCP 握手,因此 nc -z 对所有端口都会报告成功,包括目标端已关闭的端口。解读任何端口探测结果之前,请先进行校准:

# Probe a port the destination certainly does NOT serve
nc -z -G 5 -w 5 <destination-ip> 65001 && echo "nc is lying: the TUN answered for a closed port"
# Control: an address that cannot answer at all (TEST-NET-3, RFC 5737)
nc -z -G 5 -w 5 203.0.113.199 443 || echo "control OK: this probe can still report failure"
  • 显示第一行 → 在这台机器上,nc -z <that host> <any port> 对目标端的情况完全不提供信息。请继续执行此步骤,并忽略后续所有端口探测结果。
  • 不显示第一行(但会显示对照行)→ 在此路径上,nc -z 的结果可信,因此此步骤的前提不成立。继续之前,请重新检查是否确实使用了 TUN;如果没有,端口开放或关闭的结果就是真实证据,步骤 2B/5A 可能更适用。

主机名也有同样的陷阱:虚假 IP DNS 会为不存在的名称返回答案,因此 HTTP 200 并不能说明响应来自哪台机器。也请对此进行校准——dig +short @1.1.1.1 <a-name-you-just-invented> 应返回空结果。如果它返回了地址(通常是 198.18.x.x),则说明 DNS 答案是合成的,在这台机器上,任何基于名称的结果都无法确认主机身份。

然后,通过带外方式询问目标端。 要确定“我的数据包是否到达了?”,唯一的方法是使用一条不经过故障数据路径的通道。在云端 VM 上,这就是提供商的来宾代理控制平面:它通过提供商自己的网络连接到实例,因此即使你能拨通的所有端口都被封锁,它仍然有效。

  • Alibaba Cloud — aliyun ecs DescribeCloudAssistantStatus --RegionId <region> --InstanceId.1 <instance-id>(LastHeartbeatTime 在最近一分钟内,证明机器正常运行且其代理运行正常),然后运行 aliyun ecs RunCommand --RegionId <region> --InstanceId.1 <instance-id> --Type RunShellScript --ContentEncoding Base64 --CommandContent <base64> --Timeout 60,再运行 aliyun ecs DescribeInvocationResults --RegionId <region> --InvokeId <id> 收集输出(Output 字段采用 base64 编码)。已于 2026-08-30 执行并验证命令。
  • AWS / GCP / Azure 有对应功能(SSM Run Command、基于来宾代理的访问、Run Command 扩展)。此处未验证语法——请查阅提供商文档。
  • 没有来宾代理,或主机所在的提供商不提供此功能 → 此步骤无法确定问题;你需要能通过物理方式或控制台访问主机的人查看主机日志。不要用其他客户端探测来替代。
  • 心跳过期或不存在 → 代理或主机已停止运行。这是关于主机的事实,并不能证明隧道或防火墙哪一方有问题;请先修复或调查此问题。

提出唯一能确定责任归属的问题:

# run these INSIDE the destination, through the out-of-band channel
journalctl -u ssh -n 20 --no-pager || journalctl -u sshd -n 20 --no-pager   # unit is `ssh` on Debian/Ubuntu, `sshd` on RHEL
grep -a sshd /var/log/auth.log 2>/dev/null | tail -20    # Debian/Ubuntu, only if rsyslog is installed
grep -a sshd /var/log/secure   2>/dev/null | tail -20    # RHEL family

⚠️ 先检查 journal,不要把空的 auth.log 当作结论。 在 Ubuntu 24.04 上,该文件仍然存在,也仍会收到 CRON 和 sudo 记录,但默认未安装 rsyslog,而且 sshd 不会向其中写入任何内容——在一台正在运行的 24.04 主机上实测:auth.log 中有 90 行,其中来自 sshd 的记录为 0 行,而 journalctl -u ssh 中则有完整记录。仅查看那里的 auth.log,恰恰会制造出本步骤旨在避免的“没有尝试到达”这一错误结论。 如果服务记录的是 correct key but not from a permitted host,说明连接尝试已到达 sshd:请使用SSH 隧道来源限制检查专用密钥的 from= 身份验证限制。下方关于动态地址的警告针对的是防火墙允许列表;它们不属于同一强制执行层。请按照该参考文档中由所有者控制的修正方法和面向公共用户的验证步骤操作,然后再返回排查数据包丢弃问题。

要实时查看连接尝试是否到达,请在主机上运行以下命令,同时从客户端重试连接——它只显示运行当时打开的套接字,因此无法说明已失败并关闭的连接尝试:

ss -tn | grep ':22 '
  • 存在条目 → 数据包已到达,且 sshd 正在关闭连接。这是服务器端问题(MaxStartups 耗尽、AllowUsers/DenyUsers、hosts.deny、磁盘已满、fork 失败),不是隧道问题,也不属于本技能的范围。
  • 没有与你的连接尝试对应的条目 → 不要据此断定“数据包从未到达”。 日志中没有条目,只能证明没有任何内容到达 sshd;主机本地的数据包过滤器也会产生同样的结果。两者的修复方法截然不同,只需运行一条命令即可区分:
# on the host, while the client retries; needs root
timeout 20 tcpdump -nni any "tcp port 22 and host <your-egress-ip>" -c 10
ss -tan '( sport = :22 )'          # look for SYN-RECV, not just LISTEN
  • tcpdump 中出现 SYN,但 ss 只显示 LISTEN,没有 SYN-RECV → 数据包确实已到达,但在套接字层之前被主机本地防火墙丢弃。网络路径没有问题;请停止排查隧道,转到下方的主机防火墙检查。
  • tcpdump 中完全没有 SYN → 现在你才可以认定数据包“从未到达”。阻断点位于主机之前:可能是云防火墙/网络 ACL,也可能是你自己的 TUN。继续查看下文。

⚠️ 重传的 SYN(每秒出现一次相同的序列号)是静默丢弃的明显迹象;被拒绝的连接会产生 RST,而关闭的端口则会立即产生 RST。

如果数据包没有到达,请检查允许列表中的地址是否与你的实际出口 IP 一致。 使用 TUN 时,curl ifconfig.me 返回的是代理出口地址,而不是目标端防火墙看到的地址。权威值是服务提供商为你自己的 API 调用记录的源 IP:

# Alibaba Cloud example. Do NOT grep for '"sourceIpAddress":"' — the CLI pretty-prints with a
# space after the colon, so that pattern silently matches nothing. Parse the JSON instead.
aliyun actiontrail LookupEvents --RegionId <region> --MaxResults 20   | python3 -c 'import json,sys,re; d=json.load(sys.stdin);       print("\n".join(sorted({e.get("sourceIpAddress","") for e in d.get("Events",[])
            if re.fullmatch(r"[0-9.]+", e.get("sourceIpAddress",""))})))'

IP 过滤器是关键:内部服务间事件在该字段中携带的是主机名(例如 rmc.resourcemanager.aliyuncs.com),而不是你的地址。

⚠️ 只有当你的服务提供商 API 流量和被阻断的连接经过相同的转发路径(两者都直连,或两者都经过代理)时,这个值才是权威的。步骤 2J 解释了原因:TUN 会按规则路由,因此服务提供商的 API 域名和你的目标主机可能经过不同的路径,拥有不同的出口地址——在这种情况下,记录的 IP 并不是目标端防火墙看到的 IP。请先确认两者匹配同一条规则,再相信这个比较结果。

通常有两个允许列表,修好其中一个并不代表问题已解决。 云主机通常会在两处限制端口:一处是服务提供商的安全组(机器之外),另一处是主机自身的防火墙(ufw、firewalld、原始的 iptables/nftables)。它们由不同的工具编辑,规则会各自独立变动,添加到其中一处的规则在另一处不可见。请务必同时检查两处:

# outside the machine — provider security group (Alibaba Cloud shown)
aliyun ecs DescribeSecurityGroupAttribute --RegionId <region> --SecurityGroupId <sg-id>

# inside the machine, via the out-of-band channel — host firewall
ufw status verbose 2>/dev/null            # Debian/Ubuntu, if ufw is in use
firewall-cmd --list-all 2>/dev/null       # RHEL family
iptables -S 2>/dev/null | head -30        # raw rules, and the INPUT policy
nft list ruleset 2>/dev/null | head -60
  • 你当前的出口 IP 不在任一列表中 → 这就是问题所在。将其添加到对应层(安全组:aliyun ecs AuthorizeSecurityGroup,撤销时使用 RevokeSecurityGroup;ufw allow from <ip> to any port <n> proto tcp,撤销时使用 ufw delete allow ...)。绑定到动态住宅或移动地址的允许列表会悄无声息地失效:规则看起来仍然正确,而你的地址变更那天不会留下任何日志。
  • 安全组中有你的 IP,但数据包仍然无法到达 sshd → 前一条中较早的表述对此情况判断有误。这并不意味着阻断发生在本地。先运行上面的 tcpdump + ss 命令组合:如果 SYN 已到达,但没有 SYN-RECV,说明是主机防火墙在丢弃数据包;你在自己的机器上做再多处理也改变不了这一点。只有在 tcpdump 显示没有任何数据包到达时,阻断才发生在本地;然后继续执行下文。

⚠️ 先运行 iptables -S,不要只检查服务级工具。 默认的 -P INPUT DROP 规则加上显式放行 80/443,从各个角度看起来都像是一台健康的机器(此技能在其他地方检查的项目也全都正常——sshd 已启动并监听 0.0.0.0:22、配置有效、没有 fail2ban、hosts.deny 为空、磁盘和内存正常),但它仍会丢弃每个 SSH 数据包。跳过数据包过滤器,只检查 fail2ban 和 hosts.deny,是把一台被阻断的主机误判为“完全健康”的最简单方式。

在责怪自己的机器之前,换一个观测点。 如果有其他主机使用与你相同的出口(同一局域网中的另一台机器、手机热点、同事的笔记本电脑),但使用的不是你的网络栈,就从那里运行相同的探测。这是判断“问题在我还是在对方”的成本最低的对照方法,也是 TUN 无法干扰的手段,因为数据不会经过 TUN。两种结果,各有含义:

  • 干净的主机也失败,且其出口 IP 与你相同 → 目标端正在拒绝你的连接;问题从来就不在隧道。
  • 干净的主机连接成功 → 阻断确实发生在本地,而且你现在也有了一条可用路径(ssh -J <clean-host> <destination> 会转发 TCP,而你的密钥仍留在自己的机器上)。

第二个观测点不一定非得是另一台主机——换一个网络也可以(手机热点、另一个 WiFi)。2026-09-24:某家咖啡馆的 WiFi 将 JMS 的订阅域名、主站和所有节点都丢进了黑洞(连续 2 小时每次 TCP 连接都超时),这种表现与“服务提供商被 GFW 屏蔽”毫无二致——每一级自愈都失败了,因为每次修复操作都经过同一个场所的出口。切换 WiFi 后,一切恢复正常,配置没有任何改动。换个场所只需两分钟;在根据单一场所的探测结果断定“服务提供商宕机/被屏蔽”之前,先试试看。

通过明确要求代理转发,而不是让 TUN 拦截,来确认本地是否存在阻断:

ssh -o ProxyCommand="nc -X connect -x 127.0.0.1:<proxy-port> %h %p" <host>
  • HTTP/1.1 503 Service Unavailable(或 SOCKS error 1)→ 代理拒绝转发该端口。22 端口常见这种情况,因为许多规则集只允许 HTTP/HTTPS 端口。
  • 连接成功 → 代理能够传输此流量,因此阻断具体发生在 TUN 的自动拦截环节;下方的修复方法 1 是针对性的解决方案。

有两种修复方法:

  1. 在 TUN 工具中为该主机/端口添加 DIRECT 规则,然后重新测试。这需要更改他人的代理配置;如果代理不是你的,请先征得设备所有者同意。
  2. 更好的做法是,针对任何无人值守的任务,将自动化从 SSH 迁移到你刚刚使用的带外通道。它能应对这两种故障模式——允许列表失效以及代理拒绝该端口——而且不依赖任何本地网络状态。
步骤 2L:提供商级故障(同一提供商的所有节点都以相同方式卡住)

症状:某个提供商的每个节点都呈现相同特征——TCP 连接成功,但 TLS 握手始终无法完成(time_appconnect=0.000);重启本地代理应用和数据包隧道扩展也没有任何变化。通过代理访问时,所有境外目标都无法连接;直连规则流量仍正常。

为什么需要单独列出此步骤:这种情况看起来像步骤 2H/2J(本地 TUN 异常)和步骤 2K(单个目标的防火墙问题),但故障组件实际上是提供商的数据平面——本地修复都无济于事,而步骤 2K 中的场所测试已有结论:场所问题也会导致普通 TCP 连接失败,而这里的连接成功。唯一的本地修复方法是改用其他提供商的节点。

使用绕过 TUN 的探测,在约 10 秒内确认(使用 --interface en0 + --resolve,真实 IP 通过 dig @223.5.5.5 获取):

  1. 基线检查:通过相同物理路径访问的无关 TLS 主机可以响应(例如,api.github.com 返回 200)→ 网络链路和通用 TLS 正常。
  2. 同一提供商的普遍性:同一订阅下的两个或更多节点出现相同的 TLS 卡顿 → 问题在提供商级别,而非某个节点失效。
  3. 控制平面:直接访问订阅域名——521(Cloudflare 边缘节点正常,源站故障)表示提供商确认自身发生故障;200/302/404 则表示控制平面正常,本次事件仅影响数据平面。

修复方法:记录当前选择的节点(以便回滚),按相同方式探测其他订阅中的候选节点(time_appconnect 必须非零),然后切换。2026-10-22 实测:一家提供商的订阅源站返回 521,其全部五个 VLESS 节点都卡在 TLS 阶段;另一订阅的住宅 IP 专线正常,选中后数秒内便恢复了网络。若没有第二个订阅,诚实的判断就是“提供商故障——等待”;不要反复重启本地应用,也不要仅凭这一特征就认定“GFW 封锁了该提供商”(从客户端看来,提供商核心故障和协议级封锁无法区分——但切换到其他节点都能解决)。判定节点已恢复或失效,必须依据数据平面结果,而非中间层结果:TLS/Reality 再次响应并不能证明节点已恢复;切换后立即尝试一次浏览器访问失败,也不能证明节点已失效(客户端重连时可能会将所有流量黑洞 1–2 秒——请等待 5–10 秒)。通过或失败应以经由该节点的 url-test 探测结果为准。若要在不影响用户当前活动通道的情况下运行此测试,请使用带有延迟 API 的第二个本地客户端核心:references/node-liveness-verification.md(Shadowrocket 凭据提取、mihomo 模板、控制组校准及实测陷阱)。

步骤 3:修复代理工具配置

识别代理工具并应用相应的修复方法。各工具的详细说明请参阅 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>
# Should show Tailscale utun interface, NOT en0
步骤 4:配置 Tailscale SSH ACL

如果 SSH 可以连接,但返回 operation not permitted,Tailscale ACL 可能要求每次连接都通过浏览器进行身份验证。

在 Tailscale ACL 管理页面,确保 SSH 部分使用 "action": "accept":

"ssh": [
    {
        "action": "accept",
        "src": ["autogroup:member"],
        "dst": ["autogroup:self"],
        "users": ["autogroup:nonroot", "root"]
    }
]

注意:"action": "check" 要求每次连接都进行浏览器身份验证。要使用非交互式 SSH 访问,请改为 "accept"。

步骤 5:修复 WSL 中的 Tailscale 安装

如果 SSH 可以连接且 ACL 检查通过,但 tailscaled 日志中出现 be-child ssh 退出代码 1 的错误,则说明通过 snap 安装的 Tailscale 存在沙箱限制,导致无法执行 SSH shell。

诊断 — 检查 WSL 中的 tailscaled 日志:

# For snap installs:
sudo journalctl -u snap.tailscale.tailscaled -n 30 --no-pager

# For apt installs:
sudo journalctl -u tailscaled -n 30 --no-pager

查找:

access granted to [email protected] as ssh-user "username"
starting non-pty command: [/snap/tailscale/.../tailscaled be-child ssh ...]
Wait: code=1

修复 — 将 snap 安装替换为 apt 安装:

# 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

重要:新安装可能会分配不同的 Tailscale IP。使用 tailscale status --self 检查。

步骤 5A:修复 WSL 上 Tailscale SSH 代理无响应故障

症状:TCP 端口 22 可访问(nc -z -w 5 <ip> 22 成功),但 SSH 立即失败,并显示:

kex_exchange_identification: Connection closed by remote host

始终不会收到 SSH banner。即使通过 apt 安装 Tailscale(而非 snap)也会出现此问题。

根本原因:在 WSL 上启用 tailscale up --ssh 时,Tailscale 会在应用层(内核网络栈之上)拦截端口 22 的连接。如果 Tailscale 内置的 SSH 代理发生故障,它会接受 TCP 连接,但在发送 SSH banner 前立即关闭连接。

关键诊断 — 在 WSL 实例上:

# This will show 0 packets even during active SSH attempts
sudo tcpdump -i any port 22 -c 5 -w /dev/null 2>&1

没有数据包意味着 Tailscale 正在连接到达内核网络栈之前拦截它们。内核中的 sshd 从未收到连接。

与步骤 5 的区别:步骤 5 介绍 snap 沙箱问题,即 be-child ssh 失败。这是另一种问题——无论采用何种安装方式,Tailscale 的 SSH 代理本身都会无响应地失败。

修复 — 禁用 Tailscale 的 SSH 代理,并使用常规 sshd:

# On the WSL instance:
sudo tailscale up --ssh=false

# Verify sshd is running
sudo service ssh status
# If not running:
sudo service ssh start

# Verify from the client machine:
ssh -o ConnectTimeout=10 <user>@<tailscale-ip> 'echo SSH_OK'

禁用 Tailscale SSH 后,连接会像往常一样经由内核网络栈到达 sshd。步骤 4 中的 Tailscale ACL "action": "accept" 不再适用——身份验证改由 sshd 使用 SSH 密钥或密码处理。

何时保留 --ssh:只有在确实需要 Tailscale 的 SSH 功能(基于 ACL 的访问控制、无需管理 SSH 密钥)时才保留。如果常规 sshd 可以正常工作,为提高可靠性,建议使用 --ssh=false。

步骤 5B:修复 macOS 上的 App Store 版 Tailscale(缺少 tailscale ssh)

症状:运行 tailscale ssh 会返回:

The 'tailscale ssh' subcommand is not available on macOS builds
distributed through the App Store or TestFlight.

根本原因:macOS 的 App Store 版 Tailscale 受到沙箱限制,且不包含 tailscale ssh 子命令。

修复 — 安装独立版:

  1. 卸载 App Store 版(从 /Applications 中删除)
  2. 从 https://pkgs.tailscale.com/stable/#macos 下载独立版
  3. 安装到 /Applications

安装后配置 CLI:独立版 tailscale CLI 二进制文件嵌入在应用程序包中。在 shell 配置文件中添加别名:

# ~/.zshrc
alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"

验证:

source ~/.zshrc
tailscale version
tailscale ssh <user>@<hostname>   # Should work now
第 5C 步:Windows 主机 TUN 代理导致整台机器断网(包括 WSL 及其 Tailscale)

症状:在 Windows + WSL2 工作站上,所有网络同时中断——国内和海外、主机和 WSL 都无法联网,Tailscale 也无法连接(“连 Tailscale 都挂了”)。关闭主机的 TUN 代理(v2rayN sing-box TUN、Clash TUN)后即可恢复,而不是切换网卡。

根因概况:WSL2 NAT 没有自己的网络路径;主机 TUN 接管默认路由,因此失效的代理节点会导致主机以及其后的所有设备(包括 WSL 中 tailscaled 的控制平面连接)都无法联网。这与 macOS 的脑裂情况(第 2J 步)不同:两个平面都会中断,而不是只有一个。

此场景有单独的参考文档,涵盖本 SKILL.md 未重复的四个方面:通过事件日志时间线确认多个恢复操作中究竟是哪一个修复了网络(Microsoft-Windows-NetworkProfile/Operational 事件 10000/10001 会标出 TUN 适配器,例如 singbox_tun);三个会在诊断时悄无声息地产生假“无输出”的 WSL→Windows 互操作问题(PATH、GBK 代码页、无 TTY 管道中断);当只有一个 tailscaled 实际在线时,如何区分 Windows 中的 tailscaled 和 WSL 中的 tailscaled(Tailscale 适配器上的 APIPA 169.254.x.x 表示引擎从未启动);以及预防方法(从 WSL 使用按进程设置的代理,而不是主机 TUN)。

诊断此类情况前,请先阅读 references/windows_host_tun_wsl_cascade.md。

第 6 步:端到端验证

运行完整的连通性测试:

# 1. Check route is correct (must show Tailscale's utun, not en0 or Shadowrocket's utun)
route -n get <tailscale-ip>
# Also confirm which utun is Tailscale's:
ifconfig | grep -A2 'inet 100\.'

# 2. Test TCP connectivity
nc -z -w 5 <tailscale-ip> 22

# 3. Test SSH
ssh -T -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=yes <approved-ssh-alias> 'echo SSH_OK && hostname && whoami'

针对选定的在线对端解析已批准的别名。更改其地址族时,请按照同一对端 SSH 恢复流程保留已验证的主机密钥身份;不要禁用主机密钥检查。若密钥未知或已更改,必须先独立核验身份,然后再继续。

三项检查都必须通过。如果第 1 步失败,请重新检查第 3 步。如果第 1 步显示错误的 utun(例如 Shadowrocket 的 utun,MTU 为 4064,而不是 Tailscale 的 MTU 1280),这也属于路由冲突。如果第 2 步通过,但第 3 步因 kex_exchange_identification 失败,请重新检查第 5A 步(Tailscale SSH 代理拦截)。如果第 2 步失败,请检查 WSL sshd 或防火墙。如果第 3 步因其他错误失败,请重新检查第 4 至第 5 步。

对于 DNS 相关修复(第 2I 步),以上三步并不足够,因为它们未涵盖系统 DNS 恢复。请改用第 2I 步末尾的四维验证:守护进程健康状态、逐个解析器运行 dig、dscacheutil,以及不使用任何变通方案运行最初失败的命令。

SOP:通过 Tailscale 进行远程开发

通过 Tailscale 和代理工具进行远程开发的主动配置指南。请在遇到问题之前完成以下步骤。

前提条件
  • 两台机器都已安装并运行 Tailscale
  • 代理工具(Shadowrocket/Clash/Surge)已配置为兼容 Tailscale(参见上文第 3 步)
  • SSH 连接正常:ssh <tailscale-ip> 'echo ok'
1. 代理安全的 Makefile 模式

任何会对 localhost 执行 curl 的 Makefile 目标都必须使用 --noproxy localhost。这是必需的,因为 http_proxy 通常会在 ~/.zshrc 中全局设置(在中国很常见),而 Make 会继承 Shell 环境变量。

## ── 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."

规则:

  • 每次调用 curl http://localhost 都必须包含 --noproxy localhost
  • Docker 命令(docker exec)不受 http_proxy 影响,无需修复
  • redis-cli、pg_isready 通过 TCP 直接连接,无需修复
2. SSH 隧道 Makefile 目标

添加以下目标,以便通过 Tailscale SSH 隧道进行远程开发:

## ── 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)'"

设计决策:

选择原因
?=(条件赋值)允许覆盖: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)

3. 多端口隧道

当项目需要多个服务时(开发服务器 + 对象存储 + API 网关):

# 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

每个 -L 标志都是独立的。如果某个端口已在本地被占用,ExitOnForwardFailure=yes 会中止整个隧道——请先解决端口冲突。

4. SSH 非登录 Shell 设置

这经常导致“交互使用正常,但在脚本中失败”的问题。 SSH 非登录 Shell 不会加载 ~/.zshrc(Linux 上则不会加载 ~/.bashrc),因此通过 nvm、Homebrew、uv、cargo 或任何 Shell 级管理器安装的工具都不会出现在 $PATH 中。设置在 ~/.zshrc 中的代理环境变量也不会加载。

这会影响通过 ssh user@host "command" 运行的所有远程命令,包括 CI/CD 流水线、由 cron 触发的 SSH,以及 Makefile 远程目标。请在所有远程命令前加上 source ~/.zshrc 2>/dev/null;(macOS)或 source ~/.bashrc 2>/dev/null;(Linux/WSL)。

常见故障:即使命令在交互式 SSH 会话中可以正常运行,ssh user@host "uv run ..." 或 ssh user@host "node ..." 仍会返回 command not found。

详情和示例请参阅 references/proxy_conflict_reference.md § SSH 非登录 Shell 陷阱。

对于运行远程命令的 Makefile 目标:

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")
5. 端到端工作流
首次设置(远程机器)
# 1. Clone repo and install dependencies
ssh <tailscale-ip>
cd /path/to/project
git clone [email protected]: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

关键在于:.env 中的 APP_URL=http://localhost:3010 对本地和远程开发都适用。SSH 隧道会让本地机器能够访问远程服务器的 localhost。身份验证回调始终重定向到 localhost:3010,因此总能正确解析。

6. 检查清单

开始远程开发前,请确认:

  • 已连接 Tailscale:tailscale status
  • SSH 可用:ssh <tailscale-ip> 'echo ok'
  • 已配置代理工具:[Rule] 中包含 IP-CIDR,100.64.0.0/10,DIRECT
  • skip-proxy 包含 100.64.0.0/10
  • tun-excluded-routes 不包含 100.64.0.0/10
  • NO_PROXY 包含 .ts.net,100.64.0.0/10
  • 已安装 autossh:which autossh
  • Makefile 中的 curl 命令带有 --noproxy localhost
  • 远程开发服务器正在运行:ssh <ip> 'source ~/.zshrc 2>/dev/null; curl --noproxy localhost -sf http://localhost:3010/'
  • 隧道可用:make tunnel-bg && curl -sf http://localhost:3010/

参考资料

评论

支持 Markdown