Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Subnetra —— 私有三层组网:各 Spoke 把加密流量打到 Hub,由 Hub 按策略路由在彼此之间中继

简介

把你的服务器、站点和设备连接成一张私有的加密网络——只需一个随处可跑的小巧二进制文件,从云主机到 MikroTik 路由器都能部署。

本文档站点为中英双语。使用顶栏的 中文 / EN 开关切换语言,或阅读 English documentation

Subnetra 是什么?

Subnetra 把分散在不同地点的机器——分支办公室、数据中心、移动办公的笔记本、家庭实验室、 容器、路由器——连接成一张扁平的私有子网

它采用星型拓扑(Hub-and-Spoke):一个可达的 Hub 在各个 Spoke 之间中继流量, 于是任意节点都能用一个固定的虚拟 IP 访问其他任意节点——哪怕大多数节点都藏在 NAT 后面。 每个数据包都在普通的 UDP 隧道里全程加密传输,而整套东西就是一个自包含的二进制文件—— 无需额外安装任何东西,没有内核模块,也没有一堆守护进程。

你能用它做什么

  • 🏢 打通分支办公室与数据中心——在一张私有 overlay 上做站点到站点(site-to-site)的整段子网路由。
  • 💻 给移动办公的笔记本一个固定私有 IP——它会随你在 Wi-Fi、4G/5G、家庭网络之间漫游而保持不变。
  • 🧪 访问家庭实验室 / IoT / 容器里的服务——就像它们和你在同一个局域网里一样。
  • 🛰 从 NAT 后面对外发布一整段局域网——例如让 MikroTik 路由器把 192.168.88.0/24 暴露给整张网。
  • 📦 在塞不下重型 VPN 的地方运行——资源受限的容器、BusyBox、小型 ARM 设备、边缘路由器。

核心亮点

  • 随处可跑,一个文件即装——单个静态二进制(Linux 下小于 512KB),零外部依赖。 可直接丢进云主机、容器、BusyBox、树莓派以及 MikroTik RouterOS。支持 amd64 / arm64 / armv7 / armv5,外加一个原生 macOS Spoke。
  • 默认加密,链路上低特征——每个包都用 ChaCha20-Poly1305 加密,每条链路独立密钥, 带防重放。没有任何特征字(magic bytes),未通过认证的包会被静默丢弃——在端口扫描器 看来,这个隧道就像没有任何东西在监听。报头混淆默认开启obfuscate,需全 mesh 一致): 20 字节封装报头按包做 XOR 掩码,使整个数据报看起来像随机字节,NAT 保活节奏也被去周期化—— 被动观察者拿不到协议指纹。设 "obfuscate": false 可改发可读的明文报头(例如抓包调试)。 混淆隐藏的是协议指纹,而非包长或时序。见 线协议 → 报头混淆
  • 一张带策略路由的扁平私有子网——给每个节点分配一个 overlay IP,按整段子网做站点到站点路由, 并由 Hub 做 Spoke 到 Spoke 的中继,让 NAT 后的节点也能互相访问。
  • 天生穿透 NAT——Spoke 通过内置保活(keepalive)维持自己的 NAT 映射, Hub 则会在 Spoke 漫游到新地址时自动重新学习它的端点——无需外部 ping 脚本,无需手动重连。
  • 路由可热改——运行时注入或更新转发规则,零中断:不重启、不丢包。
  • 为运维而生——同时提供人类可读 JSON 两种状态输出、按原因细分的丢包计数器 (告诉你流量为什么没通)、每个对端的健康/online 标志,以及用于告警的 Prometheus 导出器。
  • 简单的声明式配置——把一个节点声明成 hubspoke,转发表会自动推导出来。 还能给对端起名字,让 status 里显示的是 bj-office-gw,而不是 id=2

快速上手

最快的方式是用容器镜像(Hub 通常是一台公网云主机):

# 1. 创建配置——一个 Hub,一个或多个 Spoke。
cp config.example.json config.json
#    给每条对端链路设置一把唯一的 64 位十六进制密钥:openssl rand -hex 32

# 2. 运行(隧道需要 TUN 设备 + NET_ADMIN)。
docker run -d --name subnetra \
    --cap-add=NET_ADMIN --device=/dev/net/tun \
    -v "$PWD/config.json":/etc/subnetra/config.json:ro \
    ghcr.io/jamiesun/subnetra:latest

# 3. 查看状态。
docker exec subnetra subnetra status

更喜欢裸二进制?从最新发布 下载对应架构的静态构建,然后跟随 安装快速上手 指南。完整的「一个 Hub + 两个 Spoke」 生产部署演练见 生产部署

运维与可观测

subnetra status 把那些「设计上静默丢弃」的包变成可计数的信号, 让你能判断流量为什么通或不通:

subnetra v0.6.0 [running]
mode=raw_direct local_id=1 udp_port=18020 tun=snr0 peers=2
peers:
  id=2 name=bj-office-gw endpoint=203.0.113.2:18020 allowed_src=10.0.0.2/32
  id=3 name=alice-laptop endpoint=203.0.113.3:18020 allowed_src=10.0.0.3/32
traffic: tun_rx / udp_tx / udp_rx / tun_tx / relay / keepalive …
drops:   unknown_peer / auth_or_invalid / spoof / no_route …

subnetra status --json 把同样的数据输出为一个稳定、带版本号的 JSON 对象——其中包含每个 对端的 last_seen_age_secondsonline 标志——配合开箱即用的 Prometheus 导出器即可 变成可抓取的指标。完整的状态字段、丢包分类和告警示例见 可观测性与排障

如何阅读本文档

项目状态

框架层、纯算法层与系统调用数据通路(TUN、就绪反应堆、AF_UNIX 控制面、守护进程主 循环)均已实现,并在开发容器中端到端跑通;macOS 原生 utun/poll(2) Spoke 由 comptime 的 src/os/ 后端支撑。v1(raw_direct + PSK + 防重放 + RCU 策略)即交付物; v2 可靠性模式(kcp_arqfec_xor)仅为预留接口点——见 路线图

许可证

MIT © 2026 jettwang。

安装

Subnetra 以 单个静态二进制 形式交付。无需系统级安装,也没有共享库需要管理—— 按你的环境选择合适的交付方式即可。

快速安装(交互式)

Linux 或 macOS 上,最快的方式是安装脚本。它会自动识别系统与架构、解析 最新 发布、用发布附带的 SHA256SUMS.txt 校验下载产物,并安装 subnetrasubnetrad——在写入任何文件前会先让你确认:

curl -fsSL https://raw.githubusercontent.com/jamiesun/subnetra/main/install.sh | sh

脚本是交互式的,并且 只安装两个二进制——绝不改动你的网络、防火墙或服务 (Subnetra 始终把主机网络规划留给你)。若目标目录中已安装 Subnetra,脚本会显示当前 版本并在覆盖前征求确认。如需无人值守安装,用 --yes 接受默认值:

curl -fsSL https://raw.githubusercontent.com/jamiesun/subnetra/main/install.sh | sh -s -- --yes
选项含义
--dir <路径>安装位置(默认 /usr/local/bin)。
--version <vX.Y.Z>固定到某个发布,而非最新。
--service一并安装(禁用态的)systemd/launchd 服务单元。
--yes跳过所有确认(非交互)。

要把 Subnetra 作为常驻服务运行,加上 --service:它会以禁用状态安装加固过的 systemd(Linux)或 launchd(macOS)单元——绝不启动、也绝不碰你的网络——然后打印 完成配置并启用的步骤。完整服务配置见 部署

想手动安装,或脚本未覆盖的平台?使用下面的 发布二进制,或在 Releases 页面 浏览全部产物。

方式适用于备注
安装脚本一行命令安装到 Linux / macOS解析最新版、校验、交互确认
容器镜像Linux 主机、RouterOS / BusyBox 容器多架构 amd64 / arm64 / armv7 / armv5
发布二进制裸 Linux 主机、离线安装同时提供可 docker load 的镜像 tar 包
macOS Spoke 二进制Apple Silicon / Intel Mac(仅 Spoke)由 Runbook 验收,未纳入 CI 门禁
OpenWrt 路由器MIPS / ARM 家用与 SOHO 路由器(Spoke)静态 musl 二进制 + procd 服务
从源码构建开发、自定义目标需要 Zig 0.16.0+

无论采用哪种方式,守护进程在运行时都需要两样东西:NET_ADMIN 能力(用于创建 TUN 网卡)以及对 /dev/net/tun 的访问权。

容器镜像

带标签的发布会向 GHCR 推送多架构镜像,Docker 会自动选择正确的架构:

docker pull ghcr.io/jamiesun/subnetra:latest

# 守护进程需要 NET_ADMIN + TUN 网卡,并把 config.json 挂载到其工作目录
# (/etc/subnetra)。
docker run -d --name subnetra \
    --cap-add=NET_ADMIN --device=/dev/net/tun \
    -v "$PWD/config.json":/etc/subnetra/config.json:ro \
    ghcr.io/jamiesun/subnetra:latest

镜像内置 Docker HEALTHCHECKsubnetra status),因此当守护进程开始提供控制套接字 后,docker ps / Compose / Kubernetes 会报告其为 healthy,停止响应时报告 unhealthy

amd64arm64arm/v7 镜像基于 busybox:musl 构建(内含两个静态二进制、 config.example.json 以及一个用于容器内调试的微型 BusyBox shell)。arm/v5 镜像基于 scratch 构建(仍为静态 musl,无调试 shell),并拼入同一份 :latest / :version 清单。Compose 与 Kubernetes 细节见 容器

发布二进制

可在 Releases 页面 浏览并 下载任意发布。每个发布(vX.Y.Z)都会附上 amd64arm64armv7armv5mipselmips静态二进制 tar 包。Linux 二进制基于 musl-libc 全静态链接——ldd 显示 not a dynamic executable

MIPS / OpenWrt: mipsel 为小端(ramips/mt7621/mt7628,多数现代 OpenWrt 设备), mips 为大端(ath79/Atheros)。如何选对架构以及 procd 服务,见 OpenWrt Spoke 指南。

产物名带版本号,因此先解析一次版本,再下载、校验、安装:

ARCH=amd64   # 取值:amd64 | arm64 | armv7 | armv5 | mipsel | mips
VER=$(curl -fsSLI -o /dev/null -w '%{url_effective}' \
        https://github.com/jamiesun/subnetra/releases/latest | sed 's#.*/tag/##')

curl -fsSLO "https://github.com/jamiesun/subnetra/releases/download/$VER/subnetra-$VER-linux-$ARCH.tar.gz"
curl -fsSLO "https://github.com/jamiesun/subnetra/releases/download/$VER/SHA256SUMS.txt"
sha256sum --ignore-missing -c SHA256SUMS.txt        # 安装前先校验

tar -xzf "subnetra-$VER-linux-$ARCH.tar.gz"
cd "subnetra-$VER-linux-$ARCH"
sudo install -m 0755 subnetrad subnetra /usr/local/bin/
subnetrad --version

releases/latest/download/<asset> 路径始终指向当前发布,但产物名内嵌版本号—— 因此仍需按上面解析 VER,或者直接用 安装脚本

离线 / 隔离网络安装

无法访问容器仓库的设备,可使用每个发布附带的、按架构区分的可 docker load 镜像 tar 包(subnetra-image-<version>-<arch>.tar.gz):

docker load < subnetra-image-<version>-arm64.tar.gz   # -> ghcr.io/jamiesun/subnetra:<version>
docker run -d --name subnetra \
    --cap-add=NET_ADMIN --device=/dev/net/tun \
    -v "$PWD/config.json":/etc/subnetra/config.json:ro \
    ghcr.io/jamiesun/subnetra:<version>

macOS Spoke 二进制

每个发布还附带原生 macOS 二进制,用于以 Spoke 身份运行 Subnetra—— subnetra-<version>-macos-arm64.tar.gz(Apple Silicon)与 -amd64.tar.gz(Intel)。 它们是 Mach-O 二进制,仅链接 libSystem(零第三方依赖)。

安装脚本 同样适用于 macOS,并会自动为你清除 Gatekeeper 隔离属性。

tar -xzf subnetra-<version>-macos-arm64.tar.gz
cd subnetra-<version>-macos-arm64
# Gatekeeper 会隔离下载的二进制——清除隔离属性(或从源码构建):
xattr -d com.apple.quarantine subnetrad subnetra 2>/dev/null || true

./subnetra --print-network-plan --config config.json   # 预览主机网络规划
sudo ./subnetrad --config config.json                  # 创建 utun 需要 root

创建 utun 网卡以及应用 ifconfig / route 规划都需要 root。macOS 仅作为 Spoke 支持(Hub 仍保持 Linux/RouterOS);见 macOS Spoke 指南。

从源码构建

需要 Zig 0.16.0 及以上。

# 本机构建(默认 ReleaseSmall;本地开发可加 -Doptimize=Debug)
zig build

# 静态交叉编译
zig build -Dtarget=x86_64-linux-musl     # amd64
zig build -Dtarget=aarch64-linux-musl    # arm64
zig build -Dtarget=arm-linux-musleabihf  # armv7(硬浮点)
zig build -Dtarget=arm-linux-musleabi    # armv5(软浮点)
zig build -Dtarget=mipsel-linux-musl     # mipsel(小端:ramips/mt7621 — OpenWrt)
zig build -Dtarget=mips-linux-musl       # mips(大端:ath79/Atheros — OpenWrt)

# 运行测试
zig build test

# 运行守护进程
zig build run

产物位于 zig-out/bin/subnetrad(守护进程)与 subnetra(控制工具)。

调整对端数量上限

对端注册表与解析后的配置都是固定容量、零分配的数组,因此单个节点能容纳的最大网格对端 数量是一个编译期构建选项(-Dmax-peers),而非运行时配置字段。默认 32,上限 128

zig build -Dmax-peers=64                       # 把本节点对端上限提升到 64
zig build -Dmax-peers=128 -Dtarget=aarch64-linux-musl   # 与交叉编译目标组合

一个 hub 最多管理这么多 spoke。这是逐节点的容量旋钮——它不在链路上协商,因此一个 只连接单个 hub 的 spoke 无需跟随 hub 的更大上限。控制面策略表大小 (MAX_POLICY_ENTRIES)由该值自动推导,因此提升上限会自动增大策略容量(默认 32 对应 272 条策略表)。

把它调得过高是在用内存与延迟换容量:reactor 是单线程、每包对对端做 O(N) 扫描(开启 obfuscate 时每个入站数据报还要逐对端试解掩),因此超大网格更应当拆分为多个 hub, 而不是让单个 hub 挂上数百个 spoke。

ARMv5 注意: ARMv5 没有硬件原子指令,因此标准库的线程化 I/O 脚手架会引用 musl 未提供的 legacy __sync_* 内建函数。由于 Subnetra 严格单线程, src/atomic_shim.zig 提供了一份可证明正确的普通(非原子)实现,在 comptime 处门控, 为 ARMv6 以前的目标编译进来——其他所有架构逐字节不受影响。

验证安装

# Linux:确认二进制全静态
ldd ./subnetrad          # -> "not a dynamic executable"
ls -lh ./subnetrad       # -> < 512 KB

# 任意平台:打印版本横幅
./subnetrad --version

下一步:前往 快速上手 拉起一个 Hub 和一个 Spoke。

快速上手

本指南拉起最小可用的网格:一个 Hub两个 Spoke,构建一个虚拟的 10.0.0.0/24 叠加网。两个 Spoke 才能体现 Hub 的价值——它在两个 Spoke 之间中继 流量,而 Spoke 彼此之间从不直接通信。假设你已安装 subnetrad 守护进程与 subnetra 控制工具(见 安装)。

全文中:Hub 的公网地址为 203.0.113.1:18020,Spoke A(叠加网 10.0.0.2)为 203.0.113.2,Spoke B(叠加网 10.0.0.3)为 203.0.113.3

1. 生成每链路密钥

每条链路都需要 自己的 32 字节预共享密钥(64 个十六进制字符)。绝不要在多个对端 之间复用同一把密钥——所以这个两链路的网格需要 两把 密钥:

openssl rand -hex 32   # → KEY_A,用于 Hub ↔ Spoke-A 链路
openssl rand -hex 32   # → KEY_B,用于 Hub ↔ Spoke-B 链路

2. 编写配置

最简单的方式是设置一个 role,让守护进程在启动时自动 推导转发策略。

Hub203.0.113.1 上的 config.json)—— 列出两个 Spoke:

