LabHub
学习 学习路径 课程

地址、子网与网关

复现发夹 NAT,再用两种办法解掉

在 LabHub 中继续学习

目标

在一个 Pod 中创建家中笔记本电脑、路由器和服务器三个位置,并实际复现仅更改 目标地址时响应无法返回的情况。然后连源地址也一起更改, 使通信成功,并与从名称解析侧解决同一问题的方法进行比较。

为什么重要

“外部可以访问,只有家里无法访问”这类报告很难猜测原因。 并非无法连接,也并非无法解析名称;响应确实返回了, 但接收端将其丢弃。丢弃操作不会在日志中留下任何内容。

本实验让你亲眼看到被丢弃的瞬间。调用 connect() 的 UDP 套接字 只接收来自所连接对端的数据,内核会悄无声息地丢弃其余数据。这与笔记本电脑 等待来自路由器地址的响应,却丢弃来自服务器私有地址的响应 完全相同。

此 Pod 中可以做和不能做的事

实验 Pod 没有内核权限,因此 ip netnsnfttcpdump 都无法运行。 不过,127.0.0.0/8整个网段都是本机自身,所以即使没有权限,也能 在一台主机上同时使用 127.0.0.10127.0.0.20127.0.0.99。 我们用这三个地址区分三个位置。第 6 步中的 nftables 规则只需写出来, 不实际应用。

步骤

  1. /root/hairpin/01-map.txt 中写下三个位置的地址和作用。分别是 laptop 127.0.0.10router 127.0.0.99server 127.0.0.20,并另写一行说明为何能在一台主机上同时使用这三个地址。
  2. /root/hairpin/site/index.html 中写入一行 hairpin-lab-server,并通过 127.0.0.20:9000 发布该目录。将连接私有地址的结果和连接 127.0.0.99:9000 的结果一并写入 /root/hairpin/02-direct.txt
  3. /root/hairpin/03-tuple.txt 中用五行写出仅更改目标地址时(只有 DNAT)数据包的源地址和目标地址如何变化。每行分别以 request-outafter-dnatreplyexpectedmismatch= 开头。
  4. 创建 /root/hairpin/nat.py。运行 python3 nat.py dnat-only 时,必须用三个套接字复现该情况,并在最后一行输出 result=dropped。将运行结果保存到 /root/hairpin/04-dnat-only.txt
  5. 让同一工具的 python3 nat.py dnat-snat 连源地址也一起更改,从而输出 result=delivered,并将结果保存到 /root/hairpin/05-dnat-snat.txt
  6. 将真正要配置在路由器上的规则写入 /root/hairpin/06-rules.nft。服务器是 192.168.0.20,家庭网段是 192.168.0.0/24,端口是 80。需要 prerouting 和 postrouting 两条链,而且更改源地址的规则必须只匹配来自家中的流量
  7. 从名称侧解决同一问题。在 /root/hairpin/hosts.internal 中写入 home.example.test 的内部应答,使用 curl --resolve 分别模拟内部应答和外部应答,并将结果写入 /root/hairpin/07-split.txt
  8. /root/hairpin/08-compare.md 中比较两种方案。必须包含 ## 헤어핀## 스플릿## 무엇을 고르나 三节。

参考

绘制三个位置的地图

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 只更改目标地址。因此,即使通过路由器之后,源地址仍是笔记本电脑,服务器会直接向笔记本电脑响应。

依次写下四行。

在最后一行 mismatch= 中写明 replyexpected 在哪个位置不一致。端口号可任选一个值,只要始终保持一致即可。

只更改目标地址时,响应会被丢弃

关键在于 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 在交换机内即可结束,但需要管理两个位置。

比较中必须包含以下三项。

评分器会原样查找章节标题 ## 헤어핀## 스플릿## 무엇을 고르나