NSIO Docs
NSIO Docs
首页
总体架构

NSN · 站点节点

NSN · 站点节点
NSN · 架构与功能设计
NSN · 演进路线与开发计划

NSC · 用户客户端

NSC · 用户客户端
NSC · 架构与功能设计
NSC · 演进路线与开发计划

NSD · 控制中心

NSD · 控制中心
NSD · 架构与功能设计
NSD · 演进路线与开发计划

NSGW · 数据网关

NSGW · 数据网关
NSGW · 架构与功能设计
NSGW · WireGuard Peer Relay 协议层
NSGW · 演进路线与开发计划

Last Updated: 2026/5/8 08:44:39

Previous PageNSGW · 架构与功能设计
Next PageNSGW · 演进路线与开发计划

#NSGW · WireGuard Peer Relay 协议层

本页是 architecture.md §2.2 的协议层附录,详细描述 NSGW 如何在不解密的前提下中继 WireGuard 流量,以及与原生 WireGuard 客户端的兼容矩阵。设计参照 Tailscale Peer Relay。

适合的读者:NSGW 实现者、要把站点节点接入 NSIO 但只能跑原生 WG 客户端的运维。

#1. WireGuard 报文头里 NSGW 能直接看到的字段

WireGuard 只有四种报文,布局固定,前几个字节为明文:

Type名称长度NSGW 用得到的明文字段
1HandshakeInit148 Bsender_index(offset 4..8) + mac1(offset 116..132)
2HandshakeResponse92 Bsender_index(4..8) + receiver_index(8..12) + mac1(60..76)
3CookieReply64 Breceiver_index(4..8)
4TransportData变长receiver_index(4..8) + counter(8..16) + ciphertext

NSGW 只读 index(4 字节)和 mac1(16 字节),报文按字节原样转发,不修改、不重组、不解密。

#2. 三个关键路由动作

#2.1 用 mac1 把 HandshakeInit 路由到正确的 NSN

WireGuard 协议定义(白皮书):

mac1 = MAC( key = HASH("mac1----" ‖ responder_static_pubkey),
            data = msg[0..116] )
  • MAC = keyed BLAKE2s,output 16 bytes
  • HASH = BLAKE2s
  • responder_static_pubkey = 接收方(NSN)的静态公钥

NSGW 持有 NSD 通过 SSE gateway_wg_relay 下发的所有候选 NSN 公钥。启动 / 配对表变更时预计算并缓存:

mac1_keys[i] = HASH("mac1----" ‖ NSN_i.pubkey)

收到一个 Init 时,对每个 mac1_keys[i] 做一次 MAC,匹配的就是目标 NSN。

on init_packet(p, src_4tuple):
    for (i, key) in mac1_keys:
        if MAC(key, p[0..116]) == p[116..132]:
            dst = NSN_i.endpoint
            sender_idx = u32_le(p[4..8])
            sessions[(sender_idx, src_4tuple)] = {
                src_4tuple,
                dst,
                state = "init_forwarded",
            }
            forward(p, dst)
            return
    drop_with_log("init_no_mac1_match", src_4tuple)

复杂度:O(N) BLAKE2s-MAC per Init,N = 已注册 NSN 数。握手频率每 2 分钟一次,N=1k 时单包 ≈ 几十 µs,完全可负担;若需要更大规模,可做 mac1 前缀哈希分桶。

#2.2 用 receiver_index 把 Response / Transport 反向路由

NSN 回的 HandshakeResponse 里:

  • receiver_index = NSC 在 Init 里写的 sender_index(NSN 把对端的 idx 原样回写)
  • sender_index = NSN 自己分配的新 idx
on response_packet(p, src_4tuple):  # src_4tuple 是 NSN 这一段的来源
    rcv_idx = u32_le(p[8..12])
    snd_idx = u32_le(p[4..8])
    sess = sessions.find_by_init(rcv_idx)   # 上一步登记的 NSC 会话
    if not sess:
        drop_with_log("response_orphan", src_4tuple)
        return
    sessions[(snd_idx, src_4tuple)] = {
        src_4tuple,
        dst = sess.src_4tuple,           # NSN 这一头反向指 NSC
        state = "response_forwarded",
    }
    forward(p, sess.src_4tuple)

