家庭网络分层拓扑(二):一条策略路由如何让 VPN 网关人间蒸发

目录 / Step 导航

上一篇《家庭网络分层拓扑:从远端笔记本到双出口的完整链路》讲了这套拓扑该怎么搭。这一篇讲它怎么坏的——一个只错了一行、却让人绕了整整一晚的故障。

最讽刺的地方在于:上一篇里已经写对了那一行,落地时却没抄进去。 而上一篇紧接着的另一句解释,恰好让那一行看起来可以省。

文中所有公网 IP、商业服务商品牌、个人域名与主机名均已抹去,只保留可复用的结构。


一、TL;DR

VPN 客户端可以正常上网、可以访问家里任何一台内网机器,唯独访问不了 VPN 网关 172.x.y.1 本身——ping 不通,全端口静默。

排查过程中先后怀疑并证伪了:客户端防火墙、DSM 防火墙(把整个防火墙关掉照样不通)、"网关和 NAS 不是同一台机器"。

真正的根因是 NAS 上这张表:

ip rule:    1: from 172.x.y.0/24 lookup 100
table 100:  default via 192.168.0.254 dev ovs_eth0     ← 只有这一条

NAS 回复 VPN 客户端时,回包的源地址是 172.x.y.1,它落在 172.x.y.0/24 里。 于是回包命中这条规则、去查 table 100、只匹配到那条 default,被送去了旁路网关。而旁路网关没有回 VPN 网段的路由,包就此消失。

请求进得来,回包出不去。修复是补回上一篇里就写着的那条回程路由:

ip route replace 172.x.y.0/24 dev tun0 src 172.x.y.1 table 100

二、症状

远端笔记本通过 OpenVPN 拨回家,拿到 172.x.y.2/24,网关 172.x.y.1。一切正常:

隧道本身健康得很——tun0 上已经跑了 1.4 GB。就是网关这一个地址,像不存在一样。


三、排查:三个被证伪的假设

这次排查真正的价值不在结论,而在排除的过程。我依次相信过三个假设,全是错的。

3.1 假设一:客户端侧的问题

第一反应总是查本机。全部排除:

检查项 结果
tun0 状态 UP,累计 RX 1.4 GB / TX 838 MB
默认路由 ip route get 8.8.8.8via 172.x.y.1 dev tun0
本机防火墙 ufw-user-input 里有 iifname "tun0" accept,已放行 346 个包
rp_filter = 2(loose),不会丢反向路径

tcpdump -i tun0 确认 ICMP echo request 确实从客户端发出去了,零回包。问题在对端。

3.2 假设二:NAS 防火墙 —— 证伪

这是最自然的猜测,而且证据看起来严丝合缝:群晖 DSM 的防火墙规则是按网络界面分档的,VPN 接口那一栏如果没有放行规则就落到默认拒绝;而转发流量走 FORWARD + NAT,不受 INPUT 规则影响——完美解释了"能上网但访问不了网关本身"。

于是把整个防火墙关掉

结果:一模一样,还是不通。

这个假设死得干干净净。后来上机验尸也确认了它从头到尾就是无辜的:

Chain INPUT   (policy ACCEPT)
Chain FORWARD (policy ACCEPT)
Chain OUTPUT  (policy ACCEPT)

Chain DOS_PROTECT (1 references)
  DROP icmp -- ovs_eth0 * ...        ← 全部限定 in ovs_eth0,tun0 流量根本不匹配
  DROP tcp  -- ovs_eth0 * ...

Chain MAILSERVER_PLUS (1 references)
  DROP tcp -- * * multiport dports 5230,5252,8500:8507,...   ← 只 DROP 邮件端口

三条主链全是 policy ACCEPT,两个自定义链一个只管 ovs_eth0、一个只管邮件端口。没有任何一条规则能碰到发往 172.x.y.1 的包。

教训一:一个假设能解释所有已知现象,不代表它是对的。 它只是还没被证伪而已。要证伪它,就得去做那个能区分真假的实验——这里就是"把防火墙整个关掉",而不是继续在规则里微调。

3.3 假设三:搞错了机器 —— 证伪

既然 NAS 的 LAN 地址 192.168.0.4 一切正常,而 172.x.y.1 全死,那会不会它俩根本不是同一台机器?也许 VPN 服务跑在别的设备上,我一直在错误的机器上关防火墙。

用 TTL 判定:

$ ping -c 2 -t 1 192.168.0.4
64 bytes from 192.168.0.4: icmp_seq=1 ttl=64 time=7.37 ms      ← TTL=1 就到了

$ ping -c 1 192.168.0.244
64 bytes from 192.168.0.244: icmp_seq=1 ttl=63 time=12.2 ms    ← 对照:后面还有一跳

TTL=1 的包穿不过任何路由器(路由器会把它减到 0 然后丢弃)。它能被 192.168.0.4 收到并回复,只有一种解释:这个地址就在隧道的对端本机上。假设三出局。

3.4 转折点一:filtered 还是 closed

/dev/tcp 这种土办法只能告诉你"连不上",区分不了被丢弃没人监听。nmap 可以:

$ nmap -Pn --reason -sS -p 22,80,443,5000,8080 172.x.y.1
22/tcp   filtered  ssh            no-response
80/tcp   filtered  http           no-response
443/tcp  filtered  https          no-response
5000/tcp filtered  upnp           no-response
8080/tcp filtered  http-proxy     no-response

$ nmap -Pn --reason -sS -p 22,80,443,5000,8080 192.168.0.4    # 同一台机器
22/tcp   open      ssh            syn-ack ttl 64
80/tcp   open      http           syn-ack ttl 64
443/tcp  open      https          syn-ack ttl 64
5000/tcp open      upnp           syn-ack ttl 64
8080/tcp closed    http-proxy     reset ttl 64                 ← 注意这一格

192.168.0.4:8080 回了 RST。这说明内核在正常处理发往那个地址的包,没监听的端口就规规矩矩拒绝。

172.x.y.1 上,连 22/80/443/5000 这些已经证明开着的端口都零响应。sshd 和 DSM 都是 bind 在 0.0.0.0 上的,只要包带着一个本机地址进了内核,它们必然应答。

结论:包没进到 TCP 协议栈。

3.5 转折点二:在服务端抓包

到这一步只能上 NAS 了。同时 ping 两个地址,在 NAS 的 tun0 上抓:

$ tcpdump -nli tun0 "icmp or tcp port 5000"

172.x.y.2 > 172.x.y.1: ICMP echo request, seq 1        ← 到了
172.x.y.2 > 172.x.y.1: ICMP echo request, seq 2        ← 到了
172.x.y.2 > 172.x.y.1: ICMP echo request, seq 3        ← 到了,一个回包都没有
172.x.y.2.59692 > 172.x.y.1.5000: Flags [S]            ← 到了
172.x.y.2.59692 > 172.x.y.1.5000: Flags [S]            ← 到了,没有 SYN-ACK

172.x.y.2 > 192.168.0.4: ICMP echo request, seq 1
192.168.0.4 > 172.x.y.2: ICMP echo reply,   seq 1       ← 同一接口,秒回
172.x.y.2.40308 > 192.168.0.4.5000: Flags [S]
192.168.0.4.5000 > 172.x.y.2: Flags [S.]                ← SYN-ACK

请求包完好无损地到达了内核,OpenVPN 交付无误,iptables 是 ACCEPT,但内核一声不吭。

这一刻方向变了:问题不在入方向,在出方向

3.6 定位:ip route get

Linux 的路由查找是可以直接问的。把三种情况并排一比,答案就跳出来了:

# 回包(源地址 = VPN 网关自己)
$ ip route get 172.x.y.2 from 172.x.y.1
172.x.y.2 from 172.x.y.1 via 192.168.0.254 dev ovs_eth0  table 100    ← 走局域网了!

# 对照:源地址 = NAS 的 LAN 地址
$ ip route get 172.x.y.2 from 192.168.0.4
172.x.y.2 from 192.168.0.4 dev tun0                                    ← 正常

# 对照:不指定源地址
$ ip route get 172.x.y.2
172.x.y.2 dev tun0  src 172.x.y.1                                      ← 正常

找到了。


四、根因

4.1 机制

上一篇里,为了让 VPN 客户端享受和家里设备一致的分流体验,做了源地址策略路由:凡是源地址属于 VPN 网段的流量,默认路由换成旁路网关。

问题在于,NAS 有两个地址:LAN 侧 192.168.0.4,以及 tun0 上的 172.x.y.1。当它回复一个发往 172.x.y.1 的请求时,回包的源地址必须是 172.x.y.1(否则四元组对不上)。而 172.x.y.1 就在 172.x.y.0/24——它命中了那条规则:

