学习 Tailscale (1): 最简单的状态防火墙穿越

昨天在博客里写道要学习下 Tailscale, 今天简单试验一下 Tailscale 博客文章 How NAT traversal works 里面的最简单情况:UDP 通信在已知对方 IP 和 端口的情况下,穿越对方的状态防火墙

原理

原理方面上述的文章已经介绍得非常清楚了,尤其图片画得很清晰,我根据我的理解描述下。

通常情况下,状态防火墙(stateful firewall) 会对入站流量进行检测,只有在最近一段时间内曾做过目的地址的 (ip, port) 发来的流量才会被接受。

于是就有了这个最简单的穿越状态防火墙的例子,即双方互相知道对方的 IP 的端口:

  1. 假设 A (2.2.2.2, 12345) 与 B (3.3.3.3, 54321) 需要通信,双方提前约好
  2. A 给 B 发了一个包,显然,肯定会被 B 的防火墙阻止,但是 B 的信息会被 A 的防火墙临时记录下来
  3. B 给 A 回复一个包,这时,A 的防火墙根据当前状态会选择接受这个包(这也是显然的,不然 A 就没法上网了)。另外,A 的信息这时也会被 B 的防火墙记录
  4. 于是,在这之后,A 给 B 发的包也会被 B 的防火墙接受。双向通信建立起来了。

啊,没有 NAT 的网络多么美好。

验证

于是用两个有公网 IPv6 地址的设备进行了小实验复现这个过程,双方都开启了 ufw 防火墙,平时无法直接访问到它的 UDP 端口。

只需要在两台机器上几乎同时地运行以下脚本,就能看到双方可以正常通信了:

import sys
import time
import socket

PORT_I = 12345
PORT_R = 54321

ADDR_I = ("<initiator address>", PORT_I)
ADDR_R = ("<responder address>", PORT_R)

# initiator
def initiator():
    sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
    sock.bind(ADDR_I)
    sock.sendto(b"Ping", ADDR_R)
    while data := sock.recvfrom(1024):
        print(f"recv: {data[0]} from {data[1]}")
        time.sleep(1)
        sock.sendto(b"a"*10, ADDR_R)

# responder
def responder():
    sock = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
    sock.bind(ADDR_R)
    sock.sendto(b"Pong", ADDR_I)
    while data := sock.recvfrom(1024):
        print(f"recv: {data[0]} from {data[1]}")
        time.sleep(1)
        sock.sendto(b"b"*10, ADDR_I)

if __name__ == '__main__':
    if len(sys.argv) != 2:
        print("Invalid args")
    if sys.argv[1] == "1":
        initiator()
    elif sys.argv[1] == "2":
        responder()

但是这种情况过于理想,现实中收数据的一方必须几乎同时作出反应才行,这需要它提前知道对方要给自己发消息。这也很容易验证,A 先运行脚本之后,B 等上一小会再运行,发现这次就不能通信了。

因此,还需要一个”侧信道“,即一个双方都能访问的协调用的服务器,通知接收方及时回复消息。

总结

水了一篇博客,演示了一个最简单的 UDP 通信穿越状态防火墙的例子, 下一步计划是实现一个简单的用于协调双方的服务器。