之后所有 type-3(CookieReply)与 type-4(TransportData)包,receiver_index 就足以单步查表转发:

on cookie_or_transport(p, src_4tuple):
    rcv_idx = u32_le(p[4..8])
    sess = sessions[(rcv_idx, src_4tuple)]
    if not sess:
        drop_with_log("transport_orphan", rcv_idx)
        return
    forward(p, sess.dst)

整体即一个加了 mac1 索引的 UDP NAT。

#2.3 数据流图

NSC                       NSGW                       NSN
 │ Init {sndr=A, mac1=X}    │                          │
 ├─────────────────────────►│ mac1 命中 NSN.pubkey      │
 │                          │ sessions[A,NSC]={dst=NSN} │
 │                          ├─────────────────────────►│
 │                          │ Response {sndr=B,rcv=A}   │
 │                          │◄─────────────────────────┤
 │                          │ A→NSC (反查)              │
 │                          │ sessions[B,NSN]={dst=NSC} │
 │ Response {...}           │                          │
 │◄─────────────────────────┤                          │
 │ Transport {rcv=B, ...}   │                          │
 ├─────────────────────────►│ B→NSN (单跳查表)          │
 │                          ├─────────────────────────►│
 │                          │ Transport {rcv=A, ...}   │
 │                          │◄─────────────────────────┤
 │ Transport {...}          │                          │
 │◄─────────────────────────┤                          │

#3. 为什么客户端完全无感

WG 协议本身有两项原生特性,让中继对端"自然"工作:

  1. Endpoint 与 PublicKey 解耦:peer 配置里的 Endpoint = NSGW:port 决定初始往哪发;PublicKey 仍是对端的真实静态公钥(不是 NSGW 的)。NSGW 不持任何相关密钥,物理上无法解密。
  2. Roaming Endpoint:当 WG 收到一条 mac1 校验通过且解密成功的握手报文,会自动把对应 peer 的 endpoint 更新为该报文的来源 IP:port(自 WG 内核模块定型起就有,2017+)。所以即便初始配置写得不一样,握手一旦从 NSGW 来,两端各自把"对端"锁定在 NSGW,后续所有出包自然走 NSGW。

要使用中继,客户端只需把 Endpoint 指向 NSGW;不需要客户端版本支持任何新协议、新字段、新握手扩展。

#4. 与原生 WireGuard 的兼容矩阵

#4.1 标准接入示例(NSC 与 NSN 都用原生 WG 客户端)

NSC 端(任意 wg-quick / 内核 WG / 商用 WG 客户端):

[Interface]
PrivateKey = <NSC_priv>
Address    = 10.0.0.1/32
ListenPort = 0

[Peer]
PublicKey  = <NSN_pub>                  # ← 仍是 NSN 的真实公钥
Endpoint   = nsgw.example.com:51820     # ← 改这一行就够了
AllowedIPs = 10.0.0.2/32
PersistentKeepalive = 25

NSN 端镜像配置即可。NSC 与 NSN 之间会话密钥端到端协商,NSGW 物理上无法解密。

#4.2 客户端实现支持

实现兼容备注
Linux 内核 WG(wg/wg-quick)✅一切按上节配置
wireguard-go(用户态参考实现)✅mac1 / 报头格式与内核态一致
BoringTun(Cloudflare 用户态)✅同上
WireGuard for Windows / macOS / iOS / Android 官方 App✅协议层无差异
OpenWrt / 各路由器固件 WG✅同 Linux 内核
商用 WG 客户端 / 嵌入式 WG✅只要遵循 WG 标准报头

#4.3 协议特性兼容

特性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 通常即可

#4.4 终结模式与中继模式同端口共存

NSGW 在同一个 51820/udp 上同时承担两种角色,凭 mac1 自动分派:

mac1 == MAC(HASH("mac1----" ‖ NSGW_self.pub),  init[:116])  → 终结模式 (§2.1)
mac1 == MAC(HASH("mac1----" ‖ NSN_i.pub),      init[:116])  → 中继到 NSN_i (§2.2)
全部不匹配                                                   → 丢弃 + 计数

