在 Tailscale 子网路由器上关闭源 NAT 后,隧道继续工作,但经过它的连接却停了。请求带着远端客户端自己的 overlay 地址到达目标,这正是此次改动意义所在,而回复现在需要一条 LAN 从未需要了解的路径。没有这条路径,数据包落地了,回复却从普通网关离开,故障看起来就像一个任何防火墙规则都解释不了的防火墙问题。隧道是健康的;缺失的是回程。

SNAT 曾经掩盖了什么

启用 SNAT 时,路由器在转发进子网之前会把源地址改写为自己的 LAN 地址,于是目标回复给一个它本来就知道如何到达的主机,其他什么都不用配置。关闭后原始源地址得以保留,目标看到的是 overlay 范围内的地址,而不是自己网线上的邻居。这个地址真实存在且在整个 tailnet 内可路由,但从 LAN 经默认网关无法到达。文档中给出的开关是 --snat-subnet-routes=false,作用于 Linux 子网路由器本身。

这是常设设置而非逐包开关,因此该路由器服务的每个目标网络都必须具备回程路径。正因如此,这项改动是部署决策而不是一个复选框。该路由器服务的所有对象在同一刻继承这一要求。

回程路径可以放在哪里

常见的放置位置有三种,差别在于覆盖范围而非正确性。目标主机上的静态路由最直接、最不可扩展,因为每台设备都需要自己的一份。VPC 或站点路由器中的路由表一次覆盖整个网络,而携带无类静态路由的 DHCP 选项会自动送达客户端,却会在忽略它的设备上悄悄失效。

ip route add 100.64.0.0/10 via 192.168.1.10
ip route get 100.64.0.5

无论选择哪种放置位置,下一跳都是路由器的 LAN 地址,绝不是它的 tailnet 地址。LAN 主机在拥有这条路由之前无法到达 overlay,所以把下一跳放在 overlay 内部是循环的,数据包哪也去不了。正是这一个错误解释了大多数看似正确却仍然失败的配置,而且它能躲过所有只读取路由器自身路由表的检查。记下你用了哪种放置位置,否则下一个人会去测错误的那一种。

把故障读成单向流量

症状比原因更容易辨认:服务在路由器本身上有响应,从远端客户端却沉默。在目标处抓包会看到请求进来、回复出去,看起来像成功,直到你注意到这些回复去了哪里。它们正去往默认网关,这说明入程正常而回程不正常。

不如改在路由器上抓包,因为它能同时看到一次交互的两个方向,并显示回复究竟有没有到达。如果回复在目标处出现却在路由器上从未出现,说明目标侧的路由缺失或错误。如果它出现在路由器上并在那里停下,问题就在更远处,属于另一项排查。

sudo tcpdump -ni any host 100.64.0.5
ip route get 100.64.0.5

真实地址是否值得一条路由

只要目标网络无法重新配置,保留 SNAT 就仍是更简单的答案,代价是每个客户端在目标的日志和策略里都长得像路由器。当应用需要真实源地址,或网络策略必须区分共用一台路由器的客户端时,关闭它才合理。两者都是正当理由,而且都不是免费的。收益必须值得在每个目标网络上维护一条路由,再加下文的防火墙复核。

对一个小型 LAN 来说,这笔交换通常划算;对一大片无法添加静态路由的设备来说,通常不划算。把决定和理由记在路由器配置旁边,下一位管理员才知道哪种行为是有意为之。没人写下来的默认值,和疏忽没有区别。

防火墙、日志与双重角色

原本匹配路由器 LAN 地址的每条规则,现在都匹配另一个源地址,包括放行规则和任何把路由器记为客户端的日志字段。要在第一次测试之前读这些规则,而不是在第一次失败之后,因为只允许路由器地址的规则会直接挡住新源地址。把替换范围缩小到真正需要的客户端和端口,然后确认它没有悄悄放行整个 tailnet。

sudo nft list ruleset | grep -n '100\.64'
sudo iptables-save | grep -n '100\.64'

同一个节点既当出口节点又当子网路由器,是有文档记载的组合,一旦禁用子网 SNAT 就可能丢弃上行流量。把这些角色拆到不同节点上就能消除这一注意事项,并让每条路由和防火墙策略只有一个负责人。如果角色必须留在一起,就应预期这种行为,并在责怪客户端之前先测试。把路由、开关设置和角色分配记在同一份笔记里,因为下一次连接失败时它们会被一起翻出来。

相关文章