复现发夹 NAT,再用两种办法解掉
目标
在一个 Pod 中创建家中笔记本电脑、路由器和服务器三个位置,并实际复现仅更改 目标地址时响应无法返回的情况。然后连源地址也一起更改, 使通信成功,并与从名称解析侧解决同一问题的方法进行比较。
为什么重要
“外部可以访问,只有家里无法访问”这类报告很难猜测原因。 并非无法连接,也并非无法解析名称;响应确实返回了, 但接收端将其丢弃。丢弃操作不会在日志中留下任何内容。
本实验让你亲眼看到被丢弃的瞬间。调用 connect() 的 UDP 套接字
只接收来自所连接对端的数据,内核会悄无声息地丢弃其余数据。这与笔记本电脑
等待来自路由器地址的响应,却丢弃来自服务器私有地址的响应
完全相同。
此 Pod 中可以做和不能做的事
实验 Pod 没有内核权限,因此 ip netns、nft 和 tcpdump 都无法运行。
不过,127.0.0.0/8 的整个网段都是本机自身,所以即使没有权限,也能
在一台主机上同时使用 127.0.0.10、127.0.0.20、127.0.0.99。
我们用这三个地址区分三个位置。第 6 步中的 nftables 规则只需写出来,
不实际应用。
步骤
- 在
/root/hairpin/01-map.txt中写下三个位置的地址和作用。分别是laptop 127.0.0.10、router 127.0.0.99、server 127.0.0.20,并另写一行说明为何能在一台主机上同时使用这三个地址。 - 在
/root/hairpin/site/index.html中写入一行hairpin-lab-server,并通过127.0.0.20:9000发布该目录。将连接私有地址的结果和连接127.0.0.99:9000的结果一并写入/root/hairpin/02-direct.txt。 - 在
/root/hairpin/03-tuple.txt中用五行写出仅更改目标地址时(只有 DNAT)数据包的源地址和目标地址如何变化。每行分别以request-out、after-dnat、reply、expected、mismatch=开头。 - 创建
/root/hairpin/nat.py。运行python3 nat.py dnat-only时,必须用三个套接字复现该情况,并在最后一行输出result=dropped。将运行结果保存到/root/hairpin/04-dnat-only.txt。 - 让同一工具的
python3 nat.py dnat-snat连源地址也一起更改,从而输出result=delivered,并将结果保存到/root/hairpin/05-dnat-snat.txt。 - 将真正要配置在路由器上的规则写入
/root/hairpin/06-rules.nft。服务器是192.168.0.20,家庭网段是192.168.0.0/24,端口是 80。需要 prerouting 和 postrouting 两条链,而且更改源地址的规则必须只匹配来自家中的流量。 - 从名称侧解决同一问题。在
/root/hairpin/hosts.internal中写入home.example.test的内部应答,使用curl --resolve分别模拟内部应答和外部应答,并将结果写入/root/hairpin/07-split.txt。 - 在
/root/hairpin/08-compare.md中比较两种方案。必须包含## 헤어핀、## 스플릿、## 무엇을 고르나三节。
参考
- 使用
python3 -m http.server 9000 --bind 127.0.0.20 --directory /root/hairpin/site启动服务器。在后台运行时,请使用setsid nohup … >/tmp/서버로그 2>&1 </dev/null &切断标准输出。如果仍与其相连,评分器会一直等待该进程结束。 - 连接失败同样是证据。使用
curl -sS -m 3 … 2>&1将错误消息也保存到文件中。 - 第 4 步使用三个套接字的原因如下。笔记本电脑套接字对路由器地址调用
connect(),路由器套接字绑定到127.0.0.99:9500,服务器套接字绑定到127.0.0.20:9500。套接字无法伪造源地址,因此在只有 DNAT 的情况下,需要把原始源地址放入正文中告知服务器,让服务器直接向该地址响应。 - 捕获
socket.timeout并输出result=dropped。这是经过一段时间后失败,并非发生了错误。 - 第 7 步的
curl --resolve 이름:포트:주소会将该名称视为解析到了该地址。无需搭建 DNS 即可模拟分割视野。 - 评分器会以两种模式直接运行你的
nat.py。如果将结果写死,其中一种模式必然失败。
绘制三个位置的地图
127.0.0.0/8 中,只要第一个八位组是 127,就全部表示本机自身。因此,可以将服务器绑定到 127.0.0.20,也可以用 127.0.0.99 表示路由器的位置。无需内核权限,正是本实验能够在 Pod 中运行的原因。
每行写入名称、地址和作用,并在最后一行写明为什么能在一台主机上同时使用这三个地址。
私有地址可用,外部地址不可用
首先创建服务器要发布的文件。为了让评分器识别响应,其中必须包含 hairpin-lab-server 字样。
python3 -m http.server 9000 --bind 127.0.0.20 --directory /root/hairpin/site
然后分别尝试连接两次。127.0.0.20:9000 可以访问,而 127.0.0.99:9000 无法访问。目前路由器位置上没有任何程序,因此会立即拒绝连接,这就是“未配置端口转发的路由器”所处的位置。
还必须将失败一侧的消息保存到文件中。请像 curl -sS -m 3 http://127.0.0.99:9000/ 2>&1 这样同时接收标准错误。
跟踪四个元组
DNAT 只更改目标地址。因此,即使通过路由器之后,源地址仍是笔记本电脑,服务器会直接向笔记本电脑响应。
依次写下四行。
request-out——笔记本电脑最初发出时。目标地址是路由器地址。after-dnat——路由器将目标地址改为服务器之后。源地址保持不变。reply——服务器响应时。源地址是服务器的私有地址。expected——笔记本电脑等待的响应。源地址必须是路由器地址。
在最后一行 mismatch= 中写明 reply 和 expected 在哪个位置不一致。端口号可任选一个值,只要始终保持一致即可。
只更改目标地址时,响应会被丢弃
关键在于 connect()。UDP 套接字调用 connect() 后,内核中也会登记“正在等待谁的响应”,来自其他地址的数据报会在到达套接字之前被丢弃。
按以下方式放置三个套接字。
laptop bind(('127.0.0.10', 0)) connect(('127.0.0.99', 9500))
router bind(('127.0.0.99', 9500))
server bind(('127.0.0.20', 9500))
笔记本电脑发送后,由路由器接收。在只有 DNAT 的情况下,服务器看到的源地址本应是笔记本电脑,但套接字无法伪造源地址。因此,请将原始源地址放入正文中发送,让服务器直接向该地址响应。
最后以 2 秒超时限制尝试 laptop.recv(),如果发生 socket.timeout,则输出 result=dropped。
连源地址也更改后即可通信
只需修复一处。路由器向服务器转发请求时,如果使用自己的套接字发送,服务器看到的源地址就是路由器,响应也会返回路由器。路由器再将该响应转回笔记本电脑即可。
此时,笔记本电脑收到的响应源地址为 127.0.0.99:9500,与笔记本电脑调用 connect() 时连接的对端相同,因此内核会允许其通过。
关键在于只切换模式来运行同一个工具。评分器会运行两种模式,检查结果是否确实不同。
编写路由器规则
此 Pod 没有内核权限,因此无法使用 nft 应用规则。目标是准确写出规则。
需要两条链。
chain prerouting { type nat hook prerouting priority dstnat; … dnat to … }
chain postrouting { type nat hook postrouting priority srcnat; … masquerade }
不要遗漏更改源地址规则中的条件。如果无条件应用,从外部进入的请求也会将源地址改为路由器,服务器日志中的所有连接记录都会被挤压成同一个地址。请使用 ip saddr 仅选择来自家庭网段的流量。
为每条规则添加 # 注释,说明该行的作用。
从名称侧解决同一问题
分割视野 DNS 是指:在内部查询同一个名称时返回私有地址,在外部查询时返回公共地址。这样,家中设备从一开始就不会访问路由器。
不搭建 DNS 服务器,使用 curl --resolve 模拟两种情况。
curl -sS -m 3 --resolve home.example.test:9000:127.0.0.20 http://home.example.test:9000/
curl -sS -m 3 --resolve home.example.test:9000:127.0.0.99 http://home.example.test:9000/ 2>&1
关键在于使用名称而非地址进行连接。如果直接连接私有地址,证书中写入的名称与连接的地址不同,就会出现警告。
hosts.internal 中只放置内部应答值。如果同时写入外部地址,就已经不再是分割视野。
比较两种方案
两种方案的修复位置不同。发夹只需修改一台路由器,但流量会往返路由器;分割 DNS 在交换机内即可结束,但需要管理两个位置。
比较中必须包含以下三项。
- 证书:为什么必须使用名称而非地址进行连接
- 路径:流量会到达哪里再返回
- Kubernetes:当 Pod 通过自身服务的外部地址调用自己时,也会出现相同情况,应如何避免
评分器会原样查找章节标题 ## 헤어핀、## 스플릿、## 무엇을 고르나。