带来的好处:客户端配置里只有一个 Endpoint = NSGW:51820,把某对 (NSC, NSN) 登记进"中继配对表"后,流量自动从终结切到中继,客户端无感、不需要重启、不需要换 endpoint。

#5. 边界情形与失败模式

情形NSGW 行为客户端表现
NSC 配的 NSN 公钥未在 NSD 注册 / 未进配对表mac1 不匹配 → 丢包 + 计数WG 多次 retry,最终 handshake timeout
长时间空闲,NSC↔NSGW 段 NAT 失效该 4-tuple 条目自然过期WG 检测无响应,触发新 handshake → 自动恢复
NSC 漫游(IP 变更)旧 (idx, src_4tuple) 失配 → 触发新一轮 InitWG 自动 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,自动重建

#6. 不能从中继模式实现的能力

中继模式按设计放弃以下能力,需要的话切到 §2.1 终结模式:

能力为什么做不到
网关侧报文级 ACLNSGW 看不到明文 IP / 端口,只能按"是否进配对表"做公钥级粒度的允许/拒绝
allowed-ips 路由 / 子网级策略同上;若需要,留给 NSN 侧
MSS clamp不重组,无法修改 TCP SYN
按目标地址 / 服务计费只能按 (NSC, NSN) 公钥对计字节数
应用层观测物理上不可读

这是两种模式互补的本质——终结模式换"网关侧能做事",中继模式换"端到端加密 + 兼容存量 WG"。

#7. 与 NSD 的契约

#7.1 决策时机:注册时声明,不是运行时申请

中继模式是否启用,在 NSN / NSC 注册阶段就已经决定:

  1. NSN / NSC 通过 POST /api/v1/machine/register 在 body 中声明 supported_link_modes: ["direct", "relay", "terminate", "wss"] 子集(物理可达性 / 合规决定)。
  2. NSD 在策略授权层为每条 (NSC, NSN) 边取 NSC.supported ∩ NSN.supported 选最优先项,材料化为 wg_config 与 gateway_wg_relay。
  3. 模式生效后,NSC / NSN 端按下发的 wg_config 把 peer Endpoint 指向 NSGW(模式 = relay)或对端真实 endpoint(模式 = direct);没有"直连失败 → 申请中继"的运行期反馈环。
  4. 模式变更属于策略变更:admin 调权限,或节点重新登记 supported_link_modes。

跨组件总览见 总体架构 §5.4。

#7.2 SSE 事件结构

NSGW 侧的中继配对表,由 NSD 通过 SSE gateway_wg_relay(规划中)推送:

// 示意结构,字段以 NSD 实现为准
{
  "type": "gateway_wg_relay",
  "version": 1,
  "relay_pairs": [
    {
      "nsc_pubkey": "<base64>",   // 仅作展示;NSGW 路由不用 NSC 公钥(中继模式无 NSC 侧 mac1 demux 路径)
      "nsn_pubkey": "<base64>",   // ← mac1 demux 用这个
      "nsn_endpoint": "100.64.0.2:51820",
      "addressing": "receiver_index",   // 或 "port"
      "listen_port": null               // addressing=port 时为分配的端口
    }
  ]
}

NSGW 对该事件做 diff apply:新增条目 → 加入 mac1 候选 key 表;删除 → 同步移除并清理 sessions。NSGW 完全被动,不接收 NSC/NSN 的运行期申请、也不主动决策。

#8. 最小实现规模估计

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 子系统可独立部署、独立扩展。

#9. 不在 Peer Relay 范围内

  • NSGW 自己作为 WG 端点参与握手 —— 那是 §2.1 终结模式的事。
  • WSS 中继(WsFrame) —— 那是 §2.3,用于 UDP 完全被阻断的环境。两者并行存在、不互替。
  • STUN / ICE / hole-punch 自身 —— 中继是 hole-punch 失败后的兜底;前者放在客户端 / NSD 协调侧。
  • 应用层策略 / 计费 / 审计 —— 由 NSN 与 NSGW 终结模式承担。