V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Distributions
Ubuntu
Fedora
CentOS
中文资源站
网易开源镜像站
yangchuansheng33
V2EX  ›  Linux

WireGuard 教程:使用 DNS-SD 进行 NAT-to-NAT 穿透

  •  
  •   yangchuansheng33 ·
    yangchuansheng · 2021-02-01 13:48:12 +08:00 · 3090 次点击
    这是一个创建于 1395 天前的主题,其中的信息可能已经有所发展或是发生改变。

    WireGuard 是由 Jason A. Donenfeld 等人创建的下一代开源 VPN 协议,旨在解决许多困扰 IPSec/IKEv2OpenVPNL2TP 等其他 VPN 协议的问题。2020 年 1 月 29 日,WireGuard 正式合并进入 Linux 5.6 内核主线。

    利用 WireGuard 我们可以实现很多非常奇妙的功能,比如跨公有云组建 Kubernetes 集群,本地直接访问公有云 Kubernetes 集群中的 Pod IP 和 Service IP,在家中没有公网 IP 的情况下直连家中的设备,等等。

    如果你是第一次听说 WireGuard,建议你花点时间看看我之前写的 WireGuard 工作原理。然后可以参考下面两篇文章来快速上手:

    如果遇到某些细节不太明白的,再去参考 WireGuard 配置详解

    本文将探讨 WireGuard 使用过程中遇到的一个重大难题:如何使两个位于 NAT 后面(且没有指定公网出口)的客户端之间直接建立连接。

    WireGuard 不区分服务端和客户端,大家都是客户端,与自己连接的所有客户端都被称之为 Peer

    1. IP 不固定的 Peer

    WireGuard 的核心部分是加密密钥路由( Cryptokey Routing ),它的工作原理是将公钥和 IP 地址列表(AllowedIPs)关联起来。每一个网络接口都有一个私钥和一个 Peer 列表,每一个 Peer 都有一个公钥和 IP 地址列表。发送数据时,可以把 IP 地址列表看成路由表;接收数据时,可以把 IP 地址列表看成访问控制列表。

    公钥和 IP 地址列表的关联组成了 Peer 的必要配置,从隧道验证的角度看,根本不需要 Peer 具备静态 IP 地址。理论上,如果 Peer 的 IP 地址不同时发生变化,WireGuard 是可以实现 IP 漫游的。

    现在回到最初的问题:假设两个 Peer 都在 NAT 后面,且这个 NAT 不受我们控制,无法配置 UDP 端口转发,即无法指定公网出口,要想建立连接,不仅要动态发现 Peer 的 IP 地址,还要发现 Peer 的端口。

    找了一圈下来,现有的工具根本无法实现这个需求,本文将致力于不对 WireGuard 源码做任何改动的情况下实现上述需求。

    2. 中心辐射型网络拓扑

    你可能会问我为什么不使用中心辐射型( hub-and-spoke )网络拓扑?中心辐射型网络有一个 VPN 网关,这个网关通常都有一个静态 IP 地址,其他所有的客户端都需要连接这个 VPN 网关,再由网关将流量转发到其他的客户端。假设 AliceBob 都位于 NAT 后面,那么 AliceBob 都要和网关建立隧道,然后 AliceBob 之间就可以通过 VPN 网关转发流量来实现相互通信。

    其实这个方法是如今大家都在用的方法,已经没什么可说的了,缺点相当明显:

    • 当 Peer 越来越多时,VPN 网关就会变成垂直扩展的瓶颈。
    • 通过 VPN 网关转发流量的成本很高,毕竟云服务器的流量很贵。
    • 通过 VPN 网关转发流量会带来很高的延迟。

    本文想探讨的是 AliceBob 之间直接建立隧道,中心辐射型( hub-and-spoke )网络拓扑是无法做到的。

    3. NAT 穿透

    要想在 AliceBob 之间直接建立一个 WireGuard 隧道,就需要它们能够穿过挡在它们面前的 NAT 。由于 WireGuard 是通过 UDP 来相互通信的,所以理论上 UDP 打洞( UDP hole punching ) 是最佳选择。

    UDP 打洞( UDP hole punching )利用了这样一个事实:大多数 NAT 在将入站数据包与现有的连接进行匹配时都很宽松。这样就可以重复使用端口状态来打洞,因为 NAT 路由器不会限制只接收来自原始目的地址(信使服务器)的流量,其他客户端的流量也可以接收。

    举个例子,假设 Alice 向新主机 Carol 发送一个 UDP 数据包,而 Bob 此时通过某种方法获取到了 Alice 的 NAT 在地址转换过程中使用的出站源 IP:PortBob 就可以向这个 IP:Port( 2.2.2.2:7777 ) 发送 UDP 数据包来和 Alice 建立联系。

    其实上面讨论的就是完全圆锥型 NAT( Full cone NAT ),即一对一( one-to-one ) NAT 。它具有以下特点:

    • 一旦内部地址( iAddr:iPort )映射到外部地址( eAddr:ePort ),所有发自 iAddr:iPort 的数据包都经由 eAddr:ePort 向外发送。
    • 任意外部主机都能经由发送数据包给 eAddr:ePort 到达 iAddr:iPort 。

    大部分的 NAT 都是这种 NAT,对于其他少数不常见的 NAT,这种打洞方法有一定的局限性,无法顺利使用。

    4. STUN

    回到上面的例子,UDP 打洞过程中有几个问题至关重要:

    • Alice 如何才能知道自己的公网 IP:Port
    • Alice 如何与 Bob 建立连接?
    • 在 WireGuard 中如何利用 UDP 打洞?

    RFC5389 关于 STUNSession Traversal Utilities for NAT,NAT 会话穿越应用程序)的详细描述中定义了一个协议回答了上面的一部分问题,这是一篇内容很长的 RFC,所以我将尽我所能对其进行总结。先提醒一下,STUN 并不能直接解决上面的问题,它只是个扳手,你还得拿他去打造一个称手的工具:

    STUN 本身并不是 NAT 穿透问题的解决方案,它只是定义了一个机制,你可以用这个机制来组建实际的解决方案。

    RFC5389

    STUNSession Traversal Utilities for NAT,NAT 会话穿越应用程序)是一种网络协议,它允许位于 NAT (或多重 NAT )后的客户端找出自己的公网地址,查出自己位于哪种类型的 NAT 之后以及 NAT 为某一个本地端口所绑定的公网端口。这些信息被用来在两个同时处于 NAT 路由器之后的主机之间建立 UDP 通信。该协议由 RFC 5389 定义。

    STUN 是一个客户端-服务端协议,在上图的例子中,Alice 是客户端,Carol 是服务端。AliceCarol 发送一个 STUN Binding 请求,当 Binding 请求通过 Alice 的 NAT 时,源 IP:Port 会被重写。当 Carol 收到 Binding 请求后,会将三层和四层的源 IP:Port 复制到 Binding 响应的有效载荷中,并将其发送给 Alice。Binding 响应通过 Alice 的 NAT 转发到内网的 Alice,此时的目标 IP:Port 被重写成了内网地址,但有效载荷保持不变。Alice 收到 Binding 响应后,就会意识到这个 Socket 的公网 IP:Port 是 2.2.2.2:7777

    然而,STUN 并不是一个完整的解决方案,它只是提供了这么一种机制,让应用程序获取到它的公网 IP:Port,但 STUN 并没有提供具体的方法来向相关方向发出信号。如果要重头编写一个具有 NAT 穿透功能的应用,肯定要利用 STUN 来实现。当然,明智的做法是不修改 WireGuard 的源码,最好是借鉴 STUN 的概念来实现。总之,不管如何,都需要一个拥有静态公网地址的主机来充当信使服务器

    5. NAT 穿透示例

    早在 2016 年 8 月份,WireGuard 的创建者就在 WireGuard 邮件列表上分享了一个 NAT 穿透示例。Jason 的示例包含了客户端应用和服务端应用,其中客户端应用于 WireGuard 一起运行,服务端运行在拥有静态地址的主机上用来发现各个 Peer 的 IP:Port,客户端使用原始套接字( raw socket )与服务端进行通信。

    /* We use raw sockets so that the WireGuard interface can actually own the real socket. */
    sock = socket(AF_INET, SOCK_RAW, IPPROTO_UDP);
    if (sock < 0) {
    	perror("socket");
    	return errno;
    }
    

    正如评论中指出的,WireGuard 拥有“真正的套接字”。通过使用原始套接字( raw socket ),客户端能够向服务端伪装本地 WireGuard 的源端口,这样就确保了在服务端返回响应经过 NAT 时目标 IP:Port 会被映射到 WireGuard 套接字上。

    客户端在其原始套接字上使用一个经典的 BPF 过滤器来过滤服务端发往 WireGuard 端口的回复。

    static void apply_bpf(int sock, uint16_t port, uint32_t ip)
    {
    	struct sock_filter filter[] = {
    		BPF_STMT(BPF_LD + BPF_W + BPF_ABS, 12 /* src ip */),
    		BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, ip, 0, 5),
    		BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 20 /* src port */),
    		BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, PORT, 0, 3),
    		BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 22 /* dst port */),
    		BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, port, 0, 1),
    		BPF_STMT(BPF_RET + BPF_K, -1),
    		BPF_STMT(BPF_RET + BPF_K, 0)
    	};
    	struct sock_fprog filter_prog = {
    		.len = sizeof(filter) / sizeof(filter[0]),
    		.filter = filter
    	};
    	if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &filter_prog, sizeof(filter_prog)) < 0) {
    		perror("setsockopt(bpf)");
    		exit(errno);
    	}
    }
    

    客户端与服务端的通信数据都被定义在 packetreply 这两个结构体中:

    struct {
        struct udphdr udp;
        uint8_t my_pubkey[32];
        uint8_t their_pubkey[32];
    } __attribute__((packed)) packet = {
        .udp = {
            .len = htons(sizeof(packet)),
            .dest = htons(PORT)
        }
    };
    struct {
        struct iphdr iphdr;
        struct udphdr udp;
        uint32_t ip;
        uint16_t port;
    } __attribute__((packed)) reply;
    

    客户端会遍历配置好的 WireGuard Peer (wg show <interface> peers),并为每一个 Peer 发送一个数据包给服务端,其中 my_pubkeytheir_pubkey 字段会被适当填充。当服务端收到来自客户端的数据包时,它会向以公钥为密钥的 Peer 内存表中插入或更新一个 pubkey=my_pubkeyentry,然后再从该表中查找 pubkey=their_pubkeyentry,一但发现 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 结构体中的 ipport 字段是从客户端收到的数据包中提取的 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 用具体的结构和查询模式对这种记录类型进行了扩展,用于发现给定域下的服务,我们可以直接利用这些扩展语义。

    由于字数限制无法发表全文,原文请查看:https://fuckcloudnative.io/posts/wireguard-endpoint-discovery-nat-traversal/

    4 条回复    2021-05-05 00:53:31 +08:00
    ioiioi
        1
    ioiioi  
       2021-02-17 11:16:01 +08:00
    就喜欢作者的这种研究精神,而且深入浅出,容易理解。只是链接失效了。
    yangchuansheng33
        2
    yangchuansheng33  
    OP
       2021-02-19 14:45:43 +08:00
    @ioiioi 哪个链接失效了
    1423
        3
    1423  
       2021-04-20 18:51:11 +08:00
    yangchuansheng33
        4
    yangchuansheng33  
    OP
       2021-05-05 00:53:31 +08:00
    @1423 我也没说不是翻译的啊,你这搞得一副破案的样子。。。具体可看我原博客的底部评论
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1021 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 25ms · UTC 20:17 · PVG 04:17 · LAX 12:17 · JFK 15:17
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.