客户端会遍历配置好的 WireGuard Peer(wg show <interface> peers),并为每一个 Peer 发送一个数据包给服务端,其中 my_pubkey 和 their_pubkey 字段会被适当填充。当服务端收到来自客户端的数据包时,它会向以公钥为密钥的 Peer 内存表中插入或更新一个 pubkey=my_pubkey 的 entry,然后再从该表中查找 pubkey=their_pubkey 的 entry,一但发现 entry 存在,就会将其中的 IP:Port 发送给客户端。当客户端收到回复时,会将 IP 和端口从数据包中解包,并配置 Peer 的 endpoint 地址(wg set <interface> peer <key> <options...> endpoint <ip>:<port>)。
entry 结构体源码:
struct entry { uint8_t pubkey[32]; uint32_t ip; uint16_t port; };entry 结构体中的 ip 和 port 字段是从客户端收到的数据包中提取的 IP 和 UDP 头部,每次客户端请求 Peer 的 IP 和端口信息时,都会在 Peer 列表中刷新自己的 IP 和端口信息。
上面的例子展示了 WireGuard 如何实现 UDP 打洞,但还是太复杂了,因为并不是所有的 Peer 端都能打开原始套接字(raw socket),也并不是所有的 Peer 端都能利用 BPF 过滤器。而且这里还用到了自定义的 wire protocol,代码层面的数据(链表、队列、二叉树)都是结构化的,但网络层看到的都是二进制流,所谓 wire protocol 就是把结构化的数据序列化为二进制流发送出去,并且对方也能以同样的格式反序列化出来。这种方式是很难调试的,所以我们需要另辟蹊径,利用现有的成熟工具来达到目的。
6. WireGuard NAT 穿透的正解其实完全没必要这么麻烦,我们可以直接利用 WireGuard 本身的特性来实现 UDP 打洞,直接看图:
你可能会认为这是个中心辐射型(hub-and-spoke)网络拓扑,但实际上还是有些区别的,这里的 Registry Peer 不会充当网关的角色,因为它没有相应的路由,不会转发流量。Registry 的 WireGuard 接口地址为 10.0.0.254/32,Alice 和 Bob 的 AllowedIPs 中只包含了 10.0.0.254/32,表示只接收来自 Registry 的流量,所以 Alice 和 Bob 之间无法通过 Registry 来进行通信。
这里有一点至关重要,Registry 分别和 Alice 与 Bob 建立了两个隧道,这就会在 Alice 和 Bob 的 NAT 上打开一个洞,我们需要找到一种方法来从 Registry Peer 中查询这些洞的 IP:Port,自然而然就想到了 DNS 协议。DNS 的优势很明显,它比较简单、成熟,还跨平台。有一种 DNS 记录类型叫 SRV记录(Service Record,服务定位记录),它用来记录服务器提供的服务,即识别服务的 IP 和端口,RFC6763 用具体的结构和查询模式对这种记录类型进行了扩展,用于发现给定域下的服务,我们可以直接利用这些扩展语义。
7. CoreDNS选好了服务发现协议后,还需要一种方法来将其与 WireGuard 对接。CoreDNS 是 Golang 编写的一个插件式 DNS 服务器,是目前 Kubernetes 内置的默认 DNS 服务器,并且已从 CNCF 毕业。我们可以直接写一个 CoreDNS 插件,用来接受 DNS-SD(DNS-based Service Discovery)查询并返回相关 WireGuard Peer 的信息,其中公钥作为记录名称,fuckcloudnative.io 作为域。如果你熟悉 bind 风格的域文件,可以想象一个类似这样的域数据:
_wireguard._udp IN PTR alice._wireguard._udp.fuckcloudnative.io. _wireguard._udp IN PTR bob._wireguard._udp.fuckcloudnative.io. alice._wireguard._udp IN SRV 0 1 7777 alice.fuckcloudnative.io. alice IN A 2.2.2.2 bob._wireguard._udp IN SRV 0 1 8888 bob.fuckcloudnative.io. bob IN A 3.3.3.3 公钥使用 Base64 还是 Base32 ?到目前为止,我们一直使用别名 Alice 和 Bob 来替代其对应的 WireGuard 公钥。WireGuard 公钥是 Base64 编码的,长度为 44 字节:
$ wg genkey | wg pubkey UlVJVmPSwuG4U9BwyVILFDNlM+Gk9nQ7444HimPPgQg=Base 64 编码的设计是为了以一种允许使用大写字母和小写字母的形式来表示任意的八位字节序列。
—
不幸的是,DNS 的 SRV 记录的服务名称是不区分大小写的:
DNS 树中的每个节点都有一个由零个或多个标签组成的名称 [STD13, RFC1591, RFC2606],这些标签不区分大小写。
—
Base32 虽然产生了一个稍长的字符串(56 字节),但它的表现形式允许我们在 DNS 内部表示 WireGuard 公钥:
Base32 编码的目的是为了表示任意八位字节序列,其形式必须不区分大小写。