WireGuard 教程:使用 DNS-SD 进行 NAT-to-NAT 穿透 - V2EX
V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
Distributions
Ubuntu
Fedora
CentOS
中文资源站
网易开源镜像站
yangchuansheng33
V2EX    Linux

WireGuard 教程:使用 DNS-SD 进行 NAT-to-NAT 穿透/h1>
  •  
  •   yangchuansheng33
    yangchuansheng 2021-02-01 13:48:12 +08:00 3989 次点击

    这是一个创建于 1717 天前的主题,其中的信息可能已经有所发展或是发生改变。

    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 voi 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 哪个链接失效了
    slowman
        3
    slowman  
       2021-04-20 18:51:11 +08:00
    yangchuansheng33
        4
    yangchuansheng33  
    OP
       2021-05-05 00:53:31 +08:00
    @1423 我也没说不是翻译的啊,你这搞得一副破案的样子。。。具体可看我原博客的底部评论
    关于     帮助文档     自助推广系统     博客     API     FAQ     Solana     1061 人在线   最高记录 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 25ms UTC 18:12 PVG 02:12 LAX 11:12 JFK 14:12
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86