前言
首先第一个问题是“这个项目是什么?”。答:异地组网,即把多个地方的局域网连接起来形成一个更大的局域网。
异地组网本质就是 VPN,属于 Site-to-site 类型。本质上就是每个节点都能向对方推送路由,且对方接受路由。
WireGuard、OpenVPN 等都支持这种模式。有做个异地组网的朋友或许还用过如 Tinc、Nebula、ZeroTier、Tailscale 以及 Netmaker 等 P2P/Mesh 产品,后三种相比 WireGuard、OpenVPN 上更为易用。
那么本文的组网与这些有什么区别?
- 采用了 Mesh 拓扑(并非指 Wifi 的 Mesh 组网,详情看文章底部解释)
- 大部分人用 OpenVPN 或者 WireGuard 都是用中心服务器的形式,即所有流量都需要经过中心服务器。那么中心服务器就成为了所有连接的瓶颈。
- 如 OpenVPN、WireGuard 这类产品虽然支持 Mesh 拓扑,但配置非常繁琐。如 WireGuard 实现 Mesh 有两种模式:一种是在一个 Interface 配置中包含所有节点(Peer),配置简单,但限制较大;另一种是每两个节点之间就构建一条线路,这意味着节点越多,需要配置的 interface 越多,该方案配置麻烦,如果是直接运行 WireGuard,那么每个机器都要占用一个端口,但好处是限制较低。
- Tinc、Nebula 等需要自己管理证书、密钥、公钥等,每个机器都要手动分发,且当新增设备后,已入网的设备都需要再分发配置,比较麻烦;且 Tinc 在性能上相比 WireGuard 低;
- ZeroTier、Tailscale 使用中心 WebUI 进行管理,解决了分发麻烦的缺点,默认也是采用 Mesh 拓扑。
- 动态路由:自动选择最佳路由
- 目前大部分 Mesh 组网都是用静态路由的形式,如 WireGuard 中的
AllowedIPs
配置项,OpenVPN 中的PushRoute
,Tailscale、ZeroTier 也是在 WebUI 上写死子网路由。 - 最佳路由的定义也是有分歧的。当你想传文件的时候,需求是带宽要大,但当你想联机打游戏的时候,需求是延迟要小。而 Tailscale 在寻找中转节点的时候,只会按照延时最小的规则去连接。
- 有时候直连并非是最好的链路,假设以带宽为链路质量指标,节点 A、B、C 都能互相通信,B 到 A、C 都是 100M 带宽,而 A 到 C 只有 10M,那么经过 B 中转的线路才是最好的。
- 这个需求就是能否自定义链路的权重。
- 目前大部分 Mesh 组网都是用静态路由的形式,如 WireGuard 中的
- 链路冗余:当链路失效时自动切换可用链路
- 目前网络上搜到的大部分 Mesh 组网,大部分都是只负责两个节点的 P2P 线路(直连线路),如果断了那就是断了。
- 但如 ZeroTier 以及 Tailscale 等,会有“中转节点”的设计,在 P2P 线路没通前会先经过中转节点,如果 P2P 线路一直不通,则一直使用中转节点进行通信。但我在使用 Tailscale 的时候体验并不好:自建了8个中转节点,包括三大运营商、教育网以及云厂商的线路,但仍然经常连接不上。
- 在我使用 Tailscale 的时候,经常出现 A 和 C 不能直连,但两者都能和 B 通信,但不会把 B 作为中转节点使用,而只能使用 Derper 中转节点。无疑造成了资源的浪费。
综上,本文旨在建立一个 Mesh
网络,且能够根据用户需求去定义和设置路由权重,且能做到链路冗余。
本文设计的局限性:
- 公网 IP 节点,无论是不是动态的,支持 IPv6。本文设计并不会涉及 NAT 打洞;
- 使用了 Linux 系统的网络栈,所有操作都是在 Linux 系统上;
- 没有友好的用户界面,不易操作;
架构
WireGuard 作为最底层,使用 UDP 连接,意味可以在绝大部分网络上运行。同时 WireGuard 也提供加密。
由于 WireGuard 是 L3 网络,不支持广播,而使用的动态路由协议需要广播工作,所以需要在 WireGuard 之上再建立一层支持 L2 的网络。这里我选择了 VXLAN。
本文的重点是动态路由协议,这里选择的动态路由协议是 Babel。
WireGuard 和 VXLAN 接口都需要分配 IP,这里用的是 IPv4 地址。为方便管理,设定如下规则:
- WireGuard 网络地址分配规则是
10.100.x.y/16
- VXLAN 网络的地址分配规则是
10.200.x.y/16
假设节点 A 的 WireGuard 地址是 10.100.11.4
,那么其 VXLAN 接口的地址是 10.200.11.4
WireGuard
WireGuard 是基于 UDP 的 VPN 协议。基于 UDP 意味绝大部分网络(TCP/IP)都能够运行。当然由于 UDP 以及协议设定(固定协议号),在一些 ISP 或防火墙环境下,直连 WireGuard 的效果不佳甚至直接被阻断。下文“改进和展望”中有提到如何绕过防火墙。
WireGuard 自身具有加密功能,相比 GRE+IPSEC 方案,配置上简介了许多,且性能损耗很低。
配置
本方案中 WireGuard 只需要启用一个 Interface。配置上,每个 Peer 的 AllowedIPs
只包括 Peer 本身的/32地址。
由于节点众多,如果每个机器都手动配置,那复杂度堪比 Tinc/Nebula。为了避免过于繁琐,我用了 wg-meshconf 来管理所有节点的信息,它还可以直接生成每个节点的配置文件,接着只要使用 scp
发送到节点上即可。
当节点多了,scp 还是有一些繁琐:假设已经有了10台机器,添加一台机器意味着要执行 11 次 scp 命令,如果用 wg-quick 意味这还需要重新启动 interface。
这里我用 hono 写了一个简单的后端,每个节点根据自己的 hostname 请求自己的配置。每个节点分发一个 bash 脚本,脚本流程如下:
- 使用
curl
请求自己的配置 - 使用
jq
解析响应 - 用
iproute2
以及wg
来配置 interface 以及 peer
把这个 bash 脚本放到 crontab 定时执行,如果有新的节点加入,那么脚本执行后就会自动更新。
但以上方案还是略微繁琐,特别对于讨厌脚本/Crontab 的朋友。好事是由于 WireGuard 的“优秀设计”,目前有挺多基于 WireGuard 的 Mesh 方案,我用过的一个是 wiresmith,这一软件通过与 Consul(其 KV 数据库)通信,上报自己的配置和获取 Peer 的配置,从而更新 Peer 的配置,这种方式类似 ZeroTier/Tailscale 等,通过一个后端来管理所有节点。
弃用原因:
- 个人不想使用 Consul,因为需要搭建服务器以及配置证书,而使用 Cloudflare Worker 对于我来说比较方便;
- 后续的 VXLAN 配置仍然需要使用 iproute2 来配置,干脆把 WireGuard 以及 VXLAN 的配置命令写到一个脚本上(当然你也可以选择魔改 wiresmith)
题外话:目前大多数的在 WireGuard 上使用动态路由协议的文章,都是设计成每两个节点之间一个 interface,这是由于 AllowedIPs 的限制(WireGuard 在同一个 interface 下,如果有多个 Peer 的 AllowedIPs 都包含了相同的路由,那么在整个运行期间,会随机选取一个并一直使用作为出口,参考)。
DDNS
由于我这边许多节点都是动态 IP,还需要考虑一个 DDNS 的问题。
这点我主要参考了 OpenWRT 的 wireguard_watchdog,其流程大致如下:
- 获取 WireGuard Interface 配置
- 使用
wg show <iface> latest-handshakes
获取握手信息 - 筛选出超时的节点,用
wg set <iface> peer <public_key> endpoint <endpoint>
重新配置
VXLAN
VXLAN 诞生是试图解决与大型云计算部署相关的可扩展性问题。其封装是将 OSI 第2层以太网帧封装在第4层 UDP 数据报内(WireGuard 是将 OSI 第3层的 IP Packet 封装在 UDP 数据报内)。封装以太网帧意味能够支持广播,满足了大部分动态路由协议对网络的要求。而基于 UDP 则保证了性能,且 VXLAN 没有加密、混淆功能,减少了开销。
配置
本文创建的 VXLAN 接口是组播类型的。
使用 ip link add <VXLAN_iface> type VXLAN id <VXLAN_id> dev <wg_iface> dstport 0
创建 VXLAN 接口。
VXLAN_iface
是 VXLAN 的 interface 名字;VXLAN_id
是类似 vlan id 的概念,在整个网络保持一致即可,比如我用的是 114514;wg_iface
是刚才创建的 WireGuard 接口名;
再使用 ip addr
命令给 VXLAN 接口分配 ip 地址。
接着添加转发表。
bridge fdb append to 00:00:00:00:00:00 dst <vtep_ip> dev <iface>
vtep_ip
:由于我们的 VXLAN 运行在 WireGuard 之上,所以 vtep_ip 是 WireGuard Peer IPiface
:VXLAN 的接口名
这里,每个 WireGuard Peer 都要运行上述命令,可以写一个脚本使用 wg
获取 Peer,然后在一个循环中执行上述命令。
假设节点 A 通过 VXLAN 与节点 B 通信(单播),此时会向所有的 Peer 发送 ARP 请求报文,其他节点收到后,会记录源 MAC 地址和 IP 地址信息到 FDB 表中,即 ARP 学习。对于节点 B,除了学习外,还需要响应,此时节点 A 会根据响应学习,并记录到 FDB 表中。
动态路由协议 babel
babel 是一个比较新的动态路由协议,在 2021 年,IETF Homenet 才制作其标准版本。 相对于 ospf、rip 等。 设计之初就考虑了 Mesh 网络,
开头提到的,链路冗余以及自定义路由权重(Cost/Weight)都由 Babel 提供。
本文使用 Bird2 运行 Babel,WireGuard 的所有节点都运行 Bird。
Bird 配置(/etc/bird.conf
)如下:
log syslog all;
debug protocols all;
debug channels all;
router id 10.200.X.Y;
protocol direct {
ipv4;
interface "enp2s0"; # 假设 enp2s0 接入的一个本地局域网(192.168.1.1/24)
}
protocol device {
}
protocol kernel {
ipv4 {
export all;
};
}
protocol babel {
ipv4 {
export all;
};
interface "<VXLAN_iface>" {
hello interval 1s;
};
}
上述会把 192.168.1.1/24 通过 Babel 广播出去。
MASQUERADE/IP FORWARD
每个节点需要开启 IP Forward(IP 转发)以及做 MASQUERADE。
这里我用的是 Firewalld,新建或使用已有的 Zone,比如我使用的是名为 Trusted
的 Zone,第一步先把网络接口加入进入
firewall-cmd --permanent --zone=trusted --add-interface=<wg_iface>
firewall-cmd --permanent --zone=trusted --add-interface=<vxlan_iface>
firewall-cmd --permanent --zone=trusted --add-interface=<other_iface> # 其他接口,比如其他 Tunnel 或者局域网的 eth0 等
firewall-cmd --permanent --zone=trusted --add-forward
firewall-cmd --permanent --zone=trusted --add-masquerade
firewall-cmd --reload
其中 permanent
参数是持久化的意思,如果带有这个参数,执行命令的时候并不会生效,而是在 reload
之后才会生效。如果不带这个参数会立刻生效,但 firewalld 重启后就会丢失规则。
扩展
动态修改路由 Cost
目前所有的 Cost 都是由公网带宽乘以一个常数系数得到。网络路由的原则是保留最大带宽,则需要宽带越大,Cost 越小(节点自动选择 Cost 最小的路由)。
跟路由协议一样,静态的 Cost 在某些场景上并不那么好用,比如:
- 网络有一个节点跑 PCDN,在晚高峰阶段会进行限速。如果依旧按照平常的 Cost,那么是对不上宽带需求的
- 一个节点长时间下载/上传,占用了公网带宽,此时希望可以根据网关或者其他方式获取剩余宽带,动态修改 Cost
动态修改路由 Cost 也方便切换到以延时为指标的 Cost 或者更高级的 Cost 指标(例如 EIGRP 的五要素)。这类指标或要素与时间有较大关系。
UDP/WireGuard 限制
在部分 ISP 或者防火墙的环境下,直接使用 WireGuard 效果并不好。此时可以考虑两种方式,
- 切换成其他 VPN,如 OpenVPN
- 继续使用
- Udp2Raw 这类方案,把 UDP 伪装成 TCP 发送(不是真正的 TCP 流,因此避免了计算、重传等)
- 使用各类端口转发程序(如 Gost),如此 WireGuard 就跑在各种协议(WebSocket、KCP)上,这样会多一层协议开销
Mesh 拓扑
Mesh 是网络拓扑中的一种,但近几年由于 Wifi 技术上也经常采用 Mesh 组网这词,导致许多朋友认为这个是 Wifi 组网。实则不是,但一样的是,网络设计上都用了 Mesh 的拓扑。
Mesh 拓扑的网络,每个节点之间都是尽可能互相连接的,这意味着,从一个节点到另外一个节点,有很多线路可以走,可以直连、或经过别的节点进行中转。
如上图,Node 1 想和 Node 5 进行通信,有若干种线路可选:
- 直连,Node 1 和 Node 5 之间有一条直连线路
- 1 -> 3 -> 5
- 1 -> 2 -> 3 -> 5
- 1 -> 2 -> 4 -> 3 -> 5
- 1 -> 4 -> 4 -> 3 -> 5
- 1 -> 4 -> 2 -> 3 -> 5
Bird 还是 FRR?
FRR 的可配置项比 Bird 要少。
有一次测试我采用了 OSPF,但无奈 FRR 的可配置项太少了,诸如邻居、包类型都无法修改。但这些 Bird 都是能够控制的。
FRR 的优点之一可能在于支持的协议稍微多一点,如 EIGRP。
OSPF/EIGRP
由于 OSPF 需要选举出 DR/BDR,设计上就已经和 Mesh 网络有些背道相驰了。
但用 OSPF 也是能够做到 Mesh 网络拓扑,但是要区分多个 Area。这样一来,配置变得也非常麻烦。
EIGRP 没有选举之说,但不会考虑可达性,假如当节点 A(还接了一个 192.168.1.0/24 的网段)到节点 B 链路不通,但都可以和节点 C 通信。节点 B 那边收到的 192.168.1.0/24 的路由,其下一条是节点 A 而不是节点 C。
Babel 会使用 Hello 包以及 IHU(I Heard You)来判断节点是否可达,假设在上面的场景中,A 和 B 由于不能互相通信,那么在节点 A 上对于目的 192.168.1.0/24 的下一条会设置为节点 C。