本页是 architecture.md §2.2 的协议层附录,详细描述 NSGW 如何在不解密的前提下中继 WireGuard 流量,以及与原生 WireGuard 客户端的兼容矩阵。设计参照 Tailscale Peer Relay。
适合的读者:NSGW 实现者、要把站点节点接入 NSIO 但只能跑原生 WG 客户端的运维。
WireGuard 只有四种报文,布局固定,前几个字节为明文:
| Type | 名称 | 长度 | NSGW 用得到的明文字段 |
|---|---|---|---|
| 1 | HandshakeInit | 148 B | sender_index(offset 4..8) + mac1(offset 116..132) |
| 2 | HandshakeResponse | 92 B | sender_index(4..8) + receiver_index(8..12) + mac1(60..76) |
| 3 | CookieReply | 64 B | receiver_index(4..8) |
| 4 | TransportData | 变长 | receiver_index(4..8) + counter(8..16) + ciphertext |
NSGW 只读 index(4 字节)和 mac1(16 字节),报文按字节原样转发,不修改、不重组、不解密。
WireGuard 协议定义(白皮书):
MAC = keyed BLAKE2s,output 16 bytesHASH = BLAKE2sresponder_static_pubkey = 接收方(NSN)的静态公钥NSGW 持有 NSD 通过 SSE gateway_wg_relay 下发的所有候选 NSN 公钥。启动 / 配对表变更时预计算并缓存:
收到一个 Init 时,对每个 mac1_keys[i] 做一次 MAC,匹配的就是目标 NSN。
复杂度:O(N) BLAKE2s-MAC per Init,N = 已注册 NSN 数。握手频率每 2 分钟一次,N=1k 时单包 ≈ 几十 µs,完全可负担;若需要更大规模,可做 mac1 前缀哈希分桶。
NSN 回的 HandshakeResponse 里:
receiver_index = NSC 在 Init 里写的 sender_index(NSN 把对端的 idx 原样回写)sender_index = NSN 自己分配的新 idx之后所有 type-3(CookieReply)与 type-4(TransportData)包,receiver_index 就足以单步查表转发:
整体即一个加了 mac1 索引的 UDP NAT。
WG 协议本身有两项原生特性,让中继对端"自然"工作:
Endpoint = NSGW:port 决定初始往哪发;PublicKey 仍是对端的真实静态公钥(不是 NSGW 的)。NSGW 不持任何相关密钥,物理上无法解密。要使用中继,客户端只需把 Endpoint 指向 NSGW;不需要客户端版本支持任何新协议、新字段、新握手扩展。
NSC 端(任意 wg-quick / 内核 WG / 商用 WG 客户端):
NSN 端镜像配置即可。NSC 与 NSN 之间会话密钥端到端协商,NSGW 物理上无法解密。
| 实现 | 兼容 | 备注 |
|---|---|---|
Linux 内核 WG(wg/wg-quick) | ✅ | 一切按上节配置 |
wireguard-go(用户态参考实现) | ✅ | mac1 / 报头格式与内核态一致 |
| BoringTun(Cloudflare 用户态) | ✅ | 同上 |
| WireGuard for Windows / macOS / iOS / Android 官方 App | ✅ | 协议层无差异 |
| OpenWrt / 各路由器固件 WG | ✅ | 同 Linux 内核 |
| 商用 WG 客户端 / 嵌入式 WG | ✅ | 只要遵循 WG 标准报头 |
| 特性 | NSGW 的处理 | 客户端要求 |
|---|---|---|
mac1(每报文都带) | 解析以路由 Init,Response/Transport 透传 | 自 WG 协议定型起就有,所有现行实现都支持 |
mac2(Cookie 防 DDoS) | 完全透传,不参与生成 / 校验 | 客户端按协议正常工作 |
PresharedKey(PSK) | 完全透传 | NSC 与 NSN 共享 PSK 即可,NSGW 不感知 |
| Roaming endpoint | 不要求,但有了更顺滑 | 任何主流 WG 都支持 |
PersistentKeepalive | 强烈建议设 ≤ 25 秒 | NSC ↔ NSGW 段 NAT 映射 30~60 秒会过期 |
| IPv4 / IPv6 endpoint | 双栈监听,4-tuple 用 16 字节地址兼容 | 任意 |
| WG MTU | 透传,不重组(无 MSS clamp 能力) | 维持默认 1420 通常即可 |
NSGW 在同一个 51820/udp 上同时承担两种角色,凭 mac1 自动分派:
带来的好处:客户端配置里只有一个 Endpoint = NSGW:51820,把某对 (NSC, NSN) 登记进"中继配对表"后,流量自动从终结切到中继,客户端无感、不需要重启、不需要换 endpoint。
| 情形 | NSGW 行为 | 客户端表现 |
|---|---|---|
| NSC 配的 NSN 公钥未在 NSD 注册 / 未进配对表 | mac1 不匹配 → 丢包 + 计数 | WG 多次 retry,最终 handshake timeout |
| 长时间空闲,NSC↔NSGW 段 NAT 失效 | 该 4-tuple 条目自然过期 | WG 检测无响应,触发新 handshake → 自动恢复 |
| NSC 漫游(IP 变更) | 旧 (idx, src_4tuple) 失配 → 触发新一轮 Init | WG 自动 rekey,正常 |
sender_index 32-bit 偶发碰撞 | (idx, src_4tuple) 复合键消歧 | 各自正常 |
| NSN 在拥塞 / DDoS 防护态 | 透传 Type-3 CookieReply,按 receiver_index 反查回送 | WG 带 mac2 重发 Init,正常协议路径 |
| NSN 整体下线 | gateway_wg_relay SSE 移除其 mac1 key,后续 Init 落入"无匹配" | 客户端报无响应 |
| 报文 > NSGW 出向 PMTU | 不重组,透传;ICMP "Frag Needed" 由两端 WG 自行学习 | WG 内置 PMTU 调整 |
| 旧版 WG 客户端 | 只用 mac1 + index,不依赖任何新增字段 | 兼容 |
| NSGW 重启 | 内存中 sessions 丢失 | 客户端下次 retransmit 触发新 Init,自动重建 |
中继模式按设计放弃以下能力,需要的话切到 §2.1 终结模式:
| 能力 | 为什么做不到 |
|---|---|
| 网关侧报文级 ACL | NSGW 看不到明文 IP / 端口,只能按"是否进配对表"做公钥级粒度的允许/拒绝 |
allowed-ips 路由 / 子网级策略 | 同上;若需要,留给 NSN 侧 |
| MSS clamp | 不重组,无法修改 TCP SYN |
| 按目标地址 / 服务计费 | 只能按 (NSC, NSN) 公钥对计字节数 |
| 应用层观测 | 物理上不可读 |
这是两种模式互补的本质——终结模式换"网关侧能做事",中继模式换"端到端加密 + 兼容存量 WG"。
中继模式是否启用,在 NSN / NSC 注册阶段就已经决定:
POST /api/v1/machine/register 在 body 中声明 supported_link_modes: ["direct", "relay", "terminate", "wss"] 子集(物理可达性 / 合规决定)。NSC.supported ∩ NSN.supported 选最优先项,材料化为 wg_config 与 gateway_wg_relay。wg_config 把 peer Endpoint 指向 NSGW(模式 = relay)或对端真实 endpoint(模式 = direct);没有"直连失败 → 申请中继"的运行期反馈环。supported_link_modes。跨组件总览见 总体架构 §5.4。
NSGW 侧的中继配对表,由 NSD 通过 SSE gateway_wg_relay(规划中)推送:
NSGW 对该事件做 diff apply:新增条目 → 加入 mac1 候选 key 表;删除 → 同步移除并清理 sessions。NSGW 完全被动,不接收 NSC/NSN 的运行期申请、也不主动决策。
NSGW 一侧的代码:
| 模块 | 估算 |
|---|---|
| mac1 候选表 + 预计算 BLAKE2s key | ~100 行 |
| 报头 parser(类型分派、4 / 8 字节 idx 取) | 几十行 |
Session 表((idx, 4-tuple) 复合键 + LRU + idx-by-source 反查) | ~150 行 |
| UDP 收发循环 | 标准 socket 收发 |
gateway_wg_relay SSE 消费 + 配对表 diff | ~100 行 |
| 指标 / 日志 / 计数 | ~50 行 |
合计约 500–800 行 Go,纯 user-space UDP socket,不依赖任何内核 WG / netlink——这一点是与终结模式实现路径的关键差异,也意味着 peer-relay 子系统可独立部署、独立扩展。