Skip to content

twice-NAT:源和目的一起改

静态映射那一篇强调过一个优点:端口转发只改目的, 源地址原样保留,所以内网服务器能拿到真实客户端 IP。

这一篇讲的是那个优点变成致命缺陷的情况。

问题:内网服务器不把回包交给 NAT

端口转发能成立,隐含着一个前提:内网服务器的回包必须经过同一台 NAT 设备

如果服务器的默认路由不指向这台 NAT,回包会按它自己的路由走掉:

这在真实网络里非常常见:

  • 服务器所在的网段有自己的网关,NAT 设备只是旁挂的
  • 多出口环境,服务器的默认路由指向另一条链路
  • 服务器有多块网卡,回包从另一块出去了

统称非对称路由

解法:把源地址也改成 NAT 自己的

既然服务器是按"回给谁"来选路的,那就让它以为客户端就在 NAT 这边。

一个包上做了两次地址翻译 —— 这就是 "twice" 的字面意思。

配置

应用配置

bash
./labctl reset base
./labctl apply base 07

两步:

bash
# 1. 建一个 twice-NAT 专用地址池(注意 twice-nat 关键字)
vppctl nat44 add address 203.0.113.200 twice-nat

# 2. 静态映射加 twice-nat 修饰
vppctl nat44 add static mapping tcp \
    local 10.10.0.12 9000 external 203.0.113.1 8080 twice-nat

twice-NAT 用的是独立地址池

show nat44 addresses 会把两个池分开列:

text
NAT44 pool addresses:
203.0.113.1              ← 普通出方向用
  tenant VRF independent
NAT44 twice-nat pool addresses:
203.0.113.200            ← twice-NAT 改写源地址时用
  tenant VRF independent

分开是有道理的:普通池的地址会被大量出方向会话占用端口, twice-NAT 需要稳定可控的地址,混在一起容易互相挤占。

验证

跑自检

bash
./labctl verify base 07

自检把普通静态映射和 twice-NAT 并排跑一遍。

text
── 1) 对照组:普通静态映射 ────────────────────
  h2 看到的源地址: 203.0.113.11:37827
✓ 是 s1 的真实地址

── 2) 换成 twice-NAT ─────────────────────────
  h2 看到的源地址: 203.0.113.200:50291
✓ 变成了 twice-nat 池地址 203.0.113.200
text
  ┌──────────────┬────────────────────┬──────────────────────┐
  │ 配置          │ 内网服务器看到的源   │ 代价                  │
  ├──────────────┼────────────────────┼──────────────────────┤
  │ 普通静态映射   │ 203.0.113.11:37827 │ 服务器必须路由回 NAT   │
  │ twice-NAT    │ 203.0.113.200:50291│ 丢失真实客户端 IP      │
  └──────────────┴────────────────────┴──────────────────────┘

会话表:唯一会出现两个外网地址的地方

bash
./labctl vppctl base show nat44 sessions
text
    i2o 10.10.0.12 proto TCP port 9000 fib 0
    o2i 203.0.113.1 proto TCP port 8080 fib 0
       external host o2i 203.0.113.11:44321 i2o 203.0.113.200:50291
                     ─────────────────────  ─────────────────────
                     真实客户端               替身地址
       i2o flow: match:   saddr 10.10.0.12  sport 9000  daddr 203.0.113.200 dport 50291
                 rewrite: saddr 203.0.113.1 sport 8080  daddr 203.0.113.11  dport 44321
       o2i flow: match:   saddr 203.0.113.11  sport 44321 daddr 203.0.113.1  dport 8080
                 rewrite: saddr 203.0.113.200 sport 50291 daddr 10.10.0.12   dport 9000
                          ────────────────── 源也改        ───────────────── 目的也改
       static translation

关键在 o2i flow 的 rewrite:同时含 saddr 和 daddr。 普通静态映射的 o2i rewrite 里只有 daddr —— 这一行就是区分两者最快的方式。

external host 那一行罕见地列了两个地址:真实客户端和它的替身。 排障时想知道"某条会话背后的真实客户端是谁",看这里。

代价:真实客户端 IP 没了

这是必须接受的交换。内网服务器的访问日志里,所有客户端都变成了 203.0.113.200 这一个地址。受影响的包括:

  • 访问日志与审计
  • 按源 IP 的限流、风控、封禁
  • 地域分析
  • 基于 IP 的白名单