{
  "role": "hub",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 1,
  "listen_ports": [18020, 18023, 18026],
  "peers": [
    { "id": 2, "endpoint": "203.0.113.2:18020", "allowed_src": "10.0.0.2/32", "psk": "…KEY_A…" },
    { "id": 3, "endpoint": "203.0.113.3:18020", "allowed_src": "10.0.0.3/32", "psk": "…KEY_B…" }
  ]
}

Spoke A203.0.113.2 上的 config.json):

{
  "role": "spoke",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 2,
  "local_tun_ip": "10.0.0.2/24",
  "local_routes": ["10.0.0.2/32"],
  "peers": [
    { "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…KEY_A…" }
  ]
}

Spoke B203.0.113.3 上的 config.json)—— 同样的结构,换成自己的 id 与地址:

{
  "role": "spoke",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 3,
  "local_tun_ip": "10.0.0.3/24",
  "local_routes": ["10.0.0.3/32"],
  "peers": [
    { "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…KEY_B…" }
  ]
}

每条链路各有自己的 PSK:KEY_A 由 Hub 的 peer 2 与 Spoke A 共享;KEY_B 由 Hub 的 peer 3 与 Spoke B 共享——两把密钥互不相同。Spoke 无需配置 listen_portsspoke 只绑定单个默认端口)。每个字段详见 配置参考

3. 运行前校验

--check 解析配置、运行全部防呆规则,并在不触碰网络的情况下退出:

subnetrad --check --config config.json
# Spoke A: subnetra v… (mtu=1452, udp_ports={ 18020 }, mode=raw_direct, local_id=2, peers=1) [config ok]
# Hub:     subnetra v… (mtu=1452, udp_ports={ 18020, 18023, 18026 }, mode=raw_direct, local_id=1, peers=2) [config ok]

4. 打印并应用主机网络规划

守护进程会创建 TUN 网卡,但 不会 配置主机地址、路由或 MTU(那会破坏零依赖保证)。 改为让它打印出确切的命令:

subnetrad --print-network-plan --config config.json

检查输出的 ip link / ip addr / ip route 命令并执行(macOS 上输出 ifconfig / route)。细节(包括安全 MTU 如何计算)见 主机网络规划

请在每个 Spoke 上执行。Hub 没有配置 local_tun_ip,所以它的网络规划只创建一个裸 TUN 设备——它是一个没有叠加网地址的纯中继。

5. 启动守护进程

# 在 Hub 与两个 Spoke 上(创建 TUN 需要 NET_ADMIN / root):
sudo subnetrad --config config.json

正式部署时,请改用 systemd 或 launchd 托管——见 生产部署

6. 验证连通性

Spoke A 上 ping Spoke B——报文走 A → hub → B 再返回,正好验证 Hub 的中继:

ping 10.0.0.3

然后在任一节点查看实时计数:

subnetra status

在 Spoke 上你应当看到 udp_tx / udp_rx 在增长,且对端被列为 online;在 Hub 上, relay_* 计数会随着它在两个 Spoke 之间转发而增长。如果流量 没有 流动,drop 计数会 告诉你原因——阅读 可观测性与排障

这里的 Hub 是一个纯中继,没有叠加网地址,所以 10.0.0.1 上没有任何东西可 ping。 若要让 Hub 自身可达,见 角色 → 访问 Hub 自身

7. 添加 Site-to-Site 路由(可选)

要访问 Spoke B 背后 的局域网(例如 192.168.3.0/24),Hub 需要为该前缀添加一条 中继规则。在运行时注入它——通过控制套接字热更新,无需重启:

# 在 Hub 上:
subnetra policy add --src 0.0.0.0/0 --dst 192.168.3.0/24 --action forward --target 3
subnetra policy show
subnetra save              # 把生效策略持久化回 config.json

Spoke B 还必须在本地投递该前缀(加入 local_routes),并被允许以它为源(把 Hub 上 peer 3 的 allowed_src 放宽到覆盖该前缀)。完整的 Site-to-Site 演练见 生产部署

接下来

  • 角色——从配置自动推导策略。
  • 架构——数据通路如何工作。
  • 安全模型——密钥、epoch、防重放。
  • 生产部署——服务、密钥、防火墙/NAT、升级。

架构

Subnetra 是一个 三层叠加网(Layer-3 overlay):它在加密的 UDP 承载之上、以 单 Hub 星型(Hub-and-Spoke) 结构在节点间搬运裸 IPv4 包。本页讲解一个包如何穿越 系统,以及守护进程内部如何组织。

拓扑

一个中心 Hub(通常是海外中继或托管节点)锚定整个网格。每个 Spoke(分支办公室、 RouterOS 容器、Mac)通过私有 UDP 隧道连接到 Hub。Spoke 之间不直接通信——由 Hub 按策略 在它们之间 中继。它们共同在物理专线之上构成一个虚拟子网(例如 10.0.0.0/24), 并可在各节点背后的局域网之间路由(Site-to-Site)。

flowchart LR
    subgraph SiteA["站点 A · LAN 192.168.1.0/24"]
        S2["Spoke 2<br/>10.0.0.2"]
    end
    subgraph HubBox["Hub · 中继"]
        HUB["Hub<br/>10.0.0.1 / id 1<br/>策略 + 中继"]
    end
    subgraph SiteB["站点 B · LAN 192.168.2.0/24"]
        S3["Spoke 3<br/>10.0.0.3"]
    end
    S2 <-->|"加密 UDP 承载"| HUB
    HUB <-->|"加密 UDP 承载"| S3

数据通路

每个包都经过相同的五个阶段:

flowchart TD
    TUN["1 · TUN 入口<br/>读取裸 IPv4"] --> POL{"2 · 策略查找<br/>最长前缀 CIDR"}
    POL -->|无匹配| DROP["DROP · 计数"]
    POL -->|FORWARD| SEAL["3 · 报头 + 封装<br/>20 字节报头 · ChaCha20-Poly1305 + 16 字节 tag"]
    SEAL --> EGR["4 · 出口分发<br/>raw_direct over UDP"]
    EGR -. 加密 UDP 承载 .-> RECV["5 · 接收与校验<br/>key_id · epoch · nonce · 防重放 · 内层源"]
    RECV -->|本地| DEL["投递到本地 TUN"]
    RECV -->|Hub 中继| RLY["中继给另一个 Spoke<br/>绝不回送源端"]
  1. TUN 入口。 内核把局域网/叠加网的包路由到虚拟三层网卡;反应堆非阻塞地读取裸 IPv4 包。
  2. 策略查找。 反应堆原子加载生效的策略树,对目的地址做最长前缀 CIDR 匹配,决定 FORWARD(转发给哪个对端)或 DROP
  3. 报头 + 封装。 组装 20 字节私有线报头(版本、flags、key_id、会话 epoch、序号), 用 ChaCha20-Poly1305 加密内层包并追加 16 字节认证标签。
  4. 出口分发。 封装后的数据报经 UDP 套接字发往对端 endpoint。v1 使用 raw_direct 出口;kcp_arq / fec_xor 为 v2 预留。
  5. 接收与投递。 对端校验 key_id、epoch、nonce/防重放与内层源地址,解密后,要么把 内层包写入自己的 TUN(LOCAL),要么中继给另一个 Spoke(仅 Hub,绝不回送源端)。

内部结构

守护进程是一个 单线程、事件驱动的反应堆。单线程复用三个文件描述符,永不阻塞:

FD用途
TUN_FD虚拟三层网卡上的裸 IPv4 入口/出口
UDP_FD与对端往返的加密承载套接字
UDS_FD控制面 Unix 域套接字(策略注入、状态查询)

就绪原语由 OS 后端在 comptime 选择

  • Linux——epoll 边缘触发(EPOLLET),读取直到 EWOULDBLOCK
  • macOS——poll(2)(仅 Spoke 后端;kqueue 是后续里程碑)。

源码模块:

模块职责
config.zigconfig.json 解析 + 防呆自检(MTU 区间、子网重叠、角色规则)
policy.zigCIDR 解析、最长前缀匹配、无锁 RCU ActiveTree
crypto.zigChaCha20-Poly1305、单调 nonce、滑动窗口防重放
reactor.zigpacked 线报头、出口分发、就绪循环
peer.zig每对端 endpoint + 加密注册表(密钥、计数器、重放窗口)
os/linux.zigos/darwin.zigos/mod.zigcomptime OS 后端(epoll + /dev/net/tunpoll(2) + utun
uds.zig控制套接字 + 指令分词器
stats.zig数据面计数(rx/tx、按原因分类的 drop),供 subnetra status 使用
netplan.zig--print-network-plan 主机命令生成器
main.zig / subnetra.zig守护进程入口 / 控制工具入口

两层内存分级

Subnetra 按职责对内存分级,而非套用一条统一规则:

  • 数据面(reactorcrypto):严格零分配。 所有包缓冲区在启动时通过固定分配器 锁死在常驻内存;热路径绝不调用 alloc/free。在饱和的大包负载下 RSS 曲线是平的—— 0 字节抖动。
  • 控制面 / 可靠性(uds、策略重建、未来的 ARQ/FEC):隔离 arena。 这些路径可在拥有 独立生命周期的 arena 中分配,但绝不能污染数据面的内存曲线。

无锁 RCU 策略更新

由于整个守护进程单线程,所以 任何地方都没有锁。数据面通过单个 *const PolicyTree 指针、以原子加载读取策略树。当控制面注入一条规则时,它在 arena 中 构建一棵全新的树, 然后用一次原子指针写入将其换上(RCU 模式)。旧树在事件循环空闲后回收。因此热更新零拷贝、 零抖动——一个进行中的 TCP 吞吐测试在策略切换期间观测不到可测的延迟尖刺。

Endpoint 学习(NAT / 漫游)

Spoke 通常位于 NAT 之后,公网地址会变。Subnetra 在每个数据报里携带发送方的网格 idkey_id)。当一个 已认证 的包从新的承载地址到达时,Hub 会重新学习该对端的 endpoint 并回复到那里——无握手、无重启。这使 NAT 重映射与漫游自愈。内置的 spoke→hub NAT 保活 让空闲的 NAT 孔保持打开(见 角色安全模型)。

精确的字节布局与接收方规则,见规范 线协议

安全模型

Subnetra 的传输安全在 v1 中是强制的,不可延后。本页描述威胁模型以及落实它的每一项 机制。逐字节的精确规则在规范 线协议 中;本页是其概念上 的伴读。

威胁模型

Subnetra 假设承载是敌对的:攻击者可以观察、丢弃、修改、注入、重放 UDP 数据报,并可主动 探测监听端口。设计目标是:

  • 每个内层包的 机密性与完整性
  • 隐蔽性——主动探测者无法把 Subnetra 端点与一台单纯丢包的主机区分开。
  • 捕获的密文 无法重放 进受保护网络。
  • 隔离性——攻破某个 Spoke 的密钥,不得伪造任何其他链路。

每链路预共享密钥

每个 peers[] 条目携带 自己的 32 字节 PSK(64 个十六进制字符)。不存在全网共享密钥; 仍带顶层 psk 的旧配置会被拒绝(InvalidPsk),在多个对端间复用同一把 PSK 也会被拒绝 (DuplicatePsk)。

从每把 PSK,按有序对派生一把 方向链路密钥

link_key(psk, from_id, to_id) =
    BLAKE2b-256(key = psk, msg = "subnetra-v1-link" || u32_be(from_id) || u32_be(to_id))

因此一个节点发往某对端的发送密钥,等于该对端用于接收该节点流量的接收密钥,而两个方向 使用不同的密钥。

为什么每链路密钥是强制的: 在共享 PSK 下,两个独立的每对端单调计数器可能为不同的 明文产生相同的 (key, nonce) 对,这会灾难性地破坏 ChaCha20-Poly1305。给每条方向链路 各自的密钥,使每条链路的 nonce 空间互不相交。

会话 epoch——无状态、无握手的会话

Subnetra 不在线路上建立会话。取而代之,每个守护进程生命周期在启动时采样一次 boot epoch(墙钟纳秒,u64),并派生一把全新的 每会话密钥

session_key(link_key, epoch) =
    BLAKE2b-256(key = link_key, msg = "subnetra-v1-session" || u64_be(epoch))

epoch 随每个数据报传输(8 字节)。接收方从看到的 epoch 无状态地 派生匹配密钥,并施加 一条 只进不退 的规则:

  • 更大(更晚)的 epoch 一旦通过认证,便取代旧会话并 重置防重放窗口
  • 更小(更旧)的 epoch 被丢弃(那会是对已退役会话的跨 epoch 重放)。

因为每次重启都产出新密钥,序号可以安全地从 1 重新开始,而绝不会复现历史上的 (key, nonce) 对——无需任何磁盘持久化。

失败即关闭的时钟: epoch 必须 ≥ 2024-01-01T00:00:00Z 的纳秒值且非零。时钟无法满足 此条件的节点会拒绝启动,而不是发出一个过低/会碰撞的 epoch。

可接受的残余限制: 如果某节点的墙钟在重启之间 倒退(无 RTC、尚未 NTP 同步),它 的新 epoch 可能小于对端记住的值,对端会拒绝新会话,直到其时钟越过旧值。这通过运维手段 (NTP/RTC)缓解,绝不通过协议内的 epoch 交换——按设计就没有握手。

Nonce 与防重放

96-bit AEAD nonce 由 64-bit 单调计数器 派生,每端每个数据报递增——绝不固定或复用。 接收方为每个会话维护一个 滑动窗口(位图)防重放检查:窗口之外的序号、或已见过的序号, 被丢弃;窗口内的乱序序号被接受。没有它,历史密文就能被重放进受保护的局域网。

隐蔽:静默 Drop,无魔数

Subnetra 是 无状态混淆:密文 不含固定魔数,且在任何认证或校验失败时,包被 静默丢弃——绝不回以 TCP Reset、ICMP 错误或任何可观测之物。对于向 UDP 端口发送垃圾 (或重放密文)的主动探测者而言,端点与黑洞无异,其 CPU 也没有异常尖刺。

这挫败的是主动探测。20 字节封装报头在 AEAD 之外,因此若以明文传输,被动的路径上观察者可凭 恒定的 version、重复的 epoch、低位单调的 seq 对协议做指纹识别。部署级的 obfuscate 开关(默认开启线协议 → 报头混淆)用每包 pad 对报头做 XOR 掩码,使整个数据报看起来像随机字节——零字节开销、全 mesh 一致——并把 spoke 的 NAT 保活去周期化, 使其节奏不构成指纹。它只隐藏协议指纹,隐藏包长或一般时序。设 obfuscate: false 可关闭 (改发可读的明文报头,便于抓包调试)。

