frp-panel 支持 WireGuard 组网
背景
最近正在给 frp-panel 开发一个新功能,功能是将安装有 frp-panel 的设备添加组网功能,能够简化 Site-to-Site WireGuard 的部署。
先看看效果:

我们知道普通的 WireGuard 部署中,如果机器较多,实现非星形高效组网或者调整路由,都是一件很麻烦的事情。操作者需要到每台机器上添加 Peer,如果有多跳的情况更是复杂。
而 WireGuard 本身自己并不想参与到简化组网过程的优化中,于是萌生出了诸如 Tailscale、Firezone、Netbird、Netmaker 等 P2P 组网产品。我自己也是 Tailscale 的忠实用户。
随着我的使用逐步深入,Tailscale 等产品的弱点就慢慢浮现,开始影响我的使用。
首先,这些组网产品都对直连有非常深的执念。以 Tailscale 为例,其公司提供了 DERP Server,用于直连失败时的 P2P Relay。两个 Peer 经过 DERP 通信时,也会不断尝试 NAT Traversal 实现直连。一旦发现成功,就会完全抛弃 DERP。其余产品也都是类似逻辑。
那么这样的规则有什么问题呢?从直观感受来说,如果能直连,当然是直连最好最快。但互联网是一坨巨大的 💩 山。
例如,在上海与香港的服务器通信,很可能需要绕地球一圈,而假如有一个位于深圳的 DERP,与两个 Peer 通信都有较低延迟,走 DERP 通信反而会比直连体验更好。
并且很多时候由于运营商对 UDP 的 QoS,直连的带宽会低于 Relay。
再例如,有些时候你的公司分处 A、B 两地,两处分别有一台服务器由专线连接,这时两地公司内的设备都需要加入网络,如果都是 P2P 反而会难以保证网络质量,如果都通过局域网加入对应地区的服务器,再由专线通信,网络质量也会有大幅提升。
因此在我看来,能更加智能的选择路由是一个组网工具的重要功能。
探索
上述种种原因,让我开始思考,有没有一种方式,可以既有 P2P 组网工具的便利,也有原始 WireGuard 的自定义程度呢?
想到这样的需求和 SDN 要解决的问题有重合之处。在互联网上翻翻找找也没有看到比较好的成熟方案。
既然如此,根据咱们的传统,当然是要自己写一个。
思考一番,决定先参考一下优秀的 Tailscale:https://tailscale.com/blog/how-tailscale-works
发现 Tailscale 架构中分布式与中心化结合的设计思路非常好:控制平面中心化,用于简化管理和实现安全规则;数据平面分布式,用于承载大带宽的数据传输。一味地追求完全分布式,会在架构和功能层面等都有一些妥协。
那我们也应当使用类似的架构。回头一看,frp-panel 不正是这样的设计吗!回旋镖终于打到了我自己。
设计
正好 frp-panel 也是网络相关的应用,一些概念和抽象可以很好与本次需求缝合。二话不说,开缝!
角色抽象设计
- Master:作为配置分发和信息交换中心。
- Server:作为类似 DERP 的中继。
- Client:运行 TUN 来组网。
基础的角色仍然复用已有的部分。新增的有:
- Network:网络,可以加入很多的节点,可以用 ACL 控制 Peer 间直接连接的可见性,需要用户手动创建。
- WireGuard:WG 设备,对应 WireGuard 接口,也可以理解为一个节点,每个 WG 设备对应网络中的一个 IP,用于控制 WG 配置,需要用户手动创建。
- Endpoint:WG 对公网暴露的端点,可以分配给给一个 WireGuard 设备,用于让其他 Peer 可以连接到自己(受 ACL 可见性控制),一个 WG 设备可以有多个 Endpoint,需要用户手动创建。
- Link:连接,WG 设备之间的连接就是 Link,Link 一般无需用户手动创建,ACL 通过的两个 WG 设备间会自动建立连接。也可以手动创建强制连接的 Link,用于突破 ACL 的限制。Link 具有延迟、带宽、对端 Endpoint 的属性。Link 的两端 WG 至少有一端 WG 需要具有 Endpoint。
路由架构
既然是为了解决 WireGuard 部署困难、繁琐的问题,我们与 frp-panel 最初的设计思路一致:尝试把 wireguard-go 直接在 frp-panel 中使用,这样避免了进程间通信,减少了部署需要的依赖,版本之间也更好做兼容和服务管理。
由于 WireGuard 的设计是 P2P,每一个 Peer 都作为同等地位的实体存在,一个 Peer 既能承载到他的流量,也可以在配置合适时进行转发中继。因此类似 Tailscale DERP 的身份天然就可以通过 Peer 实现(话说最近 Tailscale 也支持了 Peer Relay),更绝的是,WireGuard Peer 中继并不只限制一次转发,只要 AllowedIPs 配置正确,甚至可以无限多跳转发!
例如下面的场景,我们希望从 A 到 D 的链路中,经过 B 和 C 的中继。
A、B、C、D 四个 Peer,现在我们按照 WireGuard 的配置,配置文件如下:
# wg0-A.conf
[Interface]
PrivateKey = [A_PRIVATE_KEY]
Address = 10.10.0.1/24
ListenPort = 51820
[Peer]
# 节点 B
PublicKey = [B_PUBLIC_KEY]
Endpoint = [B_PUBLIC_IP]:51820
AllowedIPs = 10.10.0.2/32,10.10.0.3/32,10.10.0.4/32
# wg0-B.conf
[Interface]
PrivateKey = [B_PRIVATE_KEY]
Address = 10.10.0.2/24
ListenPort = 51820
[Peer]
# 节点 A
PublicKey = [A_PUBLIC_KEY]
Endpoint = [A_PUBLIC_IP]:51820
AllowedIPs = 10.10.0.1/32
[Peer]
# 节点 C
PublicKey = [C_PUBLIC_KEY]
Endpoint = [C_PUBLIC_IP]:51820
AllowedIPs = 10.10.0.3/32,10.10.0.4/32
# wg0-C.conf
[Interface]
PrivateKey = [C_PRIVATE_KEY]
Address = 10.10.0.3/24
ListenPort = 51820
[Peer]
# 节点 B
PublicKey = [B_PUBLIC_KEY]
Endpoint = [B_PUBLIC_IP]:51820
AllowedIPs = 10.10.0.1/32,10.10.0.2/32
[Peer]
# 节点 D
PublicKey = [D_PUBLIC_KEY]
Endpoint = [D_PUBLIC_IP]:51820
AllowedIPs = 10.10.0.4/32
# wg0-D.conf
[Interface]
PrivateKey = [D_PRIVATE_KEY]
Address = 10.10.0.4/24
ListenPort = 51820
[Peer]
# 节点 C
PublicKey = [C_PUBLIC_KEY]
Endpoint = [C_PUBLIC_IP]:51820
AllowedIPs = 10.10.0.0/32,10.10.0.2/32,10.10.0.3/32,10.10.0.4/32
我们随便看看这些配置文件就能轻松发现,如果此时要新增一个节点,改动所有的 Peer 配置是必然的。
既然 frp-panel 天然拥有了中心化的配置分发能力,我们随便就能给所有 Peer 实时下发这些配置,因此配置修改难不倒我们。但还存在一个问题:当网络变得越来越复杂,我们应该如何知道每个 Peer 中的 AllowedIPs 分别应该填哪些地址或网络范围?
不难发现,这其实与路由协议的需求非常相似,目标都是给一个网络中的每个节点计算出合适的路由表,最终让 IP 地址各处可达。
那么当然需要研究一下现在的通用实践:OSPF 协议。
研究后发现路由协议的应用场景虽然与我们目标类似,但还是复杂了很多。由于路由协议都设计有“传播时间”这种概念,对于我们这种小型网络实际上是用不上的,并且为了处理分布式的路由器不一致的现象导致的网络不可达,协议使用了复杂的抽象去处理这些问题。而这是我们无需关注的部分。
通过对比发现,frp-panel 的 Master 节点实际上可以拥有全局状态,能够准确掌握整个网络的拓扑结构,因此问题就被简化了很多。Client 将所有的信息都传递至 Master,让 Master 聚合所有的信息,通过全局视角为每个 Client 计算出到达其他所有 Client 的最短路,这样就能获得网络中所有设备的最佳路由表。
总体流程如下:
这样我们就完成了架构设计,最终结果证明这样的架构是非常可用的。但还存在一些改进点,例如现在的配置太过依赖 Master,跟 EasyTier 的作者交流了几句,思考发现 frp-panel 是不是可以考虑在 Client 做一些路由计算的工作来提升网络的鲁棒性,不过这样也会遇到配置可信、配置不一致的问题。后续再看看能不能搞吧。
传输层设计
这里的传输层不是 TCP/IP 协议中的传输层, 而是在 WireGuard 数据报文之下,在 TCP/IP 传输层之上构建的一个中间层,用于混淆、加密、中继 WireGuard 数据包。
由于 WireGuard 使用 UDP 作为传输层协议,而 UDP 在运营商处有严重的 QoS。因此我们当然是要支持 TCP 作为额外的传输层。
并且仅仅是 TCP 还不够,很多时候我们的服务器处于复杂的网络环境下,其他服务器直连的体验不能保证一定是良好的。此时一般的解决方案是引入 CDN 作为加速手段,单纯的 TCP 是不能被 CDN 加速的,所以支持 WebSocket 是一个更好的选择。
因此我们就将传输层确定为 WireGuard 原始的 UDP 协议和基于 TCP 的 WebSocket 协议,后续也期望能基于 💿🗃️ 做更多的协议(也许)。
实现
到此我们就完成了整体流程、协议的设计,现在按照设计开始实现。
后端
后端比较重要的有两点:
首先是路由计算,我们选择 Dijkstra 算法,计算每个 Node 到其他所有 Node 的最短路。并且在计算最短路时,按照我的需求,需要加入延迟、带宽作为边权,以便处理某些直连很慢但 Relay 很快的情况。
其次是传输层,这部分最开始我以为会是很困难的一部分,结果实现后发现 wireguard-go 的接口设计非常优雅,我极其轻松的就将 WebSocket 无缝融入了 WireGuard 之下,但这部分太过细节,为了实现高性能的传输层需要有,写到这边文章中想必会让内容变得非常冗长,这里如果有朋友想要了解后续可以单起一篇文章来讲解。
最终我们的目录结果如下:
# coder@dev ~/frp-panel/services/wg ()> tree
.
├── acl_builder.go # ACL 规则处理器
├── helper.go
├── helper_test.go
├── multibind # 用于处理聚合多个传输层
│ ├── multi_bind.go
│ ├── multi_endpoint.go
│ └── transport.go
├── routing_planner.go # 路由计算器
├── runtime_parser.go # WireGuard 运行时信息解析
├── topology_cache.go # 网络拓扑缓存,用于 Master 计算最短路
├── transport # 传输层,目前只有 WS 的实现,UDP 的实现内置在 wireguard-go 中
│ └── ws
│ ├── bind.go # Bind,用于传递数据给 WireGuard 引擎
│ ├── endpoint.go
│ └── types.go
├── uapi_builder.go
├── wireguard.go # WireGuard 引擎
└── wireguard_manager.go # 用于管理一个 Client 上的多个 WireGuard
非常的优雅。
前端
后端我们已经写完了!现在开始写点前端。
按照惯例我们的前端必须非常炫酷好用。市面上的各种组网软件都是各种填表、命令行、参数等等,即便是已经熟练使用各种组网软件也难免有记错的时候。
所以我们必须要做一个好用的前端,对于新手和大佬都要足够友好。我们在很多计算机网络的文章中都看到过各种拓扑图,非常直观,但画这种拓扑图并不轻松,每改一次配置都要画好一阵才能好看。
出于这种考虑,我认为直接搞一个自动生成的拓扑图,并且可以直接在图中查看、修改各种配置才是一个炫酷的交互方式。并且为了方便调试各种 ping、trace、测速,还应该加入终端、日志在这张图中。这些功能想想都刺激,两个节点之间一拖一连就能组网,多舒服啊!
半夜给我整激动了,于是决定用 React Flow 来搞这个图,经过一番折腾,效果如下:
这是一个从 frp-panel 抽取出的 Demo,给大家体验一下,功能很可能不全哦。
太舒服了家人们。
最后
现在组网功能已经提交到 frp-panel 的主线,编译 Release 为 Alpha 版可用,我已经使用几个月了,但总感觉有没发现的 Bug,大家也可以一起来试试,真的好用家人们。
后续也有很多功能需要完善,比如更多的传输层、NAT Traversal、客户端自主路由、分布式高可用、更炫酷的前端等等,慢慢来吧。唉,累死我了。