WireGuard 插件
wireguard 插件为 Umoo 管理的设备提供自动化、多租户网状 VPN 网络。每个设备组都可以加入专属的 WireGuard 网络。平台自动处理密钥对生成、IP 分配、STUN NAT 穿透及设备配置下发——服务端无需手动操作 wg 工具。
目录
架构概览
插件分布在两个通过消息总线通信的进程中:
┌────────────────────────────────────────────────────────────────────────┐
│ 后端(umoo server) │
│ │
│ ┌──────────────────────┐ ┌───────────────────────────────────┐ │
│ │ WireGuardService │ │ AdminService RPCs │ │
│ │ ─────────────────── │ │ ─────────────────────────────── │ │
│ │ • CreateMeshNetwork │◄────│ CreateWireGuardNetwork │ │
│ │ • IP 分配 │ │ EnrollGroupInNetwork │ │
│ │ • 节点注册 │ │ AddDeviceToNetwork │ │
│ │ • 配置构建 │ │ SetPeerRole / AssignRelay │ │
│ │ • 配置分发 │ │ ListWireGuardPeers │ │
│ └──────────┬───────────┘ └───────────────────────────────────┘ │
│ │ NATS 总线 │
└─────────────┼──────────────────────────────────────────────────────────┘
│
╔═════════╧═════════╗
║ 消息总线 ║
║ (NATS / local) ║
╚═════════╤═════════╝
│
┌─────────────┼──────────────────────────────────────────────────────────┐
│ 设备代理(umoo-agent) │ │
│ │ │
│ ┌─────────────────────────────────▼───────────────────────────────┐ │
│ │ WireGuardPlugin(代理端) │ │
│ │ ────────────────────────────────────────────────────────── │ │
│ │ • 订阅:cmd.plugin.wireguard.join │ │
│ │ cmd.plugin.wireguard.full_configs │ │
│ │ • 发布:evt.plugin.wireguard.pubkey(密钥 + 端点) │ │
│ │ evt.plugin.wireguard.endpoint(STUN 结果) │ │
│ │ • 管理:wg0、wg1、……(每个已加入网络一个) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘每设备每网络一个 WireGuard 接口。 如果一台设备属于三个组网络,则运行三个独立的 wgN 接口,各自拥有独立的密钥、IP 和节点列表。
IP 地址分配
IPAllocator(internal/backend/plugins/wireguard/ip_allocator.go)完全在内存中管理每网络 IP 池,采用顺序分配和显式释放策略。
工作原理
CIDR "100.64.1.0/24"
├── .0 保留(网络地址,跳过)
├── .1 → 第一次 Allocate() 调用
├── .2 → 第二次
│ ...
├── .254 → 第 254 次
└── .255 保留(广播地址,跳过)- 顺序分配,非随机 —
nextHost计数器递增并循环。这使节点 IP 在开发阶段可预期,但计数器会在服务器重启后重置。 - 重启注意事项:分配器仅存在于内存中。重启时,
PreloadAllocator会从各网络存储的subnet_cidr按网络调用。已分配的 IP 不会从数据库重播——现有节点保留allocated_ip列的值,但内存计数器从.1重新开始。若新节点加入一个已有.1的网络,分配器将再次分配.1,导致UpsertPeer调用冲突。生产环境修复方案:PreloadAllocator应通过查询wg_peers将所有现有节点 IP 预先标记为已分配。 - 最小子网:
/30(2 个可用主机)。分配器拒绝更小的子网。 - IPv6:不支持——仅接受 IPv4 CIDR。
默认 CIDR
推荐默认使用 100.64.0.0/10(RFC 6598 共享地址空间)。该范围:
- 在公共互联网上不可路由
- 不被家用路由器(
192.168.x)或云 VPC(10.x)使用 - 不与 Docker 桥接范围(
172.17.0.0/16)冲突
各组网络应从该范围内划分 /24 子网,例如:
组 A → 100.64.1.0/24 (254 台设备)
组 B → 100.64.2.0/24
...
组 N → 100.64.255.0/24设备端插件
源码:internal/agent/plugins/wireguard/
代理插件管理一组 networkState 条目(每个已加入网络一个),每个条目映射到一个 WireGuard 接口(wg0、wg1、……)。接口名称从进程内计数器顺序分配——第一个网络为 wg0,第二个为 wg1,以此类推。
平台实现(Go 库)
WGCommandRunner 接口抽象了所有平台操作。生产实现(nativeWGRunner)使用 Go 库而非调用 wg/wg-quick 命令行工具:
| 操作 | 旧方式(CLI) | 新方式(Go 库) |
|---|---|---|
| 密钥生成 | wg genkey / wg pubkey | wgtypes.GeneratePrivateKey() |
| 节点配置/加密 | wg set <iface> peer ... | wgctrl.Client.ConfigureDevice() |
| 接口统计 | wg show <iface> dump | wgctrl.Client.Device() |
| 接口创建(Linux) | wg-quick up | netlink.LinkAdd(&GenericLink{LinkType: "wireguard"}) |
| IP 分配(Linux) | 通过 wg-quick 执行 ip addr add | netlink.AddrAdd() |
| 路由管理(Linux) | 通过 wg-quick 执行 ip route add | netlink.RouteAdd() |
| 接口销毁(Linux) | wg-quick down | netlink.LinkDel()(自动删除路由) |
| 接口创建(macOS) | wg-quick up | wireguard-go <iface> 子进程 |
| IP 分配(macOS) | 通过 wg-quick 执行 ifconfig | exec ifconfig |
| 路由管理(macOS) | 通过 wg-quick 执行 route add | exec route -q -n add -inet |
| IP 转发 | sysctl | sysctl(不变——OS 工具,非 WireGuard 专有) |
| NAT 伪装(Linux) | iptables | iptables(不变) |
| NAT 伪装(macOS) | iptables | 存根 + 警告(请手动使用 pf) |
构建标签强制平台隔离:
wg_runner_common.go— 所有平台(wgctrl 调用、密钥操作、INI 解析器)wg_runner_linux.go—//go:build linux(netlink)wg_runner_darwin.go—//go:build darwin(wireguard-go、ifconfig、route)
WireGuard 配置生命周期
当服务端推送 full_configs 命令时,代理执行:
1. WriteConfig("/etc/wireguard/wg0.conf", content)
└── 将 INI 内容解析为 parsedWGConfig
缓存在 nativeWGRunner.pending["wg0"] 中
(不写入磁盘文件)
2. QuickDown("wg0") ← 如果接口已在运行
└── netlink.LinkDel / 终止 wireguard-go 进程
3. QuickUp("wg0")
└── [Linux] netlink.LinkAdd(wireguard 类型)
netlink.AddrAdd(10.0.0.5/24)
netlink.LinkSetUp
wgctrl.ConfigureDevice(私钥、监听端口、节点)
为每个节点 AllowedIP 执行 netlink.RouteAdd
└── [macOS] wireguard-go wg0(创建 utun)
sleep 100ms
ifconfig wg0 <ip> <ip> netmask <mask> broadcast <bcast>
ifconfig wg0 up
wgctrl.ConfigureDevice
为每个节点 AllowedIP 执行 route add全隧道路由(0.0.0.0/0)将被跳过并记录警告。服务端在正常网状配置中不会发送这些路由——仅分发明确的节点 /32 路由和出口子网。
STUN NAT 穿透
应用每个网络配置后,插件会启动一个 goroutine 探测 STUN 服务器:
go p.probeAndReportSTUN(nc.NetworkID, nc.StunServers)ProbeSTUN 遍历已配置的 STUN 服务器(如果未配置则回退到 Google/Cloudflare STUN),通过 UDP 发送 RFC 5389 绑定请求。成功后,解析 XOR-MAPPED-ADDRESS 属性以获取设备的公网 IP:port。
结果以包含端点的 evt.plugin.wireguard.pubkey 事件发布。服务端随后更新节点的 endpoint 列并触发全网配置重新分发,使所有节点获取到该设备的最新直连路由。
STUN 为尽力而为:若所有探测均失败,设备进入仅中继模式,endpoint 字段保持为空。没有端点的设备仍可在分配了中继节点的情况下通信。
服务端插件
源码:internal/backend/plugins/wireguard/
服务层
WireGuardService 是核心协调器,主要职责:
网络生命周期:
CreateMeshNetwork— 验证 CIDR,创建数据库行,初始化内存IPAllocatorDeleteNetwork— 级联删除数据库,从内存中移除分配器GetDomainNetwork/ListDomainNetworks— 透传到 repository
节点生命周期:
AddDeviceToNetwork— 向设备发布cmd.plugin.wireguard.join;设备生成密钥对后通过evt.plugin.wireguard.pubkey上报HandlePubkeyReport— 幂等注册:首次调用时分配 IP,后续调用时更新端点,然后调用DistributeToNetworkHandleEndpointReport— 仅更新端点(节点已注册),重新分发RemovePeer— 删除数据库行,将 IP 释放回分配器
配置构建(BuildNetworkConfig):
为网络中的指定设备构建 WireGuardNetworkConfig,包含:
Address:此设备的 VPN IP 及前缀长度(例如100.64.1.5/24)ListenPort:来自网络记录(默认 51820)StunServers:传递给设备用于端点发现Peers:每个其他已加入设备一条记录,包含:AllowedIPs:节点的/32网状 IP,若节点为出口网关则附加出口子网Endpoint:若已知则为直连IP:port,若存在中继分配则为中继端点Keepalive:通过中继路由或目标为中继节点时为 25 秒ViaRelay:直连端点不可用时为 true
配置分发
DistributeToDevice 和 DistributeToNetwork 是两个分发入口点。
DistributeToNetwork(networkID)
└── ListPeers(networkID)
└── for each peer → DistributeToDevice(deviceID)
DistributeToDevice(deviceID)
└── ListNetworksByDevice(deviceID) ← 该设备所属的所有网络
└── for each network → BuildNetworkConfig(network, deviceID)
└── 发布 cmd.plugin.wireguard.full_configs
主题:"device/{deviceID}/cmd.plugin.wireguard.full_configs"
载荷:{ networks: [...] }始终推送完整配置。 服务端从不发送增量节点差异。每次配置推送都包含所有网络和所有节点,使代理在配置方面保持无状态——它始终拥有完整的全局视图。
DistributeToNetwork 在任何影响连通性的状态变更后自动调用:节点注册、端点更新、节点角色变更、中继分配/移除。
中继系统
WireGuard 要求双方都知道对方的公网 IP 才能建立直连。处于严格 NAT 后面的设备(对称 NAT、无端口映射的 CGNAT)无法被直接访问。
节点中继(网络内):
- 将某设备指定为中继:
SetPeerRole(isRelay=true)。该设备必须有可达的公网端点。 - 将中继分配给受 NAT 限制的节点:
AssignRelay(networkID, peerDeviceID, relayDeviceID)。 - 下次配置分发时,所有设备将看到:
- 中继节点设有
PersistentKeepalive = 25 - 受 NAT 限制的节点标记
ViaRelay = true并使用中继端点
- 中继节点设有
受 NAT 限制的设备无法接受入站连接,但可以建立出站连接。双方均设置 PersistentKeepalive 后,中继节点将维持持久隧道供受限设备使用。
平台中继(跨租户,由 super_admin 管理):
平台中继(wg_platform_relays)是可服务于多个租户的基础设施级中继服务器。通过 CreatePlatformRelay(仅 super_admin)注册,并分配给租户网络。分配到网络的功能(AssignPlatformRelayToNetwork)当前标记为 CodeUnimplemented,属于计划中的功能。
出口网关
出口网关节点将发往外部子网(如本地网络)的流量通过指定设备路由。
- 在网关设备上执行
SetPeerRole(isEgress=true, egressSubnets=["192.168.10.0/24"])。 - 下次配置分发时,所有其他节点将获得指向网关网状 IP 的
192.168.10.0/24的AllowedIPs条目。 - 代理插件在网关设备的 WireGuard 接口上应用
EnableIPForwarding+ConfigureMasquerade。
总线消息协议
所有 WireGuard 插件消息使用以下主题结构:
命令(服务端 → 设备)
| 主题 | 方向 | 载荷 |
|---|---|---|
device/{deviceID}/cmd.plugin.wireguard.join | 服务端 → 设备 | {"network_id": "<uuid>"} |
device/{deviceID}/cmd.plugin.wireguard.full_configs | 服务端 → 设备 | 见下方 |
full_configs 载荷:
{
"networks": [
{
"network_id": "uuid",
"address": "100.64.1.5/24",
"listen_port": 51820,
"stun_servers": ["stun.l.google.com:19302"],
"is_relay": false,
"is_egress": false,
"egress_subnets": [],
"peers": [
{
"public_key": "base64...",
"allowed_ips": ["100.64.1.2/32"],
"endpoint": "203.0.113.10:51820",
"keepalive": 0
},
{
"public_key": "base64...",
"allowed_ips": ["100.64.1.3/32"],
"via_relay": true,
"endpoint": "203.0.113.99:51820",
"keepalive": 25
}
]
}
]
}事件(设备 → 服务端)
| 主题 | 方向 | 载荷 |
|---|---|---|
evt.plugin.wireguard.pubkey | 设备 → 服务端 | {"network_id": "uuid", "public_key": "base64...", "endpoint": "1.2.3.4:51820"} |
evt.plugin.wireguard.endpoint | 设备 → 服务端 | {"network_id": "uuid", "endpoint": "1.2.3.4:51820"} |
服务端在 WireGuardServerPlugin.Register() 中订阅 evt.plugin.wireguard.pubkey 并路由到 HandlePubkeyReport。endpoint 字段为可选——STUN 失败时不存在该字段。
ConnectRPC API
所有 RPC 均需要 JWT 认证 + X-Tenant-ID 请求头(另有说明除外)。
网络管理
| RPC | 所需权限 | 说明 |
|---|---|---|
CreateWireGuardNetwork | tenant_admin+ | 创建独立网状网络 |
GetWireGuardNetwork | JWT | 按 ID 获取网络 |
ListWireGuardNetworks | JWT | 列出租户的所有网络 |
DeleteWireGuardNetwork | tenant_admin+ | 删除网络及所有节点 |
EnrollGroupInNetwork | tenant_admin+ | 原子性地创建网络并加入组内所有当前设备 |
CreateWireGuardNetwork 请求:
{
"tenant_id": "uuid",
"name": "production-mesh",
"subnet_cidr": "100.64.1.0/24",
"group_id": "uuid", // 可选
"listen_port": 51820 // 0 → 默认 51820
}EnrollGroupInNetwork 是组级网状设置的推荐路径。它将:
- 使用组 ID 调用
CreateMeshNetwork - 获取所有当前组成员(最多 1000 个)
- 为每个成员调用
AddDeviceToNetwork,发布join命令
入组后加入该组的设备须通过 AddDeviceToNetwork 单独处理。
节点管理
| RPC | 所需权限 | 说明 |
|---|---|---|
AddDeviceToNetwork | operator+ | 向设备发布加入命令 |
RemoveDeviceFromNetwork | operator+ | 移除节点,释放 IP |
ListWireGuardPeers | JWT | 列出网络中的所有节点 |
SetPeerRole | operator+ | 设置中继/出口标志及出口子网 |
AssignRelay | operator+ | 为受 NAT 限制的节点分配中继 |
RemoveRelayAssignment | operator+ | 移除中继分配 |
SetPeerRole 请求:
{
"tenant_id": "uuid",
"network_id": "uuid",
"device_id": "uuid",
"is_relay": true,
"is_egress_gateway": false,
"egress_subnets": []
}更改任何角色标志将立即触发 DistributeToNetwork。
平台中继管理(仅 super_admin)
| RPC | 说明 |
|---|---|
CreatePlatformRelay | 注册新的基础设施中继服务器 |
ListPlatformRelays | 列出所有平台中继 |
DeletePlatformRelay | 移除平台中继 |
AssignPlatformRelayToNetwork | (尚未实现) |
RemovePlatformRelayFromNetwork | (尚未实现) |
租户配置
TenantConfig.WireGuardConfig 字段(TenantWireGuardConfig proto 消息)提供每租户默认配置:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
mesh_enabled | bool | false | 是否为此租户启用 WireGuard 网状网络 |
ip_range | string | 100.64.0.0/10 | 用于分配每组 /24 子网的默认 CIDR 池 |
default_listen_port | int32 | 51820 | 新网络的默认 UDP 监听端口 |
default_dns | string | — | 可选 DNS 服务器,推送到设备 |
default_keepalive | int32 | 0 | 所有节点的 PersistentKeepalive(0 = 禁用,中继路由时除外) |
nat_traversal_enabled | bool | true | 是否启用 STUN 探测 |
stun_servers | []string | Google/Cloudflare STUN | 每次 full_configs 推送时附带的 STUN 服务器列表 |
这些设置存储在 tenants.settings JSONB 列中,通过 UpdateTenantConfig 更新。目前这些设置尚不自动应用于新网络创建——运营人员今天必须在 CreateWireGuardNetwork 中明确传递相应值。
运维说明
已知限制
IP 分配器非崩溃安全。 服务端重启后,内存分配器从 .1 重新开始。现有节点在数据库中保留其已分配的 IP,但计数器不知道哪些地址已被占用。若新节点在分配器遇到冲突之前加入,将分配出重复地址。修复方案:启动时按网络将所有现有节点 IP 预加载到分配器中。
full_configs 为发布即忘。 服务端发布到总线后不等待设备 ACK。若设备离线,消息将丢失(NATS 最多一次)。当设备重连且状态机进入 Syncing 状态时,不会自动重新请求网状配置——服务端必须重新分发。这是一个缺口:设备重连应触发 DistributeToDevice。
代理不上报握手信息。 数据库中存在 last_handshake_at 列,但当前代理从不写入。代理插件仅通过 ShowDump 读取握手时间用于指标导出,不上报回服务端。
macOS 伪装未实现。 ConfigureMasquerade 在 Darwin 上是空操作并记录警告。在 macOS 上运行出口网关的运营人员必须手动配置 pf。
全隧道路由被跳过。 AllowedIPs 中的 0.0.0.0/0 被代理静默丢弃并记录警告。服务端在正常配置中不发送这类路由,但自定义配置可能触发此问题。
推荐部署模式
1. 创建设备组(例如"production-nodes")
2. 调用 EnrollGroupInNetwork:
- name: "production-mesh"
- subnet_cidr: "100.64.1.0/24"
- group_id: <production-nodes UUID>
3. 将一台设备指定为中继(理想情况下为具有静态 IP 的云虚拟机):
对该设备执行 SetPeerRole(is_relay=true)
4. 对于 STUN 失败的设备(对称 NAT):
AssignRelay(peerDeviceID=<NAT 设备>, relayDeviceID=<中继设备>)
5. 若需访问本地子网,指定一台网关设备:
SetPeerRole(is_egress_gateway=true, egress_subnets=["192.168.0.0/16"])加入后添加设备
AddDeviceToNetwork(network_id, device_id)此操作向设备发布 cmd.plugin.wireguard.join。设备生成密钥对后发布 evt.plugin.wireguard.pubkey。服务端注册节点、分配 IP,并向网络中的所有节点重新分发 full_configs。
监控
代理的 CollectMetrics 方法(由遥测插件调用)导出每节点 WireGuard 统计信息:
| 指标 | 说明 |
|---|---|
wg_peer_bytes_rx | 从该节点接收的字节数 |
wg_peer_bytes_tx | 向该节点发送的字节数 |
wg_peer_last_handshake | 最后一次成功握手的 Unix 时间戳 |
标签:network_id、peer_pubkey。
这些指标通过 wgctrl.Client.Device() 采集(Linux 上有 CAP_NET_ADMIN 时无需 root;WireGuard 内核模块通过 generic netlink 暴露统计信息)。