内层源绑定(反伪造)

每个对端声明一个 allowed_src CIDR。解密后,接收方将 内层 IPv4 源地址 与该对端的 allowed_src 比对;内层源落在允许范围之外的包被丢弃(计为 spoof)。这防止一个已认证的 对端注入冒充其他节点地址空间的流量。

防反射中继保护

当 Hub 在 Spoke 之间中继时,它绝不把包 回送给来源对端。结合最长前缀策略路由,这避免了 反射环路。

NAT 保活(单向、永不确认)

role=spoke 默认启用内置 NAT 保活(keepalive_secs = 20):它每隔一段时间向其 Hub 发送 一个极小的 已认证 数据报,使 Spoke 的 NAT 孔保持打开、Hub 保持一条新鲜的回程路由。它是 单向、永不确认 的数据报,纯由静态配置门控——它 不是 握手,也不削弱无状态模型。设置 keepalive_secs 调节它,或设为 0 关闭(hub/manual 默认为 0)。

哪些秘密永不暴露

subnetra status(及 --json)刻意 绝不 序列化 PSK 或任何派生密钥。计数器、endpoint 与健康状态可观测;秘密不可。

密码学原语

原语选择参数
AEADChaCha20-Poly1305(IETF,96-bit nonce)密钥 32 B,nonce 12 B,tag 16 B
KDF / keyed hashBLAKE2b-256 原生 keyed 模式(非 HMAC)key = 父密钥,32 B 摘要

精确的密钥日程、nonce 构造、报头序列化与完整的接收方决策序列——全部由已知答案测试向量 钉死——见 线协议

设计原则

Subnetra 围绕一小组 不可妥协的约束 构建。它们不是愿景,而是塑造每个模块的、有约束力 的不变量。若某项变更与其中之一冲突,那就是变更错了。(项目内部称之为「铁律」。)

1. 零第三方依赖

无 WireGuard、无 ikcp.c、无网络框架、无外部加密库。只有 Zig 标准库与经 std.posix 的裸系统调用。即便未来的 v2 可靠性层也必须自研(基于 arena 的 ARQ),绝不 vendoring C 库。回报是一个无供应链的、可审计的单一产物。

2. 分层零动态分配

内存 按职责 约束,而非套用一条统一规则:

  • 数据面(reactorcrypto):严格零分配。 所有包缓冲区位于启动时固定的常驻内存; 热路径绝不分配。
  • 控制面 / 可靠性:独立 arena。 策略重建与 UDS 路径可在拥有各自生命周期的隔离 arena 中分配,但绝不能污染数据面的内存曲线。

验收标准——「负载下 0 字节 RSS 抖动」——针对 raw_direct 数据面;控制面热更新可短暂使用 可回收的 arena 内存。

3. 单线程、无锁反应堆

单线程;一个无锁、无分配的就绪循环。永远无线程、无锁。 因为数据面与控制面之间没有并发, 所以不需要任何互斥锁;策略热更新通过原子指针交换(RCU)完成。具体的就绪原语在 comptime 选择——Linux epoll 边缘触发、macOS poll(2)——但单线程 / 无锁 / 每包无分配这条不变量 是铁律。

4. 无状态混淆 / 隐蔽

ChaCha20-Poly1305 全加密,密文中 无魔数。认证失败时 静默 Drop——绝不回以 TCP Reset、ICMP 或任何可观测之物。端点对探测物理隐形。

这针对的是主动探测;明文封装报头会让被动观察者对协议做指纹识别,全 mesh 一致的 obfuscate 开关(默认开启线协议 → 报头混淆)以零字节开销掩盖它, 并把保活节奏去周期化(仅指纹,不含包长或时序)。

5. v1 强制传输安全

私有的每链路 PSK、每端点 64-bit 绝不复用的单调 nonce、每次重启的会话 epoch,以及滑动 窗口 防重放 检查。这些不可延后到后续版本。见 安全模型

6. 单个极小二进制

默认 -O ReleaseSmall,每个平台上零第三方依赖。

  • Linux: 基于 musl-libc 全静态;lddnot a dynamic executable;目标 ≤ 512 KB
  • macOS: 最小动态—— 链接 libSystem(Apple 不提供静态 libc),并有自己记录的 体积基线。ldd 静态检查是 仅 Linux 的门禁。

7. 测试驱动

纯逻辑随附测试;任何提交前 zig build test 必须保持绿色。线协议由机器可校验的已知答案 向量钉死(见 线协议)。

8. 无状态、无握手传输

每个数据报自描述、可独立解码——每包的 epoch 就是全部的会话建立机制。Subnetra 不做 连接建立往返、不做挑战/应答、不做带内会话协商——v1 不做,v2 也不做。任何未来的传输模式 都由 静态的每链路配置 选择(预留的 negotiation_version / flags 字段),绝不在线路上 协商。

两个后果 按设计接受、而非延后处理

  1. 路径上的攻击者可重放一个尚未观测过 epoch 的捕获数据报,以 瞬时 迁移某对端的 endpoint——它会在该对端下一个真实包到来时自愈,且路径外的攻击者无法伪造它。
  2. 墙钟在重启之间倒退的节点会被对端拒绝,直到对端时钟前进——通过 NTP/RTC 运维缓解,绝不 通过协议内的 epoch 交换。

KEEPALIVE flag(bit 0)是由静态配置门控的、单向、永不确认的 spoke→hub NAT 孔数据报。 它 不是 握手,也不削弱本条铁律;bit 1–7 仍为静态模式选择预留。

范围纪律:v1 vs v2

  • v1(已交付): raw_direct 数据面 + PSK 加密 + 防重放 + RCU 热更新策略引擎。
  • v2(路线图,仅接口): kcp_arqfec_xor——自研可靠性模式,由静态每链路配置 选择,绝非线上握手。v1 只预留 egress 分支与报头的 negotiation_version / flags 字段;v2 分支返回 error.NotImplemented

路线图上没有握手。见 路线图

为什么是这些约束?

目标是为通往最受限环境(RouterOS / BusyBox 容器)的专线打造一根钢管。确定性、极小的可审计 足迹与隐蔽性,比功能更重要。正是这些约束,让成品能部署到更重的工具去不了的地方。

配置参考

守护进程从其工作目录读取单个 config.json(用 --config <path> 覆盖)。文件缺失时回退到 编译进二进制的默认值。解析器是严格的:未知结构、非法 CIDR 或越界取值都会导致 失败即关闭 的启动。部署前用 subnetrad --check 校验任何变更。

一个最小示例(config.example.json):

{
  "negotiation_version": 1,
  "local_tun_mtu": 1452,
  "listen_ports": [18020, 18023, 18026],
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 1,
  "obfuscate": true,
  "peers": [
    { "id": 2, "endpoint": "203.0.113.2:18020", "allowed_src": "10.0.0.2/32", "name": "bj-office-gw", "psk": "…64 hex…" },
    { "id": 3, "endpoint": "203.0.113.3:18020", "allowed_src": "10.0.0.3/32", "name": "colo-hub",     "psk": "…64 hex…" }
  ]
}

顶层字段

字段类型默认说明
negotiation_version整数1线/配置版本,v1 固定为 1。为未来 静态 的每链路传输模式选择预留——绝非线上握手。
local_tun_mtu整数1452隧道 MTU,必须在 68–1500 内。默认值在 1500 字节承载上为 64 字节线开销留出余量。
listen_ports整数数组按角色而定(见说明)守护进程为承载绑定的 UDP 端口——显式列出(绝非范围计算)。本节点在 全部 端口上接收数据报,并按每个对端最近一次到达的本地 socket 回程发送(NAT 正确),因此单个端口被封锁不会让本节点下线。1–8 个端口,每个非零且互不相同。省略时默认值按角色而定: hub/manual 节点绑定完整端口集 [18020, 18023, 18026](它是可达端点,多端口可在单端口被封锁时容错),而 spoke 只绑定 [18020]——spoke 位于 NAT 之后,hub 始终回程到它的源端口,因此 spoke 上多余的监听端口只会闲置。两种角色都用 同一个数组字段 显式覆盖即可(例如 spoke 写 [18020],hub 写 [18020, 18023, 18026])。默认值刻意 不是 51820(WireGuard 的知名端口本身就是指纹)。
listen_port整数(未设置)单端口便捷/向后兼容写法:等价于 "listen_ports": [<port>]。当 listen_ports 存在时本字段被忽略;两者都省略时使用按角色而定的默认端口集。
virtual_subnetCIDR10.0.0.0/24本网格构建的叠加子网。
local_tun_ipCIDR(不设)本节点自身的叠加地址(主机 + 前缀),例如 10.0.0.2/24。用于生成主机网络规划、为 TUN 配置地址;守护进程 自行配置主机地址。role=spoke 需要一个本地目标(本字段 local_routes),并以它作为该 Spoke 的可达 IP。对 role=hub可选——推导出的表只转发给 Spoke,故不设它即让 Hub 保持纯中继、在叠加网络上 不可达;仅当需要访问 Hub 自身时才设置它(并追加一条本地投递规则)(详情)。
local_id整数0本节点网格 id。必须 非零、可放入 u161–65535),且当 peers 非空时与每个对端 id 不同。0 表示「单节点 / 无网格」。
peers数组[]配置的网格对端(固定容量、零分配)。见 对端字段
role字符串"manual"manualhubspoke。控制启动策略推导——见 角色
local_routesCIDR 数组[]role=spoke:本节点 本地 投递(到自身 TUN/主机)的子网。为空时使用 local_tun_ip(作为 /32)。
remote_routesCIDR 数组[]role=spoke经由 Hub 可达的子网。为空时,Spoke 把 virtual_subnet 路由到 Hub。
keepalive_secs整数角色默认内置 spoke→hub NAT 保活间隔。0 关闭(hub/manual 默认)。NAT 后的 spoke 默认 20。开启 obfuscate 时,每次间隔在 [secs/2, secs] 内随机化,使保活节奏不构成指纹。
obfuscate布尔true报头混淆默认开启:按包对 20 字节报头做 XOR 掩码,使数据报对被动观察者不可与随机串区分,并随机化 spoke 保活节奏。设 false 可关闭(改发可读的明文报头,例如抓包调试)。必须在网格内所有节点上设置一致(不协商;不一致则全部认证失败、fail-closed)。仅隐藏协议指纹,不隐藏包长或时序。

对端字段

peers[] 的每个条目:

字段类型默认说明
id整数对端网格 id(非零、u16)。用于派生方向链路密钥,并作为线上 key_id 选择器。
endpoint字符串对端承载地址,形如 host:port,例如 203.0.113.2:18020(使用对端 listen_ports 中的某个端口)。Hub 使用动态 DNS 时见部署指南
allowed_srcCIDR0.0.0.0/0允许该对端发送的内层源范围。解密后内层 IPv4 源落在范围之外的包被丢弃(spoof)。请显式设置——宽松的默认值会关闭反伪造。
psk十六进制串本链路的 私有 32 字节预共享密钥(64 个十六进制字符)。必填、非零、每链路唯一。用 openssl rand -hex 32 生成。
name字符串""可选的人类可读标签。过长或不可打印的值会被拒绝。

不存在全网 psk 每条链路携带自己的密钥。仍有顶层 psk 的配置以 InvalidPsk 拒绝;在多个对端间复用一把 PSK 以 DuplicatePsk 拒绝。

对端上限。 peers[] 的容量固定、零分配,由构建选项 -Dmax-peers 在编译期确定 (默认 32,上限 128);一个 hub 最多管理这么多 spoke,策略表大小随之伸缩。 见调整对端数量上限

防呆自检

config.zig 在加载时(以及 --check 下)执行这些检查;任何失败都中止启动:

  • MTU 区间: local_tun_mtu 必须为 68–1500。
  • 子网重叠: 虚拟子网不得与主机物理子网以会黑洞流量的方式冲突。
  • 网格 id: local_id 非零且与每个对端 id 不同。
  • 唯一 PSK: 没有 PSK 在对端间共享(DuplicatePsk);没有顶层 pskInvalidPsk)。
  • 角色规则(见 角色):
    • hub 拒绝 allowed_src 缺失/为 0.0.0.0/0 或与另一对端 allowed_src 重叠的对端。
    • spoke 要求恰好一个 Hub 对端、至少一个本地目标(local_routeslocal_tun_ip), 且没有 0.0.0.0/0 本地路由。

MTU 与线开销

固定的每包开销是 64 字节:20 字节私有报头 + 16 字节 AEAD tag + 28 字节外层 IPv4/UDP。 因此安全隧道 MTU 为 path_mtu − 64。默认 local_tun_mtu = 1452 假设 1500 字节承载。在更小 的路径上(PPPoE、VPN 承载),请调低它——主机网络规划 会为你计算并 告警

配置与文档其余部分的衔接

角色

与其手工注入 subnetra policy add 规则,不如设置一个 role,让守护进程在启动时推导 转发表。共有三种角色;role 默认为 "manual"

角色推导策略?典型节点
manual否(初始策略为空——自行注入规则)自定义场景、向后兼容
spoke是——本地目标 + 其余一切经 Hub分支办公室、RouterOS 容器、Mac
hub是——为每个 Spoke 的 allowed_src 生成一条转发规则中心中继

你随时可以在运行时把额外的 subnetra policy 规则叠加到推导出的表之上。

manual(默认)

manual 是原始的显式模式,也是默认值。守护进程在启动时不推导任何策略——转发表从 开始,你通过控制套接字自行安装每一条规则。早于角色特性的既有配置照常工作、不受影响。

manual 相对推导角色改变了什么:

  • 不推导策略。 你用 subnetra policy add 自行构建转发表。
  • 没有角色专属的 --check subnetrad --check 仍会跑通用合理性检查(MTU 范围、 16 位 id、与主机子网重叠),但不会施加 hub/spoke 的结构性规则(每对端的 allowed_src、恰好一个 Hub、一个本地目标、无 0.0.0.0/0 本地路由)。配错的转发意图 要你自己发现。
  • 保活默认为 0 若一个 manual 节点位于 NAT 之后,请自行设置 keepalive_secsspoke 会替你处理)。

manual 没有 改变什么——安全性完全一致。 角色只选择启动期策略,绝不触及数据面。 每链路加密、会话 epoch 排序、抗重放,以及——关键的——每对端 allowed_src 内层源校验 全都照旧运行。策略仅按目的地匹配(最长前缀);每个对端的 allowed_src 独立地约束该对端 可声称的内层源地址。因此手工构建的 manual无法被诱骗去接受伪造的内层源——你放弃的 是推导出来的便捷表角色专属护栏而非密码学保证。

何时使用 manual

  • hub/spoke 形态在单个节点上无法表达的拓扑——例如一个节点同时是对上的 Spoke、对下的 中继hub/spoke 各自只校验一种姿态;manual 让一个节点兼具两者)。这超出了推导 角色所验证的单层模型,因此转发表——以及上游 Hub 的 allowed_src 聚合——由你负责。
  • 逐字复现一张手调的策略表,或与早于角色的配置向后兼容。

手工构建转发表

规则按目的地最长前缀匹配;src 取宽松值(0.0.0.0/0)。--target 0 投递到本地 TUN, 其它任何 target 则中继给该对端 id:

# 在 Linux 上 CLI 默认值已与守护进程一致,无需设置 SUBNETRA_SOCK。
# 把本节点自身的叠加地址本地投递。
sudo subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.9/32  --action forward --target 0
# 把一个下游前缀中继给 peer 5;其余一切上送 Hub(peer 1)。
sudo subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.32/27 --action forward --target 5
sudo subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.0/24  --action forward --target 1
sudo subnetra policy show      # 核对顺序
sudo subnetra save             # 跨重启持久化