回包 src=172.x.y.1 dst=172.x.y.2
  → ip rule 1: from 172.x.y.0/24   ✓ 命中
  → 查 table 100
  → 表里只有 default via 192.168.0.254
  → 回包被从 ovs_eth0 发给旁路网关
  → 旁路网关没有回 172.x.y.0/24 的路由
  → 丢弃

再往深一层:策略路由表一旦被规则命中,就自成体系了。 main 表里那条 172.x.y.0/24 dev tun0 完全帮不上忙,因为查找压根不会走到 main。而 table 100 里只有一条默认路由——一张只有 default 的路由表,意味着"任何目的地址都往下一跳扔",包括那些本该直连的目的地。

4.2 真正的教训:文档是对的,落地漏了一行

这才是这次故障最值得记的部分。

上一篇原文给的命令是两条

# 1. 独立路由表:VPN 客户端的"默认路由"指向旁路网关(而不是上游网关)
ip route add default via 192.168.0.254 dev <内网接口> table 100
ip route add 172.x.y.0/24 dev tun0 table 100   # 回程路由也要在这张表里

第二行就是修复本身。 它当时就写在那里,还带着注释。

而实际落地到 NAS 开机任务里的脚本,是这样的:

#!/bin/sh
sleep 30
ip route replace default via 192.168.0.254 table 100
ip rule show | grep -q "from 172.x.y.0/24 lookup 100" || ip rule add from 172.x.y.0/24 table 100

回程路由那一行没了。

为什么会漏?因为上一篇紧接着的解释是这么写的:

这段话让人相信:table 100 是给客户端用的,NAS 自己永远不会查它。 既然如此,往一张"客户端专用表"里放一条 VPN 网段的路由,看起来就像是冗余——客户端本来就在那个网段里,它发给同网段的包根本不会走三层。于是抄写脚本时顺手省掉了。

问题是那句话不完整:NAS 自己的流量里,有一类的源地址不是内网地址,而是 172.x.y.1。这类流量恰好就是"所有对 VPN 客户端的应答"。

教训二:一条配置为什么存在,比这条配置是什么更重要。 上一篇写了"回程路由也要在这张表里",但没写"给谁回程"。注释解释了 What,没解释 Why,于是在落地时被当成冗余删掉了。凡是看起来冗余的配置,要么补上它存在的理由,要么它迟早会被某个人(包括三个月后的你自己)删掉。

教训三:策略路由表不会 fallback 到 main。 一张只有 default 的表会把所有流量都往下一跳扔,包括本机的应答。凡是这张表的使用者需要访问的直连网段,都得显式写进去。

4.3 为什么 192.168.0.4 一直是好的

因为它命中的是另一条规则(DSM 自己维护的):

7: from 192.168.0.4 lookup ovs_eth0-table

ovs_eth0-table 里只有 192.168.0.0/24匹配不到 172.x.y.2。策略路由的规则链在"查表未命中"时会继续往下走,于是落到 32766: from all lookup main,main 表里有 172.x.y.0/24 dev tun0 —— 正确出隧道。

同一台机器,两个源地址,两条完全不同的命运。

4.4 最精彩的细节:它能主动发包,却收不了包

排查中期有个现象一度让我判断失误——172.x.y.1 能给客户端发 ICMP

$ ping -t 1 8.8.8.8
From 172.x.y.1 icmp_seq=1 Time to live exceeded      ← 它活着,而且能发包给我

TTL 耗尽时,NAS 用 172.x.y.1 作源地址发回了 time-exceeded,一路顺利到达客户端。既然它能发,为什么 echo reply 发不出来?

区别在于源地址是什么时候被绑定的

这正是 ip route get 172.x.y.2(不带 from)返回 dev tun0、而 ip route get 172.x.y.2 from 172.x.y.1 返回 via 192.168.0.254 的原因。

教训四:出入不对称是源地址策略路由的指纹。 一个地址"能主动发、不能被访问",几乎必然是出方向的源地址路由问题,而不是入方向的过滤问题。下次先跑 ip route get <对端> from <本机该地址>,能省几小时。


五、修复

table 100 必须补全直连路由:

ip route replace 172.x.y.0/24  dev tun0     src 172.x.y.1  table 100   # 关键
ip route replace 192.168.0.0/24 dev ovs_eth0 src 192.168.0.4 table 100 # 建议
ip route replace default via 192.168.0.254   dev ovs_eth0    table 100

