主题
NAT 到底改了什么
上一章你看到 s1 报告"我看到你的地址是 203.0.113.1"。这一章我们把包拆开, 逐字节看清 NAT 动了哪里、没动哪里,以及那些被动过的字节带来了什么连锁反应。
一个 TCP 报文里,NAT 关心的字段
先把包摊平。一个走在以太网上的 TCP 报文,前 54 个字节长这样:
text
以太网帧
┌────────────────────────────────────────────────────────────────────┐
│ 目的MAC(6) │ 源MAC(6) │ 类型(2) │ 载荷 │
└────────────────────────────────────────────────────────────────────┘
↑ 这三个字段由路由决定,和 NAT 无关(但会被 ip4-rewrite 改)
IPv4 头(20 字节)
┌──────┬──────┬────────────┬──────────────────────────────────────┐
│版本/长│ TOS │ 总长度 │ 标识 │标志/片偏移│ TTL │协议│ 校验和 │
├──────┴──────┴────────────┴──────────────────────────────────────┤
│ 源 IP 地址(4) ← ★ NAT 改这里 │
├──────────────────────────────────────────────────────────────────┤
│ 目的 IP 地址(4) ← ★ NAT 改这里 │
└──────────────────────────────────────────────────────────────────┘
↑ 改了地址,
IP 校验和必须重算
TCP 头(20 字节起)
┌────────────────────┬────────────────────┐
│ 源端口(2) │ 目的端口(2) │ ← ★ NAPT 改这里
├────────────────────┴────────────────────┤
│ 序列号(4) │ 不动
├─────────────────────────────────────────┤
│ 确认号(4) │ 不动
├──────┬──────┬──────┬────────────────────┤
│头长/保│ 标志 │ 窗口 │ 校验和 │ 紧急指针 │ ← 校验和必须重算
└──────┴──────┴──────┴────────────────────┘出方向(in2out)改的是左边:源 IP + 源端口。 回程(out2in)改的是右边:目的 IP + 目的端口。
一共就四个字段,加两个校验和。听起来简单,但每一处都有讲究。
讲究一:校验和不能重算,只能增量修正
IP 头校验和覆盖整个 IP 头,TCP 校验和覆盖 TCP 头 + 载荷 + 一个包含源/目的 IP 的伪首部。 所以改了 IP 地址,TCP 校验和也得跟着变 —— 哪怕你一个字节的载荷都没碰。
如果老老实实重算 TCP 校验和,就要把整个载荷(可能 1400 字节)都读一遍求和。 对一台每秒转发几百万包的设备来说,这是不可接受的开销。
实际做法是增量修正(RFC 1624)。校验和是反码求和,具有这样的性质: 只要知道"哪个 16 位字从什么变成了什么",就能直接算出新校验和,不必重读全部数据。
text
旧地址 10.10.0.11 → 拆成两个 16 位字:0x0A0A 0x000B
新地址 203.0.113.1 → 0xCB00 0x7101
新校验和 = 修正(旧校验和, 0x0A0A→0xCB00, 0x000B→0x7101)
代价:几次加减,与载荷长度无关这解释了一类诡异故障
如果一个 NAT 实现的增量修正有 bug(比如没处理反码算术里 +0 和 -0 的边界), 症状会是:大部分包正常,偶尔某些特定地址组合的包被对端当作校验和错误丢弃。 表现出来就是"网络时好时坏,某些网站就是打不开"。RFC 1624 整篇就是在讲这个坑。
讲究二:ICMP 没有端口,借用 Identifier
ICMP 报文头里没有端口。那 NAPT 怎么区分两台内网主机同时 ping 同一个目标?
答案是借用 Echo Identifier 字段:
text
ICMP Echo 报文头(8 字节)
┌──────────┬──────────┬────────────────────────┐
│ 类型(1) │ 代码(1) │ 校验和(2) │
├──────────┴──────────┼────────────────────────┤
│ Identifier(2) │ Sequence(2) │
└─────────────────────┴────────────────────────┘
↑ ★ NAT 把它当"端口"用这个字段本来的用途是:主机同时发起多个 ping 时,用它把收到的应答和对应的请求配对。 NAT 直接征用了这个语义 —— 反正它也是"用来区分同一对地址之间的多个会话"的标识。
上一章的实测输出正好印证:
text
i2o 10.10.0.11 proto ICMP port 32 fib 0
o2i 203.0.113.1 proto ICMP port 63327 fib 0
i2o flow: rewrite: saddr 203.0.113.1 daddr 203.0.113.11 icmp-id 63327
o2i flow: rewrite: saddr 203.0.113.11 daddr 10.10.0.11 icmp-id 32注意 VPP 在会话表里就把 icmp-id 显示在 port 字段的位置。在它的数据结构里, TCP 端口、UDP 端口、ICMP id 是同一个字段,只是语义随协议不同。
ICMP 差错报文是另一回事
上面说的是 Echo(ping)。而 ICMP 差错报文(比如 Destination Unreachable、 Time Exceeded)根本没有 Identifier —— 它的载荷里装的是触发这个差错的原始 IP 包的头部。
NAT 处理这类包时必须"往里挖一层":解析出内嵌的原始包头,用那个头去查会话表, 然后同时改写外层头和内嵌头。这就是为什么 traceroute 能穿过 NAT 正常工作 —— NAT 实现专门为此做了处理。这也是 NAT 实现里最容易出 bug 的地方之一。
讲究三:改了地址要重新查路由
这是很多人漏掉的一步。改完源地址之后,包不能直接从原来打算走的接口发出去。
想想为什么:出方向 NAT 改的是源地址,目的地址没变,看起来路由结果应该一样。 但如果配置了策略路由、或者是 out2in 方向(改的是目的地址),路由结果就完全不同了。
VPP 的做法很干脆:NAT 节点做完改写,把包扔回 ip4-lookup 重新查一次路由。 我们从实测的 trace 里能直接看到这个顺序:
text
→ ip4-input 解 IP 头
→ nat-pre-in2out 分流:这个包要做 NAT
→ nat44-ed-in2out 查会话表
→ nat44-ed-in2out-slowpath 新建会话 + 完成改写
→ ip4-lookup ← ★ 改写后重新查路由
→ ip4-rewrite 根据路由结果改写链路层头
→ host-eth2-output 发出去完整看一遍:一个包的真实旅程
下面是从实验环境里实际抓下来的 trace,一个 h1 ping s2 的首包。为了聚焦我删掉了部分细节, 但每一行都是真的。
自己抓一次
bash
./labctl reset base && ./labctl apply base 01
./labctl vppctl base clear trace
./labctl vppctl base trace add af-packet-input 12
ssh chenzhong@192.168.49.182 \
'docker exec clab-natbase-h1 sh -c "echo hi | nc -u -w 1 203.0.113.12 9001"'
./labctl vppctl base show tracetext
00:08:10:962150: ethernet-input
IP4: aa:c1:ab:c3:66:ff -> aa:c1:ab:27:cc:ec
00:08:10:962158: ip4-input
UDP: 10.10.0.11 -> 203.0.113.12 ← 原始地址
tos 0x00, ttl 64, length 31, checksum 0xe87e
UDP: 52515 -> 9001 ← 原始端口
length 11, checksum 0x5700
00:08:10:962168: nat-pre-in2out
in2out next_index 2 arc_next_index 10 ← 判定:走 in2out 分支
00:08:10:962170: nat44-ed-in2out
NAT44_IN2OUT_ED_FAST_PATH: sw_if_index 1, next index 3
search key local 10.10.0.11:52515 remote 203.0.113.12:9001 proto UDP
← 用四个值去查会话表
00:08:10:962174: nat44-ed-in2out-slowpath
NAT44_IN2OUT_ED_SLOW_PATH: translation result 'success' via i2of
i2of match: saddr 10.10.0.11 sport 52515 daddr 203.0.113.12 dport 9001
rewrite: saddr 203.0.113.1 sport 52515 daddr 203.0.113.12 dport 9001
o2if match: saddr 203.0.113.12 sport 9001 daddr 203.0.113.1 dport 52515
rewrite: saddr 203.0.113.12 daddr 10.10.0.11 dport 52515
← 一次性把来回两个方向都建好
00:08:10:962189: ip4-lookup
UDP: 203.0.113.1 -> 203.0.113.12 ← 源地址已改
ttl 64, length 31, checksum 0xb691 ← IP 校验和已重算
UDP: 52515 -> 9001
length 11, checksum 0x2513 ← UDP 校验和已重算把改写前后并排看,一目了然:
text
进来的包 出去的包
源 IP 10.10.0.11 → 203.0.113.1 ★改
源端口 52515 → 52515 保留
目的 IP 203.0.113.12 → 203.0.113.12 不动
目的端口 9001 → 9001 不动
IP 校验和 0xe87e → 0xb691 ★重算
UDP 校验和 0x5700 → 0x2513 ★重算
TTL 64 → 63(后续 ip4-rewrite 减 1)你应该注意到
源端口 52515 没有被改。NAT 只在必须的时候才动端口 —— 具体规则见 端口分配与会话老化。
附赠:首包丢失是怎么发生的
上面那次抓包,如果你完整看 trace,会发现首包其实没发出去:
text
00:08:10:962192: ip4-glean
UDP: 203.0.113.1 -> 203.0.113.12
00:08:10:962199: ip4-drop
00:08:10:962244: error-drop
rx:host-eth1
00:08:10:962246: drop
ip4-glean: ARP requests sent ← 原因在这里VPP 改完地址、查完路由,发现下一跳 203.0.113.12 的 MAC 地址还不知道。 它做了两件事:发一个 ARP 请求,然后把这个包丢掉。
紧接着的第 2 个包就是 s2 的 ARP 应答:
text
Packet 2
→ af-packet-input
→ ethernet-input
ARP: aa:c1:ab:03:75:11 -> aa:c1:ab:40:e9:0d
→ arp-input
reply, type ethernet/IP4之后邻居表有了,后续包一路通畅。
这解释了一个常见现象:刚配好 NAT 的第一个 ping 经常超时,第二个开始就正常了。 不是 NAT 有问题,是 ARP 还没解析完。所以本教程所有自检脚本在正式测量前都会先"热身"一下:
bash
# 第一个包用来触发 ARP 和建会话,丢了也不管
nx h1 ping -c 1 -W 1 203.0.113.11 >/dev/null 2>&1 || true
# 从第二个包开始才认真测
nx h1 ping -c 3 -W 2 203.0.113.11这个技巧在排错时很有用
判断"到底是 NAT 不通还是 ARP 没解析",只要 ping 两次。第一次不通第二次通 → ARP; 两次都不通 → 真有问题。然后 show ip neighbors 确认。
小结
| 问题 | 答案 |
|---|---|
| NAT 改哪些字段? | 源/目的 IP、源/目的端口(ICMP 是 Identifier),加两个校验和 |
| 校验和怎么处理? | 增量修正,代价与载荷长度无关 |
| ICMP 没端口怎么办? | 借用 Echo Identifier;差错报文还要改内嵌的原始包头 |
| 改完就能发吗? | 不能,要重新查路由(VPP 里是回到 ip4-lookup) |
| 为什么首包常丢? | ARP 未解析,ip4-glean 发 ARP 请求并丢包 |
下一篇拆解会话表 —— NAT 的全部状态都在那张表里。