每个对端仍必须带上正确的 allowed_src,以匹配它被允许声称的内层源——该绑定无论这些规则 如何都会被强制执行。

spoke

一个暴露自身叠加 IP、其余一切经中继的家庭/办公 Spoke,只需要:

{
  "role": "spoke",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 2,
  "local_tun_ip": "10.0.0.2/24",
  "local_routes": ["10.0.0.2/32"],
  "peers": [
    { "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…64 hex…" }
  ]
}

这会自动推导出:

  • 10.0.0.2/32 → LOCAL(投递到本节点自身的 TUN)
  • 10.0.0.0/24 → hub(id 1)(其余一切经中继)

要发布 Spoke 背后的局域网(Site-to-Site),把它加入 local_routes (例如 ["10.0.0.2/32", "192.168.2.0/24"]),使推导表把该前缀本地投递。

内置 NAT 保活

spoke 默认开启 NAT 保活keepalive_secs = 20)。它每隔一段时间向其 Hub 发送一个 极小的已认证数据报,使空闲 Spoke 的 NAT 孔保持打开、Hub 保持新鲜回程路由——无需外部 pinger、无需 cron。显式设置 keepalive_secs 调节,或设为 0 关闭。

spoke 的校验规则

subnetrad --check 强制:

  • 恰好 一个 Hub 对端,
  • 至少一个本地目标(local_routes local_tun_ip),
  • 没有 0.0.0.0/0 本地路由(那会把主机默认路由绑到隧道并将其黑洞)。

hub

对应的 Hub 只需列出它的 Spoke;每个对端的 allowed_src 成为指向该对端的一条转发规则:

{
  "role": "hub",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 1,
  "peers": [
    { "id": 2, "endpoint": "203.0.113.2:18020", "allowed_src": "10.0.0.2/32", "psk": "…64 hex…" },
    { "id": 3, "endpoint": "203.0.113.3:18020", "allowed_src": "10.0.0.3/32", "psk": "…64 hex…" }
  ]
}

这会推导出 10.0.0.2/32 → peer 210.0.0.3/32 → peer 3。Hub 按最长前缀匹配在 Spoke 之间中继,且绝不把包反射回源端。

访问 Hub 自身

推导出的 Hub 表 只转发给 Spoke——它从不投递到 Hub 自身的 TUN。因此默认情况下 Hub 没有叠加地址、在叠加网络上不可达:它是一个纯中继。这通常正是所需——中继不在网格上暴露 任何可寻址的东西。

若要让 Hub 自身经隧道可达(用于 SSH 登录它,或在叠加网络上托管服务),需要做 件事:

  1. local_tun_ip 给它一个地址,使网络规划为其 TUN 配置地址,并且

  2. 为该地址追加一条本地投递规则——把该节点按 manual 运行并使用显式策略表,或在推导出的 Hub 表之上叠加一条规则:

    # 把 Hub 自身的叠加地址本地投递到它的 TUN
    sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.1/32 --action forward --target 0
    

不设 local_tun_ip(也不加这条规则)即让 Hub 保持纯中继,叠加网络上没有任何东西能寻址它。

hub 的校验规则

subnetrad --check 拒绝:

  • allowed_src 缺失(或宽松的 0.0.0.0/0)的对端,因为 Hub 无法判断一个包属于哪个 Spoke;
  • 两个 allowed_src 前缀 重叠 的对端,那会使转发产生歧义。

Hub 的保活默认为 0(它不向 Spoke 发起保活)。

可直接编辑的示例

仓库的 deploy/ 目录提供可编辑的 hub.jsonspoke-a.jsonspoke-b.json 以及服务单元。完整的 Hub + 双 Spoke 演练在 生产部署

主机网络规划

subnetrad 会创建 TUN 网卡,但刻意 配置主机地址、路由或 MTU。自动配置主机网络意味 着要 shell 外调或链接额外库,那会破坏零依赖单二进制保证。取而代之,守护进程为加载的配置 打印出确切的命令,供你审阅并执行。

打印规划

# 打印本节点的主机网络规划(默认按 1500 字节承载)。
subnetrad --print-network-plan --config config.json

# 覆盖承载路径 MTU(例如位于 PPPoE / VPN 承载之后):
subnetrad --print-network-plan --path-mtu 1420 --config config.json

输出是 确定的、仅打印 的——主机上不会有任何改动。后端在 comptime 选择,因此规划与你的 平台匹配:

  • Linux 输出 ip link / ip addr / ip route 命令。
  • macOS 输出 ifconfig / route 命令。

它输出什么

对于加载的配置,Linux 规划输出:

  • ip link set <tun> mtu <local_tun_mtu> up
  • ip addr add <local_tun_ip> dev <tun>——设置可选的 local_tun_ip 配置字段 (例如 "local_tun_ip": "10.0.0.2/24");否则显示一个占位符。
  • 为每个对端的 allowed_src 输出 ip route add <subnet> dev <tun>——宽松的 0.0.0.0/0 会被 跳过,以免黑洞默认路由。
  • 一条可选的 TCP MSS clamp 提示(nftables / iptables),以避免 PMTU 黑洞。

MTU 计算

规划从真实线开销计算安全隧道 MTU:

报头 20  +  AEAD tag 16  +  外层 IPv4/UDP 28  =  64 字节
最大隧道 MTU = path_mtu − 64

如果配置的 local_tun_mtu 超过该值,规划会打印一条 告警——这正是「小包能通、大传输 卡死」(PMTU 黑洞)的经典原因。在标准 1500 字节路径上,1436 是安全默认值;在 1420 字节 承载上,你应把 local_tun_mtu 调低到 1356

探测真实路径 MTU

--print-network-plan 默认 假设 承载为 1500 字节(可用 --path-mtu 覆盖)。但真实路径 往往更小——PPPoE 是 1492,CGNAT/移动/隧道上行各不相同——而常规的发现手段(内核 Path MTU Discovery)依赖路由器回送 ICMP「需要分片」。这类 ICMP 经常被过滤(即 PMTU 黑洞),而这恰恰 就是混淆 overlay 所运行的网络环境。

mtu-probe 工具 主动、端到端、走 普通 UDP 地测量路径,不依赖 ICMP,并直接打印应配置的 local_tun_mtu。在一端运行响应方, 在另一端运行探测方:

zig build tool:mtu-probe

# 在远端节点(例如 hub,使用其公网端点):
zig-out/tools/mtu-probe --listen 18020

# 在近端节点——置 Don't-Fragment 位后二分搜索能往返的最大数据报,
# 然后给出安全的 local_tun_mtu:
zig-out/tools/mtu-probe --probe 203.0.113.9:18020
#   underlay path MTU : 1492 bytes   (largest UDP payload that round-tripped: 1464 + 28 IPv4/UDP)
#   subnetra overhead : 64 bytes
#   recommended       : local_tun_mtu = 1428

把打印出的值设为 local_tun_mtu(再跑一次 --print-network-plan 确认告警消失)。巨型帧路径上 加 --ceil 9000。详见 tools/README.md

应用它

审阅输出的命令,然后执行(多数需要 root):

subnetrad --print-network-plan --config config.json | sudo sh   # 审阅之后再执行!

请先检查输出、再有意识地粘贴命令,而不是直接管道进 shell——规划本就是为了可审计。

为什么这样设计

把主机网络配置排除在守护进程之外意味着:

  • 二进制保持零依赖且极小,
  • 同一个守护进程在 Linux、容器与 macOS 上行为完全一致,
  • 运维者保有对主机地址与路由每一次改动的完全控制权与可审计记录。

为规划提供输入的字段(local_tun_iplocal_tun_mtu、每个对端的 allowed_src)见 配置参考,将其接入服务见 生产部署

生产部署

本页浓缩了完整的 Hub + 双 Spoke 生产演练。详尽版本——含流量整形、网卡调优与基准测试—— 见仓库中的 docs/deployment.zh-CN.md。 可直接编辑的产物位于 deploy/subnetrad.servicenet.subnetra.subnetrad.plisthub.jsonspoke-a.jsonspoke-b.json)。

0. 组件

一次部署有一个 Hub(稳定的公网 UDP 端点)和一个或多个 Spoke(仅出站,常位于 NAT 之后)。它们运行相同的 subnetrad 守护进程与 subnetra 控制工具;区别在配置 (角色)。

1. 安装二进制

使用发布 tar 包或容器镜像。在裸主机上:

sudo install -m 0755 subnetrad subnetra /usr/local/bin/

2. 准备配置与密钥

config.json 放到服务期望的位置(单元使用 /etc/subnetra/config.json),属主 root、 权限 0600,因为它含有 PSK:

sudo install -d -m 0750 /etc/subnetra
sudo install -m 0600 hub.json /etc/subnetra/config.json
subnetrad --check --config /etc/subnetra/config.json
# subnetra v… (mtu=…, mode=raw_direct, local_id=…, peers=…) [config ok]

openssl rand -hex 32 为每条链路生成 PSK,并 每链路使用唯一 值。见 安全模型

3. 主机网络

守护进程打印——但绝不应用——主机规划。审阅并执行它(见 主机网络规划):

subnetrad --print-network-plan --config /etc/subnetra/config.json

4. 作为服务运行

Linux —— systemd

sudo install -m 0644 deploy/subnetrad.service /etc/systemd/system/subnetrad.service
sudo systemctl daemon-reload
sudo systemctl enable --now subnetrad
journalctl -u subnetrad -f

该单元只请求 CAP_NET_ADMIN,授予 /dev/net/tun,以 ExecStartPre 运行 subnetrad --check,失败时重启,其余均沙箱化(ProtectSystem=strictNoNewPrivileges、受限地址族)。编辑注释掉的 ExecStartPost 行以匹配你的 --print-network-plan 输出。

macOS —— launchd

macOS Spoke 作为系统守护进程运行(创建 utun 需要 root):

sudo install -m 0644 deploy/net.subnetra.subnetrad.plist \
    /Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl enable system/net.subnetra.subnetrad
sudo tail -f /var/log/subnetrad.log
# subnetra v… (… mode=raw_direct …) tun=utun4 sock=/var/run/subnetra.sock [ready]

utunN 名字由内核分配——从 [ready] 横幅读取它,并在守护进程起来 之后 应用规划。见 macOS Spoke 指南。

5. 安装中继策略(Hub)

捷径: 如果你的配置设置了 "role": "hub" / "spoke",守护进程会 在启动时推导出 整套策略,本节可跳过。见 角色

对于 role=manual,在运行时通过控制套接字安装中继/投递规则(热更新、无需重启)。在 Linux 上 CLI 默认值已与守护进程一致,无需设置 SUBNETRA_SOCK(仅自定义路径时才设置):

# Hub:把叠加网流量中继给正确的 Spoke
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 2
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.3/32 --action forward --target 3
sudo -E subnetra policy show
sudo -E subnetra save        # 持久化一份可重放的快照

# Spoke:把发往本地叠加地址的隧道流量投递到本地 TUN(target 0)
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 0

6. 运维

subnetra status 显示对端、流量与按原因分类的 drop;--json 是供监控使用的稳定 schema。 见 可观测性与排障

7. 防火墙 / NAT

  • Hub 必须接受来自互联网、发往全部已配置 listen_ports 的入站 UDP。默认值是显式端口集 18020, 18023, 18026(不是范围),避开 WireGuard 知名端口指纹;单个端口被封锁或限速也 不会让节点离线。
  • 每个 Spoke 只需要对 Hub 的 出站 UDP 可达性——无需入站端口转发(由 Spoke 发起)。 因此 Spoke 只需 一个 监听端口(按角色而定的默认值只绑定 [18020]);多个 listen_ports 是 Hub 一侧的特性,因为 Hub 才是被 Spoke 主动连接的可达端点。
  • 若某 Spoke 的 NAT 映射变化,Hub 会从下一个已认证数据报重新学习它的新 endpoint。保持 Hub 端点稳定;Spoke 始终发起。

NAT 保活(内置)

空闲 Spoke 的 NAT 映射会超时(UDP 常约 ~30 秒),之后入站中继会被黑洞。role=spoke 默认 运行 内置保活keepalive_secs = 20):每隔一段时间一个极小的已认证数据报保持 NAT 孔 打开,并保持 Hub 学到的 endpoint 新鲜。它零分配,不增加线程或外部进程。用 keepalive tx / keepalive rx 计数确认。设 keepalive_secs = 0 关闭(例如不在 NAT 后的 Spoke)。

Hub 使用动态 IP(DDNS)

endpoint 是数字 IP:port,且端点学习是单向的——Spoke 无法发现搬了家的 Hub。请优先为 Hub 使用 稳定公网 IP。若必须让它位于动态地址之后,在每个 Spoke 上用一个小型 DDNS 监视器解决 :重写 endpoint 并重启(无状态的)守护进程——无需改动守护进程。

Hub 位于 NAT 之后(静态端口映射)

Hub 不需要是一台公网 IP 主机——它需要的是一个稳定、可入站到达的 UDP 端点。如果边缘路由器 有一组从固定公网 UDP 端口到 Hub 内网 IP:listen_ports静态端口映射(DNAT),那么 位于 NAT 之后的主机同样符合条件:

  • Spoke 拨向_外部_地址。 每个 Spoke 的对端 endpoint 是该映射中的一个公网 IP:port(通常是主端口 :18020),而非 Hub 的私网地址。
  • listen_ports 是_内部_目标。 为每个已配置 UDP 端口做转发(默认是公网 18020/18023/18026 → 内网 18020/18023/18026)。单数 listen_port 仍是单端口部署的 向后兼容别名;当 listen_ports 存在时会被忽略。
  • 映射必须是静态的。 是固定端口映射,而非按流改写源端口的动态 PAT。若公网 IP 本身也会变, 请与上面的 DDNS 方案结合使用。
  • 同一局域网内的 Spoke 需用内部端点(hairpin)。 处于同一 NAT 内的 Spoke 通常无法经由 公网 IP 到达 Hub,除非路由器支持 NAT 发夹/回环——请给这些 Spoke 配置 Hub 的内网 IP:port(主端口)。
  • CGNAT 无法承载 Hub。 若“公网“地址本身是运营商级 NAT、没有入站端口控制,你就无法向它做 端口映射;这样的主机只能当 Spoke。

这仍然是一个普通的单 Hub——只是其可达性经由 DNAT——因此仍处于已验证的单层模型之内。端点 学习依旧是单向的:保持外部映射稳定;始终由 Spoke 发起。

8. 高可用

v1 按设计 单 Hub。数据面是单路径、无状态、无握手的,守护进程 从不探测对端健康、也不自动 切换路径(一条明确的非目标)。因此多 Hub 与故障 切换都构建在守护进程 之外,用普通的配置 + 操作系统工具实现,并由 subnetra status --json 中 的 仅观测 健康信号驱动(onlinelast_seen_age_seconds、一个平直的 auth_or_invalid)。 官方认可两种方案。

方案 A —— active/standby Hub VIP(推荐)

两台 Hub 主机置于一个 VRRP/keepalived VIP(或 anycast 前缀)之后,二者共享 完全相同 的配 置——相同 local_id、相同的每 Spoke PSK、相同策略——因此每个 Spoke 只看到 一个 对端 (VIP),无需任何特殊配置:一个普通的 role=spoke,把 VIP 作为它唯一的 Hub。