第一条是修复本身。第二条是顺手优化:在此之前,VPN 客户端访问家里其他机器要先绕到旁路网关再拐回来(实测多一跳,回包 ttl=63),还要白白穿一遍透明代理的 netfilter 路径;加上直连路由后走直连。

完整的落地脚本(群晖「控制面板 → 任务计划 → 用户定义的脚本」,用户选 root):

#!/bin/sh
VPN_NET=172.x.y.0/24
TUN_IP=172.x.y.1
TUN_DEV=tun0
LAN_NET=192.168.0.0/24
LAN_IP=192.168.0.4
LAN_DEV=ovs_eth0
PROXY_GW=192.168.0.254
RT=100
PREF=1

# ---- 不依赖 tun0,立刻可做 ----
ip route replace default via "$PROXY_GW" dev "$LAN_DEV"  table "$RT"
ip route replace "$LAN_NET" dev "$LAN_DEV" src "$LAN_IP" table "$RT"

# 显式指定 pref。不写的话由内核自动分配,那是运气不是设计。
ip rule list | grep -q "from ${VPN_NET}" ||
    ip rule add from "$VPN_NET" table "$RT" pref "$PREF"

# ---- 依赖 tun0:等它真正带上地址,最多 60 秒 ----
# 不能用固定 sleep:早了 tun0 还没起,下面这条会失败;晚了纯属白等。
n=0
until ip addr show "$TUN_DEV" 2>/dev/null | grep -q "inet ${TUN_IP}/"; do
    n=$((n + 1))
    [ "$n" -ge 60 ] && exit 0
    sleep 1
done

# 修复点:回程路由。少了它,NAS 从 172.x.y.1 发出的回包会命中上面那条 rule,
# 在 table 100 里只匹配到 default,被甩给旁路网关,再也回不到隧道。
ip route replace "$VPN_NET" dev "$TUN_DEV" src "$TUN_IP" table "$RT"

ip route flush cache
exit 0

相比最初那版,有几处值得单独说:

改动 原因
补回 172.x.y.0/24 dev tun0 本文的全部内容
sleep 30 → 轮询等 tun0 带上地址 固定 sleep 是赌运气:早了 ip route ... dev tun0 src 172.x.y.1 直接失败(源地址还不是本机地址),晚了白等。改成轮询后,脚本才具备"可反复执行"的前提
ip rule add 显式写 pref 不写 pref 时由内核自动分配。我这次恰好拿到 1(排在其他规则之前),换个加载顺序落到别的位置,匹配行为就变了
exit 0 如果这脚本被用作 OpenVPN 的 route-up 钩子,返回非 0 会让 OpenVPN 直接退出
grep -q "from ${VPN_NET}" 只匹配源前缀 不匹配 lookup 部分。否则一旦给 table 100 在 /etc/iproute2/rt_tables 里起了名字,输出会从 lookup 100 变成 lookup <name>,幂等判断失效,重复执行会不断累积规则

验证:

$ ping -c 3 172.x.y.1
3 packets transmitted, 3 received, 0% packet loss
rtt min/avg/max/mdev = 8.150/8.496/9.049/0.395 ms

$ ip route get 172.x.y.2 from 172.x.y.1
172.x.y.2 from 172.x.y.1 dev tun0  table 100        ← 留在隧道里了

防火墙重新启用后复测,不受影响。


六、实测:route-up 在 server 模式下根本不触发

修好之后还剩一个问题:这条回程路由该挂在哪里才不会再丢

NAS 上原本挂着一个 OpenVPN 的 route-up 钩子脚本,内容正是那条回程路由命令。如果它可靠,那就够了,不需要别的。但故障发生时它加的路由并不在表里——所以它到底有没有被执行过,必须实测。

实验设计

一次重启,同时回答两个问题:钩子有没有被调用路由会不会自己回来

  1. route-up.sh 插两条互相独立的埋点:写文件 + logger 进 syslog。任何一条留下记录,就证明它被执行过。
  2. 删掉 table 100 里的回程路由。
  3. 重启 VPN 服务。
  4. 每秒采样一次 table 100,连续 150 秒,记录路由首次出现的精确秒数。

因为重启会掐断隧道(而我正是从这条隧道连进 NAS 的),整个流程写成脚本用 setsid nohup 脱离终端执行,结果落盘,等隧道恢复后再回来取。

结果

[01:21:15] del rc=0 ; table100 条数=2          ← 回程路由已删除
[01:21:15] >>> synopkg restart VPNCenter
           restart package [VPNCenter] successfully