补救办法是应用层携带:HTTP 用 X-Forwarded-For, TCP 层可以用 PROXY protocol —— 但这两个都需要后端应用配合改造, 不是网络层能单方面解决的。

先想想能不能不用 twice-NAT

twice-NAT 是给非对称路由擦屁股的。如果你能改路由,优先改路由:

text
  能不能给内网服务器加一条明细路由,让去往外网的回包走 NAT?
      ip route add 203.0.113.0/24 via <NAT的内网地址>

  能不能把服务器的默认路由改成指向 NAT?

  能不能用策略路由,按源地址把回包引到 NAT?

这三条任何一条能做到,都比 twice-NAT 好 —— 因为能保住真实客户端 IP。 只有在路由确实动不了(跨部门、跨厂商、云上托管网络)时,才上 twice-NAT。

它治不了发夹弯

发夹弯(hairpinning)指的是:内网客户端通过公网地址访问内网服务器

text
  h1 (10.10.0.11) ──→ 203.0.113.1:8080 ──→ 应该回到 h2 (10.10.0.12)
       内网                 公网地址              内网

这个需求很实在 —— 内网员工和外网用户用的是同一个域名, DNS 解析出来就是公网地址。

twice-nat 和它的兄弟 self-twice-nat 看起来都该能解决这个问题。 但在 VPP 26.06 上实测,三种配置都不通

text
── 4) twice-NAT 治不了发夹弯 ──────────────────
  场景:内网的 h1 也想通过公网地址 203.0.113.1:8080 访问内网的 h2
✓ 如预期地不通
  换 self-twice-nat 再试
✓ self-twice-nat 也不通

为什么不通

VPP 其实建了发夹弯需要的会话,翻译逻辑也走通了。抓 trace 看得很清楚:

bash
./labctl vppctl base clear trace
./labctl vppctl base trace add af-packet-input 10
# 从 h1 访问 203.0.113.1:8080
./labctl vppctl base show trace
text
i2of match:   saddr 10.10.0.11  sport 43821 daddr 203.0.113.1 dport 8080
     rewrite: saddr 203.0.113.1 sport 43821 daddr 10.10.0.12  dport 9000
              ↑ 源改成了 NAT 地址              ↑ 目的换成了后端
              去程完全正确

o2if match:   saddr 10.10.0.12  sport 9000  daddr 203.0.113.1 dport 43821
     rewrite: saddr 203.0.113.1             daddr 10.10.0.11  dport 43821
              ↑ 源地址改了      ↑ 少了 sport 8080!

问题出在回程:源端口没有被还原

h1 是发往 203.0.113.1:8080 的,但收到的回包来自 203.0.113.1:9000 (后端的真实端口)。TCP 栈看到一个从没见过的端口发来的包,判为非法,回 RST。 那个 RST 又打到 NAT 上,VPP 记一笔:

text
4  nat44-ed-in2out-slowpath   non-SYN packet try to create session   error

看到这个计数器时先想到发夹弯

non-SYN packet try to create session 的意思是"有个非 SYN 的 TCP 包想建新会话"。 最常见的两个成因:

  1. 发夹弯没配通 —— 客户端对错误端口回了 RST(本节的情况)
  2. 会话老化后对端还在发 —— 见会话老化

实际怎么解决

不要在 NAT 上解决。标准做法是分离解析(split-horizon DNS):

text
  内网 DNS:  service.example.com  →  10.10.0.12    ← 内网地址
  外网 DNS:  service.example.com  →  203.0.113.1   ← 公网地址

内网客户端解析到内网地址,流量根本不出去绕一圈,也就没有发夹弯这回事。 这个方案更简单、性能更好,而且不依赖任何 NAT 特性。

小结

解决什么内网服务器的回包不经过 NAT(非对称路由)
配置nat44 add address X twice-nat + 静态映射加 twice-nat
效果内网服务器看到的源地址变成 twice-nat 池地址
识别方式会话表 o2i flow 的 rewrite 同时含 saddr 和 daddr
代价丢失真实客户端 IP,需要 XFF / PROXY protocol 补救
优先考虑能改路由就改路由,twice-NAT 是退而求其次
治不了发夹弯 —— 用分离解析(split-horizon DNS)解决

下一篇:VRF 多租户 —— 两个租户用完全相同的内网地址。

实验环境基于 netlab + containerlab + VPP 26.06