flowchart LR
    subgraph Spokes
        S2["Spoke · 10.0.0.2"]
        S3["Spoke · 10.0.0.3"]
    end
    VIP(["Hub VIP · 203.0.113.10:18020"])
    subgraph HubPair["Hub 对 · 共享 local_id + PSK"]
        HA["hub-a · ACTIVE"]
        HB["hub-b · standby"]
    end
    S2 <-->|"加密 UDP"| VIP
    S3 <-->|"加密 UDP"| VIP
    VIP --- HA
    VIP -. 接管 .- HB

每台 Hub 上的最小 keepalived,用 notify 钩子在接管时 重启 守护进程:

vrrp_instance subnetra {
    state BACKUP            # 两台都 BACKUP + nopreempt,避免无谓抖动
    interface eth0
    virtual_router_id 51
    priority 150            # hub-a 用 150,hub-b 用 100
    nopreempt
    advert_int 1
    virtual_ipaddress { 203.0.113.10/32 }
    notify_master "/usr/local/sbin/subnetra-takeover.sh"
}
# /usr/local/sbin/subnetra-takeover.sh —— 本机抢到 VIP 时执行。
#!/bin/sh
# 重启,使守护进程采样到一个高于旧 active 的全新 boot epoch(见下)。
systemctl restart subnetrad

两条注意事项是关键:

  • epoch 顺序(头号坑)。 每个数据报都携带发送方的 boot epoch(启动时的墙钟纳秒),且接收 方是 仅向前 的:epoch 比 Spoke 已接受的 更低 的会话,会在 进入加密前 被丢弃,直到墙钟 越过它。一个长时间空闲、且启动早于 active 的 standby 会呈现 更低 的 epoch,从而被静默黑洞。 缓解:让 两台 Hub 都用 NTP,并在 接管时重启守护进程(即 notify_master 钩子),让它打上 一个全新的、更高的 epoch。这正是此处 active/standby 优于 active/active 的原因。
  • 端点重学习窗口。 端点学习是单向的,因此新的 active 起步时 没有 任何已学习的 Spoke 端点: Hub→Spoke 的 中继 会黑洞,直到每个 Spoke 的下一次 keepalive 重新教会它(Spoke→Hub 立即可用 ——由 Spoke 主动发起)。恢复时间以 keepalive_secs(Spoke 默认 20)为上界;在 Spoke 上调小它 可加快切换。

方案 B —— 静态双 Hub(独立身份)

两个 完全独立 的 Hub——local_id 不同、PSK 不同、无共享密钥、无 epoch 耦合——都中继同一个 overlay。每个 Spoke 把 两者 都列为对端并保留一个主用,靠改 Spoke 的策略来切换。这同时带来 就近性:把区域前缀指向区域内 Hub,只有跨区域目的地才走长途链路(每个区域 Hub 都必须承载你希 望就近可达的那些 Spoke)。

由于 role=spoke 校验 恰好一个 Hub 对端,双 Hub 的 Spoke 改用 role=manual,并通过控制套接字 安装自己的最长前缀策略:

# 主路径:整个 overlay 走 hub-1(id 1);本机做本地投递。
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.0/24 --action forward --target 1
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.5/32 --action forward --target 0
sudo -E subnetra policy show
sudo -E subnetra save

# 把 overlay 切到 hub-2(id 2):两条 /25 比 /24 更具体(最长前缀胜出),
# 从而在不触碰 /24 规则的情况下把全部 overlay 流量改道。
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.0/25   --action forward --target 2
sudo -E subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.128/25 --action forward --target 2

做负载拆分时,让两个 Hub 的前缀 互不重叠——这与 role=huballowed_src 的约束是同一条纪律 ——以避免脑裂(两个中继争抢同一目的地)。

没有在线的 policy replace 控制套接字是 只追加 的(policy add / policy show / save——没有 replacedelclear),而最长前缀只允许 更具体 的规则胜出。因此要 移动 一个前缀,你要么 (a) 用更新后的配置/快照 重启 Spoke 守护进程——它是无状态的,重启只损耗一个 keepalive 重学习窗口——要么 (b) 像上面那样推一条更具体的 覆盖 规则(表会变大;干净回退仍需重 启)。把拆分设计成 基本静态;不要把方案 B 当作亚秒级故障切换。

如何选择

方案 A —— VIP方案 B —— 静态双 Hub
目标可用性(一个逻辑 Hub)就近性 + 可用性(两个区域)
Spoke 配置不变(role=spoke,单对端)role=manual,两个 Hub,手动策略
Hub 身份共享 local_id + PSK不同 local_id + PSK
切换触发网络层(VRRP),秒级运维重启 / 前缀覆盖
主要坑epoch 顺序 + 重学习窗口无在线 replace;拆分要静态
Anycast可行,但比 VRRP 更冒险(中途 POP 迁移会扰动端点学习)不适用

密钥材料提示(方案 A)。 共享 local_id + PSK 会把相同的密钥放到两台机器上——请用同等标准 保护两者;任一被攻陷即等于所有 Spoke 链路被攻陷。两种方案中,守护进程自身都 不做 任何切换 决策。

9. 流量整形与调优

在跨 ISP 的长链路上,抖动/丢包的主因是 承载,而非检测。所有整形都在 OS 层用 tc 完成 ——不改守护进程或协议。把出站限到链路 稳定 吞吐的约 60–80%,平滑突发,并(可选)调优 套接字缓冲与 IRQ/CPU 亲和。内核在明文 snr0 设备上看到真实的内层五元组。完整配方与活叠加 基准见 docs/deployment.zh-CN.md §9–§10

容器

Subnetra 天生适合容器:守护进程是单个静态二进制,无共享库依赖。发布的镜像是多架构 (amd64 / arm64 / armv7 / armv5),Docker 会自动选择正确的一个。

要求

运行 subnetrad 的容器需要:

  • NET_ADMIN 能力(创建 TUN 网卡),
  • /dev/net/tun 的访问,
  • 挂载到其工作目录(/etc/subnetra)的一个 config.json

Docker

docker pull ghcr.io/jamiesun/subnetra:latest

docker run -d --name subnetra \
    --cap-add=NET_ADMIN --device=/dev/net/tun \
    -v "$PWD/config.json":/etc/subnetra/config.json:ro \
    ghcr.io/jamiesun/subnetra:latest

镜像内置运行 subnetra status 的 Docker HEALTHCHECK,因此当守护进程开始提供控制套接字 后,docker ps 报告容器为 healthy,停止响应时为 unhealthy

Docker Compose

services:
  subnetra:
    image: ghcr.io/jamiesun/subnetra:latest
    container_name: subnetra
    restart: unless-stopped
    cap_add: [NET_ADMIN]
    devices:
      - /dev/net/tun
    volumes:
      - ./config.json:/etc/subnetra/config.json:ro

按你的拓扑,在主机上或容器网络命名空间内应用叠加网的 主机网络规划

Kubernetes

DaemonSet(或 Deployment)携带该能力与设备运行。一个最小容器规格:

securityContext:
  capabilities:
    add: ["NET_ADMIN"]
volumeMounts:
  - name: tun
    mountPath: /dev/net/tun
  - name: config
    mountPath: /etc/subnetra/config.json
    subPath: config.json
volumes:
  - name: tun
    hostPath: { path: /dev/net/tun, type: CharDevice }
  - name: config
    secret: { secretName: subnetra-config }   # config.json 含 PSK——用 Secret

HEALTHCHECK 可干净地映射到 liveness/readiness 探针: exec: { command: ["subnetra", "status"] }

因为 config.json 携带每链路 PSK,请把它存进 Kubernetes Secret(或权限 0600 的 Compose/主机文件),绝不要放进普通 ConfigMap 或镜像层。

镜像内部

amd64arm64arm/v7 镜像基于 busybox:musl 构建:内含两个静态二进制、 config.example.json,以及一个用于容器内调试的微型 BusyBox shell 加核心工具。守护进程本身 完全静态,不需要基础镜像提供任何东西。

由于没有 musl BusyBox 发布 linux/arm/v5arm/v5 镜像独立地基于 scratch 构建(仍为 静态 musl,但无调试 shell),并拼入同一份 :latest / :version 清单。构建用锁定到 $BUILDPLATFORM 的 Zig 交叉编译,因此无需 QEMU 模拟。见 Dockerfile

离线 / 隔离网络

无法访问仓库的设备可以 docker load 每个发布附带的、按架构区分的镜像 tar 包——见 安装 → 离线安装

RouterOS 容器

MikroTik/RouterOS Container 是特例(它通过 veth 管理容器的以太网侧,镜像导入可能需要 legacy 归档布局)。它有自己的指南:RouterOS Spoke

RouterOS Spoke

本指南讲解在 MikroTik 设备上的 RouterOS Container 内,把 Subnetra 作为 Spoke 运行, 拨向一个公网 Linux Hub。完整参考(含脚本化 .rsc 的拉起/拆除)见 docs/routeros-container.md; 脚本位于 deploy/routeros/

RouterOS 为何不同

RouterOS Container 不是普通的 Linux 主机:

  • RouterOS 通过 veth 管理容器的以太网侧。
  • Subnetra 在容器 内部 创建自己的 Linux snr0 TUN 设备。
  • RouterOS 无法直接管理那个 snr0——它通过 veth 网关把流量路由 容器。
  • RouterOS 镜像导入可能需要 legacy Docker 归档布局。

推荐拓扑

把 Hub 放在有稳定 UDP 端点的公网 Linux 服务器上;把 NAT 后的 RouterOS 设备作为 Spoke 置于 其后。

公网 Linux Hub
  承载: 203.0.113.10:18020
  叠加: 10.66.0.1/24

RouterOS 办公 Spoke
  容器 veth:               172.30.66.2/30
  RouterOS veth 侧:        172.30.66.1/30
  容器内 subnetra TUN:      10.66.0.3/24
  发布的 LAN:              192.168.88.0/24

不要 把 NAT 后的 RouterOS 设备当作公网 Hub,除非它的 UDP 端点稳定且对每个 Spoke 可达。 此处 LAN 地址为示例——把 192.168.88.0/24 换成你真实的 LAN。

前置条件

  • 装有 container 包的 RouterOS v7。
  • 设备模式允许容器(/system/device-mode/print)。
  • 容器根目录与镜像归档可写的存储路径。
  • RouterOS 容器内可见 /dev/net/tun
  • RouterOS 设备到公网 Hub 的出站 UDP。
/system/package/print
/system/device-mode/print
/container/print

拉起步骤概览

  1. 配置 veth 对与地址(容器侧 172.30.66.2/30,RouterOS 侧 172.30.66.1/30)。
  2. 导入镜像(来自发布的可 docker load tar 包,或在设备可达时从 GHCR 拉取),并创建挂载 了 config.jsonNET_ADMIN/dev/net/tun 的容器。
  3. 设置 Spoke 配置role: spoke,Hub 作为唯一对端,发布的 LAN 放进 local_routes)—— 见 角色
  4. 在 RouterOS 上应用路由,使发往叠加/远端前缀的 LAN 流量路由到容器的 veth 网关,且发布 的 LAN 在隧道对端可达。
  5. 验证:在容器内 subnetra status,并跨叠加网 ping。

deploy/routeros/ 中脚本化的 .rsc 文件自动化第 1–4 步(以及配套的拆除)。精确命令(含 LAN 发布与 NAT 后 Spoke 的 NAT/endpoint 注意事项)请遵循完整指南。

Endpoint 漫游说明

协议级的端点学习意味着 Hub 会从 NAT 后 Spoke 的下一个已认证数据报重新学习其 endpoint,且 内置 NAT 保活(role=spoke 默认)保持 NAT 孔打开——因此处于变化 NAT 映射之后的 RouterOS Spoke,无需手工修正 endpoint 即可保持可达。

OpenWrt Spoke

本指南介绍如何在 OpenWrt 路由器(MIPS 或 ARM 家用/SOHO CPE)上、于 NAT 之后把 Subnetra 作为 spoke 运行,拨向一台公网 Linux hub。OpenWrt 天然契合:它本身就用 musl,静态二进制直接能跑;而一台位于 NAT 之后的路由器,正是 spoke 角色为之而生的场景。

可直接使用的 procd 服务见 deploy/openwrt/subnetrad.init

OpenWrt 的不同之处

  • 用 procd,不是 systemd。 使用随附的 /etc/init.d/subnetrad init 脚本,而非 systemd 单元
  • TUN 是内核模块。 安装 kmod-tun,守护进程才能经 /dev/net/tun 创建其 snr0 设备。
  • BusyBox 用户态。 doctor.sh 预检对 BusyBox 友好,可直接运行。
  • 闪存小。 每个二进制都远小于 512 KB,两个都能轻松放进 overlay(/usr/sbin/usr/bin)。

选对二进制

Subnetra 按架构发布静态 musl tarball。用 opkg print-architecture(或 uname -m)把你的 路由器映射到对应包:

OpenWrt target(举例)opkg 架构Release tarball
ramips(mt7621 / mt7628),多数现代 MIPSmipsel_24kc…-linux-mipsel.tar.gz
ath79 / Atheros(大端 MIPS)mips_24kc…-linux-mips.tar.gz
mvebu / ipq40xx / sunxi(32 位 ARM)arm_cortex-a*…-linux-armv7.tar.gz
filogic / ipq807x / bcm27xx(64 位 ARM)aarch64_cortex-a*…-linux-arm64.tar.gz

MIPS 字节序要分清。 mipsel 是小端(ramips 及多数现代设备);mips 是大端 (ath79/Atheros)。装错了会无法 exec。拿不准时查 opkg print-architecture

安装

# 1. TUN 模块(一次性)
opkg update && opkg install kmod-tun