[01:22:21] <<< restart rc=0                    ← 服务确实重启,openvpn 新 PID @ 01:22:00
[01:24:53] *** 150 秒内路由从未出现 ***
--- route-up 自证日志 ---                       (空)
--- syslog 中的埋点 ---                          (空)

两条独立埋点全部为空。而同一个脚本手工执行完全正常:

[01:19:46] route-up FIRED  dev=tun0 ifconfig_local=172.x.y.1
[01:19:46] route-up ip-route rc=0

所以不是脚本坏了,是 OpenVPN 压根没有调用它

原因大概率在于 --route-up 的语义是"路由添加完成后执行"。一个 --server 实例并不为自己添加客户端路由,这个钩子自然就没有触发点。文档里它看起来像个通用的"隧道起来后执行"钩子,实际上不是。

教训五:没验证过的兜底,比没有兜底更危险。 这个脚本躺在那儿好几天,看起来像一层保险,实际从未执行过一次。它唯一的作用是让人误以为问题已经被兜住了——包括写它的人自己。任何自动恢复机制,都必须真的触发一次给你看。

顺带排除的几种可能

可能性 排除方式
DSM 开机任务补上的 last_start_time 仍停在上次开机,期间没有重跑
有什么在轮询 删掉后静置 30 秒,路由始终没有回来
表号撞车(DSM 也在用 100) /etc/iproute2/rt_tables 里只有 2/3/5/7/8,没有 100
别的脚本在写这张表 全盘 grep table 100、VPN 套件启停脚本、crontab,均无结果

一个没能复现的矛盾

诚实记录:中间还有一轮 5 秒粒度的测试,路由曾在重启后约 28 秒出现过一次。上面这轮 1 秒粒度、150 秒的严格测试没能复现,上表里的几种可能也都排除了,我没查出那次是谁加的

我按否定结论定性——它更保守,也和更严格的那次测试一致。但把这个矛盾记在这里,因为一份只写"结论很干净"的 RCA 是不可信的。

工程结论

route-up 这条路走不通,所以:

唯一的自愈手段是轮询:把第五节那个幂等脚本再注册一个「计划的任务 → 每天,重复间隔 5 分钟」。脚本本身设计成可反复执行就是为了这个。

那个从未执行过的 route-up.sh,连同配置里的 route-upscript-security 2 两行,已经一并删掉了。留着只会让下一个排查的人(很可能就是几个月后的自己)以为有兜底。


七、可复用的排查清单

按这个顺序走,大部分"某个地址访问不了"的问题都能收敛:

# 1. 包出去了吗?(客户端)
tcpdump -ni <出接口> host <目标>

# 2. 被丢弃还是没人监听?filtered vs closed 是完全不同的两件事
nmap -Pn --reason -sS -p <ports> <目标>

# 3. 目标和你以为的是同一台机器吗?TTL=1 打不穿任何路由器
ping -t 1 <目标>

# 4. 包到达对端内核了吗?(服务端)
tcpdump -ni <入接口> host <源>
#    到了却没回包 ⇒ 问题在出方向,别再查防火墙的 INPUT 了

# 5. 回包会往哪走?—— 决定性的一步
ip route get <对端> from <本机应答用的源地址>
ip route get <对端>                          # 对照:不指定源地址
ip rule show                                 # 看有没有按源匹配的规则
ip route show table <被命中的表>              # 看那张表是不是"残表"

第 5 步里"带 from 和不带 from 结果不一样"就是决定性证据。


八、结语

这个故障的技术根因只有一行路由,但它能活下来,靠的是四层叠加:

  1. 文档解释了 What,没解释 Why——"回程路由也要在这张表里"写了,但没写这条路由是给谁回程的。
  2. 旁边一句不完整的断言给了错误的安全感——"NAS 自己的流量源地址是内网地址,不命中",对主动流量成立,对应答流量不成立。
  3. 落地脚本和设计文档没有对账——两条命令只抄了一条,而且没人复核。
  4. 兜底机制从未被验证过——那个 route-up 钩子看起来能补回丢失的路由,实测证明它一次都没执行过。它没有修复任何问题,只是让人以为问题已经被修复了。

分层解耦是好设计,但每一层的边界条件都得自己守住。这次踩的坑,只有当一台机器同时是网关和主机时才会出现——它既要转发别人的包,又要用那个网关地址回自己的包,而策略路由只认源地址,分不清这两者。