
家里光猫后面接路由器再接一台 OpenWrt是否已经有了两层甚至三层 NAT只数盒子不够。同一台设备可以桥接、路由也可以在路由时做地址转换。“经过两个路由器”和“被改写了两次地址”不是同一个命题。实验里同样经过两台路由器零次、一次、两次SNAT都能收到回复但服务端看到的源地址分别不同。能上网只能说明这条通信走通了不能替我们数出中途改写了几次地址。这次固定两台路由器和三条链路前三组保留相同路由只改变 NAT 规则第四组保留两层 SNAT再删除指定回程路由。这样分别观察地址转换以及转换之后回复真正需要哪些路由。最后再讨论为什么从里面发出的请求能收到回复却不能据此断定外面也能主动连进来。1. 先分开路由、NAT和防火墙机制回答的问题路由目标网络应该通过哪个接口、哪个下一跳到达NAT这条通信中的地址或端口是否需要转换防火墙过滤这类流量允许通过还是应当拒绝/丢弃家用配置常把它们一起启用容易让人误以为路由一定需要 NAT或者 NAT 就是一整套安全策略。传统 NAT 包括地址转换及涉及端口的 NAPT这里实际测试的是 IPv4 源地址转换 SNAT没有模拟所有家庭路由器的映射和过滤行为。RFC 30222. 两台路由器始终不变的拓扑我们在 WSL Ubuntu-24.04 中建立四个独立网络命名空间只用 veth 相连C客户端 - R1 - R2 - S服务端三个链路网段如下全部是实验文档地址不是真实互联网链路左侧地址右侧地址C—R1C192.0.2.2/24R1 LAN192.0.2.1/24R1—R2R1 WAN203.0.113.1/24R2 LAN203.0.113.2/24R2—SR2 WAN198.51.100.1/24S198.51.100.2/24C 的默认网关为 R1R1 的默认下一跳为 R2。开始时R2和S也有通往下游网段的明确返回路由。两台路由器在各自命名空间中开启转发。没有物理上联没有修改宿主或家庭路由器的防火墙也没有开放公网端口。S只提供一个实验UDP回显服务端口50123。收到请求后把它实际看到的发送者地址写进回复。C检查返回内容也确认回复确实到达。3. 四组实验不靠配置名字推断场景地址转换S实际看到的源IPC收到回复无NATR1、R2都不转换192.0.2.2是一次NAT仅R1做SNAT203.0.113.1是两次NATR1、R2分别做SNAT198.51.100.1是两次NAT移除下游回程路由同上198.51.100.1是四次请求来自同一客户端IP使用不同源端口40001到40004。这样每组测试建立自己的新流不让已有连接的NAT状态掩盖刚修改的规则。没有NAT那组能成功并不是因为“私有地址或文档地址能随便穿过互联网”。它是因为我们自己控制全部实验节点明确配置了返回路由。路由负责让这些子网互相可达NAT不是让路由生效的必需步骤。4. 两次转换究竟发生了什么以源端口40004的请求为例本次两层SNAT都保留了端口客户端原始来源192.0.2.2:40004 经R1转换后 203.0.113.1:40004 经R2转换后 198.51.100.1:40004 最终目标 198.51.100.2:50123端口没变是本次观测不是所有NAT都会保留源端口的保证。地址/端口冲突或具体策略可能导致不同结果。服务端的socket记录证实了最后一行来源。同时我们读取R1和R2各自的conntrack记录检查这条流的原方向及回复方向。整理相关字段后位置原方向来源 → 目标回复方向来源 → 目标R1192.0.2.2:40004→ SS →203.0.113.1:40004R2203.0.113.1:40004→ SS →198.51.100.1:40004现在把两张conntrack记录接成一次回包S发出的目标不是C而是R2转换后的地址。每一层先还原本流的目的再依照还原后的目的选路。回复先发给R2转换后的地址再根据各层保存的映射反向转换回到C。不是S自动发现了隐藏在后面的全部网段也不是包里的地址永远不变。nftables的状态型NAT为流建立转换绑定后续方向上的包据此处理。nftables NAT说明所以这里特意给每个场景换一个源端口规则是否已经改了与正在观察的流是否新建是两个问题。不应拿一条既有流的后续包直接判断新规则对新流有没有生效。本次没有清空宿主连接跟踪表也不需要读者为了复现去中断真实连接。5. 为什么删了回程路由回复仍然回来第四组里我们删掉了R2通往192.0.2.0/24的路由。S通往192.0.2.0/24和203.0.113.0/24的路由。不是删掉全部路由每条直连链路仍然存在C和R1的默认路由也仍然保留。S看到请求来自198.51.100.1回复目标就在它的直连网络里不需要了解C所在的192.0.2.0/24。R2反向转换后包去往R1的203.0.113.1仍是R2的直连网络。R1反向转换后才去往自己的直连客户端C。因此本次回包可以逐层返回。这说明的是这条由内向外发起的流能借助两层转换和已有直连路由完成返回不是“NAT让路由表不再重要”。规则的作用也限定得很窄只匹配实验源网段、目标S、UDP50123。没有给任意目的地址配置一个开放的转发出口。6. 能收到回复为什么不等于外面能主动访问刚才返回的是一个已建立实验流的回复各层有对应的转换状态。而外部新发起的请求需要另行回答目标地址在外部是否可路由 新请求送到哪个端口 到达边界后应该转发给哪个内部地址 过滤规则是否允许内部服务是否监听 返回路径是否可用是否经过需要处理这条流的转换节点状态型SNAT的回复处理不会自动替所有内部服务创建任意新入站入口。需要的可能是明确的目标转换、路由和放行策略具体取决于网络结构也存在端点映射、打洞等其他情形不能说“所有外部主动通信都绝不可能”。双重NAT会增加要协调的边界。如果还有运营商侧转换上游地址也未必是自己能控制的入口。只检查自己的OpenWrt配置可能还不够。本篇没有测试DNAT、新入站连接或打洞。上面是对出站回复与新请求区别的机制解释不能把它包装成我们家远程访问已经通过或已经失败的证据。NAT也不等同于防火墙。有没有允许新连接要看地址映射、路由和过滤的组合不能仅凭“NAT开着”判断安全。7. 回到家庭拓扑下一步不是盲目关NAT如果发现设备层级较多先问自己的目标只是扩大无线覆盖还是要建立独立子网与策略边界扩大同一局域网的覆盖可以研究桥接AP保留独立网络则需要理解路由、转换和隔离的关系。不能为了消除“双重NAT”把所有防火墙一并关闭也不能只关NAT而忘记补返回路径。判断依据应是实际接口、地址、路由与转换规则而不是“光猫加两个路由器”这个外观描述。这组实验留下最重要的两点路由器数量不等于NAT次数出站流的回复成功不等于新入站服务已经可达。后面再研究远程访问时我们就知道应该补哪一类证据而不是继续拿“能上网”替它验收。附录一验证程序怎么避免只测了个“能通”如果四组只是各ping一次读者仍然不知道转换发生在哪里。这次程序记录三类证据客户端绑定的地址/端口以及它实际收到回复。服务端socket实际看到的来源。两台路由器的NAT规则、连接跟踪原/回复方向和最后的路由表。断言既检查四组源地址的变化也检查第四组确实没有那些回程路由并验证两台路由器分别存在对应的转换状态。UDP服务用Python标准socket实现仅收四条自建请求不涉及完整应用协议NAT交给Linux内核和官方nftables工具处理不用手工改包模拟。最终passedtrue。实验服务和四个命名空间在退出时清理工具只是下载解包到D盘临时目录没有安装成常驻服务。完整源码、依赖准备和运行命令放在文末附录。正文中的命名空间变量由编排脚本创建不需要在真实网卡上手动制造故障。附录二完整源码与复现以下是完整文件不需要先下载仓库。环境限定为 Ubuntu 24.04 amd64本次为 WSL2需要 Bash、iproute2、ping、Python 3、sysctl、timeout 和 dpkg-deb不能把 Windows PowerShell 当成 Bash。实验需要 root 和创建网络命名空间的权限普通受限容器可能无法运行。不要在生产路由器上运行。在 Linux 中建立任意工作目录例如mkdir -p ~/network-lab-double-nat/scripts cd ~/network-lab-double-nat将下面每个代码块按标题文件名放入 scripts 目录使用 UTF-8 和 LF 换行。脚本按自身位置寻找文件不依赖我的 D 盘路径。先执行bash scripts/prepare-network-lab-tools.sh仅下载并解包软件包不安装系统服务。该辅助程序准备这组网络实验共用的工具部分工具本篇不会调用。需要正常的软件源、网络连接和包索引索引过期时先自行更新索引。在线下载分支尚未在本轮重新测试本轮使用已有官方 deb 缓存验证解包和运行。文件scripts/prepare-network-lab-tools.sh#!/usr/bin/env bash set -euo pipefail base$(cd -- $(dirname -- $0)/.. pwd) tools$base/tmp/dhcp-tools-20261004 . /etc/os-release test $ID ubuntu test $VERSION_ID 24.04 test $(dpkg --print-architecture) amd64 for tool in ip ping python3 sysctl dpkg-deb timeout; do command -v $tool /dev/null; done packages(dnsmasq-base busybox-static libnetfilter-conntrack3 libnftables1 libnfnetlink0 libmnl0 libnftnl11 libxtables12 nftables conntrack) mkdir -p $tools stage$(mktemp -d $tools/download.XXXXXX) if test $# 2 test $1 --from-cache; then test -d $2 for deb in $2/*.deb; do cp -- $deb $stage/; done elif test $# 0; then (cd $stage apt-get download ${packages[]}) else echo Usage: prepare-network-lab-tools.sh [--from-cache PACKAGE_DIRECTORY] 2 exit 1 fi for package in ${packages[]}; do found0 for deb in $stage/*.deb; do if test $(dpkg-deb -f $deb Package) $package; then found1; break; fi done test $found 1 || { echo Missing package: $package 2; exit 1; } done : $tools/package-versions.txt for deb in $stage/*.deb; do dpkg-deb -x $deb $tools/extracted dpkg-deb -f $deb Package Version Architecture $tools/package-versions.txt cp -- $deb $tools/ done export LD_LIBRARY_PATH$tools/extracted/usr/lib/x86_64-linux-gnu:$tools/extracted/lib/x86_64-linux-gnu $tools/extracted/usr/sbin/dnsmasq --version | head -n 2 $tools/extracted/usr/sbin/nft --version $tools/extracted/usr/sbin/conntrack --version echo Tools ready. No package installation or service startup was performed.文件scripts/verify-network-nat.sh#!/usr/bin/env bash set -euo pipefail test $(id -u) 0 base$(cd -- $(dirname -- $0)/.. pwd) tools$base/tmp/dhcp-tools-20261004 for deb in $tools/*.deb; do dpkg-deb -x $deb $tools/extracted; done nft$tools/extracted/usr/sbin/nft ct$tools/extracted/usr/sbin/conntrack export LD_LIBRARY_PATH$tools/extracted/usr/lib/x86_64-linux-gnu:$tools/extracted/lib/x86_64-linux-gnu test -x $nft test -x $ct out$base/data/verification/network-nat-$(date %Y%m%d-%H%M%S) mkdir -p $out exec (tee $out/stdout.txt) 21 $nft --version token$(cat /proc/sys/kernel/random/uuid) cnt-${token:0:8}-c r1nt-${token:0:8}-r1 r2nt-${token:0:8}-r2 snt-${token:0:8}-s created() server cleanup() { if test -n $server; then kill $server 2/dev/null || true; wait $server 2/dev/null || true; fi for ns in ${created[]}; do ip netns del $ns; done } trap cleanup EXIT for ns in $c $r1 $r2 $s; do ip netns add $ns; created($ns); ip -n $ns link set lo up; done ip -n $c link add eth0 type veth peer name lan netns $r1 ip -n $r1 link add wan type veth peer name lan netns $r2 ip -n $r2 link add wan type veth peer name eth0 netns $s ip -n $c link set eth0 up ip -n $s link set eth0 up for ns in $r1 $r2; do ip -n $ns link set lan up ip -n $ns link set wan up ip netns exec $ns sysctl -qw net.ipv4.ip_forward1 done ip -n $c addr add 192.0.2.2/24 dev eth0 ip -n $r1 addr add 192.0.2.1/24 dev lan ip -n $r1 addr add 203.0.113.1/24 dev wan ip -n $r2 addr add 203.0.113.2/24 dev lan ip -n $r2 addr add 198.51.100.1/24 dev wan ip -n $s addr add 198.51.100.2/24 dev eth0 ip -n $c route add default via 192.0.2.1 ip -n $r1 route add default via 203.0.113.2 ip -n $r2 route add 192.0.2.0/24 via 203.0.113.1 ip -n $s route add 192.0.2.0/24 via 198.51.100.1 ip -n $s route add 203.0.113.0/24 via 198.51.100.1 for ns in $r1 $r2; do ip netns exec $ns $nft add table ip labnat ip netns exec $ns $nft add chain ip labnat post { type nat hook postrouting priority srcnat; policy accept; } done ip netns exec $s python3 $base/scripts/lab-nat-probe.py serve $out $out/server.txt 21 server$! for attempt in $(seq 1 100); do test ! -f $out/server.ready || break; sleep 0.05; done test -f $out/server.ready run_case() { local name$1 port$2 peer$3 echo $name ip netns exec $c python3 $base/scripts/lab-nat-probe.py probe $out $name $port $peer ip netns exec $r1 $ct -L -p udp $out/$name-r1-conntrack.txt 21 ip netns exec $r2 $ct -L -p udp $out/$name-r2-conntrack.txt 21 } run_case no-nat 40001 192.0.2.2 ip netns exec $r1 $nft add rule ip labnat post oifname wan ip saddr 192.0.2.0/24 ip daddr 198.51.100.2 udp dport 50123 counter snat to 203.0.113.1 run_case one-nat 40002 203.0.113.1 ip netns exec $r2 $nft add rule ip labnat post oifname wan ip saddr 203.0.113.0/24 ip daddr 198.51.100.2 udp dport 50123 counter snat to 198.51.100.1 run_case two-nat 40003 198.51.100.1 echo Remove lab downstream return routes ip -n $r2 route del 192.0.2.0/24 ip -n $s route del 192.0.2.0/24 ip -n $s route del 203.0.113.0/24 ip -n $r2 route show $out/r2-final-routes.txt ip -n $s route show $out/server-final-routes.txt run_case two-nat-no-return-routes 40004 198.51.100.1 for ns in $r1 $r2; do ip netns exec $ns $nft -j list ruleset $out/$ns-rules.json; done wait $server server python3 - $out PY import json import sys from pathlib import Path p Path(sys.argv[1]) names (no-nat, one-nat, two-nat, two-nat-no-return-routes) rows [json.loads((p / f{name}.json).read_text()) for name in names] assert all(row[client_source][0] 192.0.2.2 and row[reply_received] for row in rows) assert [row[server_seen_peer][0] for row in rows] [192.0.2.2, 203.0.113.1, 198.51.100.1, 198.51.100.1] assert 192.0.2.0/24 not in (p / r2-final-routes.txt).read_text() assert 192.0.2.0/24 not in (p / server-final-routes.txt).read_text() assert 203.0.113.0/24 not in (p / server-final-routes.txt).read_text() for router, original_source, translated_source in ( (r1, 192.0.2.2, 203.0.113.1), (r2, 203.0.113.1, 198.51.100.1)): text (p / ftwo-nat-no-return-routes-{router}-conntrack.txt).read_text() lines [line for line in text.splitlines() if sport40004 dport50123 in line] assert len(lines) 1 assert fsrc{original_source} dst198.51.100.2 sport40004 dport50123 in lines[0] assert fsrc198.51.100.2 dst{translated_source} sport50123 dport40004 in lines[0] (p / result.json).write_text(json.dumps({passed: True, cases: rows, scope: four isolated namespaces; IPv4 UDP SNAT; no public exposure or inbound/DNAT test}, indent2)) print(ASSERTIONS PASSED: two routers, zero/one/two SNAT, replies received in all four cases) PY echo Evidence: $out文件scripts/lab-nat-probe.pyimport json import socket import sys from pathlib import Path mode, directory, *args sys.argv[1:] out Path(directory) if mode serve: rows [] with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.bind((198.51.100.2, 50123)) sock.settimeout(20) (out / server.ready).touch() for _ in range(4): payload, peer sock.recvfrom(2048) request json.loads(payload) row {case: request[case], server_seen_peer: list(peer)} rows.append(row) sock.sendto(json.dumps(row).encode(), peer) (out / server-peers.json).write_text(json.dumps(rows, indent2)) elif mode probe: case, source_port, expected_peer args with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: sock.bind((192.0.2.2, int(source_port))) sock.settimeout(2) sock.connect((198.51.100.2, 50123)) sock.send(json.dumps({case: case}).encode()) reply json.loads(sock.recv(2048)) assert reply[case] case assert reply[server_seen_peer][0] expected_peer row {case: case, client_source: list(sock.getsockname()), client_target: list(sock.getpeername()), **reply, reply_received: True} (out / f{case}.json).write_text(json.dumps(row, indent2)) print(json.dumps(row)) else: raise SystemExit(Unknown mode)运行与结果判断在上述工作目录执行bash scripts/prepare-network-lab-tools.sh sudo bash scripts/verify-network-nat.sh输出保存在工作目录的 data/verification 下以运行时间命名。正常完成会显示断言通过还应检查本次目录的 result.json 中 passed 为 true。预设的失败对照属于实验内容不意味着脚本整体失败依赖缺失、权限不足或断言失败则不能算通过。不要使用 python -O 或 PYTHONOPTIMIZE 禁用断言。脚本只修改自己新建的随机名称网络命名空间正常退出及普通错误退出会执行清理。断电、强制终止或 kill -9 不能保证清理可用 ip netns list 检查只处理确认属于本次实验的资源不要批量删除其他命名空间。输出中的地址来自隔离实验不需要填写家庭账号、真实设备地址或认证信息。