# 2. 解析最新 release + 你的架构,下载、校验、安装
ARCH=mipsel   # mipsel | mips | armv7 | arm64 之一(见上表)
VER=$(uclient-fetch -qO - \
        https://api.github.com/repos/jamiesun/subnetra/releases/latest \
        | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p')
base="https://github.com/jamiesun/subnetra/releases/download/$VER"
cd /tmp
uclient-fetch -q "$base/subnetra-$VER-linux-$ARCH.tar.gz"
uclient-fetch -q "$base/SHA256SUMS.txt"
sha256sum -c SHA256SUMS.txt 2>/dev/null | grep "subnetra-$VER-linux-$ARCH.tar.gz: OK"
tar -xzf "subnetra-$VER-linux-$ARCH.tar.gz"

# 3. 放置二进制(守护进程进 sbin,客户端进 bin)
install -m 0755 "subnetra-$VER-linux-$ARCH/subnetrad" /usr/sbin/subnetrad
install -m 0755 "subnetra-$VER-linux-$ARCH/subnetra"  /usr/bin/subnetra
subnetrad --version

配置 spoke

把本节点的配置(含 PSK)放在 /etc/subnetra/config.json,root 属主、权限 0600。一个 把路由器 LAN 经隧道发布出去的最小 spoke:

{
  "role": "spoke",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 7,
  "local_tun_ip": "10.0.0.7/24",
  "local_routes": ["10.0.0.7/32", "192.168.1.0/24"],
  "peers": [
    { "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…64 hex…" }
  ]
}

spoke 角色推导出什么见 角色;用 config-gen / keygen 可生成一套带全新每链路 PSK 的 hub + spoke 配置。

mkdir -p /etc/subnetra
# (把配置拷进去,然后锁权限)
chmod 0600 /etc/subnetra/config.json
subnetrad --check --config /etc/subnetra/config.json   # 离线校验

安装 procd 服务

# 从 checkout 拷,或直接下载 raw 文件:
uclient-fetch -qO /etc/init.d/subnetrad \
  https://raw.githubusercontent.com/jamiesun/subnetra/main/deploy/openwrt/subnetrad.init
chmod 0755 /etc/init.d/subnetrad
/etc/init.d/subnetrad enable
/etc/init.d/subnetrad start
logread -e subnetrad        # 确认启动(且没在 --check 处失败)

init 脚本会在启动前先跑 subnetrad --check(配置错就快速失败,而不是 respawn 死循环), 保持守护进程被 respawn,并在网络配置 reload 时重启它。

应用主机网络计划

Subnetra 从不自己改路由——只打印计划。预览后在服务起来之后再应用(snr0 设备只在守护 进程运行时存在):

subnetra --print-network-plan --config /etc/subnetra/config.json
# 然后应用打印出的 ip/route 命令,例如:
ip link set snr0 mtu 1400 up
ip addr add 10.0.0.7/24 dev snr0

要让它在重启后仍生效,把等价配置加进 /etc/config/network(一个绑定 snr0proto noneinterface 加静态路由)或一个小的 hotplug 脚本——但路由应用始终放在 OpenWrt 侧, 绝不进守护进程。

验证

subnetra status                 # 对端、流量、按原因分类的丢包
sh doctor.sh                    # TUN / 能力 / 时钟 预检(BusyBox 可用)
ping -c3 10.0.0.1               # 经叠加网 ping 到 hub

拓扑提示

  • NAT 之后 = 理想 spoke。 内置 NAT 保活(role=spoke 默认,keepalive_secs = 20) 保持孔打开、保持 hub 学到的端点新鲜,因此漫游/CGNAT 变动的映射无需手工纠正即可保持可达。
  • 有静态端口映射的路由器可做 hub。 若这台 OpenWrt 有一组稳定公网 UDP 端口经 DNAT 映射 到它的 listen_ports,它可以改跑 role=hub——见 Hub behind NAT
  • 时间同步。 会话密钥使用 CLOCK_REALTIME boot epoch 且 forward-only 排序,所以请运行 sysntpd 并让时钟在启动前/时稳定(init 启动较晚,START=95)。见 生产部署 的时间同步说明。

macOS Spoke

macOS 作为拨向既有 Linux/RouterOS HubSpoke 受支持。数据通路在 utun + poll(2) 上原生运行,由 comptime 在 src/os/ 之后选择。macOS Hubkqueue 与自动路由 变更明确不在范围内。

由于 macOS 没有网络命名空间,且托管的 mac CI runner 无法在无提权下创建 utun,所以 macOS Spoke 是 Runbook 验收 而非 CI 门禁。权威流程见 docs/macos-spoke-acceptance.md

前置条件

  • 一台有 sudo 权限的真实 Mac(Apple Silicon 或 Intel)——创建 utun 需要 root。
  • 一个发布的 macOS 二进制,或 Zig 0.16.0+ 以从源码构建。
  • 一个可达、已正常工作的 Linux/RouterOS Hub,具备稳定承载端点,并为这台 Mac 签发了每对端 PSK
  • 至少一个可跨隧道 ping 的远端叠加目标。

macOS 二进制是 最小动态 的——它只链接 libSystem(仍零第三方依赖),因此 不是 静态 可执行文件。不要 对它运行仅 Linux 的 ldd → not a dynamic executable 检查。

安装

从发布 tar 包(subnetra-<version>-macos-arm64.tar.gz-amd64):

tar -xzf subnetra-<version>-macos-arm64.tar.gz
cd subnetra-<version>-macos-arm64
# Gatekeeper 会隔离下载的二进制——清除隔离属性(或从源码构建):
xattr -d com.apple.quarantine subnetrad subnetra 2>/dev/null || true

或用 zig build 本地构建(见 安装)。

配置

写一个 Spoke config.json(见 角色),以 Hub 作为唯一对端,并 填入这台 Mac 的叠加地址:

{
  "role": "spoke",
  "virtual_subnet": "10.0.0.0/24",
  "local_id": 4,
  "local_tun_ip": "10.0.0.4/24",
  "local_routes": ["10.0.0.4/32"],
  "peers": [
    { "id": 1, "endpoint": "203.0.113.1:18020", "allowed_src": "10.0.0.0/24", "psk": "…64 hex…" }
  ]
}

预览主机规划

在 macOS 上规划输出 ifconfig / route 命令(守护进程绝不应用它们):

./subnetra --print-network-plan --config config.json

运行

sudo ./subnetrad --config config.json
# subnetra v… (… mode=raw_direct …) tun=utun4 sock=/var/run/subnetra.sock [ready]

utunN 网卡名由 内核分配——从 [ready] 横幅读取它,然后用真实名字应用规划:

sudo ifconfig utun4 inet 10.0.0.4 10.0.0.4 mtu 1400 up
sudo route add -net 10.0.0.0/24 -interface utun4

在 launchd 下运行

为持久化 Spoke,安装系统守护进程 plist(以 root 运行、异常退出时重启、记录到 /var/log/subnetrad.log):

sudo install -m 0644 deploy/net.subnetra.subnetrad.plist \
    /Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl bootstrap system /Library/LaunchDaemons/net.subnetra.subnetrad.plist
sudo launchctl enable system/net.subnetra.subnetrad

sudo launchctl kickstart -k system/net.subnetra.subnetrad(重启)与 sudo launchctl bootout system/net.subnetra.subnetrad(停止)管理它。

KeepAlive 可能把守护进程重启到 不同utunN;重读横幅并重新应用规划。Subnetra 刻意把路由留给你(无自动路由变更)。

验证

sudo ./subnetra status      # 对端在线、计数在涨
ping 10.0.0.3               # 跨隧道的一个远端叠加目标

出口节点与 outbound

Subnetra 是一条 三层加密通道,不是代理、也不是规则引擎。它不解析域名、不做 DNS、不匹配 GeoIP —— 按设计,这些都属于专门的 工具。它擅长的是:把流量全程加密、穿透 NAT 地送到一个拥有你想要的网络访问能力的 spoke。那个 spoke 就是出口,在它上面跑一个小代理,就把 mesh 变成了一个干净的 outbound。

做法刻意保持简单:在出口 spoke 上跑一个 SOCKS5 代理,绑定到它的 overlay IP,然后让一个 支持规则的客户端(Shadowrocket、mihomo、sing-box)指向它。Subnetra 充当抵达该代理 的安全通道;由客户端决定哪些目的地走出口。

为什么不把规则做进 Subnetra? 内置 DNS、L7 路由、路径管理器都是明确的 非目标。按域名选择目的地是 DNS + L7 的活儿;把 那一层交给成熟客户端,让 Subnetra 在底下专心当传输通道。

拓扑

规则引擎客户端 ─► spoke A ─► hub ─► 出口 spoke B ─► 互联网
 (如 Shadowrocket) (10.0.0.2) (中继)  (10.0.0.3)      (B 的上行)

hub 负责中继 A ↔ B(spoke 之间从不直接互相中继)。只有 B 需要目标网络的访问能力; 客户端通过 B 访问。运行客户端的设备通过它本地的 spoke A 抵达 B 的 overlay IP (10.0.0.3)—— A 本身,或者把 overlay 路由进来的、位于 A 局域网内的某台主机。

1. 在出口 spoke 上跑一个 SOCKS5 代理

在 B 上跑一个小型 SOCKS5 服务,绑定到 B 的 overlay IP,使其能通过 mesh 访问, 绝不暴露在 B 的公网网卡上。zsocks 是一个微型、零依赖 的 SOCKS5 服务(同样用 Zig 写、与 Subnetra 同源同理念 —— 单静态二进制、内存有上界、 支持 TCP CONNECT 与 UDP ASSOCIATE、可选鉴权):

# 仅绑定 overlay IP,并要求用户名/密码鉴权。
zsocks --listen 10.0.0.3 --port 1080 --user alice --pass <secret>

常用参数(详见 zsocks --help):

参数用途
-l, --listen <host>绑定地址 —— 设为 B 的 overlay IP(10.0.0.3
-p, --port <port>监听端口(默认 1080
-u/-P, --user/--pass启用 RFC1929 鉴权(建议开启)
--max-conns <n>并发连接上限(默认 256)
--no-udp仅 TCP;不需要时关闭 UDP ASSOCIATE
--udp-advertise <h>UDP 中继地址 —— 保持默认即可,overlay IP 本身可直达

支持 UDP ASSOCIATE,因此 QUIC / HTTP-3 应用也能正常工作;对客户端通告的 UDP 中继地址 默认就是 listen host(10.0.0.3),它在 overlay 上可直达,所以这里不需要设置 --udp-advertise

由于代理绑定在 overlay 地址上,去往它的全部流量都留在加密 mesh 内部,且每个 overlay 报文 都带着正常的 overlay 源/目的 IP —— 因此 hub 的逐对端 allowed_src 反伪造保持收紧。这里 没有 ip_forward、没有 masquerade:代理在 B 上重新发起连接,回程路径就是它自己的 socket。

2. 让客户端的规则引擎指向它

在客户端上,只把你选定的目的地送出口。下面是 Shadowrocket / Surge 配置格式;mihomo 与 sing-box 等价。示例把一个国外知名音乐流媒体服务(Spotify)走出口,其余一律直连:

[Proxy]
via-exit = socks5, 10.0.0.3, 1080, alice, <secret>

[Rule]
DOMAIN-SUFFIX,spotify.com,via-exit
DOMAIN-SUFFIX,scdn.co,via-exit
FINAL,DIRECT

mihomo 等价:

proxies:
  - name: via-exit
    type: socks5
    server: 10.0.0.3        # 出口 spoke B 的 overlay IP —— 只能经 Subnetra 抵达
    port: 1080
    username: alice
    password: <secret>
rules:
  - DOMAIN-SUFFIX,spotify.com,via-exit
  - DOMAIN-SUFFIX,scdn.co,via-exit
  - MATCH,DIRECT

未命中的一律直连(分流隧道),所以只有选定的目的地才走 A → hub → B 这条路径。

DNS

域名规则仍然需要名字从正确的位置去解析:如果客户端在本地解析,可能拿到 A 所在地区的 端点。让客户端把 DNS 经出口解析(Shadowrocket 的代理 DNS,或 mihomo 的 fake-ip 配合 经代理可达的解析器),这样命中的域名就会从 B 的位置解析。

注意事项

  • B 是出口。 它的 IP 承载客户端的流量 —— 对运行 B 的人是实打实的责任;B 能看到目的地 元数据(SNI、DNS),尽管 TLS 载荷仍是端到端加密的。务必开启代理鉴权,并只绑定 overlay IP。
  • 双跳。 流量走 客户端 → A → hub → B → 目标再返回:增加延迟,且 hub 现在要承载这 部分带宽 —— 给它做整形(见 生产部署 → 流量整形与调优)。
  • MTU 叠加。 你是在隧道里再套隧道;按主机网络规划 的指引设置内层 MTU。

验证

# 经出口代理 —— 返回的公网 IP 应该是 B 的:
curl -s --socks5-hostname alice:<secret>@10.0.0.3:1080 https://api.ipify.org ; echo

# 在 hub 上 —— 流量流经时中继计数会增长:
subnetra status --json | grep relay_

可观测性与排障

数据面会 按设计静默丢弃(隐蔽性)格式错误、未认证、被重放、伪造、无路由或超长的包。 subnetra status 让这些静默丢弃 可计数,于是你能在不削弱隐蔽性的前提下判断流量 为何 不通。

subnetra status

subnetrad v0.6.0 [running]
mode=raw_direct local_id=1 udp_port=18020 tun=snr0 peers=2
peers:
  id=2 endpoint=203.0.113.2:18020 allowed_src=10.0.0.2/32
  id=3 endpoint=203.0.113.3:18020 allowed_src=10.0.0.3/32
traffic:
  tun_rx packets=... bytes=...
  udp_tx packets=... bytes=...
  udp_rx packets=... bytes=...
  tun_tx packets=... bytes=...
  relay  packets=... bytes=...
  endpoint_learned=..
  keepalive rx=.. tx=..
drops:
  tun: not_ipv4=.. no_route=.. drop_rule=.. local_loop=.. unknown_target=.. oversized=.. egress_err=.. send_err=..
  udp: unknown_peer=.. auth_or_invalid=.. not_ipv4=.. spoof=.. no_route=.. drop_rule=.. unknown_target=.. no_reflect=.. oversized=.. send_err=..

守护进程未运行时,subnetra status 以非零退出,便于脚本检测。PSK 与派生密钥 绝不 打印。

解读 drop 分类

计数器含义可能原因
udp: unknown_peer数据报报头的 key_id 匹配不到任何已配置对端发送方网格 id 错误,或非请求流量
udp: auth_or_invalidPSK/epoch 或线格式不匹配PSK 不一致、密钥轮换时间偏差、时钟/epoch 问题,或破坏线格式的版本差
udp: spoof对端发送的内层源 超出allowed_srcallowed_src 配错,或确有伪造
udp: no_route / tun: no_route没有策略规则匹配目的地缺少转发规则 / 角色推导
udp: no_reflectHub 避免把包回送源端正常保护;非错误
tun: not_ipv4TUN 上出现非 IPv4 帧IPv6 或其他流量打到三层设备
*: oversized包超过安全 MTU调低 local_tun_mtu / 增加 MSS clamp

良性信号:上升的 endpoint_learned 只是统计在新 UDP endpoint 上见到的已认证对端(漫游 / NAT 重映射)。keepalive rx / tx 行统计内置 spoke→hub NAT 保活:发出端 Spoke 计 tx, 接收端 Hub 计 rx

机器可读状态(--json

为监控与自动化,subnetra status --json 以稳定的、带版本 的 JSON 对象输出相同数据—— 于是健康状态可被抓取而无需解析自由文本(且仍绝不序列化秘密):

subnetra status --json | jq .
{
  "schema_version": 1,                 // 仅在 schema 破坏性变更时递增
  "version": "0.5.1",
  "mode": "raw_direct",
  "local_id": 1,
  "listen_port": 18020,
  "tun": "snr0",
  "peers": [
    {
      "id": 2,
      "endpoint": "203.0.113.7:51822",
      "name": "bj-office-gw",            // 可选运维标签(未设时为 "")
      "allowed_src": "10.66.0.2/32",
      "last_seen_age_seconds": 5,        // 对端从未认证过则为 null
      "online": true                     // last_seen 在新鲜窗口(~90s)内
    }
  ],
  "counters": { "tun_rx_packets": 3, "udp_tx_packets": 0 /* …每个数据面计数器… */ }
}
  • online / last_seen_age_seconds 给出每对端心跳(新鲜窗口约 ~90 秒——长到能容忍偶尔丢失 几个保活而不抖动)。
  • counters 携带人类视图中的 每个 计数器,抓取绝不漏字段。
  • 在你的监控里钉住 schema_version;它仅在破坏性变更时递增。

Prometheus textfile 导出器

守护进程里刻意 没有 HTTP 服务器(多余的攻击面,违背单二进制理念)。取而代之, deploy/subnetra-textfile-exporter.shsubnetra status --json 转成 node_exporter 的 textfile collector 指标(唯一前置依赖 是 jq):

sudo install -m 0755 deploy/subnetra-textfile-exporter.sh /usr/local/bin/
sudo install -m 0644 deploy/subnetra-textfile-exporter.service /etc/systemd/system/
sudo install -m 0644 deploy/subnetra-textfile-exporter.timer   /etc/systemd/system/
sudo systemctl enable --now subnetra-textfile-exporter.timer

它(原子地)输出:

指标类型备注
subnetra_upgauge读到状态为 1,宕机/未绑定为 0
subnetra_build_info{version,mode,tun,local_id,listen_port}gauge常量 1;身份在标签里
subnetra_peer_online{id,allowed_src}gauge在新鲜窗口内为 1
subnetra_peer_last_seen_age_seconds{id,allowed_src}gauge从未认证则省略
subnetra_<counter>_totalcounter每个 counters 字段,防漂移

有用的告警表达式:subnetra_up == 0subnetra_peer_online == 0subnetra_peer_last_seen_age_seconds > 120,以及上升的 rate(subnetra_drop_udp_auth_or_invalid_total[5m]) > 0(PSK/epoch/线偏差)或 rate(subnetra_drop_udp_spoof_total[5m]) > 0

排障清单

  1. 守护进程在跑? subnetra status(非零退出 ⇒ 宕机)。检查 journalctl -u subnetrad
  2. 配置有效? subnetrad --check
  3. 对端在线?online / last_seen_age_seconds
  4. 大传输卡死但 ping 正常? MTU/PMTU——重查 主机网络规划 并增加 MSS clamp。
  5. auth_or_invalid 在涨? PSK 不一致、密钥轮换偏差、时钟/epoch 倒退,或部分升级期间 破坏线格式的版本差(见 升级与发布)。
  6. spoof 在涨? 某对端的内层源超出其 allowed_src
  7. no_route 缺少策略规则——检查 角色 或用 subnetra policy add 注入一条。

升级与发布

Subnetra 是单个静态二进制,没有持久化的磁盘上数据面状态,因此节点升级机械上就是「换掉 二进制并重启」。真正的风险是整个网格上的 线兼容性,下面的流程管理它。

升级与回滚 Runbook

由于传输是 失败即关闭 的——AEAD 认证失败的数据报被静默丢弃——一个跨越破坏线格式边界的 半升级网格会 静默分区:没有错误,只有两端不断上涨的 auth_or_invalid drop 计数。

安全流程:

  1. 阅读发布说明 中的任何破坏线格式变更。若线格式不变,节点在升级期间互通,先后顺序无关 紧要。
  2. 灰度金丝雀。 先升级一个 Spoke,并在它与 Hub 两端观察 subnetra status——online 保持 true,auth_or_invalid 保持平直。
  3. 滚动升级 其余节点。二进制替换是原子的;重启无状态(每个生命周期派生全新会话 epoch)。
  4. 回滚 就是反向的二进制替换 + 重启。不涉及任何状态迁移。
# 每个节点
sudo install -m 0755 subnetrad subnetra /usr/local/bin/
subnetrad --check --config /etc/subnetra/config.json
sudo systemctl restart subnetrad     # 无状态重启;派生新 epoch
subnetra status                      # 确认对端在线、auth_or_invalid 平直

留着上一个二进制以便即时回滚,并在发布过程中把 auth_or_invalid 当作你的分区告警。

密钥轮换 Runbook

PSK 是每链路的。要在不停机日的情况下轮换某条链路的密钥,可利用每个方向/epoch 独立 keyed 这一点:

  1. 生成新 PSK(openssl rand -hex 32)。
  2. 把该链路 两端 都更新为新 PSK。
  3. 重启两端守护进程(或重启一端、接受短暂的 auth_or_invalid 抖动直到另一端跟上)。因为没有 共享网格密钥,只有这一条链路受影响。

变更期间观察 auth_or_invalid:两端切换交叉时的瞬时上升是预期的;持续 上升意味着两端在密钥 上不一致。

完整分步在 docs/deployment.zh-CN.md §6「Key rotation runbook」

切发布(维护者)

发布版本只存在于 唯一一处build.zig.zon.version 字段。它在构建时经 build_options 模块注入守护进程横幅——绝不在 src/ 里硬编码版本串

发布 vX.Y.Z

  1. build.zig.zon.version 提升到新的 X.Y.Z(语义化版本)。
  2. 通过正常 PR 流程在 main 上提交该提升。
  3. 给该提交打 vX.Y.Z 标签——标签 必须 等于 v + build.zig.zon 版本。一个守卫作业会在 两者不一致时让发布失败,因此不匹配的标签绝不会发出去。
  4. 推送 v* 标签触发 .github/workflows/release.yml, 它构建四架构静态二进制、GHCR 多架构镜像、离线可 docker load 的按架构镜像 tar 包,以及 macOS Spoke 二进制,并把它们全部连同合并的 SHA256SUMS.txt 发布到 GitHub Release。

在未先提升 build.zig.zon 匹配之前,不要 创建 v* 标签。发布流程文档见 docs/release.md

校验下载

每个发布都附带 SHA256SUMS.txt。安装或 docker load 任何产物前先校验:

sha256sum -c SHA256SUMS.txt 2>/dev/null | grep subnetra-<version>-linux-amd64.tar.gz

线协议

本页是 Subnetra v1 线协议 的可读摘要。权威、规范的规格——带 RFC 2119 关键词与已知答案 测试(KAT)向量——是 docs/PROTOCOL.md。 若本文与规格或其向量有出入,以规格/向量为准。

为什么要规范规格? 任何语言、任何实现,只要复现下面的行为,就是一个符合规范的 Subnetra 端点,可与参考(纯 Zig)实现并肩加入网格。该行为由 tests/protocol-vectors.json 中的 KAT 向量钉死,在 zig build test 下对照活代码校验,并用 zig build vectors 重新生成。

模型

Subnetra 在加密的 UDP 承载之上、以单 Hub 星型拓扑转发裸 IPv4 包。每个节点有一个数字网格 id0 < id ≤ 65535),它同时充当线上 key_id 选择器。每条方向链路 (from_id → to_id) 有自己的密钥。wire_version1

密码学原语

原语选择参数
AEADChaCha20-Poly1305(IETF,96-bit nonce)密钥 32 B,nonce 12 B,tag 16 B
KDF / keyed hashBLAKE2b-256,原生 keyed 模式(非 HMAC)key = 父密钥,32 B 摘要

密钥日程

喂进 KDF 的所有整数都是 大端;标签是无 NUL 的 ASCII。

link_key(psk, from_id, to_id) =
    BLAKE2b-256(key = psk, msg = "subnetra-v1-link" || u32_be(from_id) || u32_be(to_id))

session_key(link_key, epoch) =
    BLAKE2b-256(key = link_key, msg = "subnetra-v1-session" || u64_be(epoch))

发送方使用 link_key(psk, local_id, peer_id);接收方用 link_key(psk, peer_id, local_id) 派生匹配密钥。psk 是每链路的 32 字节秘密,绝不得 在对端间复用。

Nonce

nonce(seq) = u64_le(seq) || 0x00 0x00 0x00 0x00      // 8 字节 LE + 4 个零字节

AEAD 使用 空 AAD;16 字节 tag 跟在密文之后。

会话 epoch

每个守护进程生命周期采样一次 boot epoch(墙钟 ns,u64),它 必须2024-01-01T00:00:00Z 的 ns 值且非零。无法满足者 失败即关闭(拒绝启动)。epoch 随每个 数据报传输,接收方据此无状态派生匹配会话密钥——没有握手

数据报格式

+------------------+---------------------------+----------------+
|   报头 (20 B)    |   密文 (len(inner))       |   tag (16 B)   |
+------------------+---------------------------+----------------+

报头(20 字节,固定)

偏移大小字段编码含义
01versionu8必须为 1
11flagsu8bit 0 = KEEPALIVE;bit 1–7 保留,必须为 0
22key_idu16 LE发送方网格 id——接收方的对端选择器
48epochu64 LE发送方 boot epoch;绝不为 0
128sequ64 LE每会话单调序号 / nonce 基准

key_id 是未认证的选择器——它 被 AEAD 覆盖(AAD 为空)。伪造的 key_id 只会 选错密钥、认证失败、数据报被丢。它让漫游/NAT 后的发送方按身份而非源端点被识别。

大小端陷阱: 报头的 epochseq小端,但喂进会话 KDF 的同一个 epoch 与 喂进链路 KDF 的 from_id/to_id大端。KAT 向量正是为了抓住这个错误而存在。

保活(flags bit 0)

设置了 KEEPALIVE = 0x01 的数据报是单向 spoke→hub NAT 保活,封装在 内层明文之上, 因此总长为 20 + 16 = 36 字节。它复用同一套 seq + epoch + 防重放机制,永不确认,且 不是 握手。早于此 bit 的接收方会直接丢弃保活(严格 flags == 0 检查),不影响数据投递。

报头混淆(可选)

20 字节报头在 AEAD 之外,因此若以明文传输,即便没有特征字,被动 观察者也能凭恒定的 version、 重复的 epoch、低位单调的 seq 对协议做指纹识别。部署级的 obfuscate 开关(默认开启)以零字节 开销消除该指纹:发送方仅对报头用每包 pad 做 XOR 掩码

pad = BLAKE2b-256(key = link_key, "subnetra-v1-obfs" || tag)[0..20]

pad 由方向性 link_key 与数据报自身的 16 字节明文 tag 派生;包体本就伪随机、保持不变。XOR 对称, 故接收方用公开的 tag 即可还原。由于被掩码的 key_id 无法再直接读取,入站会逐一试每个对端的接收 链路密钥(重算 pad、校验 version + key_id、再解掩),AEAD 认证仍是真正的安全闸。它不协商—— 每个节点必须把 obfuscate 设成一致(不一致则 fail-closed)——且只隐藏协议指纹,隐藏包长或时序。 开启混淆的 spoke 还会把 NAT 保活间隔在 [keepalive_secs/2, keepalive_secs] 内随机化,使保活节奏不构成 固定周期特征。默认开启;设 obfuscate: false 可关闭,关闭后线上字节与 v1 完全一致(报头在抓包中可读)。 完整规范与 KAT 向量见 docs/PROTOCOL.md §3.4

发送方(出口)

向对端 D 发送内层 IPv4 包 P

  1. key = session_key(link_key(psk, local_id, D.id), local_epoch)
  2. seq = 本链路单调计数器的下一个值(从 1 起,严格递增,会话/epoch 内绝不重复)。
  3. 发出报头 version=1, flags=0, key_id=local_id, epoch=local_epoch, seq
  4. 追加 ChaCha20-Poly1305-Seal(key, nonce(seq), "", P)
  5. 发往 D 的 UDP endpoint。

任何可能重置计数器的事件(如重启)发生时,发送方 必须 同时获取新 epoch(从而获得新 会话密钥)。

接收方(入口)

对来自源端点 S 的数据报,按此 规范顺序

  1. 身份选择:按 key_id(而非端点)。无匹配对端 ⇒ 丢弃。(开启报头混淆后, 报头被掩码,本步改为逐一试每个对端的接收链路密钥来解掩;第 2–9 步不变。)
  2. 报头校验——若 len < 20version != 1、设置了保留的 flags bit,或 epoch == 0, 则丢弃。
  3. epoch 排序(只进不退)。 epoch < cur ⇒ 在任何加密之前丢弃;epoch == cur ⇒ 用缓存 密钥;epoch > cur ⇒ 派生一个 候选 密钥但尚不提交。
  4. 认证与解密:用链路密钥与 nonce(seq)。失败 ⇒ 丢弃。(此时尚未改动任何状态——伪造的 更高 epoch 或错误 key_id 无法毒化会话。)
  5. 提交更新的 epoch:仅在此刻(置 cur = epoch、缓存密钥、重置 防重放窗口)。
  6. 防重放——对 seq 施加 64 条目滑动窗口;重放/过旧 ⇒ 丢弃。(保活在此短路:记录端点 + last-seen 后即停止——没有内层包。)
  7. 内层源检查——解密后的源地址必须落在 Pallowed_src 内;否则丢弃(反伪造)。
  8. 端点学习——仅在第 4–7 步之后,可选地把 S 记录为 P 的当前 endpoint(漫游/NAT)。 仅运行时状态;绝不写入配置。
  9. 路由——本地投递或(仅 Hub)中继给另一对端;Hub 绝不得 反射回源端对端。

第 3–5 步的顺序(在改动接收状态 之前 认证)与第 8 步(仅在 4–7 之后学习端点)对 安全至关重要。

防重放窗口

每个接收会话保存一个 64-bit 滑动窗口(highest + 一个 64-bit 位图,bit i = 「highest − i 见过」):seq > highest 把窗口前移并接受;窗口内未见过的 seq 被接受并标记;重放或早于窗口 的 seq 被丢弃。

接受的残余风险(按设计、无握手)

  • 观测前 epoch 重放。 捕获 epoch E 的已认证数据报、并在接收方观测到 E 之前 重放它的 路径上攻击者,可瞬时迁移该对端学到的 endpoint。它会在对端下一个真实包到来时自愈,且路径外 攻击者无法伪造它。
  • 重启间时钟倒退。 墙钟倒退的节点发出更低的 epoch,对端会拒绝,直到其时钟越过旧值。通过 运维(NTP/RTC)缓解,绝不通过协议内交换。

两者都直接源自无状态、无握手设计

命令行参考

Subnetra 提供两个二进制:

  • subnetrad——守护进程(数据面 + 控制套接字)。
  • subnetra——控制工具(通过控制 Unix 域套接字与运行中的守护进程通信)。

subnetrad(守护进程)

subnetrad [--config <path>] [--check] [--print-network-plan] [--path-mtu <n>]
          [--version | -V] [--help | -h]
标志参数说明
--configpathconfig.json 路径。默认为工作目录下的 config.json;缺失时回退到编译进的默认值。
--check解析配置、运行全部防呆规则、打印解析后的横幅并退出, 触碰网络。用作预检(以及 systemd 的 ExecStartPre)。
--print-network-plan为加载的配置打印确定的主机网络规划(ip/ifconfig/route 命令)并退出。主机上无任何改动。见 主机网络规划
--path-mtu整数打印规划时覆盖假定的承载路径 MTU(默认 1500)。安全隧道 MTU 为 path_mtu − 64
--version-V打印版本横幅并退出。
--help-h打印用法并退出。

不带动作标志时,subnetrad 运行守护进程:创建 TUN 设备、绑定 UDP 承载与控制套接字,并进入 反应堆循环。创建 TUN 设备需要 CAP_NET_ADMIN(Linux)或 root(macOS utun)。

# 校验、预览主机规划,然后运行
subnetrad --check --config /etc/subnetra/config.json
subnetrad --print-network-plan --config /etc/subnetra/config.json
sudo subnetrad --config /etc/subnetra/config.json

subnetra(控制工具)

subnetra status [--json]
subnetra policy show
subnetra policy add --src <CIDR> --dst <CIDR> --action forward --target <id>
subnetra save
subnetra --version | --help
命令说明
status显示守护进程健康、对端、流量计数与按原因分类的 drop 计数。守护进程未运行时 非零 退出。
status --json以稳定的、带版本 的 JSON 对象输出同样数据,供监控使用。绝不序列化秘密。见 可观测性
policy show打印生效的策略树(等待守护进程回复)。
policy add注入一条规则,经 RCU 热更新、无需重启(fire-and-forget)。
save把生效策略快照回写磁盘(等待守护进程回复)。
--version / --help打印版本 / 用法。

policy add 参数

标志参数说明
--srcCIDR匹配内层 前缀(例如 192.168.1.0/24,或 0.0.0.0/0 表示任意)。
--dstCIDR匹配内层 目的 前缀(最长前缀优先)。
--actionforward动作。(v1 通过转发到目标来路由;未路由流量被丢弃。)
--target网格 id命中后发往何处:某对端的网格 id,或 0 表示 本地投递 到本节点自身的 TUN。

示例:

# Site-to-Site:访问 id 为 3 的 Spoke 背后的 LAN 192.168.2.0/24
subnetra policy add --src 192.168.1.0/24 --dst 192.168.2.0/24 --action forward --target 3

# Hub:把叠加网流量中继给正确的 Spoke
subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 2

# Spoke:把发往本地叠加地址的隧道流量投递到本地 TUN
subnetra policy add --src 0.0.0.0/0 --dst 10.0.0.2/32 --action forward --target 0

subnetra policy show
subnetra save

运行时注入的规则叠加在由 role 推导出的任何策略之上。用 subnetra save 持久化生效的树,使其在重启后存续。

环境变量

变量说明
SUBNETRA_SOCK控制 Unix 域套接字路径。默认 /run/subnetra/subnetra.sock(Linux)/ /var/run/subnetra.sock(macOS)——与守护进程绑定及 systemd 单元使用的路径一致,因此 subnetrasubnetrad 开箱即一致。仅当需要使用非默认路径时才设置(两个进程必须保持一致)。
# 通常无需设置——默认值已与守护进程一致。仅当守护进程使用自定义套接字路径时才设置:
export SUBNETRA_SOCK=/run/subnetra/subnetra.sock
sudo -E subnetra status

退出码

  • 守护进程宕机时 subnetra status 返回 非零——便于健康检查与 Docker HEALTHCHECK
  • 配置无效时 subnetrad --check 返回非零,因此可用于门控服务启动。

树外工具

这些辅助工具位于 tools/绝不 打进守护进程:

构建步骤工具用途
zig build tool:keygenkeygen生成每链路的 64 位十六进制 PSK
zig build tool:config-lintconfig-lint离线 config.json 校验(不依赖时钟)
zig build tool:wire-decodewire-decode离线只读数据报解码器
tools/doctor.sh环境预检:/dev/net/tunCAP_NET_ADMINip、时钟

路线图

Subnetra 刻意交付一个小而完整的 v1,并预留一组狭窄的 v2 接口点。本页描述什么已交付、 什么被预留,以及——同样重要——什么 永远不会 被构建。

v1 —— 已交付

发布的数据面是 raw_direct:一个无状态、零分配、无握手的隧道,具备:

  • ChaCha20-Poly1305 全加密,含每链路密钥 + 每次重启的会话 epoch,
  • 64-bit 单调 nonce + 滑动窗口防重放,
  • CIDR 最长前缀策略引擎,带无锁 RCU 热更新,
  • 单线程反应堆(Linux epoll / macOS poll(2)),
  • 由已知答案向量钉死的规范线协议

逐模块进度见 开发状态表

v2 —— 仅预留接口点

PRD 为有损/长途专线预留了两个 自研 出口模式。它们今天 仅为设计——分支返回 error.NotImplemented,在维护者签署设计 RFC (docs/v2-reliability-rfc.md) 之前不授权任何代码:

模式思路内层 MTU
kcp_arq基于 arena 的选择性重传 ARQ,吸收专线上小而零星的丢包(不用 ikcp.c——自研)1428
fec_xor前向纠错(已知朴素 4:1 XOR 不够;真正的设计必须做得更好)1428

树中已存在的预留点:

预留所在位置
EgressMode { raw_direct, kcp_arq, fec_xor }src/reactor.zig(v2 ⇒ error.NotImplemented
mtuFor(mode) → 1452 / 1428 / 1428src/reactor.zig
flags 报头字节(v1 必须为 0KEEPALIVE 除外)src/reactor.zigdocs/PROTOCOL.md
negotiation_version(每配置)src/config.zig

关键在于:v2 模式由 静态的每链路配置 选择,绝非线上握手。negotiation_version / flags 字段仅用于 静态 模式选择。

明确的非目标

这些不是「暂未」——而是 永不,因为它们会破坏设计原则

  • 无线上握手 / 挑战应答 / 能力交换。 每包 epoch 就是 会话建立。
  • 守护进程内无健康探测或自动切换路径管理器。 数据面单路径;故障切换是 外部 决策 (见 生产部署 → 高可用)。
  • 无隧道内调度器 / 自适应速率控制器。 流量整形在 OS 层用 tc 做。
  • 无第三方依赖。 即便 v2 可靠性也不行——ARQ 必须自研。
  • 守护进程内无 DNS 解析器。 endpoint 是数字的;动态 Hub 在运维侧解决(Spoke 上的 DDNS 监视器)。

更改任何非目标都是一个 修订铁律的 RFC,而非一个功能 PR——且有意不在待办之列。

保活例外(已在 v1 中)

wire_version = 1 下唯一的新增是单向、永不确认的 spoke→hub NAT 保活(flags bit 0)。它向后 兼容,且 不是 握手——见 安全模型

开发

Subnetra 用 纯 Zig 编写,零第三方依赖——只有标准库与经 std.posix 的裸系统调用。 本页讲解构建、测试与本地集成测试框架。

前置条件

  • Zig 0.16.0 或更高。
  • 用于特权集成测试框架:一台 Linux 主机(或提供的开发容器),具备 /dev/net/tun--privileged

构建与测试

# 本机构建(默认 ReleaseSmall;本地开发用 -Doptimize=Debug)
zig build

# 静态交叉编译到每个发布目标
zig build -Dtarget=x86_64-linux-musl     # amd64
zig build -Dtarget=aarch64-linux-musl    # arm64
zig build -Dtarget=arm-linux-musleabihf  # armv7(硬浮点)
zig build -Dtarget=arm-linux-musleabi    # armv5(软浮点)

# 单元测试(任何提交前必须保持绿色)
zig build test

# 运行守护进程
zig build run

产物落在 zig-out/bin/subnetradsubnetra

有用的构建步骤

步骤作用
zig build test运行单元测试
zig build vectors把线协议一致性向量(JSON)打印到 stdout
zig build tools-test运行 tools/ 工具的单元测试
zig build tool:keygen构建/运行每链路 PSK 生成器
zig build tool:config-lint构建/运行离线配置校验器
zig build tool:wire-decode构建/运行离线数据报解码器
zig build tool:mtu-probe构建/运行端到端路径 MTU 探测器

项目布局

路径用途
build.zigbuild.zig.zon双二进制构建(守护进程 + 控制工具),静态 musl 交叉编译;版本在 .version 单一来源
src/config.zig配置解析 + 防呆自检
src/policy.zigCIDR 最长前缀匹配 + 无锁 RCU ActiveTree
src/crypto.zigChaCha20-Poly1305、单调 nonce、防重放
src/reactor.zig线报头、出口分发、就绪反应堆
src/peer.zig每对端 endpoint + 加密注册表
src/os/comptime OS 后端:linux.zig(epoll + /dev/net/tun)、darwin.zigpoll(2) + utun)、mod.zig(选择器)
src/uds.zig控制套接字 + 指令分词器
src/stats.zig数据面计数器
src/netplan.zig--print-network-plan 生成器
src/main.zigsrc/subnetra.zig守护进程 / 控制工具入口
tools/树外辅助工具(绝不打进守护进程)
docs/设计文档、规范协议、部署与 RFC

测试驱动工作流

纯逻辑随附测试。PRD 的验收测试包括 JSON/防呆检查、CIDR 重叠/匹配、RCU 热替换安全、加密不变量 (密文恰好增长 16 字节 tag),以及 nonce 单调 / 防重放行为。线协议由从活代码生成的 已知答案 向量zig build vectors)钉死,并在 zig build test 中有漂移哨兵。

本地集成测试(开发容器)

特权 Hub-and-Spoke 测试框架仅限 Linux,因此在 .devcontainer/ 下提供一个可复现的 Linux 容器。它仅是开发/测试辅助——发布产物仍是单个静态 musl 二进制。

# 构建 Linux 工具链镜像(Debian-slim + 锁定 Zig 0.16.0)
docker build -t subnetra-dev -f .devcontainer/Dockerfile .

# 运行集成 / 预检测试框架
docker run --rm --privileged --device=/dev/net/tun \
    -v "$PWD":/workspace subnetra-dev test/integration/run.sh

test/integration/run.sh 构建二进制、强制静态链接与 ≤ 512 KB 约束、冒烟运行守护进程、交叉构建另一个 musl 架构、运行 单元测试,然后跨网络命名空间运行一个 3 节点 Hub-and-Spoke 端到端测试:真实投递 spoke-A → Hub(中继) → spoke-B、线上加密(承载上无明文泄漏)、负载下不停顿的 RCU 策略热更新、 诚实的 drop 计数、承载丢包(netem)下的韧性与完全恢复,以及端点漫游 / NAT 重映射。

吞吐/PPS 基线见同级的 test/integration/bench.sh, 它搭起同样的星型、用 -Doptimize=ReleaseFast 构建(仅测量),并从每个守护进程自己的计数器读取 达成的 pps / Gbps / Hub-CPU%。

贡献原则

Subnetra 受一份严格的运营契约 (AGENT.md)治理。简言之:做外科手术 式、目标对齐的变更;保持 zig build test 绿色;维护零依赖、单线程、(数据面)零分配、无握手 不变量;并在宣布任务完成前验证二进制仍静态链接且在体积预算之内。见 设计原则

常见问题

一句话说,Subnetra 是什么?

一款纯 Zig、零依赖的三层 UDP 隧道,产出小于 512 KB 的单个静态二进制,以星型叠加网连接站点 与设备,并对每条链路全加密。

它是 VPN 吗?和 WireGuard 有何不同?

它是三层加密叠加网,所以解决与 WireGuard 类似的问题。差异是刻意的权衡:

  • 无握手。 Subnetra 无状态、无握手;每包 epoch 加静态配置取代了会话协商。没有 Noise 握手、没有 rekey 定时器、没有漫游握手。
  • 单个静态二进制、无内核模块、无第三方库——它完全在用户态、在 TUN 设备上运行,并交叉编译 到低至 armv5 的 musl 目标。
  • 天生星型(Hub-and-Spoke),带二进制内 CIDR 策略引擎做站点间路由与 Hub 中继,热更新无需 重启。

它不试图做 WireGuard 的即插即用替代品;它面向固定的、运维管理的部署,其中极小可审计的二进制 与静态拓扑是首要诉求。

和 n2n 有何不同?

两者都构建加密叠加网,但设计几乎相反:

  • 星型,非 P2P。 n2n 的招牌是 supernode 协助的 NAT 打洞,让 edge 之间直连。Subnetra 是严格 星型,并 刻意不做 P2P / 打洞——每个 Spoke 到 Spoke 的包都经 Hub 中继,换取单一可预测路径 (一项非目标)。
  • 三层,非二层。 n2n 是以太(TAP)叠加网,带广播、ARP 与任意协议。Subnetra 只按 CIDR 路由 IPv4——没有广播域。
  • 无握手、单一固定加密。 n2n 有注册协议、按 community 可选 cipher。Subnetra 无注册往返,只有 一种强制 AEAD(ChaCha20-Poly1305),且每链路一把唯一密钥。
  • 静态,非发现。 n2n 经 supernode 动态发现对端。Subnetra 用静态数字 endpoint,守护进程内无 发现、无 DNS。

要即插即用的 P2P 与 L2 局域网语义,选 n2n;要极小、可审计、拓扑确定的单路径隧道,选 Subnetra。

为什么无握手?那不安全吗?

不。每个包都用每链路密钥经 ChaCha20-Poly1305 加密并认证。重放被 64-bit 单调 nonce 与滑动窗口 过滤器阻止,且每次重启的 会话 epoch 被混入密钥派生,使旧的捕获无法跨重启重放。「无握手」 意味着没有 协商 往返——不是说包未经认证。见 安全模型

它会隐藏自己是隧道吗?算「隐身」吗?

部分如此。混淆是 无状态、尽力而为 的:一个格式错误或未认证的数据报被静默丢弃、无任何 错误回复,因此监听者不会向盲扫描者暴露自己。Subnetra 声称击败成熟的 DPI 对手,并对此 诚实——见 设计原则 → 无状态混淆

全 mesh 的 obfuscate 开关默认开启:它掩盖 20 字节报头、使数据报对被动观察者看起来像随机字节, 并把保活节奏去周期化(它隐藏指纹,而非包长或时序)。设 obfuscate: false 可改发可读的明文报头 (例如抓包调试),此时被动观察者即可对协议做指纹识别。见 线协议 → 报头混淆

支持哪些平台?

  • Linux 是生产目标:x86_64aarch64armv7(硬浮点)、armv5(软浮点),全部静态 musl。Hub 通常跑在 Linux 上。
  • macOS 经原生 utun 设备作为受支持的 Spoke / 开发 平台(最小动态,仅链接 libSystem)。 它不是生产 Hub 目标。

它有多大 / 多快?

Linux 发布二进制是单个静态 musl 可执行文件,小于 512 KB。数据面单线程、无锁,且热路径 严格零分配,所以内存使用有界且可预测。要在自己硬件上得吞吐基线,运行 test/integration/bench.sh

我该用什么 MTU?

固定线开销是 64 字节(20 字节报头 + 16 字节 tag + 28 字节外层 IPv4/UDP)。因此安全隧道 MTU 为 path_mtu − 64——例如 1500 字节承载上为 1452。据此设置 local_tun_mtu,并用 subnetrad --print-network-plan --path-mtu <n> 计算并预览主机规划。见 主机网络规划

Subnetra 会替我配置主机网络吗?

不会——按设计它 只打印 规划;绝不改动主机路由或防火墙状态。subnetrad --print-network-plan 输出确切的 ip/route 命令,让你(或你的配置管理工具)有意识、可审计地应用。守护进程确实会 创建并管理它自己的 TUN 设备。

PSK 如何工作?两条链路能共用一把密钥吗?

每条 链路(每个对端对、每个方向)使用自己的 64 位十六进制预共享密钥。密钥必须 每链路 唯一——绝不要跨对端复用 PSK。用 zig build tool:keygen 生成。方向密钥从 PSK 派生,因此 A→B 与 B→A 两个方向使用不同密钥。

如何路由 Spoke 背后的真实 LAN(站点间)?

remote_routes/local_routes 中设置 LAN 前缀,并添加把目的前缀转发到正确网格 id 的策略 规则。Hub 在 Spoke 之间中继。见 配置 → 角色policy add 示例。

如何在 RouterOS / MikroTik 上运行?

通过 RouterOS 容器 特性(设备上的静态二进制容器)。见 运维 → RouterOS

Hub 是动态 IP——怎么办?

endpoint 刻意是数字的(守护进程内无 DNS)。在运维侧解决:在 Spoke 上跑一个小型 DDNS 监视器, 重写 Hub endpoint 并重载。Spoke 的 NAT 保活让路径保持打开。见 安全模型 → NAT 保活

有内置的故障切换 / 多路径吗?

没有。数据面刻意单路径;故障切换是 外部 决策(VRRP / 健康检查 DNS / 编排)。这让守护进程 保持小而可预测。见 部署 → 高可用路线图

v2(kcp_arq / fec_xor)何时到来?

那些是 预留接口点,仅为设计,在维护者批准设计 RFC 之前返回 error.NotImplemented。v1 只 交付 raw_direct。见 路线图

为什么用 Zig,为什么零依赖?

为了得到一个极小、静态链接、可审计的二进制,具备可预测内存且无供应链——整个数据面就是标准库 加裸系统调用。设计原则(「八条铁律」)完整解释了缘由。

权威规格在哪里?

规范的线上契约是 docs/PROTOCOL.md; 产品需求是 docs/subnetra-develop.md。 本站点对它们做摘要与运维化;若它们与本站点冲突,以它们为准