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

Rove 文档

应用出口平面(application egress plane)。 为 Agent API、投资交易、SaaS 多云调用和其他对路径敏感的应用流量, 做身份、策略、选路、出口与审计。一个二进制,控制面挂了也能靠本地缓存继续服务。

Rove 优化的是应用访问网络的那一跳。客户端接进来后,认证、策略、限速全部在节点本地内存当场判完——不查数据库、不发 RPC。「谁能用、怎么走」来自你自己的控制面快照;节点只消费编译好的策略,离线也能服务。

它服务的不是「谁都能连的通用上网」,而是一条可认证、可分流、可限速、可审计的应用出口。

先看这些场景:

  • Agent / LLM API:把模型推理、工具调用、Webhook 从多云、多区域、多供应商里选路出去,按模型或域名拆出口,给每个 Agent 身份单独限速。
  • 投资交易 / 行情:券商、交易所、行情和风控回调走固定出口与低抖动路径;失败就拒绝,绝不悄悄改道。
  • SaaS 与多云 API:同一应用访问 AWS / Azure / GCP / 自建服务时,用地址簿和策略选最近或合规的出口。
  • Webhook 与回调出口:支付、券商、IM 机器人的回源 IP 必须稳定、可审计。
  • 远程与隔离网段:把办公或 CI 里的应用流量送进只在内网可达的服务,不必给整台机器开 VPN。

节点自己不保存业务真相。用户、密码、分流策略这些数据的唯一来源是你的控制面;节点只是通过 HTTP 定期拉取一份「编译好的快照」,在内存里热替换,控制面挂了也能靠本地缓存继续服务。


主干:identity → policy → route → egress → transport → observability

做什么
identity谁在连。每个 listener adapter 只负责把接入协议译成用户身份。
policy这个身份绑定哪条 routing policy。
route有序 first-match-wins。命中 egress / direct / block
egress从哪个命名出口出去;未命中执行 default_action(可写成 deny-by-default)。
transport出口怎么实现:直连、HTTP / SOCKS5 上游、反向 hop、Subnetra overlay。
observability每条连接留下「谁、去哪、哪条规则判的、从哪个出口出去」。

HTTP CONNECT、SOCKS5、TUIC 与 T1 SNI 网关都是 listener adapter,不是产品本身。新的接入方式必须能证明自己服务的是应用入口,并且有 fail-closed 的身份路径,才能加到这条主干上。


它能做什么

  • 怎么接都行:HTTP(S) CONNECT、明文 HTTP absolute-form、SOCKS5(含 UDP);监听上叠一层 TLS 就是 https / socks5tls;还有 TUIC v5(QUIC)前端。
  • 应用改不了代理也能受控出站:T1 SNI 透明网关将服务端精确允许的 DNS 名转入同一条身份、策略、出口、限速和审计链路,不终止 TLS、不接受任意目标。
  • 想从哪儿出去都行:本地直连、HTTP / SOCKS5 上游;hop 藏在 NAT 后也没关系——它主动用 QUIC 反向连上来注册。
  • 入口藏在 NAT 后也能接公网rove-relay 提供经过授权的动态 TCP/UDP 端口;用户 TLS 私钥仍留在 Rove。
  • 能打进隔离网段:内嵌 Subnetra 加密 Layer-3 组网,不用 TUN、不要 NET_ADMIN、不用另起进程。
  • 策略当场判:账号密码 + 有效期、可复用 routing policy、有序域名/IP route、每用户限速和连接数上限,全部在内存完成。
  • 地址簿当软件发布rove-abctl 把 AWS / Azure / GCP 官方地址段和企业应用域名构建成带 SHA-256 校验的 .rab 地址集,规则里一句 book:openai 就能引用;坏数据集自动保留旧版本。
  • 控制面松耦合:定期 HTTP 拉快照、内存热替换;拉不到就用本地缓存,断网也能启动。
  • 看得见、管得住:JSONL 访问日志(可转 syslog)、内置 SNMP;隔离环境还能走 MQTT 下发指令。
  • 坏了就拒绝:认证失败、账号过期、快照损坏,一律拒绝服务,绝不悄悄退化成开放代理。

它不是什么

  • 不是控制面,也不是管理后台。节点只消费快照,不管理用户、套餐、计费。
  • 不是公共出口、不是跨境接入服务、不是机场。软件和网络运营是两回事,所有线路由部署者自行准备。
  • 不是通用反向代理或 API 网关。origin 必须由服务端声明,不能由客户端的 Host / URL 指定。 已交付的 T1 SNI 出口网关与规划中的 L7 T2 见 应用出口网关;发布内网服务请用 reverse ingressSubnetra
  • 不会「失败即放行」。认证失败、账号过期、策略拒绝、快照无效时,一律保守拒绝。

架构一览

   应用客户端 ──►  listener adapter          ──►  identity → policy → route → egress  ──►  transport  ──► 目标
   HTTP / SOCKS5 / TUIC / SNI     (可叠 TLS / QUIC;SNI 原样透传)              ▲
                                                            │  热替换快照 (ArcSwap)
                                                  控制面 HTTP 拉取 + 本地缓存

一个节点只需要回答三个问题:我是谁node_id)、控制面在哪snapshot_url + token)、 监听哪些口[[listeners]])。访问日志默认开启;SNMP、MQTT、反向 hop、reverse ingress、 Subnetra 等管理或扩展能力默认关闭,用到再开。


使用边界

Rove 是通用的应用网络基础设施,用于出口治理、路径优化与访问审计。

  • 软件与网络运营是两回事。 本项目只发布软件,不运营网络:不提供官方公共出口节点, 不提供任何形式的公共跨境网络接入服务,没有订阅、节点分发或流量套餐。所有出口线路 都由部署者自行准备并自行负责。
  • 不存在开放代理形态。 HTTP/SOCKS5/TUIC 使用客户端凭据;T1 SNI listener 则必须绑定当前快照中 有效的服务端身份和闭合 origin 白名单。身份无效、策略命中阻断、快照编译失败或上游不可达时一律拒绝连接, 不会降级为直连或匿名放行。
  • 部署者是合规责任主体。 使用 Rove 组建的任何网络路径,都需遵守部署地与流量落地地的 法律法规,以及你与网络运营商、云厂商、上游服务商之间的协议。
  • 不面向消费级代理市场。 项目不提供机场面板、订阅链接、流量计费、客户端一键配置这类 功能,相关需求不在项目范围内。

从这里开始

我想……去看
5 分钟先把它跑起来快速开始
用二进制 / Docker / 源码部署到生产安装与部署
弄清每个配置项什么意思配置详解
客户端接入参数客户端接入
应用改不了代理,只能改 DNS 或 base_url应用出口网关(T1 SNI 已交付;T2 规划中)
理解用户、routing policy、named egress 怎么算数据模型与策略决策
构建、发布并接入大型域名/IP 地址簿rove-addrbook 指南
对接我自己的控制面控制面同步协议
看典型部署拓扑怎么搭最佳实践场景
看性能数据与压测方法基准测试报告
遇到问题快速排查常见问题 · 故障排查

想了解项目边界、非目标与质量门禁,见 项目画像与方向

快速开始

本章用最短路径把一个能用的出口节点跑起来,并用 curl 验证。全程不需要控制面 —— 我们先用一份本地快照缓存 喂给节点,让它离线也能鉴权和转发。

需要 Rust 1.88+(源码构建)或 Docker。想要预编译二进制见 安装与部署

1. 拿到二进制

git clone https://github.com/talkincode/rove.git
cd rove
cargo build --release --bins
# 产物:target/release/rove 与 target/release/rove-hop

2. 写一份最小配置 config.toml

node_id = "dev-local-01"

[control_plane]
snapshot_url = "https://control.example.com/snapshot"  # 本地试用先随便填,反正连不上会用缓存
token = "dev"
poll_interval_secs = 30
cache_path = "./data/snapshot.json"

[[listeners]]
name = "http-in"
protocol = "http"
listen = "127.0.0.1:8080"

[[listeners]]
name = "socks5-in"
protocol = "socks5"
listen = "127.0.0.1:1080"

[log]
level = "info"

3. 放一份本地快照缓存 data/snapshot.json

节点启动时先读缓存再联网,所以哪怕控制面不可达,只要缓存里有用户就能鉴权。下面这份是 当前快照 schema(schema_version: 1):用户 alice(密码 s3cret)绑定 open routing policy (无 route、无 default egress = 纯直连):

{
  "schema_version": 1,
  "version": 1,
  "users": {
    "alice": { "password": "s3cret", "policy": "open" }
  },
  "routing_policies": {
    "open": { "routes": [] }
  },
  "egresses": {}
}
mkdir -p data
# 把上面的 JSON 存成 data/snapshot.json
# 可选:rove validate-snapshot --node-id dev-local-01 data/snapshot.json

完整字段(过期、限速、连接数、有序 route、named egress)见 数据模型与策略决策。Rove 只接受这一套 routing_policies + egresses 快照形状。

4. 启动

./target/release/rove -c config.toml

看到监听日志即成功。省略 -c 时默认读当前目录的 config.toml

5. 用 curl 验证

# HTTPS 目标走 HTTP CONNECT
curl -x http://alice:[email protected]:8080 https://www.example.com -I

# 明文 HTTP 目标走 absolute-form 转发
curl --proxy http://127.0.0.1:8080 --proxy-user 'alice:s3cret' http://www.example.com -I

# SOCKS5 入口
curl -x socks5h://alice:[email protected]:1080 https://www.example.com -I

拿到 HTTP/2 200 就通了。故意把密码打错,应该得到 407(HTTP)或被 SOCKS5 拒绝 —— 这说明鉴权在工作。


下一步

安装与部署

Rove 是一个静态的 Rust 二进制,TLS 走 rustls(ring),不依赖 OpenSSL 等系统库,部署非常简单。 下面按由易到难给出四种方式。

系统要求

  • 运行:任意 64 位 Linux / macOS。二进制自带 CA 根证书,无额外系统依赖。
  • 源码构建:Rust 1.88+
  • 端口:监听端口按需放行。反向 hop 用的是 UDP(QUIC),别只放行 TCP。

方式一:预编译二进制(推荐)

GitHub Releases 下载对应平台的压缩包,里面包含 roverove-hoprove-relayrove-abctlREADME.mdLICENSEconfig.example.tomlrelay.example.toml

tar -xzf rove-<版本>-linux-x86_64.tar.gz
./rove --config config.toml

方式二:Docker

官方镜像发布在 ghcr.io/talkincode/rove,基于 alpine;ENTRYPOINT 已是 rove, 默认读 /etc/rove/config.toml

docker run -d --name rove \
  -p 8443:8443 -p 1080:1080 \
  -v "$PWD/config.toml:/etc/rove/config.toml:ro" \
  -v "$PWD/certs:/etc/rove/certs:ro" \
  -v "$PWD/data:/var/lib/rove:rw" \
  -v "$PWD/logs:/var/log/rove:rw" \
  ghcr.io/talkincode/rove:latest

要点:

  • 配置里的 cache_path 应指向挂进容器的可写目录(如 /var/lib/rove/snapshot.json), 这样容器重启也能靠缓存热启动。
  • 默认开启的访问日志应把 access_log.dir 指向持久化目录(如 /var/log/rove)。
  • 证书文件按 [listeners.tls] 里的路径挂进去。
  • 使用 rove-addrbook 时挂载地址簿目录并把 [addrbook].path 指向容器内 .rab;目录挂载才能让 host 侧原子 rename 的热更新对容器可见。运行镜像不包含离线构建工具 rove-abctl
  • 需要自定义 CA(例如上游 hop 是自签名证书)时,用环境变量 Rove_EXTRA_CA_CERTS=/etc/rove/certs/your-ca.crt 追加信任,而不是全局关校验。

从源码构建镜像

docker build -t rove:local .

方式三:源码构建

git clone https://github.com/talkincode/rove.git
cd rove
cargo build --release --bins

产物在 target/release/ 下:rove(主节点)、rove-hop(独立出口)、 rove-relay(公网 reverse-ingress relay)与 rove-abctl(离线地址簿构建/验证/发布工具)。 --locked 可保证使用 Cargo.lock 锁定的依赖版本。


方式四:systemd 常驻

/etc/systemd/system/rove.service

[Unit]
Description=Rove forward proxy
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=rove
Group=rove
WorkingDirectory=/opt/rove
ExecStart=/opt/rove/rove --config /opt/rove/config.toml
Restart=on-failure
RestartSec=3
# 加固建议
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/rove/data /opt/rove/logs
AmbientCapabilities=

[Install]
WantedBy=multi-user.target
sudo useradd --system --no-create-home --shell /usr/sbin/nologin rove
sudo systemctl daemon-reload
sudo systemctl enable --now rove
journalctl -u rove -f

监听 <1024 的端口(如 443、161)需要相应权限。可以用 AmbientCapabilities=CAP_NET_BIND_SERVICE 授予绑定低端口的能力,而不必用 root 运行整个进程。


升级

  1. 停止服务(systemctl stop rove 或停容器)。
  2. 替换二进制 / 拉取新镜像。
  3. 启动。节点会先读本地缓存快照再联控制面,所以升级期间即便控制面短暂不可达也能立即恢复服务。

当前版本收到 SIGINT / SIGTERM 后会先停止新接入,并在配置的有界窗口内排空在途连接;超时后强制结束剩余会话。编排环境仍建议先通过 /readyz 摘流再停止实例。


方式五:RouterOS 容器(rove-hop 反向出口)

在 MikroTik 设备上部署 NAT 后 hop 时,不要手搓 rootfs:使用 Release 中的

rove-hop-routeros-<version>-arm64.tar.gz

(内含 Docker-save 镜像、.rsc 部署/卸载脚本、完整运维手册与 hop_id 命名规范)。

专题文档:RouterOS 容器部署 rove-hop


下一步

配置详解

Rove 用一个扁平的 TOML 文件配置。对比 GOST 一大堆 services / chains / hops / connectors,这里一个节点 只回答三件事:我是谁、控制面在哪、监听哪些口。访问日志默认开启;SNMP、MQTT、反向 hop 等能力默认关闭。

完整可复制的样例见仓库根目录的 config.example.toml。 本章逐段解释。


节点身份

node_id = "edge-tokyo-01"

节点的唯一标识。它会出现在访问日志、SNMP、MQTT 消息里,也用于从快照的 node_overrides 里挑出属于本节点的 分组覆盖(见 控制面同步协议)。同一 fleet 内务必唯一、稳定。


[control_plane] 控制面

用户与策略的唯一真相来源。节点只拉取编译好的快照,不在本地管理用户。

[control_plane]
snapshot_url = "https://control.example.com/snapshot"
token = "REPLACE_WITH_NODE_TOKEN"
poll_interval_secs = 30
cache_path = "./data/snapshot.json"
字段说明
snapshot_url完整地址,节点原样请求,只会追加 ?since=&since=不会拼接任何固定路径。填控制面实际暴露的那个地址。
token请求头 Authorization 携带的节点令牌。所有节点可共用同一个 URL 和令牌。
poll_interval_secs轮询周期。连续失败会指数退避,最高退到 5 分钟或该值(取较大者)。
cache_path本地快照缓存。启动先读它,实现离线/控制面不可达时的热启动。Unix 上写入权限为 0600

工作方式:启动 → 读缓存立即服务 → 后台立刻拉一次控制面 → 之后按周期轮询。拿到新版本先解码编译验证, 只有可服务的快照才会原子写回缓存并热替换。304version <= since 时静默、不重编译。 详见 控制面同步协议


[health] 存活与就绪探针(默认关闭)

[health]
enable = false
listen = "127.0.0.1:9090"
control_plane_unreachable_secs = 90
字段说明
enable是否启动独立 HTTP 探针端口。
listen探针监听地址;默认仅回环,若暴露到其它网络应由防火墙限制来源。
control_plane_unreachable_secs已有可服务快照时,控制面连续失败多久后把 readiness 标为 degraded。短暂抖动不会立即摘除节点。
  • GET /healthz:进程能响应即返回 200
  • GET /readyz:仅在已加载快照、至少一个显式配置的 TCP/TUIC listener 仍活跃、未进入停机排空、控制面未持续不可达时返回 200;否则返回 503。仅启用 Subnetra hub、未配置 TCP/TUIC listener 时不要求该计数。
  • JSON 只包含程序版本、快照版本/schema、同步时间与失败计数,不包含 URL、令牌、用户、密码或策略内容。
  • JSON 的 data_plane.requireddata_plane.active_listeners 可直接说明 listener readiness 的判定依据。

[shutdown] 优雅停机

[shutdown]
grace_period_secs = 30

收到 SIGINT / SIGTERM 后,节点立即停止 TCP、TUIC 与 Subnetra hub 的新接入,并等待在途代理连接完成。 超过 grace_period_secs 后仍未结束的连接会被强制终止,进程随后以正常退出码结束。排空开始、完成或超时都会写运行日志。


[[listeners]] 监听入口

可配置任意多个。protocol 可为 httpsocks5 或 TLS 透明的 sni 应用出口网关。对 HTTP/SOCKS5,是否存在 [listeners.tls] 决定是否升级为 TLS 变体;sni 则禁止 TLS 段,因为它必须原样透传客户端 TLS。socks5 入口支持 UDP ASSOCIATE(UDP 出口经反向 hop,见 客户端接入 · SOCKS5 UDP ASSOCIATE)。

# 明文 HTTP 应用入口(HTTPS 用 CONNECT;http:// 用 absolute-form)
[[listeners]]
name = "http-in"
protocol = "http"
listen = "0.0.0.0:8080"

# 加 TLS 段 → HTTPS(HTTP CONNECT over TLS)
[[listeners]]
name = "https-in"
protocol = "http"
listen = "0.0.0.0:8443"
[listeners.tls]
cert = "./certs/server.crt"
key  = "./certs/server.key"

[[listeners.tls.certificates]]
server_names = ["proxy.example.net"]
cert = "./certs/proxy.example.net.crt"
key  = "./certs/proxy.example.net.key"

# 明文 SOCKS5
[[listeners]]
name = "socks5-in"
protocol = "socks5"
listen = "0.0.0.0:1080"

# 加 TLS 段 → SOCKS5-over-TLS
[[listeners]]
name = "socks5tls-in"
protocol = "socks5"
listen = "0.0.0.0:1081"
[listeners.tls]
cert = "./certs/server.crt"
key  = "./certs/server.key"

# TLS-transparent application egress gateway: applications retain the original
# DNS name and TLS SNI; DNS directs only these allowed names to this listener.
[[listeners]]
name     = "egress-sni"
protocol = "sni"
listen   = "0.0.0.0:443"
identity = "team-agent"        # required active snapshot user
origins  = ["api.example.com"] # exact, closed DNS-name allowlist

# T1 always reads a bounded ClientHello window; enabled/mode are ignored here.
[listeners.sniff]
max_bytes = 16384
timeout_ms = 500
字段说明
name入口名,用于日志/统计聚合。
protocolhttp(CONNECT + 明文 HTTP absolute-form)、socks5(RFC1928 + 用户名密码认证)或 sni(T1 TLS 透明应用出口网关)。
listen监听地址 ip:port
[listeners.tls].cert / .keyPEM 默认证书与私钥。存在此段即在监听层包裹 TLS;客户端未发送 SNI 或名称未命中时返回这张证书。
[[listeners.tls.certificates]]可重复的 SNI 证书映射;server_names 是选择该 cert / key 的精确 DNS 名称列表。
[listeners.sniff]可选 TCP 首包观察;默认关闭。observe 只记日志;route 按嗅探域名选 hop / 阻断(仅 CONNECT)。
identitysni 必填:绑定到当前快照中的用户。未知或过期用户在连接时拒绝。
originssni 必填:至少一个精确 DNS 名组成的本地闭合白名单;不接受 URL、IP、通配符或重复项。

协议与 TLS 正交仅适用于 HTTP/SOCKS5http + TLS = httpssocks5 + TLS = socks5tls。 一个 TCP listener 可通过 [[listeners.tls.certificates]] 在同一 IP、端口和进程内按 ClientHello SNI 选择多张证书,无需为每个域名复制 listener 或 Rove 实例。域名匹配不区分大小写,但配置项必须唯一; 每张额外证书至少声明一个域名。空域名列表、重复域名、证书不覆盖声明域名、证书与私钥不匹配都会 让启动失败。SNI 只负责选证书,不参与用户认证、策略或租户隔离。

本地 Docker 验收可运行 ./scripts/accept-local-tls-sni.sh。脚本会在主机 18443 端口启动同一 Rove listener,分别验证 local-rovealt.local-rove 的证书指纹,并通过两个域名各完成一次 HTTP CONNECT;可用 Rove_SNI_ACCEPT_PORT 覆盖主机端口。

节点会在启动后台服务前绑定并校验全部 TCP/TUIC listener;任一显式配置的地址、协议、证书或私钥无效都会让启动非零失败,而不是留下缺失入口后继续报 ready。 HTTP absolute-form 转发与 CONNECT 共用认证、过期、策略、连接数、限速和访问日志语义;代理会移除 Proxy-Authorization 等逐跳头、把请求行改为 origin-form,并强制单请求连接关闭。它不做透明代理、 缓存、内容改写或浏览器网关。

sni应用出口网关的 T1。它不终止 TLS:在 sniff.max_bytes / sniff.timeout_ms 的有界窗口内仅提取 ClientHello SNI,精确匹配 origins 后才走该 listener 的 identity → policy → route → egress 链路,并回放所有已读字节。SNI 缺失、畸形、超限、 不在白名单、绑定用户未知/过期、策略 block 或出口失败均直接关闭连接,绝不按任意名称拨号或回退直连。 listen 的端口会作为 origin 端口;通常应把允许域名的 DNS 改写到本 listener 的 :443,同时保证 Rove 的 egress DNS 仍能解析真正的 origin,避免回环。

需要为精细化运营补充隧道内域名,或按嗅探域名分流时,可在任一 HTTP/SOCKS5 listener 上打开 sniff:

[listeners.sniff]
enabled = true
mode = "route"     # observe = 只记日志;route = 先回 200/SOCKS5 成功再读首包,然后按域名选 hop / 阻断
max_bytes = 16384  # 1..65536;达到上限后记 limit_exceeded,已读字节仍回放
timeout_ms = 500   # 1..5000;route 会等待这个窗口,observe 不会延迟首包

识别器只看 client→target 的连接起始字节(TLS ClientHello SNI 或 HTTP/1 Host)。observe 不改变握手/拨号时序; route 仅作用于 HTTP CONNECT 与 SOCKS5 CONNECT:先向客户端确认隧道,再读首包、用 requested + sniffed 双候选决策,然后才拨出。绝对形式 HTTP 转发与 UDP ASSOCIATE 不走 route。结果写入访问日志及 listener 固定枚举计数;不保存 URL、HTTP body、header 集合或 TLS payload。ECH 内层域名、QUIC/HTTP3 和 UDP 不可见。

route 与 TUIC 共用同一套安全规则(见下方 [tuic_listeners.sniff]):任一候选命中 block 即拒绝;只有 requested 是 IP 时 sniffed 域名才能命中 egress route 选择出口;实际拨号目标永不改写。timeout / unsupported / malformed 回退到只按 requested host 决策,已读字节照常回放。 客户端怎么连见 客户端接入


[log] 运行日志

[log]
level = "info"   # error | warn | info | debug | trace

这是 tracing 的运行日志等级,和结构化访问日志、MQTT 诊断相互独立 —— 把 level 调到 error 也不会影响访问日志的完整记录。


[access_log] 访问日志(默认开启)

每条完成的连接落一行 JSON,是排查「某用户/某目标连不上」时 grep 的对象。

[access_log]
enable = true
dir = "./logs"
file_prefix = "access"
retention_days = 7
channel_capacity = 8192

[access_log.syslog]
enable = false
address = "syslog.example.com:514"
protocol = "udp"    # udp | tcp
facility = "local0"
tag = "rove"

字段含义、记录形状与 syslog 转发细节见 访问日志。默认不建议在生产关闭。


[mqtt] 异步运维通道(默认关闭)

网络隔离场景下,节点主动连 MQTT broker,响应用户策略查询、同步指令、拨测追踪。

[mqtt]
enable = false
broker = "tcp://mqtt.example.com:1883"   # 或 ssl://...:8883
client_id = ""                            # 留空用 rove-<node_id>
username = ""
password = ""
qos = 1
reply_topic_prefix = "rove/replies/"

[mqtt.topics]
user_query = "rove/user/query"
sync_command = "rove/sync/command"
node_status = "rove/node/status"
probe_trace = "rove/probe/trace"
diagnostics_command = "rove/diagnostics/command"

[mqtt.diagnostics]   # 诊断会话的安全上限(爆炸半径),不是开关
default_ttl_secs = 30
max_ttl_secs = 300
max_sessions = 16
max_sessions_per_user = 2
channel_capacity = 256

[mqtt.tls]
enable = false

消息契约见 MQTT 运维通道。 建议把 broker 凭据放在独立的 username / password 字段;即使兼容 URL userinfo,启动日志也只记录 broker 的 scheme、host 与 port。


[snmp] 只读 SNMP agent(默认关闭)

供 Cacti / LibreNMS 等标准 NMS 直接轮询每 listener、每出口的流量计数。

[snmp]
enable = false
listen = "0.0.0.0:161"
community = ""                              # v2c community;留空则 v2c 关闭
allow_cidrs = ["127.0.0.1/32", "::1/128"]  # 来源白名单,白名单外直接丢弃
state_path = "./data/snmp-state.json"      # v3 engineBoots 持久化

# SNMPv3 USM 用户(可多个)
# [[snmp.v3_users]]
# username = "cacti"
# auth_protocol = "sha256"      # sha1 | sha256
# auth_password = "change-me-auth"
# priv_protocol = "aes128"      # 留空表示不加密
# priv_password = "change-me-priv"

只实现 GET / GETNEXT / GETBULK,SET / TRAP / INFORM、MD5 / DES 永不支持。MIB 表与 Cacti 接入见 SNMP 监控


[reverse_hop] 反向 hop 数据面(默认关闭)

当 hop 位于 NAT / 防火墙后、edge 无法主动拨号时,改由 hop 用 QUIC 主动拨到 edge 注册。

[reverse_hop]
enable = false
listen = "0.0.0.0:9443"           # QUIC 的 UDP 监听地址(放行 UDP!)
cert = "./certs/server.crt"       # QUIC 强制 TLS 1.3
key  = "./certs/server.key"
tokens = ["REPLACE_WITH_REVERSE_HOP_TOKEN"]
duplicate = "reject"              # 同 hop_id 重复注册:reject | replace
max_streams_per_hop = 256
open_timeout_secs = 10
# initial_mtu = 1332              # 可选:固定 edge 侧 QUIC 路径 MTU(UDP 载荷字节,1200-1500)

原理、多 edge、观测与 NAT 保活见 反向 hop 数据面

压缩隧道适配:当 edge/hop 之间的 QUIC 跑在已被压缩、路径固定的外层隧道里时,用 initial_mtu 固定 QUIC 的最大 UDP 载荷(≈ 隧道路径 MTU 减去 IPv4 的 28 或 IPv6 的 48 字节),quinn 便从该值起步且不再向上探测,避免大包被载体静默丢弃。edge 侧写在 [reverse_hop].initial_mtu;hop 客户端(rove-hop)侧用 --reverse-initial-mtu。留空则走 quinn 默认 PMTUD(从 1200 起步向上发现),QUIC 本身可自愈,一般无需设置。

[[reverse_ingress]] NAT 后反向公网入口(默认关闭)

Rove 接入点位于 NAT 后时,主动连接公网 rove-relay,在 relay 上申请预授权 TCP/UDP 端口。配置可重复,每段独立连接一个 relay:

[[reverse_ingress]]
enable = true
relay = "relay.example.com:9444"
server_name = "relay.example.com"
token_env = "Rove_INGRESS_RELAY_TOKEN"
initial_mtu = 1452
max_streams = 1024
max_udp_flows = 4096
reconnect_min_secs = 1
reconnect_max_secs = 30

[[reverse_ingress.listeners]]
id = "https-public"
transport = "tcp"
public_port = 443
local_listener = "https-in"

[[reverse_ingress.listeners]]
id = "tuic-public"
transport = "udp"
public_port = 8443
local_listener = "tuic-in"
max_inner_datagram = 1200

local_listener 只能引用本配置已声明的 listener;relay 不能要求 Rove 连接任意 内网地址。public_port = 0 表示动态分配。证书、relay 配置、真实 IP 溯源、 MTU 与故障语义见 反向公网入口

[[tuic_listeners]] TUIC 前端入口(QUIC,可选)

QUIC 原生前门,面向移动端与实时应用;能隧道 UDP。与 [[listeners]](TCP)相互独立。

[[tuic_listeners]]
name   = "tuic-in"
listen = "0.0.0.0:8443"        # QUIC 的 UDP ip:port(放行 UDP!)
cert   = "./certs/server.crt"  # QUIC 强制 TLS 1.3
key    = "./certs/server.key"
alpn   = ["h3"]                # 必须与客户端一致
# initial_mtu = 1332           # 可选:固定 QUIC 路径 MTU(UDP 载荷字节,1200-1500),压缩隧道用
[tuic_listeners.sniff]
enabled = true
mode = "route"                 # observe | route;UDP Packet 不处理

认证用快照里的 frontends.tuic(uuid + password,独立于登录密码)。TCP 请求复用现有出口并按用户限速; UDP 请求走反向 hop 的 UDP 出口。initial_mtu[reverse_hop] 同义(跑在压缩/固定 MTU 隧道里才需设)。 [tuic_listeners.sniff] 与 TCP listener 使用相同边界,但只包裹 TUIC TCP Connect。完整说明见 TUIC 前端接入

HTTP/SOCKS5 CONNECT 与 TUIC TCP Connect 的 route 模式共用同一套规则。TUIC 在 outbound connect 前读取首包;HTTP/SOCKS5 必须先向客户端确认隧道,客户端才会发送首包。窗口均为 timeout_ms / max_bytes

  • requested host 与 sniffed host 任一命中 block 都拒绝,block 永远优先;
  • 只有 requested host 是 IP 时,sniffed 域名才能命中 proxy 规则选择出口;
  • 实际 CONNECT/直连目标始终是 requested host/port,嗅探域名不会改写目的地址;
  • timeout、unsupported、malformed、limit_exceeded 或 incomplete 均回退到 requested host 决策;
  • 已读取字节通过前缀流逐字节回放,仍进入原有限速和流量统计。

[subnetra] 内嵌组网底座(默认关闭)

原生实现 Subnetra v1 加密 Layer-3 隧道,在 overlay 上跑 HTTP/SOCKS——无需单独部署守护进程、 无需 TUN。mode = "hub" 接受 spoke 充当隧道内代理入口;mode = "spoke" 拨到 hub,把流量从 overlay 打进隔离网段。hub / spoke 共用同一份数据面。

[subnetra]
enable = true
mode = "hub"                  # "hub"(收 spoke、代理入口)| "spoke"(拨 hub、egress)
local_id = 1                  # 本节点 mesh id,0 < id <= 65535
listen = "0.0.0.0:18020"      # 数据面 UDP 绑定地址(放行 UDP!)
overlay_cidr = "10.0.0.1/24"  # 本节点 overlay 地址(主机位=inner IP,前缀=虚拟子网)
obfuscate = true              # 头部混淆,默认开;必须全网一致,否则 fail-closed
keepalive_secs = 25           # spoke NAT 保活间隔(秒),hub 忽略
# mtu = 1360                   # 可选:内层 overlay MTU,范围 [576, 1452],默认 1452(压缩隧道用)
proxy_protocol = "http"       # 仅 hub:overlay 上服务的协议 "http" | "socks5"
proxy_port = 8080             # 仅 hub:overlay IP 上的代理端口

[[subnetra.peers]]
id = 2
psk = "REPLACE_LINK_1_2_64_HEX_CHARS"   # 每条链路唯一的 32 字节(64 hex)预共享密钥
allowed_src = "10.0.0.2/32"             # 内源过滤前缀,也是路由键
endpoint = "203.0.113.2:18020"          # hub 可留空(学习得到);spoke 必填
name = "bj-spoke"

校验 fail-closed(psk 长度/字符、id 冲突、spoke 缺 endpoint、hub 缺 proxy 设置等启动即报错)。 hub 节点即便没有任何 [[listeners]] 也能启动(代理入口在 overlay)。作为出口时在快照里把 upstream.kind 写成 subnetramtu 用于让整张 mesh 适配已被压缩、路径固定的外层隧道(默认 1452;只调本节点发包大小与通告 MSS,不改协议线格常量,与旧节点互通不受影响)。原理、拓扑与 注意事项见 内嵌 Subnetra 组网底座

[dns] 专用出口 DNS(默认关闭)

默认 Rove 用操作系统解析器(getaddrinfo)解析出口目标,行为与旧版逐字节一致。当宿主机的 /etc/resolv.conf 指向分裂视图或不被信任的解析器,而网络里另有指定递归解析器时,配上 [dns].servers:Rove 便把所有出口域名解析——直连(Direct)、上游代理拨号(dial)、 reverse UDP 解析、edge 拨号——统一改走这些服务器,绕开宿主机配置。servers 为空或整段缺省即 无操作,继续用系统解析器(完全向后兼容,仅在显式配置时生效)。

[dns]
servers = ["10.0.0.53", "10.0.0.54:5353"]  # ip 或 ip:port;默认端口随传输
protocol = "udp"        # udp(默认)| tcp | tls/dot | https/doh
timeout_ms = 2000       # 单次查询超时(毫秒)
attempts = 2            # 每台服务器的重试次数
ipv4_first = true       # 优先 IPv4(先查 A 再查 AAAA)
cache_size = 64         # 内存 answer 缓存条数;0 关闭

配置解析 fail-closed:servers 里出现非法 ip / ip:port,或 protocol 不是 udp/tcp/tls(dot)/https(doh),启动即报错(避免拼错时静默回落系统解析器)。UDP 应答被 截断时会自动改用 TCP 重试(部分解析器会返回较大的 EDNS 应答)。字面量 IP 目标不走 DNS,直接连接。

加密 DNS(DoT / DoH)

明文 UDP/TCP DNS 仍可能被链路上抢先注入伪造应答。跨不可信链路解析时, 建议用 DoT(DNS-over-TLS,853)DoH(DNS-over-HTTPS,443)——它们对查询加密、校验服务器 证书。默认端口随传输自动选择(tls=853、https=443),bare IP 无需写端口。

[dns]
servers = ["1.1.1.1"]                 # DoT 到 Cloudflare;bare IP 自动用 853
protocol = "tls"                      # 或 "dot"
tls_server_name = "cloudflare-dns.com"  # 必填:SNI + 证书校验名
# doh_path = "/dns-query"             # 仅 DoH(protocol = "https"),默认 /dns-query
# tls_ca = "/etc/rove/dns-ca.pem"      # 自建服务器的私有 CA;留空=Mozilla 公共根 + Rove_EXTRA_CA_CERTS
# tls_insecure = false                # 跳过证书校验(自签名)——危险

信任根按优先级:tls_insecure(接受任意证书,仅自签名逃生用)> tls_ca(只信这份 CA,适合自建 内部 DNS)> 默认 Mozilla webpki 根(加上 Rove_EXTRA_CA_CERTS,适合公共 DoT/DoH)。tls/https 未填 tls_server_name 会 fail-closed 报错。若服务器证书用 IP-SAN,把 tls_server_name 填成该 IP。 加密传输走 ring,不引入 aws-lc-rs

rove-hop 独立二进制rove-hop 不读该 TOML 段,但支持等价的命令行开关 --dns-server(可重复)/ --dns-protocoludp|tcp|tls|https)/ --dns-server-name / --dns-doh-path / --dns-ca / --dns-insecure,让位于目标网络里的 hop 也能把出口目标解析 走到指定解析器;不设则回落系统解析器。

[addrbook] 版本化地址数据集(默认关闭)

[addrbook]
path = "/etc/rove/addrbook/book.rab"
poll_interval_secs = 300
字段说明
pathrove-abctl build 产出的本地 .rab 工件。配置后缺失、不可读、超过 256 MiB 或校验失败都会拒绝启动。
poll_interval_secs缺省 300;轮询本地文件变化并尝试原子热替换。0 表示只在重启时加载。

控制面快照可在 route selectors 里用 book:<category> 引用层级分类;book: 规则不需要额外 schema 版本门槛。未配置地址簿、未知分类或 selector 内存超限会拒绝整份新快照并保留旧策略。 运行期新工件必须先通过完整格式校验,再用它重新编译最近一次成功快照;任一步失败都保留旧书与旧快照。

Rove 不读取 manifest、不自动下载工件。生产应在独立发布流程中执行 fetch → build → verify/query → diff, 通过受认证通道分发,并在同一目录内原子替换文件。Docker 热更新应挂载整个目录而不是单个文件。 完整流程、六种数据源、CLI、限制与回滚见 rove-addrbook 指南


小结

  • 必填node_id[control_plane],以及至少一个 [[listeners]][[tuic_listeners]]例外:启用 [subnetra] 的 hub 走 overlay 入口时可不配置 TCP/TUIC listener)。
  • 建议保留默认开启[access_log]
  • 按需开启[mqtt][snmp][reverse_hop][subnetra][dns][addrbook]
  • 证书、令牌、缓存等敏感/本地文件不要提交进仓库;示例里一律用占位符。

客户端接入

代理客户端当前使用 HTTP、HTTPS、SOCKS5、SOCKS5-TLS 或 TUIC,全部需要各自的客户端凭据。另有不需要 客户端代理设置的 应用出口网关:T1 SNI 透明入口已可用,T2 base_url HTTPS 网关仍在规划。 不要把 HTTP/SOCKS/TUIC 入口配成「反代」。 也不要把 反向 hop反向公网入口 理解成反向代理—— 那两个词在本仓库里已经各有含义。

下面示例统一使用:用户 alice / 密码 s3cret,节点 proxy.example.com

HTTP / HTTPS

认证:用户名 + 密码(快照登录口令)。HTTPS 目标走 CONNECT;明文 HTTP 走 absolute-form。

参数HTTPHTTPS
类型HTTP 代理HTTP 代理 + TLS
地址proxy.example.comproxy.example.com
端口8080[[listeners]]listen8443
用户名快照用户名同左
密码快照 password同左
鉴权Proxy-Authorization: Basic同左
TLS服务端证书须被客户端信任;自签名需导入 CA 或关闭校验
远程 DNS由代理解析目标主机同左

SOCKS5 / SOCKS5-TLS

认证:RFC 1928 用户名密码,凭据同上。

参数SOCKS5SOCKS5-TLS
类型SOCKS5先 TLS,再 SOCKS5
地址proxy.example.comproxy.example.com
端口1080[[listeners]][listeners.tls] 的口
用户名 / 密码快照用户名 / password同左
远程 DNS开启(等价 socks5h同左
TLS证书须被信任
UDP ASSOCIATE支持,见下同左

socks5tls 需要客户端原生支持「SOCKS5 over TLS」。只想加密代理连接时,优先用 HTTPS 入口。

T1 SNI 透明应用出口网关

适用于应用不能设置代理、但能保留原始目标名称并让 DNS 指向 Rove 的场景。客户端无需填写代理地址、 用户名或密码;它仍照常连接 https://api.example.com,而部署者只将这个已允许的域名在客户端 DNS 视图中 改写到 Rove 的 protocol = "sni" listener。

  • 客户端必须发送普通 TLS ClientHello SNI;无 SNI、ECH 内层名称、QUIC/HTTP3 不适用。
  • listener 的服务端 identity 是当前快照中的用户,不是客户端凭据;origins 是闭合精确白名单。
  • listener 与 origin 使用同一端口,通常均为 443;Rove 会原样转发 TLS,不需要也不应安装 origin 证书。
  • 只为 origins 中的名称改写 DNS,并保证 Rove 的 egress DNS 解析真实 origin,而非网关自身,避免回环。

完整配置、拒绝语义和多租户边界见应用出口网关。若应用只能修改 base_url,请等待 规划中的 T2,而不是把客户端 Host 当作转发目标。

TUIC v5

节点开了 [[tuic_listeners]] 时使用。凭据是 frontends.tuic不是登录密码。

参数取值
类型TUIC v5
地址监听主机
端口listen(UDP)
UUID快照 frontends.tuic.uuid
密码快照 frontends.tuic.password
ALPN监听配置的 alpn(默认 h3),须逐字一致
UDP relay modenative(QUIC datagram);不支持 quic stream 模式
证书须信任服务端证书;自签名则导入 CA 或关闭校验

UDP 还要求该用户策略把目标路由到 reverse 上游,见 TUIC · UDP 出口

SOCKS5 UDP ASSOCIATE

socks5 入口支持 RFC 1928 UDP ASSOCIATE。客户端在 ASSOCIATE 成功后,把 UDP datagram 发到节点返回的 BND 地址。

  • UDP 出口只经反向 hop:用户策略须把目标路由到 reverse 上游。Direct / HTTP 上游 / SOCKS5 上游 / block 一律丢弃。
  • 不分片(FRAG 必须为 0)、不限速、只放行 association 客户端源地址的回包。
  • 关联生命周期等于那条 TCP 控制连接。

详见 reverse/2 UDP relay

排错

现象可能原因
407 Proxy Authentication Required用户名/密码错,或账号已过期
403 Forbidden账号过期,或目标命中策略 block
连接超时端口未放行;TLS 入口用了明文,或反之
TLS 证书报错自签名未导入 CA
SOCKS5 能连但解析异常未开远程 DNS

更多见 故障排查

应用出口网关

状态:T1 SNI 透明网关已交付;T2 声明式 HTTPS 网关仍在规划;T3 明确不做。 T1 的实际配置见配置详解,已有代理客户端仍应使用 HTTP CONNECT、SOCKS5 或 TUIC,见客户端接入

应用出口网关让不能设置 HTTP_PROXY 或 SOCKS 的应用,也能复用 Rove 的身份、策略、出口选择、限速和审计。 它是新的 listener adapter,不是 reverse proxy,更不是把客户端给出的 Host 或 URL 变成任意拨号目标。


先把三个容易混的词分开

名字方向现在有没有实际做什么
反向 hop出向(egress)NAT 后的出口节点用 QUIC 主动回连 edge,成为一个 egress backend
反向公网入口入向(admission)NAT 后的节点通过公网 rove-relay 接受入站代理连接
应用出口网关(本章)入向(gateway)T1 有应用把受控 DNS 名称连到 Rove,Rove 只允许服务端配置的 origin,再走既有策略与出口

需要把内网服务发布到公网,用 reverse ingress、Subnetra,或在 Rove 前面放 nginx / Envoy。 那不是本能力要做的事。


三层,不是一个功能

形态状态
T1 SNI 透明网关DNS 把允许的域名指到 Rove;读 ClientHello SNI,对照闭合 origin 白名单,TLS 不终止,按字节转发已交付
T2 L7 HTTPS 网关客户端把 base_url 指到网关;按 endpoint 查服务端声明的 origin,终止 TLS 后再出站规划中
T3 通用反代 / API 网关虚拟主机、ACME、后端池、健康检查、重写、WAF明确不做

三者都只能接到同一条 identity → policy → route → egress → transport → observability 主干上;策略层和出口层不为网关另起炉灶。

入口identity 来源target 来源
HTTP CONNECTProxy-Authorization客户端 CONNECT 行
SOCKS5RFC 1929客户端请求
TUICfrontends.tuic客户端请求
sni(T1)listener 绑定的快照用户ClientHello SNI ∩ 本地闭合白名单
gateway(T2,规划)frontends.gateway bearer token服务端声明的 endpoint → origin 表

T1|SNI 透明网关

DNS(split-horizon、CoreDNS rewrite 或受管 hosts)把目标 DNS 名称指到 Rove。应用仍以原始名称发起 TLS; Rove 在有界窗口内解析 ClientHello 的 SNI,只有它同时满足有效 TLS、规范 DNS 名和本机 origins 白名单时, 才会套用 listener 绑定身份的策略、选择出口、回放已读字节并继续透传。

不终止 TLS:节点看不到 HTTP 内容、不持有 origin 证书、不接触 API key。治理的是路径,不是数据。

配置

[[listeners]]
name     = "egress-sni"
protocol = "sni"
listen   = "0.0.0.0:443"
identity = "team-agent"
origins  = [
  "api.openai.com",
  "api.anthropic.com",
  "generativelanguage.googleapis.com",
]

# 可选:限制读 ClientHello 的时间和字节数;T1 始终使用这两个上限。
[listeners.sniff]
max_bytes = 16384
timeout_ms = 500
  • identity 必填,且连接建立时必须在当前快照中存在且未过期;未知或过期身份直接断开。 它没有客户端凭据,因而不能省略身份或使用匿名回退。
  • origins 至少一个。只接受精确 DNS 名;大小写和末尾点会规范化,URL、IP、通配符、无效或重复名称会使配置校验失败。
  • protocol = "sni" 禁止 [listeners.tls]。它透传客户端原始 TLS,而不是接收 TLS 后再解密。
  • SNI 中没有端口。Rove 将应用连接到的 listener 端口作为 origin 端口:通常把 DNS 改写到 :443,所以它也拨 :443; 使用非 443 端口时必须确保 origin 在同一端口服务。
  • sniff.enabledsniff.mode 不改变 T1 的准入语义;T1 总会在 max_bytes / timeout_ms 上限内读取 ClientHello。

实际数据路径

应用 ── TLS ClientHello(SNI=api.example.com) ──► Rove :443
                                                     │
          快照身份有效 + 精确 origins 命中          │
                                                     ▼
                     identity → policy → route → egress → TLS origin :443

实现复用 sniff 的受限 ClientHello 解析、PrefixedIo 的字节回放、decide_with_sniff 的策略决策、 outbound::connect 的 direct / HTTP / SOCKS5 出口,以及 splice 的双向传输和限速。访问日志和诊断仍会记录 策略、出口和失败阶段;T1 成功或策略处理的记录额外带 ingress_mode: "sni" 和服务端 origin 标识, 不记录路径、请求头、请求体或 TLS payload。

失败即断开

T1 没有 HTTP 错误页:以下情况均在拨出前关闭 TCP,没有默认 origin、没有直连回退、没有按任意 SNI 拨号:

情形行为
配置缺少 identity / origins,白名单无效或误配 TLS启动前配置校验失败
当前快照没有绑定身份,或用户已过期关闭连接
非 TLS、ClientHello 畸形、超时、超限、ECH 导致无可见 SNI关闭连接
SNI 不在 origins关闭连接
策略结果为 block、连接数超限、出口拨号失败关闭连接

T1 不支持 QUIC / HTTP/3,也不能读取 ECH 的内层名称。若应用只能改 base_url、不能保持原始 DNS 名和 TLS SNI, 请等待 T2,而不是把 Host 当作任意目标地址。

这是 L4/SNI 边界,不是 HTTP 语义检查。 TLS 建立后 Rove 不可见加密请求中的 Host、CONNECT 或业务协议; 不要把允许的 CDN、共享 HTTPS 代理或可 domain-front 的名称当作“只允许某个应用 API”的证明。需要应用层 origin 约束时,应使用专用、不可共享的 origin,或等待会终止 TLS 的 T2。

部署注意事项

DNS 改写只应覆盖 origins 中的名称,并让客户端的 TLS 校验继续使用原始 origin 名称。Rove 自己的 egress DNS 必须把该名称解析到真正的 origin,不能再次解析到本 gateway;需要时配置独立的 [dns]、split-horizon 视图或 将网关监听地址从 egress DNS 回答中排除,否则会形成自我回环。

identity 是服务端对整条 listener 的归属,不是终端用户认证。若多租户之间需要不同策略或审计身份,应配置多个 SNI listener,并使用各自的端口 / 地址 / DNS 视图和快照用户;不要用一个 listener 承载不受控名称。


T2|L7 HTTPS 网关(规划中)

客户端把 SDK 的 base_url 指到网关,带 Authorization: Bearer …。网关将按 endpoint(Host,可选 path 前缀) 查服务端声明的 origin,再走既有策略与出口。

生死线:origin 只能来自节点本地配置或控制面快照,绝不能来自客户端的 Host、URL 或路径。 否则这不是网关, 而是带 TLS 的开放正向代理,也是 SSRF 入口。未命中的 Host 必须拒绝,不能按 Host 拨号。

T2 预计使用独立的 frontends.gateway 身份命名空间,并默认不把 path、query、请求头写入访问日志。它不做请求体 解析、API key 注入或轮换、模型路由、响应缓存、证书签发、后端池、重写或 WAF。


T3|通用反向代理 —— 不做

虚拟主机、ACME 自动签发、后端池与负载均衡、主动健康检查、灰度、重写规则和 WAF 是 nginx / Envoy / Traefik 的产品, 不是 Rove 的。发布内网服务用 反向公网入口Subnetra;更复杂的 HTTP 入口放在 Rove 前面。


怎么选

我想……使用方式
应用能配 HTTP_PROXY / SOCKS / TUIC客户端接入
应用不能配代理,但可把允许的原始域名 DNS 改到 RoveT1 SNI 透明网关
应用只能改 base_url,且需要 TLS 终止和 endpoint 映射等 T2
出口藏在 NAT 后反向 hop
让公网连进 NAT 后的 listener反向公网入口
打进隔离网段Subnetra
虚拟主机 / 证书签发 / WAFnginx / Envoy

完整边界和质量门禁见项目画像与方向

数据模型与策略决策

理解 Rove,只要理解它每次连接怎么做两件事:认证(你是谁、有没有过期)和决策(这个目标该直连、 走命名出口、还是拒绝)。这两件事的依据全在一份快照里。

为什么是「编译好的扁平快照」

「真相」留在控制面,节点只接收编译好的扁平快照:用户按用户名索引,鉴权 O(1);选择器在快照编译期 就编成 matcher,热路径上不做字符串解析,也不做任何回控制面的同步查询。控制面不可达时节点继续用 上一份有效快照服务,而不是降级放行。

三张表:identity / policy / egress

Rove 只有一种快照 schema(schema_version: 1),它把三个概念分开:

  • user:认证、限速、前端凭据,并且只引用一个 policy
  • routing policy:有序 first-match-wins routes;每条 route 的 action 严格为命名 egress、 direct 或 block;
  • named egress:可复用的单 backend 或主备 chain,凭据只保存一次。节点差异在 node_overrides.<node_id>.egresses realization 层整项替换,policy 不依赖节点。
{
  "schema_version": 1,
  "version": 42,
  "users": {
    "alice": {"password": "secret", "policy": "work"}
  },
  "routing_policies": {
    "work": {
      "routes": [
        {
          "selectors": ["book:security/blocked"],
          "action": {"type": "block"}
        },
        {
          "selectors": ["openai.com"],
          "action": {"type": "egress", "egress": "tokyo"}
        },
        {
          "selectors": ["full:private.example"],
          "action": {"type": "direct"}
        }
      ],
      "default_action": { "type": "egress", "egress": "backup" }
    }
  },
  "egresses": {
    "tokyo": {
      "type": "upstream",
      "backend": {"kind": "reverse", "addr": "tokyo-hop"}
    },
    "backup": {
      "type": "chain",
      "members": [
        {
          "id": "primary",
          "priority": 10,
          "backend": {"kind": "socks5", "addr": "10.0.0.9:1080"}
        }
      ]
    }
  }
}

users / routing_policies / egresses 缺省都是空对象。一个用户最少只需要 passwordpolicy 两个字段。

这样分表的收益是直接的:一条 policy 可以被任意多个用户复用而不复制规则;一个 egress 可以被任意多条 route 复用而不复制凭据;换出口只改 egress realization,不动任何 route。

决策流程 decide()

对每条连接的目标(域名或 IP):

1. 认证:用户名存在 → 常量时间比对密码 → 校验 expire。任一失败即拒绝。
2. 取该用户的 policy。
3. 按 routes 数组顺序求第一个命中的 route,采用它的 action。
4. 任一候选(requested host / 已验证的 sniffed host)first-match 为 block → 拒绝。
5. 都未命中 → 执行 policy 的 default_action;没有 default 则直连。

即:顺序即语义,第一个命中的 route 说了算;未命中执行 policy 的默认 action;没有默认就直连。

default_action 与 route 的 action 是同一套词汇(egress / direct / block),所以未命中 时的行为写得和命中时一样明确:

  • 纯直连策略:给用户一条空 policy("open": {})即可。
  • 全量走某个出口:policy 只配 default_action{"type":"egress","egress":"..."},不配 route。
  • 选择性分流:把需要走出口的域名/网段写成 route,其余落到 default_action 或直连。
  • deny-by-default(allowlist)default_action 设为 {"type":"block"},policy 就只能到达 它自己列出的目标。选择器没有 catch-all 写法,这是表达「只放行清单内目标」的唯一方式。

route 之间允许 selector 重叠,数组顺序决定结果。因此 block route 应该放在最前面——一条更靠前的 egress route 会让后面的 block route 永远不生效。

sniff 只影响策略身份,不改写实际 dial target:只有 requested target 是 IP 时,sniffed host 的 非 block action 才能改变路由选择。完整 wire contract、严格校验与 validator 见 快照协议

域名与 IP 匹配

route selectors[] 的每一项都按前缀区分匹配方式:

写法匹配方式示例
api.openai.com后缀匹配(默认):匹配自身及所有子域命中 api.openai.comapp.api.openai.com
full:ads.example.com精确匹配:只匹配完全相同的域名只命中 ads.example.com,不含子域
keyword:analytics关键字:域名包含该子串即命中命中 x-analytics.ioanalytics.cdn.net
10.0.0.0/8IP CIDR:网段匹配命中该网段内所有 IP
203.0.113.7单 IP(等价 /32/128只命中该地址
book:google/ads地址簿分类:匹配该分类及其子孙中的域名/IP需要节点配置 [addrbook]

book: 适合把 Provider 地址段、云厂商网段表等大型数据集从快照中分离出来。多个分类与显式规则在同一条 route 内按「或」组合;跨 route 的优先级由 routes 数组顺序决定。节点没有地址簿、分类不存在或 selector 超限时,整份新快照拒收——绝不会把 book:aws 当成一个普通域名放行。

地址簿只匹配客户端请求中的目标:域名请求查域名表,IP 字面量请求查 IP 表;不会把域名先解析成 IP 再查 Provider 网段。构建、发布、层级分类、热替换与失败恢复见 rove-addrbook 指南

出口 backend

命名 egress 的 backend 描述一个具体的出口对象:

字段说明
kindhttp(HTTP CONNECT 上游)、https(HTTP CONNECT over TLS)、socks5(SOCKS5 上游)、reverse(反向 hop,addrhop_id)、subnetra(经内嵌 overlay 出口,目标须为 overlay IPv4)
addr出口地址 host:portreverse 时填注册的 hop_idsubnetra 时不使用(目标取自请求本身)
username / password出口认证(可选;reverse / subnetra 不接受)
tls与出口的连接是否走 TLS(reverse / subnetra 不接受,二者传输自带加密)
skip_cert_verify逐 backend 开关,tls=true 时可跳过证书链/主机名/有效期校验,用于自签名或纯 IP 的 hop。默认 false

skip_cert_verify 只影响这一个 backend、只影响出站方向,不存在全局关校验的开关,也不影响入站监听的 TLS。 kind = "reverse" 的语义、部署见 反向 hop 数据面kind = "subnetra"内嵌 Subnetra 组网底座

chain 不是一种 backend kind,而是 egress 的另一种形态("type": "chain"),见下节。

出口链(chain)与主备故障转移

一个业务出口(例如 JP POP)往往有多台功能等价的主备后端。**出口链(chain)**把它们组织成 一个按优先级排序的候选集合,策略绑定逻辑出口而不是单个物理后端;主后端建立失败时自动按 优先级尝试下一个。chain 不是 A → B → 目标的串联多跳,也不做权重/轮询/并行竞速/健康 探测——它是按新连接执行的被动故障转移。

chain 就是一个命名 egress,route 照常只引用 egress ID:

{
  "schema_version": 1,
  "version": 13,
  "egresses": {
    "jp-pop": {
      "type": "chain",
      "members": [
        { "id": "jp-reverse-1", "priority": 1, "backend": { "kind": "reverse", "addr": "h1" } },
        { "id": "jp-socks-2",   "priority": 2, "backend": { "kind": "socks5", "addr": "10.2.2.1:1080" } }
      ]
    }
  },
  "routing_policies": {
    "rule-a": {
      "routes": [
        {
          "selectors": ["example.com"],
          "action": { "type": "egress", "egress": "jp-pop" }
        }
      ]
    }
  }
}
  • 成员 idpriority 在 chain 内唯一,数字越小越先尝试;backend 复用上表的字段与全部校验 规则,可混合 reverse hop 与地址型 HTTP/SOCKS5 后端,但不得嵌套 chain。
  • 同一 egress 可被多个 policy/route 复用。
  • 未知 egress 引用、空 chain、重复 ID/priority、字段冲突、超出上限(10000 个 egress / 每条 chain 16 成员)都会让整份快照编译失败,节点继续用上一份有效快照。

TCP 故障转移语义

  1. block 判断仍优先于所有出口选择(决策顺序不变)。
  2. priority 从小到大顺序尝试成员——不做随机、轮询或并行竞速。
  3. 只有隧道建立阶段的失败会触发下一成员:拨号失败/超时、TLS 或上游握手失败、 HTTP CONNECT / SOCKS5 CONNECT 建立失败、reverse hop 未注册/开流失败/超时/hop 无法连目标。
  4. 每次成员尝试有界超时(默认 10s),一次请求的总故障转移时间有上限(默认 30s,常量 CHAIN_ATTEMPT_TIMEOUT / CHAIN_TOTAL_TIMEOUT);预算耗尽即停止尝试。
  5. 任一成员返回已建立的数据流后立即固定该成员;开始转发客户端数据后的 IO 错误不会在其他 成员上重放,避免重复请求或协议状态错乱。
  6. 所有成员失败时 fail-closed(HTTP 502 / SOCKS5 拒绝),不会隐式回落直连;失败阶段记为 chain_exhausted

UDP 语义

  1. 当前只有 reverse 成员具备 UDP relay 能力;HTTP、SOCKS5、Subnetra 成员对 UDP 请求一律不合格。
  2. 建立 UDP association 时只在 reverse 成员中按优先级尝试;混合 chain 里的非 UDP 成员不会被误用。
  3. chain 没有可用 reverse 成员时保持 fail-closed(丢弃,不直连)。
  4. association 建立后粘住选中的 hop,不逐包切换、不静默迁移(出口 IP/端口与 NAT 映射保持稳定)。
  5. 若需要 UDP 主备,chain 必须至少配置两个 reverse 成员;「reverse 主、SOCKS5 备」只能为 TCP 提供备份。

可观测性

访问日志与统计同时保留逻辑与物理两个维度:决策记为 chain:jp-popdecision 字段),实际 出口记为胜出成员的安全标识(egress 字段,如 reverse:h1 / upstream:10.2.2.1:1080),并带 chain_member(成员 ID)与 attempts(建立尝试次数,全部失败时也会记录)。流量与活跃隧道 统计按物理出口维度计数。每次成员失败在 debug 日志中带 chain/member/阶段分类。日志不含成员 密码、reverse token 或 Proxy-Authorization 内容。

认证与限速

  • 认证:按用户名查用户 → 常量时间比较密码 → 校验 expire(已过期直接拒绝)。
  • 限速up_rate / down_rate 是每用户的字节令牌桶;两者都为 0 时走 copy_bidirectional 零开销快路。
  • 连接数max_connections 限制该用户并发连接,0 表示不限。

TUIC 前端身份

frontends.tuic 里给用户配 uuid + password 即启用该用户的 TUIC 前端接入frontends 是按协议命名空间的凭据表(frontends.<协议>),将来加前端协议只需加一个协议条目,不动顶层 schema,也能按协议独立启停 / 轮换:

  • 快照编译期为每个协议建立 uuid → 用户名 索引;同一协议下一个 uuid 被两个用户占用会导致编译失败(认证必须无歧义)。
  • frontends.tuic.password 独立于登录 password:TUIC 用 TLS keying-material 导出的 token 认证,节点只做「uuid 查表 → 归属用户名」,不复用登录口令、也不从报文还原用户名。
  • 认证通过后,expire / 限速 / 连接数 / routing policy 全部沿用该用户既有语义。TCP 请求按 up_rate/down_rate 限速;UDP 请求不限速(走反向 hop UDP 出口)。

节点级覆盖 node_overrides

控制面向所有节点发完全相同的快照。个别节点(例如不同边缘位置有各自的本地 hop)需要不同出口时, 快照可带一个按 node_id 索引的 node_overrides,节点拿到后用本地配置的 node_id 自行挑出属于自己的 覆盖并在本地合并。控制面自始至终不需要知道是哪个节点在请求。

override 只允许整项替换 base egresses 里已经存在的同名 egress:不能新增 node-only egress, 不能改 routing policy,也不能改 users。这条约束保证 route 表在全网是同一份,只有出口 realization 因节点而异。引入一个不存在的 egress ID 会让该节点拒收整份快照。字段语义见 控制面同步协议 · 节点级 Override

MQTT 节点状态带 snapshot_schema_version,便于控制面在未来 bump schema 前确认全网能力。

Rove Snapshot Protocol

本文档定义控制面向 Rove 节点下发的身份与策略快照协议。

Rove 只有一种快照 schema(schema_version: 1):身份、策略、出口是三张独立的表。 用户显式引用一条可复用的 routing policy,policy 用有序 route 选择命名 egress、直连或阻断, egress 表单独承载出口 realization 与凭据。

同步接口是完全扯平、与节点无关的静态接口:没有 {node_id} 路径参数,控制面不需要 知道、也不关心是哪个节点在请求,对所有请求返回完全相同的字节。需要按节点区分的 行为(如不同边缘节点用不同本地 hop)完全由节点自己在本地用 node_overrides 字段解决, 详见下方「节点级 Override」一节。

HTTP 同步接口

GET {snapshot_url}?since={version}
Authorization: Bearer {node_token}
Accept: application/json

{snapshot_url} 是节点本地配置里的完整地址(例如 https://control.example.com/snapshot, 或控制面实际暴露的任何其他地址)。节点原样请求它,不会拼接任何固定路径(比如 不会假设存在 /api/v1/... 这类结构),只会根据 snapshot_url 是否已包含 ? 追加 ?since=&since=

响应:

200 application/json  返回完整快照(所有节点收到的 body 完全一样)
304 Not Modified      节点当前 version 已是最新
4xx/5xx               同步失败,节点继续使用当前内存快照或本地缓存

规则:

  • 接口不包含 node_idsince 是节点本地当前已加载的 version,控制面只需要比较它与全局当前 version,不需要任何按节点区分的服务端逻辑。可以用一个静态文件服务(比如 nginx/S3/对象 存储直接把一份 JSON 抛出来)实现,无需动态后端。
  • Authorization 仍可以是每个节点不同的 token,但这只是凭据/鉴权的事,不影响接口本身与响应体的 扯平性。
  • version 必须单调递增。
  • 如果没有更新,优先返回 304。如果返回 200version <= since,节点也会忽略。
  • 响应必须是完整快照,不是增量 patch。
  • 控制面应避免返回明文调试字段、审计字段或与节点无关的业务数据。
  • 节点会先解码并编译验证新快照;只有编译成功的快照才会进入本地缓存和内存热替换。
  • 同步失败、响应过大、JSON 无法解析、快照 shape 无效、引用未知 policy/egress,或 egress 配置 非法时,节点继续使用当前内存快照,且不会覆盖已有本地缓存。

快照 JSON

{
  "schema_version": 1,
  "version": 42,
  "users": {
    "alice": {
      "password": "user-secret",
      "expire": "2026-12-31",
      "up_rate": 1048576,
      "down_rate": 1048576,
      "max_connections": 2,
      "policy": "shared-policy",
      "frontends": {
        "tuic": {
          "uuid": "550e8400-e29b-41d4-a716-446655440000",
          "password": "tuic-front-end-secret"
        }
      }
    }
  },
  "routing_policies": {
    "shared-policy": {
      "routes": [
        {
          "selectors": ["book:security/blocked"],
          "action": {"type": "block"}
        },
        {
          "selectors": ["domain:egress-a.example", "198.51.100.0/24"],
          "action": {"type": "egress", "egress": "egress-a"}
        },
        {
          "selectors": ["full:intranet.example"],
          "action": {"type": "direct"}
        }
      ],
      "default_action": { "type": "egress", "egress": "egress-b" }
    }
  },
  "egresses": {
    "egress-a": {
      "type": "upstream",
      "backend": {
        "kind": "socks5",
        "addr": "proxy-a.example:1080",
        "username": "upstream-user",
        "password": "upstream-secret"
      }
    },
    "egress-b": {
      "type": "chain",
      "members": [
        {
          "id": "primary",
          "priority": 10,
          "backend": {"kind": "reverse", "addr": "hop-primary"}
        },
        {
          "id": "standby",
          "priority": 20,
          "backend": {"kind": "socks5", "addr": "proxy-b.example:1080"}
        }
      ]
    }
  },
  "node_overrides": {
    "edge-tokyo-01": {
      "egresses": {
        "egress-a": {
          "type": "upstream",
          "backend": {"kind": "reverse", "addr": "tokyo-hop"}
        }
      }
    }
  }
}

字段定义

顶层

字段类型必填说明
schema_versioninteger线协议结构/语义版本,必须是 1。没有默认值:缺失该字段的文档在解码阶段就被拒收。与 version 相互独立。
versioninteger快照内容修订号,必须单调递增;用于 ?since=304
usersobject身份表,key 是用户名。缺省为空对象。
routing_policiesobject可复用 routing policy 表,key 是 policy ID。缺省为空对象。
egressesobject命名出口表,key 是 egress ID。缺省为空对象。
node_overridesobject节点级 egress 覆盖表,key 是 node_id。缺省为空对象。详见下方「节点级 Override」。

users.{username}

字段类型必填说明
passwordstringHTTP/SOCKS5 登录密码。
expirestring/nullYYYY-MM-DD;空或缺省表示不过期。超过该日期后认证失败。
up_rateinteger上传限速,单位 bytes/sec;0 或缺省表示不限速。
down_rateinteger下载限速,单位 bytes/sec;0 或缺省表示不限速。
max_connectionsinteger单节点内该身份最大活跃隧道数;0 或缺省表示不限制。
policystring该身份绑定的 routing policy ID,非空且必须存在于 routing_policies
frontendsobject前端协议凭据表,key 是协议名。缺省为空对象;当前支持 frontends.tuic

users.{username}.frontends.tuic

给用户配置该对象即可启用 TUIC v5 前端接入。TUIC 凭据独立于用户顶层 password:顶层 password 用于 HTTP/SOCKS5 登录,frontends.tuic.password 只用于 TUIC TLS keying-material token 认证。

{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "password": "tuic-front-end-secret"
}
字段类型必填说明
uuidstringTUIC 用户 UUID,使用标准带连字符格式 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx。同一份快照中不得被两个用户的 frontends.tuic 重复占用(比较时不区分大小写);重复会导致整份快照编译失败。
passwordstringTUIC 前端密码,用作 TLS keying-material exporter 的 context;独立于用户顶层登录 password

约束:

  • frontends 缺省或没有 tuic 条目时,该用户不能通过 TUIC 认证,但仍可使用已配置的其他前端。
  • TUIC 条目必须同时提供 uuidpassword;控制面应在发布快照前拒绝不完整条目。
  • listencertkeyalpninitial_mtu 是节点本地 [[tuic_listeners]] 配置,不属于用户快照,也不得放进 frontends.tuic
  • TUIC 认证成功后,expireup_ratedown_ratemax_connectionspolicy 继续沿用 该身份的通用字段。

routing_policies.{policy_id}

字段类型必填说明
routesarray有序 route 数组,缺省为空。数组顺序就是协议语义:first-match-wins,不同 route 的 selector 可以重叠。
default_actionobject/null所有 route 都未命中时执行的 action,语法与 routes[].action 完全一致。缺省等价于 {"type":"direct"}

default_action 与 route 的 action 共用同一个解码器和同一套词汇,读懂 route 就读懂 default。

routes 为空的 policy 是合法的——它退化为「只有 default_action」,或在没有 default_action 时退化为「认证后直连」。

deny-by-default(allowlist)策略:把 default_action 设为 {"type":"block"},policy 就只 能到达它自己列出的目标,其余一律拒绝。选择器没有 catch-all 写法,这是表达「只放行清单内目标」 的唯一方式:

{
  "routes": [
    { "selectors": ["api.openai.com", "api.anthropic.com"], "action": { "type": "egress", "egress": "eu-egress" } },
    { "selectors": ["10.0.0.0/8"], "action": { "type": "direct" } }
  ],
  "default_action": { "type": "block" }
}

routes[]

字段类型必填说明
selectorsstring[]至少一项,任意一项命中即视为该 route 命中。语法见下方「选择器语义」。
actionobject严格 tagged union,见下。

action 只能是以下三种之一:

{"type": "egress", "egress": "<egress_id>"}
{"type": "direct"}
{"type": "block"}

未知 type、缺失 egress、给 direct/block 带上 egress,或任何额外字段,都会拒收整份 快照。route 只保存 egress ID,凭据只存在于 egress realization,多个 policy/route 复用同一 egress 时不会复制密码。

egresses.{egress_id}

同样是严格 tagged union:

{"type": "upstream", "backend": { "...": "..." }}
{"type": "chain",    "members": [ { "...": "..." } ]}

type = "upstream" 携带一个具体 backend;type = "chain" 携带一组按 priority 排序的主备 成员。upstream backend 不能使用 kind = "chain"(chain 是独立的 egress 变体,不是一种 backend kind);chain member 也不能嵌套 chain。

backend

字段类型必填说明
kindstringhttp / https / socks5 / socks / reverse / subnetrahttps 按 HTTP CONNECT over TLS 处理;reverse 走反向 hop(addrhop_id);subnetra 经内嵌 overlay 出口(目标须为 overlay IPv4,见 内嵌 Subnetra 组网底座)。
addrstring是*出口地址,格式 host:portreverse 时填 hop_idsubnetra 时不使用。
usernamestring/null出口认证用户名(reverse / subnetra 不接受)。
passwordstring/null出口认证密码(reverse / subnetra 不接受)。
tlsboolean是否用 TLS 连接出口(reverse / subnetra 不接受,二者传输自带加密)。
skip_cert_verifyboolean缺省 false。仅在 tlstrue 时生效:true 表示跳过证书链、主机名和有效期校验(自签名证书、纯 IP hop 节点常见场景)。这是逐个 backend 的显式开关,不是全局配置,也不会影响入站监听端的 TLS 校验。

egresses.{egress_id}.members[](chain)

一条 chain 表示同一逻辑出口的按优先级主备候选集合(例如同一 POP 的主 reverse hop + 备 SOCKS5 后端),不是 A → B → 目标的串联多跳。运行时故障转移语义(仅隧道建立阶段重试、 超时预算、UDP 仅 reverse 成员、exhausted 后 fail-closed)见 数据模型 · 出口链与主备故障转移

字段类型必填说明
idstring成员的稳定 ID,同一 chain 内唯一;出现在访问日志与指标里。
priorityinteger同一 chain 内唯一;数字越小优先级越高(越先尝试)。
backendobject复用上面的 backend 结构与全部校验规则;backend.kind 不得为 chain(禁止递归)。

决策语义

节点对每个请求按以下顺序求解,全过程 fail-closed:

  1. 认证:用户名必须存在、密码匹配、未过期。任何一项不成立即拒绝,不会降级为直连。
  2. 用户 → policyrouting_policies 中的那一条 policy。
  3. requested host 与经过验证的 sniffed host 分别按 routes 顺序求各自的第一个匹配 action。
  4. 任一候选的 first-match action 为 block 都会阻断。
  5. requested target 是域名时,sniffed host 的非 block action 不改变路由。
  6. requested target 是 IP 时,sniffed host 的非 block action 优先于 requested-IP action。
  7. 仍未选中 action 时执行 policy 的 default_action;没有 default 则直连。

sniff 只影响策略身份,不改写实际 dial target;访问日志的 effective_policy_host 记录最终 用于选择 action 的 host。

选择器语义

selectors 里的每一项可以是:

  • example.comdomain:example.com:域名后缀匹配,匹配 example.com 和任意子域。
  • full:example.com:精确域名匹配。
  • keyword:openai:域名关键字匹配。
  • 203.0.113.10:单 IP。
  • 100.117.0.0/16:CIDR。
  • book:<category>:引用节点已加载的 addrbook 层级分类。

域名匹配大小写不敏感,并忽略首尾点。

book: 规则要求节点已配置 [addrbook] 并成功加载工件:未配置地址簿或分类不存在时,节点 拒收整份快照,不会把 book:aws 当作普通域名放行。快照编译期钉住当前书版本,书热替换等价于 重编译最近一份快照,两者都成功才双双替换。地址簿构建、发布、层级分类、节点接入与热替换语义见 rove-addrbook 指南

严格校验与 fail-closed

未知字段整份拒收(设计承诺)

全部 wire 结构(RawSnapshotRawUserRawRoutingPolicyRawRouteactionegressNodeOverride)都声明了 deny_unknown_fields收到任何不认识的字段,节点拒收 整份快照,而不是忽略该字段后按旧语义继续放行。

这是刻意的安全属性,不是实现细节。它意味着:

  • 未来给 policy / route / egress 增加语义字段(例如显式的兜底 action)时,不支持该字段的 旧节点会明确拒收,而不会因为「忽略未知字段」把一份收紧过的策略执行成宽松版本。
  • 任何来自其他形态的文档——包括本协议出现之前的 group 表形态——都会撞上这条规则被整份拒收, 而不会被半懂半猜地执行成一份宽松策略。
  • 代价是拒收后节点保留上一份有效快照,因此收紧类变更必须配合 schema 或 capability 门控 发布:先确认目标节点已支持,再让控制面输出带新字段的快照,否则节点会停留在旧策略上。
  • 控制面发布前用 rove validate-snapshot 预检,可以在下发之前就发现字段不被接受。

schema 版本守卫

schema_version 必须落在节点支持的 1..=MAX_SUPPORTED_SCHEMA_VERSION 区间内(当前二者都是 1)。超出范围的快照被整份拒收,节点保留此前有效的内存快照和 cache。未来若要 bump schema, 发布顺序必须是:先部署支持新 schema 的节点二进制并通过 MQTT 节点状态的 snapshot_schema_version 字段确认全网能力,再让控制面输出新 schema。

编译期校验

以下任一违反都会让整份快照编译失败,节点继续使用上一份有效快照:

  • 用户的 policy 为空,或引用了不存在的 policy。
  • route 的 selectors 为空。
  • actiondefault_action 引用了不存在的 egress,或 egress ID 为空白。
  • egress ID / chain member ID 为空。
  • chain 没有成员,或同一 chain 内 member id / priority 重复。
  • chain member 的 backend 是另一条 chain。
  • reverse / subnetra backend 携带了 username / password / tls / skip_cert_verify
  • book: 选择器所需的 addrbook 缺失,或分类不存在。
  • 节点 override 引入了 base egresses 里不存在的 egress ID。
  • 触碰任一规模上限。

规模上限

上限
users 条目数100000
routing_policies 条目数10000
egresses 条目数10000
全快照 route selector 总数200000
node_overrides 条目数10000
单条 chain 的成员数16
单个 book: 选择器展开后的字节数64 MiB

节点级 Override(node_overrides

背景

控制面通常只维护一份统一的身份/策略数据。但多节点部署里,同一条 policy 在不同边缘节点上落地 的出口往往不同——例如「经东京落地」策略,在东京节点上应该走本地 hop(127.0.0.1:11080), 在大阪节点上应该走大阪本地 hop(127.0.0.1:12080),而路由规则完全一样,只有出口 realization 不同。

node_overrides 就是为了解决这个问题:同步接口本身不区分节点(没有 {node_id} 路径参数), 控制面仍然只生成、只分发完全同一份快照给所有节点。节点拿到这份完全一样的 body 之后,在 编译快照时用自己本地配置node_id(节点 TOML 里的 node_id 字段,从未发往控制面)去 响应体自带的 node_overrides 里找属于自己的那一份,完全在本地做合并。

结构

{
  "node_overrides": {
    "{node_id}": {
      "egresses": {
        "{egress_id}": {"type": "upstream", "backend": {"...": "..."}}
      }
    }
  }
}
字段类型必填说明
node_overrides.{node_id}objectnode_id 对应的 override 集合。key 必须和节点本地配置文件里的 node_id 完全一致;同步接口本身不携带 node_id,它纯粹是节点本地用来自选的 key。
node_overrides.{node_id}.egressesobject该节点要替换的 egress,key 是 egress_id,value 是完整的 egress 定义(结构和顶层 egresses.{egress_id} 完全一样)。

合并语义

  • override 只能整项替换顶层已经存在的 egress。引入一个 base 表里没有的 egress ID 会让节点 拒收整份快照——这条规则保证 routing policy 始终是 node-independent 的:所有节点看到同一张 route 表,只有出口 realization 因节点而异。
  • 替换是整项替换,不是逐字段合并。override 里要把该 egress 的完整定义写全。
  • override 不能修改 usersrouting_policiesversion
  • 合并发生在 route → egress 引用校验和 backend 编译之前,所以 override 出来的 egress 同样 要满足所有校验规则。一个 override 把 chain 的 members 换成空数组会导致该节点拒收整份 快照(「override 后消失」按非法快照处理),而不是静默保留旧成员。
  • 其他节点的 node_overrides 条目会被忽略;一个节点只应用 key 精确等于自己 node_id 的那一条。
  • 不填 node_overrides,或者当前节点的 node_id 不在里面,行为和没有这个字段完全一样——即所有 节点用同一份 egresses

公共 validator

使用节点二进制做发布前预检,不启动 listener、同步器或守护进程:

rove validate-snapshot --node-id edge-tokyo-01 snapshot.json
cat snapshot.json | rove validate-snapshot --node-id edge-tokyo-01 -
rove validate-snapshot --node-id edge-tokyo-01 --addrbook book.rab snapshot.json

成功时 exit 0,并在 stdout 输出单行 JSON:

{"ok":true,"schema_version":1,"version":42,"users":1,"routing_policies":1,"egresses":2}

失败时 exit 非零,输出 {"ok":false,"stage":"...","error":"..."}stageargumentsreadaddrbookdecodecompile。输出不包含 snapshot 原文或凭据; 输入默认从 stdin 读取,最大 8 MiB。

--node-id 会真正参与编译:带 node_overrides 的快照必须对每一个在线 node_id 分别 validate,才能确认它在全网都能编译通过。

控制面实现建议

  • 生成前校验 user → policy、route/default → egress 的所有引用,并保持 route 数组顺序稳定: 数组顺序就是策略语义,重排等于改策略。
  • 把 block route 放在最前面。first-match-wins 意味着一条更靠前的 egress route 会让后面的 block route 永远不生效。
  • 为用户启用 TUIC 时,输出完整的 frontends.tuic.uuidfrontends.tuic.password;校验 UUID 格式,并保证同一协议内 UUID 全局唯一。
  • 不要把 TUIC 监听地址、证书路径、私钥路径、ALPN 或 MTU 写入快照;这些字段属于各节点本地 [[tuic_listeners]] 配置。
  • version 使用数据库变更序列、Unix 秒加递增序号,或其他严格单调来源。
  • 不要把空字符串作为 policy ID、egress ID、kindaddr、用户名或密码。
  • 需要按节点区分出口时,优先用 node_overrides,而不是让控制面为每个节点渲染一份不同的 响应体:users / routing_policies 继续保持全节点统一,只在 node_overrides.{node_id}.egresses 里给需要特殊处理的 egress 补一份该节点专属 realization。
  • 密码字段是敏感信息,传输必须使用 HTTPS 或受控内网链路;本地 cache 文件应按部署环境限制权限。
  • 控制面发布前使用 rove validate-snapshot 做真实编译预校验;节点拒收坏快照是最后一道 fail-closed 保护,不应作为常规校验流程。

rove-addrbook 使用、发布与 .rab v1 格式

rove-addrbook 用来管理不适合反复塞进控制面快照的大型域名与 IP 数据集。完整链路由四部分组成:

  1. rove-abctl 从本地清单和公开上游构建地址簿;
  2. .rab 保存确定性、带 SHA-256 完整性校验的发布工件;
  3. Rove 节点通过本地 [addrbook] 加载并热替换工件;
  4. 控制面快照用 route selector 里的 book:<category> 决定哪些分类参与分流或阻断。

地址簿只回答“目标属于哪些分类”,不决定用户、出口或允许/拒绝结果。快照仍是策略唯一真相; 同一个分类可以在不同 policy 的 route 里用于 egress,也可以用于 block

官方发布的收录范围

addrbook/book.toml 只收基础设施与企业应用的地址数据:公有云官方 IP 段(AWS / Azure / GCP)、 CDN(Cloudflare / Akamai)、供应商基础设施、AI 与开发者 API、交易所与金融 API、企业协作 SaaS、 广告与追踪域名(用于 block)、内网保留域名。判据只有一条:一个组织会不会为成本、时延、 稳定性、固定出口或合规去路由它。

以「哪些站点需要特殊访问」为组织方式的数据集不在收录范围内——按国境切分的 geolocation 列表、 带 !cn 后缀的取反合集、流媒体解锁与社交站点清单,服务的是消费级绕行场景,不是应用出口治理。

这是发布清单的取舍,不是格式限制.rab 格式和 book: selector 对分类内容没有任何约束: 需要其它数据的部署方自行维护一份私有 manifest,追加 [[source]] 后用同一套 rove-abctl build 构建即可,节点侧无需任何改动。相应地,部署方也自行承担所选数据源的合规责任。

目标语义很重要:地址簿匹配的是客户端请求中的目标域名或 IP 字面量,不会先解析域名再用解析结果 查询 IP 分类。因此 AWS/Azure/GCP IP 段只会命中以 IP 字面量发起的请求;要按域名分流,必须同时提供 域名数据源。TLS SNI 嗅探也不会替换 HTTP CONNECT / SOCKS5 请求中的目标。

设计约束(规范性):

  • 格式即协议。消费方之间不通过网络协议协商,只通过文件格式契约耦合。 任何布局变更都必须提升 format_version;v1 读取器遇到未知版本、未知 section kind、重复 section 一律拒绝加载,不做猜测性兼容。
  • 确定性构建。同一输入源在任何机器上构建出的工件逐字节相同 (build_epoch 由 manifest 或 --epoch 显式传入,绝不取墙钟)。 发布产物可以被第三方复现校验。
  • fail-closed。校验和不符、越界、排序违规、引用悬空——任何一项违规都 导致整个文件拒绝加载;绝不部分加载。
  • mmap-ready。所有 section 经由 section 表偏移寻址、记录定宽、小端。 当前实现解码进类型化向量;未来零拷贝 mmap 读取器无需任何格式变更。

5 分钟构建并接入

1. 准备清单与源文件

下面的目录只依赖本地文件,适合先验证完整链路:

addrbook/
├── book.toml
└── data/
    ├── corp.cidrs
    └── domains.txt

addrbook/book.toml

# 由发布系统维护的单调递增 u64;不要取构建机当前时间作为隐式默认值。
epoch = 2026072301

[[source]]
category = "corp"
kind = "cidrs"
path = "data/corp.cidrs"

[[source]]
category = "ads"
kind = "domains"
path = "data/domains.txt"

addrbook/data/corp.cidrs

10.20.0.0/16
2001:db8:20::/48

addrbook/data/domains.txt

# 默认是 apex + 子域后缀匹配
ads.example
full:telemetry.example
keyword:tracker

2. 构建、验证和抽查

源码构建会生成独立工具 target/release/rove-abctl

cargo build --release --locked --bin rove-abctl

./target/release/rove-abctl build \
  --manifest addrbook/book.toml \
  --out addrbook/book.rab

./target/release/rove-abctl verify addrbook/book.rab
./target/release/rove-abctl inspect addrbook/book.rab --categories
./target/release/rove-abctl query addrbook/book.rab sub.ads.example ads
./target/release/rove-abctl query addrbook/book.rab 10.20.1.8 corp

query 在目标后的一个或多个分类参数组成 selector:匹配时退出 0,不匹配时退出 1, 可直接用于发布脚本。

3. 让节点加载工件

[addrbook]
path = "/etc/rove/addrbook/book.rab"
poll_interval_secs = 300

节点配置了 [addrbook] 后,文件缺失、超过大小限制、校验和错误或格式不合法都会使启动失败。

4. 在快照里引用分类

在当前快照 schema 里,把 book:<category> 写进 route selectors

{
  "schema_version": 1,
  "version": 42,
  "users": {
    "alice": { "password": "replace-me", "policy": "filtered" }
  },
  "routing_policies": {
    "filtered": {
      "routes": [
        {
          "selectors": ["book:ads"],
          "action": { "type": "block" }
        },
        {
          "selectors": ["book:corp"],
          "action": { "type": "egress", "egress": "corp-hop" }
        }
      ]
    }
  },
  "egresses": {
    "corp-hop": {
      "type": "upstream",
      "backend": { "kind": "socks5", "addr": "10.0.0.9:1080" }
    }
  }
}

节点未配置地址簿、分类不存在或规则为空时,整份新快照拒收并保留上一份有效策略。

.rab v1 二进制格式

顶层布局

所有整数一律小端(little-endian)。

偏移      长度   字段
0         4      magic            = "RAB1"
4         2      format_version   = 1 (u16)
6         2      reserved         = 0 (u16;非零必须拒绝)
8         8      build_epoch      (u64) 构建纪元,由发布方定义(unix 秒或序列号)
16        4      n_sections       = 7 (u32;v1 的 7 个 section 全部必需)
20        20×n   section 表        n_sections 个 {kind u32, offset u64, len u64}
…         …      section payloads  由 section 表偏移寻址(offset 相对文件起点)
EOF-32    32     sha256           前面所有字节的 SHA-256 摘要

校验顺序(规范性):读取器必须先验证尾部 SHA-256(覆盖除自身外的全部 字节),再解析 magic 与版本,然后才解析 section。任何 section 越界、重叠、 留有未引用字节、kind 重复或 kind 未知都必须拒绝整个文件。

Section 目录

kind名称内容
1CATEGORIES层级分类表(名称池 + parent 索引)
2CATSETS去重后的分类位图池
3IP4IPv4 区间表(排序、两两不相交)
4IP6IPv6 区间表(排序、两两不相交)
5DOMAIN_EXACT精确域名表(规范化、排序、唯一)
6DOMAIN_SUFFIX后缀域名表(标签反转、排序、唯一)
7KEYWORD关键字片段表

CATEGORIES (kind=1)

n (u32) | n × { name_off u32, name_len u16, parent u32 } | pool_len (u32) | pool
  • name 是完整层级路径(如 google/ads),字符集 [a-z0-9-_.@!]/ 分隔符;表按 name 字节序排序。
  • name pool 引用必须按记录顺序首尾相接,不得重叠、复用或留空洞;因此解码 后字符串总量受工件字节数线性约束,不允许小文件放大成巨额堆分配。
  • parent 是分类表内索引;根分类为 u32::MAX。父分类必须存在且路径 必须是子路径去掉最后一段(引用完整性在加载时校验)。
  • 层级语义:选择 google 时其全部子孙(google/adsgoogle/play…) 一并选中。排序表上子孙即前缀区间 [name+"/", name+"0"),二分可得。

CATSETS (kind=2)

words (u32) | n (u32) | n × words × u64
  • 每个 catset 是 words 个 u64 的位图,bit i 对应分类表索引 i; words = ceil(categories / 64)
  • 所有地址条目通过 catset id(表内索引)引用位图,重复位图在构建期 去重(intern)。位图中置位的分类索引必须小于分类总数。

IP4 (kind=3) / IP6 (kind=4)

n (u32) | n × { start, end, catset u32 }     start/end: IP4=u32, IP6=u128
  • 闭区间 [start, end],按 start 排序且两两不相交(构建器用边界 扫描线把重叠 CIDR 合并成带合并位图的不相交区间)。
  • 查询:partition_point(start ≤ key) - 1 后验 key ≤ end,O(log n)。
  • IPv4 映射的 IPv6 地址(::ffff:a.b.c.d)在查询层折回 IPv4 表。 构建器会把 IPv6 CIDR 与该映射区重叠的部分切入 IP4 section;规范工件的 IP6 section 不得覆盖映射区,读取器会拒绝这种非规范记录。

DOMAIN_EXACT (5) / DOMAIN_SUFFIX (6) / KEYWORD (7)

n (u32) | n × { pool_off u32, len u16, catset u32 } | pool_len (u32) | pool
  • 域名规范化:小写、去尾点、去首尾空白;后缀规则输入的 *.example.comexample.com 的兼容别名(两者都匹配 apex 与任意子域)。
  • DOMAIN_EXACT 按规范化名称排序唯一;查询为整名二分。
  • DOMAIN_SUFFIX 存标签反转形式(google.comcom.google),排序 唯一;查询对宿主名每个标签边界做前缀精确二分(≤ 标签数 × log n), google.com*.google.com 均命中,notgoogle.com 不命中。
  • KEYWORD 为子串匹配,线性扫描;仅用于 keyword: 语义(v2fly 数据里 大量存在),构建时应控制数量。
  • 三个字符串 section 的 pool 引用同样必须按记录顺序连续、不重叠且完整覆盖 pool。
  • 三表的 catset 均指向 CATSETS 位图。命中结果 = 命中条目位图与查询 selector 位图求交,非空即匹配。

语义不变量(加载时强制)

  1. 分类表按名称排序、名称唯一、路径字符合法、parent 引用完整;
  2. 位图池长度 = n × words,置位索引 < 分类数;
  3. IP 表排序且不相交,start ≤ end
  4. 字符串表排序唯一、pool 引用在界内、UTF-8 合法;
  5. 所有 catset id < 位图数。

违反任意一条 → 拒绝加载(AddrBook::from_bytes 返回错误)。

Rove v1 读取器另有 fail-closed 资源上限:最多 100,000 个分类、单 section 最多 8,000,000 条记录、解码目标堆预算 256 MiB;节点从文件加载时工件本身 也不得超过 256 MiB。预算在任何 Vec::reserve / 字符串复制前预检,恶意计数 字段不能先触发巨额分配再等待语义校验拒绝。

快照规则:book:<category>

控制面快照在 route selector 中引用 addrbook 分类:

{
  "schema_version": 1,
  "version": 42,
  "users": {
    "alice": { "password": "replace-me", "policy": "ads-policy" }
  },
  "routing_policies": {
    "ads-policy": {
      "routes": [
        {
          "selectors": ["book:google/ads", "ads.custom.example"],
          "action": { "type": "block" }
        }
      ]
    }
  },
  "egresses": {}
}
  • book: 规则始终在当前快照 schema 的 selectors 中可用,不再有额外 schema 版本门槛。
  • 显式域名/IP 规则与 book: 分类按“或”组合(快照仍是“谁走哪”的唯一真相, addrbook 只提供地址数据)。
  • 快照编译期把 book: 模式解析成位图 selector 并钉住当时的书—— 一个快照永远是内部一致的(规则与书版本成对固定)。
  • 相同分类组合共享同一不可变 selector 位图;单快照唯一 selector 的总内存 上限为 64 MiB,超过即拒绝新快照、继续服务旧快照。
  • fail-closed:配置了 book: 规则但节点无 [addrbook]、或分类不存在, 整个快照被拒绝,节点继续用旧快照服务。
  • 书热替换 = 用新书重编译最近一次成功的原始快照,成功才书+快照同时 替换;失败则两者都不动(见 tests/addrbook_integration.rs)。

分类与组合语义

  • 分类名构建时会去首尾 /、转小写;每段只允许 a-z 0-9 - _ . @ !
  • 添加 google/ads 时会自动创建祖先 googlebook:google 选择自身和全部子孙, book:google/ads 只选择该子树。没有通配符、排除或正则 selector。
  • 同一数组中的多个 book: 条目是“或”;显式域名/IP 与地址簿 selector 也是“或”。
  • 路由仍按 routes 数组 first-match-wins;把更具体的 block / direct / egress route 放在前面。
  • scheme 前缀必须写成小写 book:;分类名本身匹配不区分大小写。
  • book: 只允许出现在 route selectors 中;节点级覆盖只替换已存在的 named egress,不改变 selector。

Manifest 清单

清单是 TOML 文件;相对 path 以清单所在目录为基准:

epoch = 2026072301

[[source]]
category = "geosite/google"
kind = "v2fly-domains"
path = "data/domain-list-community/data/google"
url = "https://download.example/google" # 可选,仅供 fetch 使用
字段必填说明
epoch写入工件的 u64 发布序号,缺省为 0;生产必须显式维护并递增。--epoch 可覆盖。
[[source]]至少一项;按声明顺序读取,但最终工件仍确定性排序。
source.category目标层级分类;自动转小写并创建祖先分类。
source.kind下表六种数据源之一;未知值直接失败。
source.path本地源文件路径;相对值以 manifest 目录为基准。
source.urlfetch 下载地址;build 不访问网络,只读取 path

每个 source 必须至少产出一条受支持记录;空文件、只有注释、或 v2fly 文件只有被跳过的 regexp: 都会使整个构建失败。

六种数据源

kind输入自动生成的分类
cidrs每行一个 IP 或 CIDR,支持 IPv4/IPv6只写入 category
domainsRove 规则:域名模式,也接受 IP/CIDR只写入 category
v2fly-domainsv2fly domain-list-community 文件基础分类、@attr 分类及 &affiliation 分类
aws-ip-rangesAWS ip-ranges.jsoncategorycategory/<service>
azure-service-tagsAzure Service Tags JSONcategorycategory/<systemService>
gcp-cloud-jsonGCP cloud.json / goog.jsoncategory 与可选的 category/<service>

文本源会去掉 # 之后的注释和空白行。

cidrs

接受单 IP(等价 /32/128)和 CIDR。重叠网段会在构建时拆成不相交区间并合并分类位图, 所以同一地址可以同时属于多个分类。

203.0.113.7
203.0.113.0/24
2001:db8::/32

domains

写法语义
example.com / domain:example.com后缀匹配,包含 apex 与所有子域
*.example.com后缀匹配兼容写法,同样包含 apex
full:api.example.com只匹配完整域名
keyword:tracker规范化域名包含该子串
203.0.113.0/24 / 203.0.113.7CIDR 或单 IP,与 cidrs 语义相同

域名会转小写、去首尾空白和点。keyword: 在查询时线性扫描,数量过大时应改用精确或后缀规则。 如果文件应当只允许 IP/CIDR,使用更严格的 cidrs source;它会拒绝任何域名行。

v2fly-domains

支持 domain-list-community 的 include:@attr、选择性 @-attr&affiliation

google.com
full:g.co @cn
keyword:gvid @ads
include:google-base @ads @-cn

如果 manifest 分类是 geosite/google@cn 条目同时进入 geosite/google@cn&category-special 会进入同一命名空间下的 geosite/category-special;affiliation 会先在源文件 同目录、同扩展名的文件中建立全局索引,再执行 include/filter。

安全与兼容边界:

  • regexp: 明确跳过,rove-addrbook 不提供正则匹配;
  • include 最大深度 16,循环引用失败;
  • include 只能使用源根目录内的相对路径,绝对路径、.. 和 symlink 逃逸失败;
  • 单条规则或 include 最多 64 个 metadata/filter 项;
  • 解析、展开、过滤和输出总工作量上限为 1,000,000 次;
  • 同一解析过程按文件缓存并去重,但不会跨构建保存缓存。

AWS / Azure / GCP Provider JSON

Provider 源只接受规范 CIDR:必须带前缀长度,网络地址的 host bits 必须为零。裸 IP、缺失必要字段、 无地址字段、非法前缀,或 GCP 一条记录同时声明 IPv4/IPv6 都会使构建失败。

服务名会转小写,空格、斜杠等非法字符转为 -。例如 AWS EC2 进入 aws/ec2, GCP Google Cloud 进入 gcp/google-cloud。Azure 优先用 systemService;为空时取 tag 名第一个 点分段。每条 Provider 地址也始终加入父分类,所以 book:aws 会覆盖所有服务。

rove-abctl 命令参考

rove-abctl fetch   --manifest book.toml [--only <path-substr>]
rove-abctl build   --manifest book.toml --out book.rab [--epoch <u64>]
rove-abctl inspect book.rab [--categories]
rove-abctl verify  book.rab
rove-abctl query   book.rab <host-or-ip> [category ...]
rove-abctl diff    old.rab new.rab [--max-shrink <0..100>]
rove-abctl bench   book.rab [--iterations <n>]
rove-abctl export  book.rab --out rove-addrbook.json
命令行为与退出语义
fetch仅下载声明了 url 的 source;--onlypath 子串过滤。每项 60 秒超时、128 MiB 上限,成功后原子替换源文件。
build解析全部 source、构建并自验证,再原子发布到 --out;不会联网。
inspect输出 epoch、SHA-256、大小、分类数与各 section 记录数;--categories 列出完整分类名。
verify完整解码、校验和及语义校验;有效退出 0,无效退出 1
query先列出目标命中的所有分类;带 selector 时匹配退出 0、不匹配退出 1。不带 selector 时即使无命中也退出 0
diff比较两个有效工件并执行发布异常门;异常退出 1
bench用固定的域名/IP 命中与未命中探针做内存查询微基准;默认 1,000,000 次,不能替代真实代理压测。
export.rab 全量投影为控制面 sidecar JSON(TeamsEdge rove-addrbook.json):schema_version=1、与分类 1:1 的 expansions(exact/suffix/keyword/cidrs)。节点不读此文件。

所有命令拒绝未知、重复或缺值选项;命令/用法错误退出 2 或报错退出 1,不会悄悄使用默认值。

fetch 的边界

fetch 只是受限下载器,不验证上游内容签名,也不把 URL 或下载内容写入 .rab。它依赖 HTTPS 与发布环境 自身的信任配置;高价值数据应在外部固定可信 URL/版本并保留源文件审计记录。未声明 url 的 source 会跳过,不是错误。--only 是对 source path 的区分大小写子串过滤。

Manifest 是受信任的构建配置:它能指定读取路径、下载 URL 和 fetch 写入目标,不应直接运行来源不明的 manifest。v2fly include: 的目录约束只保护该数据源的递归展开,不等于为整个 manifest 提供沙箱。

diff 发布异常门

默认 --max-shrink 30。只要新旧工件不同,以下任一条件都会返回非零:

  • 新工件 build_epoch 没有严格增加;
  • 旧分类在新工件中被删除;
  • CATSETS、IPv4、IPv6、精确域名、后缀域名或关键字任一 section 的记录数缩减超过阈值。

逐字节相同的工件直接成功,即使 epoch 相同。diff异常门而非语义证明:它不会判断新增地址是否正确, 也不会发现阈值以内但业务上错误的变化,所以仍需 query 抽查和 canary。

官方数据发布通道

仓库工作流 .github/workflows/addrbook-release.yml 每周一自动(也可手动 dispatch、或在 addrbook/** 变更合入 main 时)刷新上游、构建并把工件发布到滚动 Release 标签 addrbook-latest,下载 URL 长期稳定。仓库当前为 internal 可见性,下载需要 GitHub 认证; 仓库公开后匿名 curl 直链同样可用:

# 认证下载(internal/private 仓库)
gh release download addrbook-latest -R talkincode/rove --pattern 'book.rab*'
gh release download addrbook-latest -R talkincode/rove --pattern 'rove-addrbook.json*'

# 仓库公开后的匿名直链
curl -fsSLO https://github.com/talkincode/rove/releases/download/addrbook-latest/book.rab
curl -fsSLO https://github.com/talkincode/rove/releases/download/addrbook-latest/book.rab.sha256
curl -fsSLO https://github.com/talkincode/rove/releases/download/addrbook-latest/rove-addrbook.json
curl -fsSLO https://github.com/talkincode/rove/releases/download/addrbook-latest/rove-addrbook.json.sha256

sha256sum -c book.rab.sha256
sha256sum -c rove-addrbook.json.sha256
rove-abctl verify book.rab   # 建议部署前独立复核
  • Rove 节点加载 book.rab
  • TeamsEdge / 控制面加载 rove-addrbook.json(由 rove-abctl export 从同一 .rab 生成,同 epoch)

发布前工作流强制执行:verify、与上一版资产的 diff --max-shrink 30 异常门、固定正负 query 探针、分类与记录数下限断言,以及控制面 JSON 导出;任一失败都不更新已发布资产。 SOURCES.txt 记录构建时间、源提交、epoch 与全部上游文件校验和;azure-service-tags.json 一并发布,既是审计凭据也是下次构建在微软发布页不可达时的降级种子。预期外的缩水需人工审查后用 skip_diff_gate=true 手动 dispatch 放行。

epoch 取构建时刻 YYYYMMDDHHMMSS(UTC)。滚动标签指向首次发布时的提交,数据版本以 Release notes 与 SOURCES.txt 中的 epoch/checksum 为准;需要长期固定版本的部署应自建 发布通道归档具体工件,而不是依赖滚动标签的历史状态。

推荐构建与发布流程

本仓库自带一份可直接使用的清单 addrbook/book.toml(本地 corp/ads 源 + AWS/Azure/GCP/ Cloudflare/Telegram 官方 IP 段 + v2fly 域名大表);scripts/addrbook-refresh.sh 负责刷新 全部上游,包括两个没有稳定直链的特殊源(Azure 发布页轮换链接、v2fly 整目录 tarball)。

# 1. 刷新声明了 url= 的原始源
rove-abctl fetch --manifest book.toml

# 2. 用明确、递增的发布序号构建候选
rove-abctl build \
  --manifest book.toml \
  --out book-20260723.rab \
  --epoch 2026072301

# 3. 独立验证、查看分类并抽查关键目标
rove-abctl verify book-20260723.rab
rove-abctl inspect book-20260723.rab --categories
rove-abctl query book-20260723.rab www.example.com geosite/example

# 4. 与线上版本执行异常门
rove-abctl diff book-current.rab book-20260723.rab --max-shrink 30

# 5. 通过受认证的发布通道分发,并在同一文件系统内原子 rename

.rab 尾部 SHA-256 只证明文件内部完整,不证明发布者身份。对象存储、配置分发、制品仓库或 SSH 等外部通道必须负责认证与授权;如需供应链签名,应在 .rab 外使用组织现有的签名/证明机制。

首次启用顺序

  1. 先升级所有节点到支持当前快照 schema 和 .rab v1 的版本;
  2. 给每个节点配置并部署一份已验证地址簿,确认启动日志中的 epoch/checksum;
  3. 再让控制面发布带 book: route selector 的快照;
  4. 最后按节点/机房 canary 扩大地址簿更新范围。

如果先发布 book: 快照,未配置地址簿的节点会按设计拒收它。不同节点使用不同 checksum 时,同一快照可能 产生不同决策;fleet 发布系统应把 checksum 当作版本一致性依据。

节点配置与部署

[addrbook]
path = "/etc/rove/addrbook/book.rab"
poll_interval_secs = 300
字段说明
path必填,本地 .rab 文件;空路径、缺失或不可读会拒绝启动。
poll_interval_secs缺省 300;按文件身份轮询更新。0 表示只在启动时加载。

Rove 节点不会读取 manifest、调用 rove-abctl fetch 或从网络下载工件。构建与分发必须在节点之外完成。 运行用户只需要对 .rab 和父目录有读取/遍历权限,不需要源文件或 manifest。

Docker

主 Rove 运行镜像不包含离线构建工具 rove-abctl。在 CI/发布机使用 Release 包或源码构建工具,再把 目录只读挂进容器:

[addrbook]
path = "/etc/rove/addrbook/book.rab"
poll_interval_secs = 300
docker run ... \
  -v "$PWD/addrbook:/etc/rove/addrbook:ro" \
  ghcr.io/talkincode/rove:latest

不要把单个 book.rab 文件直接 bind mount 后再依赖 host 侧 rename 热更新;文件级 bind mount 可能继续指向旧 inode。挂载目录后,在该目录内原子替换 book.rab 才能让容器看到新文件。

systemd / 裸机

建议把地址簿放在独立只读目录,如 /var/lib/rove/addrbook/book.rab。发布程序先写同目录临时文件, fsync 后 rename;不要原地截断覆写。rove-abctl build --out 自身已使用临时文件 + 原子 rename。

热重载、失败与回滚

节点每次轮询按 mtime、长度以及 Unix 下的设备/inode 判断候选变化,并在读取前后复核文件身份;连续变化 三次的文件拒绝读取。候选通过 .rab 全量校验后:

  1. checksum 未变化:只确认文件身份,不替换策略;
  2. 尚无成功快照:直接安装新书,后续快照会引用它;
  3. 已有成功快照:用新书重新编译最近一次成功的原始快照;
  4. 重编译成功:书与运行期快照一起替换;
  5. 重编译失败:两者都不变,记录告警,并在后续轮询继续尝试该候选。

典型失败是新书删除了当前快照仍引用的分类。可以先发布移除引用的更高 version 快照,再等候选书自动重试; 也可以恢复一份包含该分类的新工件。

节点加载器只校验格式和 checksum,不强制 epoch 单调;单调门禁属于发布流程。生产回滚不要直接复制旧 .rab,而应使用旧数据重新构建一个更高 epoch 的新工件,通过 diff/query 后正常发布。

限制与安全边界

项目上限/语义
单个节点工件256 MiB
解码目标堆预算256 MiB
分类数100,000
单 section 记录数8,000,000
单个 fetch 响应128 MiB
v2fly include 深度16
v2fly metadata/filter每条 64 项
v2fly 总展开工作量1,000,000
单快照唯一 selector 位图合计 64 MiB,超过则拒收快照
selector 弱缓存最多 20,000 个组合;不延长旧快照生命周期

其他边界:

  • .rab 有完整性校验,没有内置签名、加密或来源证明;
  • 地址簿不是 DNS、GeoIP 服务或动态 API,不做域名解析和反向解析;
  • keyword: 是线性扫描;v1 不支持正则;
  • 节点不自动拉取远端工件,也没有从控制面内嵌/传输 .rab 的协议;
  • 工件包含的域名/IP 可能具有业务敏感性,文件权限和分发日志应按策略资产保护;
  • 所有解析和替换失败都 fail-closed:启动失败或保留旧书,不会退化成“分类不匹配”。

排障速查

现象检查
rove-abctl build 报 source 无有效记录检查路径是否相对 manifest、文本是否只有注释、v2fly 是否只有 regexp:
query 列出分类但退出 1目标命中了别的分类,未命中命令末尾指定的 selector;先不带分类运行查看全部命中。
节点启动报 addrbook 错误先运行 rove-abctl verify,再检查路径、权限、256 MiB 上限和容器挂载。
快照报 no [addrbook]节点未配置 [addrbook],但快照引用了 book: selector;先配置并验证 .rab,或移除该 selector。
快照报 unknown addrbook categoryinspect --categories 核对完整分类名;错误信息会带上未知分类名,先修快照引用或发布包含该分类的书。
新书一直被拒绝查看运行日志中的 new addrbook rejected;通常是最近快照引用了新书已删除的分类。
域名没有命中 Provider IP 分类这是预期:域名目标不会先 DNS 解析再查 IP 表;补充域名 source。
Docker 内看不到新书确认挂载的是目录而非单文件,并在挂载目录内原子 rename。
多节点结果不一致对比各节点加载日志的 epoch 和 checksum;快照版本相同不代表本地书相同。

协议稳定性锚点

tests/vectors/addrbook_v1.rab 是提交入库的 golden 工件, tests/addrbook_integration.rs::golden_vector_matches_deterministic_rebuildtests/fixtures/addrbook/ 的源数据重建并逐字节比对。编码器的任何输出 变化都会使该测试失败——这被定义为格式破坏,必须有意识地: 提升 format_version 或确认向后兼容、重新生成 golden 向量、在本文档记录 变更理由。

独立 hop 节点(rove-hop

rove-hop 是一个独立运行的二级出口(hop)代理:不连控制面、不读快照、不执行策略、不限速。它的定位是 「受控网络里的一个干净出口」—— 主节点(edge)把命中分流规则的流量转发给它,由它直连真实目标。

它和主节点 rove 共用同一套访问日志与 SNMP 方案,但没有用户/策略/限速这些控制面能力。

认证

hop 用单一用户名密码认证,来自命令行或环境变量:

  • --username / --password
  • Rove_HOP_USERNAME / Rove_HOP_PASSWORD

只要配置了任一入口监听,这两项就是必填的:两项都缺、或只给一半,进程启动即失败并说明该设哪些参数。 hop 没有内置回退凭据——回退值会被编进每一份发布二进制,忘记设置就等于把一个公开口令的开放代理 挂到网上。反向 QUIC-only 的 hop 不监听任何入口,因此不需要凭据。

下文聚焦单个功能的示例默认已经 export Rove_HOP_USERNAME / Rove_HOP_PASSWORD,因此命令行里不再重复这两项。

三种入口

参数含义
--socks5 ADDR明文 SOCKS5
--https ADDRHTTP CONNECT over TLS
--socks5tls ADDRSOCKS5 over TLS
--tls-cert / --tls-keyTLS 入口所需的证书与私钥
# 只启动明文 SOCKS5(凭据必填)
./rove-hop --socks5 0.0.0.0:1080 --username hop-user --password hop-pass

# 同时启动 HTTPS、SOCKS5、SOCKS5-over-TLS
./rove-hop \
  --https 0.0.0.0:8443 \
  --socks5 0.0.0.0:1080 \
  --socks5tls 0.0.0.0:1081 \
  --tls-cert ./certs/server.crt \
  --tls-key ./certs/server.key \
  --username hop-user \
  --password hop-pass

控制面快照里建一个 named egress 指向这个 hop(backend.kind = "http""socks5"addr 填 hop 地址, 带上同一套凭据),再让 routing policy 的 route 选中它,即可把匹配流量从这个 hop 发出。 见 数据模型 · 出口 backend

专用出口 DNS(可选)

hop 常位于目标网络里、亲自解析并连出用户目标域名,因此这里最需要指定可信解析器。用 --dns-server(可重复,ipip:port)把 hop 的所有出口目标解析(HTTP/SOCKS5 入口与反向隧道目标)改走指定 DNS,--dns-protocol udp|tcp|tls|https 选传输;不设则用系统解析器。 bare IP 的默认端口随传输:udp/tcp=53、tls=853、https=443。

./rove-hop --socks5 0.0.0.0:1080 \
  --dns-server 10.0.0.53 --dns-server 10.0.0.54:5353 --dns-protocol tcp

跨不可信链路时用加密 DNS(DoT/DoH)保证应答完整性,--dns-server-name 校验证书名,私有服务器用 --dns-ca 指向自签 CA:

./rove-hop --socks5 0.0.0.0:1080 \
  --dns-server 10.0.0.53 --dns-protocol tls \
  --dns-server-name dns.internal --dns-ca /etc/rove/dns-ca.pem

DoH 加 --dns-doh-path(默认 /dns-query);自签名且无 CA 时可用 --dns-insecure(危险,跳过校验)。 拼错地址或协议、或 tls/https 少填 --dns-server-name 会 fail-closed(启动即报错),不会静默回落系统解析器。

反向模式(NAT / 防火墙后)

当 edge 无法主动拨号 hop(hop 在 NAT / 私有网络后)时,hop 可以主动用 QUIC 拨到 edge 注册,由 edge 反向 开隧道。可以只跑反向会话(不配任何本地 listener),也支持多个 --reverse-quic 注册到多个 edge:

# 令牌走环境变量,避免进 argv
Rove_HOP_REVERSE_TOKEN=REPLACE_WITH_TOKEN ./rove-hop \
  --reverse-quic edge.example.com:9443 \
  --reverse-hop-id rove-hop-jp

常用反向参数:--reverse-quic(可重复)、--reverse-hop-id--reverse-token--reverse-server-name--reverse-insecure--reverse-max-streams--reverse-initial-mtu(跑在压缩/固定 MTU 隧道里时固定 QUIC 路径 MTU,UDP 载荷字节 1200-1500)。edge 侧 [reverse_hop] 配置、多 edge、观测与 NAT 保活见 反向 hop 数据面

外网出口诊断 doctor egress

内置一个手工诊断命令,不启动代理监听、不连控制面。默认从 Google、YouTube、OpenAI、Cloudflare、GitHub 里随机挑一个目标做深度诊断;也可指定 preset 名、域名、host:port 或 URL:

./rove-hop doctor egress                         # 随机目标,文本输出
./rove-hop doctor egress github.com              # 指定目标
./rove-hop doctor egress api.openai.com:443 --trace
./rove-hop doctor egress --target github --trace --json   # 给脚本用

输出按 DNS、route、TCP、TLS、HTTP 和可选 trace 分层。--trace 优先调用系统 traceroute,缺失时尝试 tracepath,逐跳输出 hop index、IP、反查主机名、RTT;系统没有 trace 工具则该层标记 skipped,不影响其他层。

MQTT 远程 egress doctor(可选,默认关)

hop 挂 edge 的用户查询 / 同步 / 拨测通道。需要 TE3 远程回收与 doctor egress --json 同构的分层报告时,单独打开 hop MQTT:

rove-hop --socks5 0.0.0.0:1080 \
  --mqtt-broker tcp://mqtt.example.com:1883 \
  --mqtt-hop-id rove-hop-jp \
  --mqtt-username mqtt-user
# 密码走 Rove_HOP_MQTT_PASSWORD,不要进 argv
用途主题方向
触发 doctorrove/hop/<hop_id>/doctor控制面 → hop
一次性回复rove/replies/hop-doctor-<id>(前缀默认 rove/replies/hop → 控制面

请求必须带 target(preset / host:port / URL)和合法 reply_topictrace 默认关,超时夹在 500ms–30s。 回包字段与 --json 相同(kind=egress_diagnosticdns/route/tcp/tls/http/trace),并附加 event=hop_egress_doctorhop_idrequest_id。token / 密码不会进回包。doctor 跑在 splice / CONNECT 热路径上; 未配 --mqtt-broker 时进程行为与现在完全一致。

详见 MQTT 对接

访问日志

hop 与主节点访问日志方案完全一致(默认开启、按天轮转、保留 7 天)。可用这些参数调整:

--access-log-disable--access-log-dir--access-log-file-prefix--access-log-retention-days--access-log-channel-capacity;转发 syslog 用 --access-log-syslog ADDR(搭配 --access-log-syslog-protocol / -facility / -tag)。详见 访问日志

SNMP 监控

hop 同样内置只读 SNMP agent:

  • 快捷开启 v2c:--snmp-listen 0.0.0.0:161 --snmp-community <secret> --snmp-allow <cidr>--snmp-allow 可重复)。
  • 需要 SNMPv3 时:--snmp-config snmp.toml 引用一个只含 [snmp] 段的 TOML 文件,避免把 v3 口令暴露在命令行。

两种方式互斥。MIB 表与 Cacti 接入见 SNMP 监控

完整参数列表随时可查:rove-hop --help

RouterOS 容器部署

在 MikroTik RouterOS 上把 rove-hop 跑在 container 里、做 NAT 后反向出口时,请直接看运维专题:

反向 hop 数据面(Reverse-Hop QUIC)

当 hop 节点位于 NAT / 防火墙 / 运营商网络 / 私有办公网之后、edge 无法主动拨号 hop 地址时,传统的 http / socks5 上游模型(要求 edge 能连到 hop)就用不了。反向 hop 数据面把方向反过来:hop 主动用 QUIC 连到 edge,edge 在这条已认证的长连接上为每个用户连接开一条独立的双向流,作为一条隧道。

  • 传输用 QUIC(ALPN rove-reverse/1):自带 TLS 1.3 加密、多路复用双向流、流级流控、流生命周期独立。
  • 一条 hop→edge 的出站连接即可满足 NAT/防火墙约束;edge 在已有连接上按需开新流。
  • fail-closed:edge 若没有目标 hop_id 的已认证会话,或开流/握手失败,请求直接报错,绝不回落直连

非目标:不拿 MQTT 当隧道传输、不引入 GOST 插件 / chain / hop 图、不把策略/鉴权/计费搬进 rove-hop、第一版不做 UDP relay。

拓扑与角色

        用户 ──HTTP/SOCKS5──▶  edge (rove)  ◀──QUIC(出站)──  hop (rove-hop --reverse-quic)  ──TCP──▶ 目标
                                   │  ReverseHopManager                 │  accept_bi + 拨号 + splice
                                   └── 每个用户连接 = 一条 QUIC 双向流 ──┘
  • hop = QUIC 客户端:主动拨 edge,注册 hop_id,然后接受 edge 开过来的隧道流,拨号真实目标并对拼字节。
  • edge = QUIC 服务端:监听 UDP,认证 hop 的注册帧,维护 hop_id → connection;策略命中反向出口时开一条流。
  • 一个用户连接映射到一条独立的 QUIC 双向流;单条流上目标拨号失败只影响该流,不会污染整条 QUIC 连接。

多 edge 注册(hop 侧)

一个 hop 出口可以同时注册到多个 edge(用户绑定到某个 hop 出口,但可能从不同 edge 入口漫游进来):

  • rove-hop 可以用多个 --reverse-quic 显式配置多条反向 edge 会话。
  • 每条会话有各自的 edge_id、地址、令牌、重连循环与观测上下文。
  • edge 之间互相不发现、不互为代理、不共享状态;每个 edge 只路由它自己收到并认证过的反向会话。

线协议(rove-reverse/1

帧是按行、带版本、有大小上限(4 KiB)的头部块,以空行结尾;读取时逐字节读到空行为止,不会越读紧跟在 OK 之后、同一条流上的原始隧道字节。

注册(hop → edge,每连接一次,走 hop 开的第一条双向流)

REGISTER rove-reverse/1
hop-id: <hop_id>
token: <token>
edge-id: <edge_id>        # 可选,仅用于观测
caps: udp                 # 可选,声明支持的能力(如 udp);缺省=仅 TCP 隧道

caps 是 reverse/2 引入的能力协商位。旧 hop 不带该头 → edge 视为仅支持 TCP,把 UDP association fail-closed 拒绝(udp_unsupported),绝不假设支持。新旧 hop 可在同一 edge 混跑。

edge 回复:

OK

ERR <code>

控制流保持打开 = 会话存活信号;连接关闭即注销。

隧道(edge → hop,每个用户连接一条流)

CONNECT <host> <port>
tunnel-id: <opaque>       # 可选,仅用于日志/指标

hop 回复 OK 后,该 QUIC 流上双向承载原始 TCP 字节;或回复 ERR <code> 后关闭该流(连接不受影响)。

稳定错误码(ERR <code>,不含任何密钥)

code含义
unauthorized注册令牌缺失或不被接受
duplicate_hop_idhop_id 已有会话且策略为 reject
bad_request帧无法解析(动词/版本/host/port 非法)
connect_failedhop 无法连接目标
at_capacity触达 per-edge 或全局并发隧道上限
udp_unsupported目标 hop 未声明 caps: udp,UDP association 被拒
udp_at_capacityhop 触达 UDP 会话数上限
internal响应端意外内部错误

reverse/2 UDP relay

reverse/2 在同一条 QUIC 连接上叠加了 UDP 中继,供前端的 UDP 出口使用(TUICPacketSOCKS5 UDP ASSOCIATE 都接到这里)。它是 Rove 里唯一可行的非 Direct UDP 出口(HTTP 上游的 CONNECT 载不了 UDP,SOCKS5 上游依赖外部支持,Direct 无分流意义)。

传输:UDP 包走 QUIC datagram(不可靠、无序、消息定界,匹配 UDP 语义,无队头阻塞、无重传);每个 UDP 会话由一条控制双向流管理建立/拆除。

控制帧(edge → hop):

ASSOCIATE <session_id>     # edge 分配 session_id(每连接唯一),hop 分配一个出口 socket
assoc-id: <opaque>         # 可选,仅用于日志

DISSOCIATE <session_id>    # 拆除会话;控制流关闭也等价于拆除

datagram 载荷(二进制头 + 原始 UDP 包):session_id(4) | atyp(1) | dst_addr | dst_port(2) | payload。每个包自带目标,一个会话可打到多个目标(与 SOCKS5 UDP 语义一致)。

hop 侧 NAT 模型每会话一个固定出口 socket(Endpoint-Independent Mapping) + address-restricted 过滤(只放行客户端已联系过目标的回包)。这既能让 client→server 实时流量(WebRTC 到 SFU、游戏到专用服务器)正常工作,又防止 hop 变成开放的 UDP 反射器。不是 symmetric NAT(会毁掉 ICE 反射候选),不是 full-cone(P2P 才需要,且受 hop 自身 NAT 限制)。

策略与安全边界:目标由 edge 侧逐包 decide() 判定,命中 block 直接丢弃;未知会话、容量满、无 UDP 能力一律 fail-closed。UDP 会话数、每会话已联系目标集合、DNS 解析缓存均有上限与 idle 驱逐(超时 > 实时保活周期)。UDP 中继不限速(与反向 hop 的 TCP splice 一致)。

明确不做:UDP 分片重组(超 datagram 上限的包丢弃并计数)、full-cone / 入站发起的 P2P。

边缘配置(edge)

config.example.toml[reverse_hop] 段:

[reverse_hop]
enable = true
listen = "0.0.0.0:9443"      # QUIC 监听的 UDP 地址
cert = "./certs/server.crt"  # QUIC 强制 TLS 1.3
key  = "./certs/server.key"
tokens = ["REPLACE_WITH_REVERSE_HOP_TOKEN"]  # 至少一个非空令牌;勿提交真实密钥
duplicate = "reject"         # reject(默认)| replace
max_streams_per_hop = 256    # 单 hop 并发隧道上限
open_timeout_secs = 10       # 开隧道超时(超时按 reverse_open fail-closed)

启用时若缺 listen / cert / key / 令牌,或 duplicate 非法,启动会 fail-closed 报错。

快照里怎么写反向出口

控制面快照把 named egress 的 backend 写成 kind = "reverse"addr = "<hop_id>"

{
  "schema_version": 1,
  "version": 1,
  "users": { "alice": { "password": "example", "policy": "reverse-egress" } },
  "routing_policies": {
    "reverse-egress": {
      "routes": [
        {
          "selectors": ["example.com"],
          "action": { "type": "egress", "egress": "jp" }
        }
      ]
    }
  },
  "egresses": {
    "jp": {
      "type": "upstream",
      "backend": { "kind": "reverse", "addr": "rove-hop-jp" }
    }
  }
}

kind = "reverse" 的 backend 不允许再带 username / password / tls / skip_cert_verify(认证由 hop 会话令牌负责、加密由 QUIC 负责),否则快照编译期就会被保守拒绝。

hop 侧运行(rove-hop --reverse-quic

hop_id 请使用统一前缀 rove-hop-(如 rove-hop-jprove-hop-cn-office-ax2), 并与快照 egresses.*.backend.addrkind = "reverse")完全一致。详见 命名规范

RouterOS 容器部署(脚本 + 可下载包):RouterOS 容器部署 rove-hop

rove-hop \
  --reverse-quic edge.example.com:9443 \
  --reverse-hop-id rove-hop-jp \
  --reverse-token "$Rove_HOP_REVERSE_TOKEN"

多 edge:每个 --reverse-quic 开启一条新会话,其后的子标志绑定到最近的那条:

rove-hop \
  --reverse-hop-id rove-hop-jp \               # 出现在第一个 --reverse-quic 之前 = 各 edge 的共享默认值
  --reverse-quic edge-a.example.com:9443 --reverse-edge-id edge-a \
  --reverse-quic edge-b.example.com:9443 --reverse-edge-id edge-b --reverse-insecure
标志说明
--reverse-quic ADDRedge 反向 QUIC 监听 host:port(可重复)
--reverse-hop-id ID前一条 edge 的 hop 身份;出现在首个 --reverse-quic 之前则作为共享默认
--reverse-token TOKEN注册令牌(env:Rove_HOP_REVERSE_TOKEN,避免进 argv)
--reverse-edge-id ID前一条 edge 的可选标签(仅日志/指标)
--reverse-server-name NAME校验的证书/SNI 名(默认取 edge host)
--reverse-insecure接受前一条 edge 的自签名 / 纯 IP 证书(显式 opt-in)
--reverse-max-streams Nper-edge 并发隧道上限(默认 256)
--reverse-initial-mtu N前一条 edge 的固定 QUIC 路径 MTU(UDP 载荷字节,1200-1500);跑在已压缩/固定 MTU 隧道里时设,不设走默认 PMTUD
--reverse-global-max-streams N跨所有 edge 的全局并发隧道上限(默认 0 = 不限)

rove-hop 可以跑反向会话(不配任何本地 listener)——纯粹作为拨向 edge 的出口。每条会话有独立的、带上限指数退避的自愈重连循环。

网络暴露与 NAT 保活

  • edge 的 [reverse_hop].listenUDP(QUIC 跑在 UDP 上),防火墙/安全组要放行该 UDP 端口,别只放行同号 TCP。
  • 隧道内建 QUIC 保活(间隔 15s,空闲超时 45s),足够压在常见 NAT 的 UDP 映射超时之下,让 hop 的出站映射在没有用户流量时也不被回收;hop 侧无需额外 keepalive。
  • hop 到 edge 只需要一条出站 UDP 流,满足 NAT/防火墙“只出不进”的约束。

认证与鉴权边界

  • v1 认证 = 注册帧里的共享令牌,跑在 QUIC 强制的 TLS 1.3 加密之上。
  • hop 可对自签名 / 纯 IP 的 edge 证书用 --reverse-insecure 显式跳过证书校验(对齐既有 skip_cert_verify 上游开关),绝不是默认行为。
  • 令牌只存在于 edge 配置与注册帧中,从不出现在访问日志、决策名或错误信息里。

可观测性

  • 访问日志决策名:反向路由记为 reverse:<hop_id>(如 reverse:rove-hop-jp),与直连的 upstream:<addr> 可区分,且不含任何密钥。

  • 失败阶段failure_stage)稳定分类,便于 grep 定位反向路由在哪一步断掉:

    stage含义
    reverse_lookup没有该 hop_id 的已认证会话(或反向数据面未启用)
    reverse_open开 QUIC 流 / 写 CONNECT / 读回复 失败或超时
    hop_connecthop 无法连接目标(或触达容量)
    stream_io隧道建立后对拼阶段 IO 失败
  • 出口维度指标:edge 侧按 reverse:<hop_id> egress 维度计数已建立的隧道;hop 侧反向隧道计在 reverse egress 维度,和 hop 自身的普通直连 listener(direct)区分开。

  • hop 侧结构化日志带 edge_id / hop_id / tunnel_id / target / 结果 / 失败阶段等维度。

限制

  • 每条 QUIC 连接一个 hop_id;同一 hop_id 的重复注册按 duplicate 策略处理(reject / replace)。
  • 令牌是共享密钥;mTLS 客户端证书鉴权可作为后续增强。
  • UDP 中继见上文 reverse/2 UDP relay:只做 native datagram、不分片、不支持 full-cone/P2P,适用于 client→server 实时场景。

RouterOS 容器部署 rove-hop

运维专题:在 MikroTik RouterOS 上用 container 部署 rove-hop 反向出口。
推荐 reverse QUIC onlyhop_id 统一前缀 rove-hop-(如 rove-hop-jp)。

可下载材料(离线包)

除本页外,Release / 文档站提供可下载部署包(手册 + 命名规范 + .rsc 脚本 + Docker-save 镜像):

获取方式说明
GitHub Releases完整部署包rove-hop-routeros-<version>-arm64.tar.gz / …-amd64.tar.gz手册 + 脚本 + Docker-save 镜像
文档站下载(离线文档包rove-hop-routeros-bundle.zip:手册 + 命名规范 + .rsc不含镜像;镜像请用 Release)

包内必读:

  • GUIDE.md — 完整运维手册(与下文章节同源)
  • HOP-ID-NAMING.mdreverse-hop-id 命名规范
  • scripts/rove-hop-routeros.rsc / rove-hop-routeros-remove.rsc
  • env.example
  • images/rove-hop-arm64.tar(Release 完整包)

仓库路径:deploy/routeros-hop/,打包脚本:scripts/pack-routeros-hop.sh


面向运维:在 MikroTik RouterOS(container 包)上部署 NAT 后反向出口 rove-hop
推荐形态:reverse QUIC only(不在路由上开 SOCKS/HTTPS 入口)。
配套:本目录脚本、Release 部署包、文档站页面。

组件rove-hop(独立 hop,不连控制面)
推荐模式--reverse-quic 主动注册到 edge
目标平台RouterOS 7.x + container 包,arm64 / x86_64
命名HOP-ID-NAMING.md,前缀 rove-hop-
脚本部署包 scripts/*.rsc;源码 deploy/routeros-hop/scripts/

1. 先建立心智模型

用户 ──▶ edge (rove)  ◀── QUIC/UDP 出站注册 ──  hop (RouterOS 容器里的 rove-hop)
              │                                    │
              └── 每条用户连接 = 一条 QUIC 流 ──────┴── TCP ──▶ 目标网站
角色职责是否常改
edge rove用户接入、策略、限速;[reverse_hop] 收 hop 注册底座一次;策略靠快照热更
hop rove-hop只做出口:注册 + 拨目标 + 字节对拼设备级,少动
快照kind=reverse + addr=<hop_id> 决定谁走这个出口经常

要点:

  1. hop 不读快照、不做策略;策略全在 edge。
  2. hop 在 NAT 后:只需要出站 UDP 打到 edge 的 reverse 端口。
  3. hop 支持自动重连(1s–30s 退避)。可以先起 hop 再启 edge。
  4. 未注册成功时,命中该出口的请求 fail-closed(不会偷跑直连)。

更完整的协议说明见仓库文档:反向 hop 数据面


2. 命名:reverse-hop-id

必须使用统一前缀:

rove-hop-<region>[-<site>][-<seq>]

示例:rove-hop-jprove-hop-cn-office-ax2

规则摘要:

  • 全小写,a-z 0-9 - only
  • 与快照 upstream.addr 逐字相同
  • 一台出口设备一个 id;不要多机共用(除非明确主备 replace)

完整规范:hop-id 命名规范(部署前先定名并写入变更单)。


3. 部署前检查清单

3.1 edge(rove)— 建议先完成

[reverse_hop]
enable = true
listen = "0.0.0.0:9443"     # UDP
cert = "/path/server.crt"
key  = "/path/server.key"
tokens = ["<长随机令牌>"]
duplicate = "reject"
max_streams_per_hop = 256
  • 防火墙/安全组放行 UDP reverse 端口(不是 TCP)
  • 证书与 hop 侧 SNI/--reverse-server-name 一致;自签/纯 IP 时 hop 需 --reverse-insecure
  • 快照中相关 group:
"upstream": { "kind": "reverse", "addr": "rove-hop-jp" }

edge 底座配置 ≈ 一次性;日常改用户/域名走快照即可。

3.2 RouterOS 设备

检查项要求
架构arm64x86_64(与镜像一致)
软件包已安装并启用 container
内存建议整机 ≥ 512 MiB 可用余量;hop 自身空闲约 1–3 MiB
存储镜像+root 约 15–25 MiB;内置 flash 紧时用 USB
出网容器网段能 masq 出网;能访问 edge 的 UDP 端口
机型示例hAP ax² / ax³、RB5009、CCR 等支持 container 的型号

查看:

/system resource print
/system package print where name=container

3.3 你需要准备的参数

变量示例说明
HOP_IDrove-hop-jp见命名规范
EDGEedge.example.com:9443host:port,UDP
TOKEN(密钥)与 edge tokens 之一相同
SERVER_NAMEedge.example.com校验证书名;默认可用 host
INSECUREno / yes自签才 yes
IMAGE_FILErove-hop-arm64.tar部署包内 Docker-save 镜像
VETH_NET172.30.68.0/30勿与现网冲突

4. 获取部署包

4.1 GitHub Release(推荐)

发布资产名(版本号随 tag 变化):

rove-hop-routeros-<version>-arm64.tar.gz
rove-hop-routeros-<version>-amd64.tar.gz   # 若该版本提供

内容通常包括:

GUIDE.md                 # 本文
HOP-ID-NAMING.md
README.md                # 一页纸速查
env.example
scripts/rove-hop-routeros.rsc
scripts/rove-hop-routeros-remove.rsc
images/rove-hop-arm64.tar # Docker-save 镜像(可直接 /container add file=)
SHA256SUMS

校验:

tar -tzf rove-hop-routeros-vX.Y.Z-arm64.tar.gz | head
sha256sum -c SHA256SUMS

4.2 文档站下载

GitHub Pages 提供同名 zip(随文档构建更新),入口见文档页 RouterOS 容器部署 rove-hop

4.3 自行打包(开发机)

# 需要:cargo-zigbuild + zig,目标 aarch64-unknown-linux-musl
./scripts/pack-routeros-hop.sh --target aarch64-unknown-linux-musl --version dev

5. 标准部署流程(reverse-only)

步骤 A — 上传镜像到 RouterOS

任选其一:

A1. Winbox / WebFig / ftp 上传
rove-hop-arm64.tar 放到路由器文件列表根目录(与脚本里 IMAGE_FILE 一致)。

A2. 设备拉文件(设备能访问你的 HTTP):

/tool fetch url="http://<你的主机>/rove-hop-arm64.tar" dst-path=rove-hop-arm64.tar

确认:

/file print where name~"rove-hop"

RouterOS 需要 Docker-save 格式(含 manifest.json)。
裸 rootfs tar/tar.gz 会报 no manifest.json in archive

步骤 B — 设置全局变量并导入脚本

在 Terminal(或 SSH)执行(先改成你的值):

:global RoveHopId "rove-hop-jp"
:global RoveHopEdge "edge.example.com:9443"
:global RoveHopToken "REPLACE_WITH_TOKEN"
:global RoveHopServerName "edge.example.com"
:global RoveHopInsecure "no"
:global RoveHopImage "rove-hop-arm64.tar"
:global RoveHopVeth "rove-hop-veth"
:global RoveHopAddr "172.30.68.2/30"
:global RoveHopGateway "172.30.68.1"
:global RoveHopHostAddr "172.30.68.1/30"
:global RoveHopRoot "/rove-hop-root"
:global RoveHopName "rove-hop"
:global RoveHopMemHigh "67108864"
:global RoveHopDns "1.1.1.1"
:global RoveHopMaxStreams "256"

导入并运行:

/import file-name=rove-hop-routeros.rsc

或先把 .rsc 存为 system script 再 /system script run ...

脚本会:

  1. 创建 veth + 主机侧地址
  2. /container add(entrypoint=rove-hop,reverse-only cmd)
  3. start-on-boot=yesmemory-high 默认 64 MiB
  4. 启动容器
  5. 添加 LAN dst-nat(reverse 不需要对外暴露端口)

若设备上已有全局 masquerade,容器出网一般即可。
若无,请为容器网段补一条 srcnat masquerade(见脚本内注释)。

步骤 C — 验收

/container print where name="rove-hop"
/log print where topics~"container" 

期望:

  • running=truearch 有值(arm64/amd64)
  • 日志类似:reverse edge session / hop 已监听 reverse(无本地 socks 也可)
  • edge 侧能看到该 hop_id 会话(或对应用户访问日志出现 reverse:rove-hop-jp

业务验收:用绑定了该 reverse 出口的测试账号访问目标站,确认源 IP 为 hop 出口公网 IP。

步骤 D — 卸载 / 重装

/import file-name=rove-hop-routeros-remove.rsc

会停删容器、veth、相关 address;默认不删镜像 tar(可手动 /file remove)。


6. 容器命令行(脚本生成的本质)

等价进程参数:

rove-hop \
  --reverse-quic edge.example.com:9443 \
  --reverse-hop-id rove-hop-jp \
  --reverse-token "$TOKEN" \
  --reverse-server-name edge.example.com \
  --reverse-max-streams 256 \
  --access-log-disable \
  --dns-server 1.1.1.1
# 自签时加:--reverse-insecure
# 不要加 --socks5 / --https(生产 reverse-only)

令牌优先来自 RouterOS 脚本变量;不要把生产 token 写进 Git。


7. 并发与重连(运维必知)

默认说明
每 hop 并发隧道256edge max_streams_per_hop 与 hop --reverse-max-streams
满载错误at_capacity只拒新隧道,fail-closed
自动重连1s 起指数退避,上限 30s
先 hop 后 edge可以edge 就绪后 hop 自动连上
QUIC 保活15s / idle 45s适配常见 NAT UDP 映射

ax² 实机量级参考(SOCKS 压测,reverse 资源同量级更轻):空闲 ~1 MiB,八流下载峰值 ~11 MiB,CPU 个位数百分比。


8. 存储与日志建议

  • 内置 flash 小时:镜像与 root-dir 放到 USB/usb1/...),layer-dir 按型号调整。
  • 生产 hop:--access-log-disable,避免打爆 flash。
  • 需要审计时:syslog 打到远端,或 USB 目录 + 短保留。
  • memory-high:办公室 64 MiB 足够;可按并发调到 32–128 MiB。

9. 故障排查速查

现象排查
no manifest.json镜像不是 docker save;换官方部署包内 .tar
download/extract failed存储满 / 架构不符 / 文件损坏
一直 reconnectingedge 未启、UDP 未放行、token 错、证书名不匹配
unauthorizedtoken 与 edge tokens 不一致
duplicate_hop_id同 id 已在线且 duplicate=reject
有会话但业务不通快照 addr 与 hop_id 不一致;或用户未进对应 group
容器起不来/log print where topics~"container";检查 entrypoint 路径
出网失败veth 地址/网关、masquerade、DNS

edge 失败阶段(访问日志 failure_stage):

  • reverse_lookup — 无该 hop 会话
  • reverse_open — 开流/握手失败
  • hop_connect — hop 连目标失败
  • stream_io — 对拼中断

10. 安全基线

  1. 令牌足够长,仅 edge 与 hop 持有;不进仓库、不进截图。
  2. 生产 reverse-only,不要把 SOCKS dst-nat 到公网。
  3. --reverse-insecure 仅实验网;生产用正规证书。
  4. 限制谁能 Winbox/API 改 container。
  5. 升级:先起新容器验证注册,再切快照/下旧容器。

11. 升级步骤

  1. 下载新版本 rove-hop-routeros-*.tar.gz,校验 SHA256
  2. 上传新 rove-hop-arm64.tar(可换文件名避免覆盖)
  3. 跑 remove 脚本停旧容器(或手动 stop/remove,保留 veth)
  4. 更新 RoveHopImage 后重跑部署脚本
  5. 确认 running + edge 会话 + 抽样业务
  6. 删除旧 tar 释放 flash

hop 无状态(不吃快照),升级窗口通常只影响该出口上的在途连接。


12. 与 SOCKS 模式的关系

reverse(推荐生产)SOCKS(仅调试)
端口暴露需 dst-nat
NAT 友好只出站 UDP要能被拨入
策略位置edge调用方自己指上游
多 edge--reverse-quic每边分别配上游

基准/排障可临时加 --socks5不要当作办公室长期入口。


13. 一页纸检查表(上线签字)

  • hop_id 符合 rove-hop-… 并已写入变更单
  • edge [reverse_hop] 已启,UDP 放行,token/证书就绪
  • 快照 kind=reverse addr=<同一 hop_id>
  • 镜像 arch 匹配,docker-save 格式
  • veth 网段无冲突,出网 masq 正常
  • 容器 running=true,日志无 fatal
  • 测试账号走 reverse:<hop_id>,出口 IP 正确
  • 访问日志关闭或外置;memory-high 已设
  • remove 脚本与回滚步骤已备份

14. 相关链接

  • 文档站:独立 hop、反向 hop、配置详解、故障排查
  • Release:https://github.com/talkincode/rove/releases
  • 镜像(通用容器):ghcr.io/talkincode/rove(RouterOS 更推荐本部署包内的 flat docker-save tar)

本文随 deploy/routeros-hop/ 发布;与 mdBook 页面 hop-routeros.md 同源维护。

reverse-hop-id 命名规范

hop_id 是 edge 识别出口的稳定主键。它出现在:

  • hop 启动参数 --reverse-hop-id
  • 控制面快照 named egress:egresses.*.backend.addrkind = "reverse"
  • 访问日志决策名 reverse:<hop_id>
  • 指标 / 排障维度

命名一旦上线并写入快照,不要随意改;改名等于换了一个新出口。


强制格式

rove-hop-<region>[-<site>][-<seq>]
规则示例
前缀必须 rove-hop-rove-hop-
region小写字母/数字,建议 ISO 国家或城市短码jp sg us cn hk
site可选,机房/网点/设备角色osaka office ax2
seq可选,同站多实例序号,从 11 2

字符集(整串):

  • a-z 0-9 -
  • 全小写
  • 不以 - 开头/结尾,无连续 --
  • 建议长度 ≤ 32(日志友好;技术上限以实现为准,勿炫技)

推荐示例

hop_id含义
rove-hop-jp日本统一出口(单点)
rove-hop-jp-osaka-1大阪 1 号 hop
rove-hop-sg-equinix-2新加坡 Equinix 2 号
rove-hop-cn-office-ax2国内办公室 ax² 容器 hop
rove-hop-us-west-1美西 1 号

反例(不要用)

错误原因
hop-s604缺统一前缀,难检索
Rove-HOP-JP大写;与日志/配置易不一致
rove_hop_jp下划线禁止
rove-hop-日本非 ASCII
jp无前缀,易与其它系统 ID 撞车
rove-hop-jp.office点号禁止
每次重启换随机串快照无法稳定指向

与快照的对应关系

edge 快照(当前 schema 概念示例):

{
  "schema_version": 1,
  "version": 42,
  "users": { "alice": { "password": "example", "policy": "jp-policy" } },
  "routing_policies": {
    "jp-policy": {
      "routes": [
        {
          "selectors": ["example.jp"],
          "action": { "type": "egress", "egress": "jp" }
        }
      ]
    }
  },
  "egresses": {
    "jp": {
      "type": "upstream",
      "backend": { "kind": "reverse", "addr": "rove-hop-jp" }
    }
  }
}

必须满足:

快照 backend.addr  ===  hop --reverse-hop-id

大小写、连字符必须完全一致


多 edge / 多 hop 建议

  • 一个物理出口一个 hop_id(不要多台机器共用同一 id,除非明确用 duplicate=replace 做主备漂移)。
  • 同一 hop 注册多个 edge:hop_id 保持同一个,只加多个 --reverse-quic
  • 同城双活:用 …-1 / …-2,策略层做 chain/主备,而不是复用 id。

运维清单(命名)

  1. 按上表选好 rove-hop-… 并写入变更单
  2. edge 快照先(或同步)写入该 id
  3. 容器/进程用同一字符串启动
  4. 用访问日志 reverse:rove-hop-… 验收流量是否命中

反向公网入口(Reverse Ingress)

当 Rove 接入点位于 NAT 后、无法直接接收公网连接时,可在公网服务器运行 rove-relay。Rove 主动建立一条经过认证的 QUIC 长连接,在 relay 上申请预授权 TCP/UDP 端口,再把公网流量送回本地 listener。

浏览器 / TUIC 客户端
        │ 域名解析到 relay 公网 IP
        ▼
rove-relay(公网端口、节点认证、租约、观测)
        ⇅ rove-ingress/1 QUIC
NAT 内 Rove connector
        ▼
本地 HTTP / SOCKS5 / TUIC listener → 策略与后端

这和 反向 hop 方向相反:

  • reverse ingress 把公网用户流量送入 NAT 内 Rove
  • reverse hop 把 Rove 已认证流量送到 NAT 后出口
  • 两者使用独立 ALPN、协议、凭据和配置,不能互换。

TLS 与 DNS

用户域名解析到 relay 公网 IP,但用户 TLS 仍在 Rove listener 终止。relay 只转发 原始 TCP 字节或 UDP datagram,不解析 ClientHello、不持有用户证书私钥。relay 自身另有一套证书,仅用于 Rove↔relay 的 rove-ingress/1 QUIC 隧道。

因此需要两套边界清晰的证书:

  1. relay.crt/key:安装在公网 relay,Rove connector 校验它;
  2. 用户域名证书:只安装在 NAT 内 Rove 的 [listeners.tls][[tuic_listeners]]

公网 relay

复制 relay.example.toml,使用独立节点令牌:

export Rove_NODE_EDGE_NAT_01_TOKEN='deployment-secret'
rove-relay --config relay.toml

relay 的授权粒度是 node_id + listener_id + transport + ports。节点只能申请授权表 中的端口;ports 支持单端口与闭区间。每个 grant 展开后最多 4096 个端口,防止 错误配置产生无界扫描。同一 transport 的端口授权不得跨 node/listener 重叠,避免 relay 重启后把旧动态端口分配给另一节点。

[[nodes.listeners]]
id = "https-public"
transport = "tcp"
ports = ["443", "10443-10449"]

rove-relay 的运行日志是结构化 JSONL,适合由 systemd/journald、容器日志驱动或 日志代理集中采集。事件包含 relay/node/listener/session/lease/ingress/flow ID、 客户端地址、流量和稳定失败阶段,不包含 token、用户密码或载荷。

节点可配置多个 token_envs 做无中断轮换:先同时接受旧/新 token,切换 connector 后再移除旧 token。撤销某个节点时删除其 grant 并重启 relay。

NAT 内 Rove

[[reverse_ingress]] 可重复;每段对应一个独立 relay 会话,分别认证、申请租约、 保活和重连。

[[reverse_ingress]]
enable = true
relay = "relay.example.com:9444"
server_name = "relay.example.com"
token_env = "Rove_INGRESS_RELAY_TOKEN"
initial_mtu = 1452
max_streams = 1024
max_udp_flows = 4096
reconnect_min_secs = 1
reconnect_max_secs = 30

[[reverse_ingress.listeners]]
id = "https-public"
transport = "tcp"
public_port = 443
local_listener = "https-in"

[[reverse_ingress.listeners]]
id = "tuic-public"
transport = "udp"
public_port = 8443
local_listener = "tuic-in"
max_inner_datagram = 1200
  • public_port = 0:从 relay 为该 listener 授权的端口池动态选择;
  • local_listener 必须精确引用当前 Rove 配置中已经声明的 TCP 或 TUIC listener;
  • 本地 listener 必须绑定 loopback 或 wildcard。connector 会改拨 loopback,relay 无法指定任意内网地址,避免形成 SSRF/内网探测通道;
  • tokentoken_env 互斥;生产使用 token_env
  • 自签 relay 可显式设置 skip_cert_verify = true,默认必须校验证书。

TCP 与 UDP 数据面

TCP 每个公网连接对应一条独立 QUIC 双向流。relay 生成 128-bit ingress_id, 传递真实客户端地址后原样拼接字节。流开始后不会跨 relay/节点重放。

UDP 走 QUIC datagram,不经过可靠 stream。relay 按公网客户端五元组建立有上限、 带 idle 回收的 flow,并分配 128-bit flow_id。connector 为每个 flow 创建独立 loopback UDP socket,因此 TUIC 回包和 QUIC 地址迁移不会串流。未知、过期、反向 错误或容量超限的 flow 一律丢弃。

隧道中断时:

  • 新 TCP 连接立即关闭;
  • 新 UDP 包直接丢弃;
  • 不缓存用户流量等待重连;
  • connector 指数退避重连;
  • 动态端口在 lease_grace_secs 内优先恢复原分配。

真实客户端 IP 与联合溯源

relay 是公网 socket 的直接接收者,因此它记录真实来源 IP。可信元数据通过已认证 QUIC 会话进入 Rove;Rove 不读取客户端提供的 X-Forwarded-For

Rove 访问日志新增:

  • client_addr_source: "reverse_ingress"
  • relay_addr
  • relay_instance_id
  • tunnel_session_id
  • TCP 的 ingress_id 或 UDP 的 flow_id

集中日志按 node_id + ingress_id/flow_id 关联,即可还原:

真实 IP → relay 公网入口 → Rove 用户 → 目标/策略 → 实际后端

客户端 IP 与用户身份的关联属于敏感数据,应限制查询权限并设置保留周期。时间戳只 用于排序,不作为唯一关联键;relay 与 Rove 主机都应启用 NTP。

MTU

配置中的 initial_mtuQuinn 最大 UDP payload,不是网卡/L3 MTU:

路径 L3 MTUIPv4 Quinn 上限IPv6 Quinn 上限
150014721452
136013321312

公网 ingress 路径为 1500 时建议两端设置 initial_mtu = 1452,并将业务 max_inner_datagram 保守设为 1200。实现每包检查 connection.max_datagram_size();编码后超限会记录 oversized_datagram_drop 并丢弃,不依赖 IP 分片、不降级为 stream。

Rove→后端的 1360 MTU 是另一个 MTU 域,不会反向压缩 relay→Rove 的 1500 路径。 若 1360 指 L3 MTU,后端 QUIC payload 应用 1332(IPv4)或 1312(IPv6);若它 已经是 Quinn initial_mtu,则不要再次扣除 IP/UDP 头。

防火墙与权限

  • relay 放行 listen 的 UDP 端口(QUIC);
  • 放行授权池中实际使用的公网 TCP/UDP 端口;
  • NAT 内 Rove 只需要主动出站 UDP 到 relay;
  • 绑定 443 等低端口时给 rove-relay 最小化授予 CAP_NET_BIND_SERVICE,不要长期以 root 运行;
  • relay 配置与节点 token 不进入策略快照。

本地基准测试

docker-compose.local.yml 已包含 rove-local-relay-ingress,并把同一组 Rove HTTP/HTTPS/SOCKS5 listener 同时暴露为本地直连与 reverse-ingress 两条路径:

./scripts/generate-local-certs.sh
docker compose -f docker-compose.local.yml up -d --build

cargo run --release --example proxy-benchmark-local -- latency \
  --paths local,reverse-ingress --modes direct

本地 TCP 路径端口为 18080/18443/11080/11081,relay TCP 路径为 38080/38443/31080/31081。benchmark JSON 的 path 字段用于分组比较;默认仍只 测 local,避免历史 all 命令的用例数和耗时翻倍。

TUIC 使用专用基准,直连端口为 10443/udp,relay 路径为 30443/udp

cargo run --release --example tuic-benchmark-local -- --path reverse-ingress

内嵌 Subnetra 组网底座

Rove 原生实现了 Subnetra v1 线格协议,把一层 轻量级 Layer-3 加密组网能力直接内置进代理进程。这样就不必单独部署 subnetra 守护进程、 不需要 TUN 设备、也不需要 NET_ADMIN:一套用户态 IP 栈在 overlay 上终结 TCP,直接把流量 交给 Rove 现有的 HTTP/SOCKS 处理,或作为出口把代理流量打进隔离网段。

与现有 Zig 版 subnetra 完全线兼容:跨实现的 known-answer-test 向量 (tests/subnetra_conformance.rs,取自参考实现 tests/protocol-vectors.json)逐字节 校验 Rove 的密钥派生、报文封装、混淆与收发决策,任何漂移都会让 CI 变红。现有的 subnetra 节点可以不做任何改动直接连上 Rove。

为什么内置

subnetra 单独部署当然可以,但会让 Rove 的部署叙事复杂化(两个进程、TUN、路由、权限)。 把协议做进底座后,「一层隧道 + 在隧道上跑 http/socks」变成一条配置即可开启,运维和对外解释 都简单得多。hub 与 spoke 共用同一份数据面,唯一区别是 mode

架构

                 ┌─────────────────────────── Rove 进程(单二进制) ───────────────────────────┐
  现有 Zig spoke │  ┌────────────┐   inner IPv4    ┌──────────────┐   TCP 流   ┌────────────┐ │
  ──────────────┼─▶│  UDP 反应器 │◀───────────────▶│ smoltcp 用户态│◀─────────▶│ HTTP/SOCKS │ │
   (hub 角色)    │  │ 加密/防重放 │                 │   IP 栈       │           │  代理引擎  │ │
                 │  └────────────┘                 └──────────────┘           └────────────┘ │
                 └────────────────────────────────────────────────────────────────────────────┘
  • UDP 反应器src/subnetra/reactor.rs)——单个 Tokio 任务独占 UDP socket 与 peer 表, 实现 §5 的收包流程:key_id 选 peer(含混淆试解掩码)、认证前不改状态的 epoch 前向排序、 64 位滑动防重放、内源过滤、认证后端点学习、按内层目的路由(hub 可 relay,禁反射)。
  • smoltcp 用户态 IP 栈src/subnetra/netstack/)——把 overlay 的内层 TCP 在用户态终结, 产出普通异步流(SubnetraStream 实现 AsyncRead/AsyncWrite),无缝接入 Rove 的 splice/代理机制。规范 §1 明确允许用户态 IP 栈作为内层包来源,因此无需 TUN。

两种角色

  • hub(入站):在 overlay_ip:proxy_port 上接受各 spoke 的连接,充当隧道内的代理入口。 NAT 后的 spoke 拨到 hub,就能「在隧道上」用到 Rove 的 HTTP/SOCKS 代理。
  • spoke(出站):主动拨到 hub,并把某些目标的代理流量从 overlay 打出去——用来触达 只能经由 mesh 到达的隔离网段服务(见下文「出口」)。

两种角色怎么摆、双方完整配置与客户端用法,见 最佳实践 · 场景七/八(含拓扑图)。

配置

config.toml 增加 [subnetra] 段(默认关闭):

[subnetra]
enable = true
mode = "hub"                 # "hub"(收 spoke、代理入口)| "spoke"(拨 hub、egress)
local_id = 1                 # 本节点 mesh id,0 < id <= 65535,充当线上 key_id 选择器
listen = "0.0.0.0:18020"     # 数据面 UDP 绑定地址(放行 UDP)
overlay_cidr = "10.0.0.1/24" # 本节点 overlay 地址:主机位是自身 inner IP,前缀是虚拟子网
obfuscate = true             # 头部混淆(协议 §3.4),默认开;必须全网一致
keepalive_secs = 25          # spoke NAT 保活间隔(秒),hub 忽略
proxy_protocol = "http"      # 仅 hub:overlay 上服务的协议 "http" | "socks5"
proxy_port = 8080            # 仅 hub:overlay IP 上的代理端口

[[subnetra.peers]]
id = 2
psk = "REPLACE_LINK_1_2_64_HEX_CHARS"  # 每条链路唯一的 32 字节(64 hex)预共享密钥
allowed_src = "10.0.0.2/32"            # §5.7 内源过滤前缀,也是 §5.9 路由键
endpoint = "203.0.113.2:18020"         # hub 可留空(从已认证流量学习);spoke 必填
name = "bj-spoke"

校验是 fail-closed 的:mode 非法、local_id 为 0、psk 长度/字符错误、peer id 冲突/重复、 spoke 缺 endpoint、hub 缺 proxy_protocol/proxy_port 等都会在启动时报错,绝不带病运行。

hub 节点即便没有任何 [[listeners]](TCP 监听)也能启动,因为它的代理入口是 overlay。

作为出口(spoke egress)

要让 Rove 把某些目标从 overlay 打出去,在控制面快照里把出口写成 kind = "subnetra"

{ "kind": "subnetra" }

匹配到该出口的请求,其目标 host 必须是 overlay 内的 IPv4 地址;Rove 会用内置的用户态栈把它 在 mesh 上拨通。subnetra 出口不接受 username/password/tls(overlay 由每链路 PSK 的 AEAD 保护),并且 fail-closed——subnetra 未启用或目标不是 overlay 地址时直接报错,绝不回落直连。

拓扑与语义要点

  • spoke 的默认路由:把 spoke 侧 hub peer 的 allowed_src 配成整个 overlay 子网 (如 10.0.0.0/24),这样 spoke 的所有 overlay 目标都会路由到 hub,由 hub 再 relay 到 目的 spoke;同时 hub relay 过来的、源为其他 spoke 的包也能通过内源过滤。
  • hub 的 peer:每个 spoke 配其精确前缀(如 10.0.0.2/32),endpoint 留空由学习得到。
  • 内层 MTU(协议 §8:1500 - 64 字节开销 → 默认上限 1452)。smoltcp 按此通告 MSS。 若整张 mesh 跑在已被压缩、路径固定的外层隧道里(例如载体只有 1360),在 [subnetra] 下设 mtu(范围 [576, 1452]),smoltcp 会据此下调对端 TCP MSS,让密封后的外层 UDP 数据报不分片地穿过隧道;不设则用协议上限 1452。注意这只调本节点发出的包大小与通告的 MSS,不改协议线格常量(max_plaintext 仍为 1452),因此与旧节点互通不受影响。
  • 混淆必须全网一致:subnetra 无握手协商,obfuscate 一端开一端关会 fail-closed 互不通。
  • 时钟与 epoch:节点启动采样一次 boot epoch(纳秒墙钟)。早于 2024-01-01 会拒绝启动 (协议 §2.3);跨重启时钟回拨会让对端在追上旧值前拒收——用 NTP/RTC 运维缓解,协议内不修复。
  • 仅 v1 raw_direct:保留的 v2 模式(kcp_arq / fec_xor)不属于 v1 线契约,未实现。

与现有 subnetra 混合部署

因为 Rove 复现的是同一套 v1 线格,Rove 既可作为 hub 接受现有 Zig spoke,也可作为 spoke 连到 现有 Zig hub,还能作为纯 relay 在两个 Zig 节点间转发。兼容性的唯一权威是 tests/vectors/subnetra-protocol-vectors.json 里的 KAT 向量;参考实现做出会改变发出字节或 收发决策的变更(并 bump wire_version)时,重新拷贝该文件即可让 CI 守住漂移。

TUIC 前端接入(QUIC)

rove 除了 TCP 上的 HTTP CONNECT / SOCKS5,还可以开一个 TUIC v5 前端入口。它跑在 QUIC(UDP + TLS 1.3)上,面向移动端与实时应用:单 UDP 口即可同时隧道 TCP 与 UDP。客户端参数见 客户端接入

节点的核心角色不变:本地完成认证、策略分流、限速、连接数限制,失败即拒绝。TUIC 只是多了一扇 QUIC 前门,出口仍复用现有能力。

参考实现:TUIC v5 协议规范

拓扑

TUIC 客户端 ──QUIC──▶ rove(TUIC 监听)
                         │ authenticate(uuid + token)
                         │ decide() 分流 · 限速 · 连接数
                         ├─ Connect(TCP) ─▶ 现有出口(direct / http / socks5 / reverse)
                         └─ Packet(UDP)  ─▶ 反向 hop UDP 出口(reverse/2)─▶ 媒体/游戏服务器
  • TCP(Connect 直接复用 outbound 出口选择,按用户限速、计连接数、写访问日志,和 HTTP/SOCKS5 完全一致。
  • UDP(Packet,native datagram 模式)reverse/2 UDP 数据面不限速(与反向 hop 的 TCP splice 一致),逐包执行策略。

认证模型(UUID + Token,独立于登录密码)

TUIC 用 uuid + token 而非用户名/密码:

  1. 客户端在一条单向流上发 Authenticate{ uuid(16B), token(32B) }
  2. token 由客户端用 TLS Keying Material Exporter(RFC 5705)在当前 TLS 会话上导出:label = 原始 uuidcontext = TUIC 密码,长度 32 字节。
  3. 节点用同一 TLS 会话、同样的 label/context 重新导出,常量时间比对。这把认证绑定到当前 TLS 握手,天然防重放。

节点侧只做查表归属:快照在编译期建立 uuid → 用户名 索引(见数据模型)。TUIC 凭据独立于登录密码,泄露前端凭据不会暴露账号登录口令;同一个 uuid 被两个用户占用会导致快照编译失败(认证必须无歧义)。

失败即拒绝:未知 uuid、token 不匹配、uuid/token 长度非法、账号过期 —— 一律认证失败并关闭连接;连接在超时时间内未完成认证也会被关闭。

配置

[[tuic_listeners]]
name   = "tuic-in"
listen = "0.0.0.0:8443"        # QUIC 的 UDP ip:port(记得放行 UDP!)
cert   = "./certs/server.crt"  # QUIC 强制 TLS 1.3
key    = "./certs/server.key"
alpn   = ["h3"]                # 必须与客户端配置的 ALPN 一致
[tuic_listeners.sniff]
enabled = true                 # 可选;默认 false
mode = "route"                 # observe | route
max_bytes = 16384
timeout_ms = 500
  • [[tuic_listeners]][[listeners]](TCP 的 HTTP/SOCKS5)相互独立,可以只开其一、都开、或都不开。
  • cert/key 必填(QUIC 强制 TLS)。缺 listen/cert/key 会在启动时 fail-closed 报错,绝不半配置启动。
  • alpn 默认 ["h3"];改了要让客户端一并改。
  • [tuic_listeners.sniff] 只处理 TCP Connect 的首包并提取 TLS SNI / HTTP Host,不处理 UDP Packetobserve 只记录;route 在拨号前让识别域名参与策略,但绝不改写 requested 目标。
  • route 中 requested/sniffed 任一命中 block 都拒绝;只有 requested 是 IP 时,sniffed 域名才能命中 egress route 选择出口。识别失败或超时回退 requested 决策,已读取字节原样回放。

要让某个用户能用 TUIC,控制面需要在快照里给该用户配 frontends.tuic(uuid + password):

{
  "schema_version": 1,
  "version": 42,
  "users": {
    "alice": {
      "password": "login-only-secret",   // 登录密码(HTTP/SOCKS5)
      "policy": "media",
      "frontends": {                     // 按协议命名空间的前端凭据
        "tuic": {
          "uuid": "550e8400-e29b-41d4-a716-446655440000",
          "password": "front-end-only-secret"  // 独立于 password
        }
      }
    }
  },
  "routing_policies": {
    "media": { "routes": [] }
  },
  "egresses": {}
}

frontends 是按协议命名的凭据表(frontends.<协议>)。将来新增 listener adapter 只需加一个协议条目,不动顶层 schema,且可按协议独立启停 / 轮换。新协议必须先证明自己服务的是应用入口,不是为了堆协议清单。

UDP 出口:必须落在反向 hop

TUIC 的 UDP 只经 reverse/2 UDP 出口(唯一可行的非 Direct UDP 出口)。要让某用户的 UDP 打到目标服务器,其 routing policy 必须把目标路由到一个 reverse named egress(backend.kind = "reverse"addr = hop_id):

{
  "schema_version": 1,
  "version": 43,
  "users": {
    "alice": { "password": "login-only-secret", "policy": "media" }
  },
  "routing_policies": {
    "media": {
      "routes": [],
      "default_action": { "type": "egress", "egress": "tokyo" }
    }
  },
  "egresses": {
    "tokyo": {
      "type": "upstream",
      "backend": { "kind": "reverse", "addr": "rove-hop-jp" }
    }
  }
}
  • 命中 block action 的目标:逐包丢弃,绝不出 hop。
  • 决策落到 Direct / HTTP 上游 / SOCKS5 上游的 UDP 包:fail-closed 丢弃(HTTP CONNECT 物理上载不了 UDP;Direct/SOCKS5-UDP 出口不在当前范围)。
  • 目标 hop 未声明 UDP 能力(旧版 hop):association 直接被拒(udp_unsupported)。
  • 决策落到出口链(chain)时:只在 chain 的 reverse 成员中按优先级尝试;chain 只有 HTTP/SOCKS5 成员时同样 fail-closed 丢弃。 association 建立后粘住选中的 hop,不逐包切换。UDP 主备需要 chain 里至少两个 reverse 成员。

适用场景是 client → server 的实时 UDP:WebRTC 连 SFU / 媒体服务器、OpenAI Realtime(WebRTC 变体)、游戏连专用服务器。详见 reverse/2 UDP relay

限制(当前范围)

  • 只做 native(QUIC datagram)UDP 模式,不做 quic-stream 可靠模式(会引入 HOL 阻塞,毁掉实时语义)。
  • 不做 UDP 分片重组:单个 UDP 包超过 QUIC datagram 上限(约 1200B)会被丢弃并计数。目标实时应用(WebRTC/DTLS/SRove、游戏网络码)本就 MTU 感知,不受影响。
  • 不支持 full-cone / 浏览器↔浏览器 P2P 打洞:一是需要更宽松的 NAT(攻击面大),二是出口 hop 自身往往也在 NAT 后,物理上打不通。client→server 场景不受此限。
  • UDP relay 不参与令牌桶限速;如需限额,先用连接/会话上限与目标端口约束兜底。

可观测性

  • 每条 TCP Connect 隧道结束写一条访问日志记录(protocol: "tuic",含用户、目标、决策、字节数、时长),复用现有 访问日志 管线,永不含密码。启用 sniff 后还会写 requested/sniffed/effective identity 与固定结果枚举;route 模式的策略阻断和出站失败也会落记录。
  • 连接建立/认证成功/失败通过结构化 tracing 日志输出。

客户端配置

客户端接入 · TUIC。要点:客户端的 uuid/password/alpn 必须与快照凭据和监听 alpn 对齐;自签名证书需在客户端开启「允许不安全 / skip-cert-verify」或导入 CA。

Rove MQTT 对接开发文档

本文档描述 Rust 版 Rove 的 MQTT 异步运维通道。该通道用于网络隔离场景:控制面不能直接访问节点时,通过 MQTT 下发查询、同步和拨测追踪指令。

MQTT 不替代代理数据面,也不要求对每个用户做实时链路追踪。链路追踪只在拨测前短时间武装,匹配到下一条连接后发布一次结果。

能力边界

  • 用户策略查询:控制面发布查询请求,Rove 从当前快照读取用户策略并向一次性回复主题返回脱敏结果。
  • 同步指令:控制面发布同步请求,Rove 立即拉取一次控制面 snapshot,并向节点状态主题发布结果。
  • 节点状态通知:Rove 连接 MQTT 后、同步指令处理后发布状态。
  • 拨测链路追踪:控制面先发布一次追踪武装指令,再发起真实代理拨测;节点匹配该连接后向回复主题发布故障阶段或成功结果。出站失败阶段会拆成 dns / dial / tls(Rove 终止的 hop TLS)以及既有的 hop_connect / reverse_*;CONNECT 隧道内的源站 TLS 仍由客户端完成,节点看不到握手,需用 rove-hop doctor egress
  • 诊断事件会话:控制面开启一个有限时长、按用户维度的会话;存活期间节点对每条匹配连接持续发布脱敏诊断事件,到期或取消时发布汇总。默认关闭、不落盘;连接完成路径使用有界同步临界区,发布不执行异步等待。

配置

默认不启用 MQTT。示例见 config.example.toml

[mqtt]
enable = true
broker = "ssl://mqtt.example.com:8883"
client_id = "" # 留空时使用 rove-<node_id>
username = "mqtt-user"
password = "mqtt-pass"
qos = 1
reply_topic_prefix = "rove/replies/"

[mqtt.topics]
user_query = "rove/user/query"
sync_command = "rove/sync/command"
node_status = "rove/node/status"
probe_trace = "rove/probe/trace"
diagnostics_command = "rove/diagnostics/command"

[mqtt.diagnostics]
default_ttl_secs = 30
max_ttl_secs = 300
max_sessions = 16
max_sessions_per_user = 2
channel_capacity = 256

[mqtt.tls]
enable = true

说明:

  • broker 支持 tcp://mqtt://ssl://tls://tcps://mqtts://
  • mqtt.tls.enable=true 且 broker 使用 tcp://mqtt:// 时,客户端会按 TLS 连接改写为 ssl://,端口保持原值。
  • reply_topic 必须以 reply_topic_prefix 开头,且不能包含 #+ 或空白字符。
  • 发布消息不设置 retained。
  • 用户密码、upstream 密码不会出现在查询响应中。
  • broker URL 即使携带兼容的 userinfo,启动日志也只输出 scheme、host 与 port;部署仍建议使用独立的 username / password 字段。

默认主题

用途默认主题方向
用户策略查询rove/user/query控制面 -> Rove
同步指令rove/sync/command控制面 -> Rove
节点状态rove/node/statusRove -> 控制面
拨测追踪武装rove/probe/trace控制面 -> Rove
诊断会话指令rove/diagnostics/command控制面 -> Rove
一次性回复前缀rove/replies/Rove -> 控制面
hop egress doctorrove/hop/<hop_id>/doctor控制面 -> rove-hop(可选,默认关)

edge MQTT 不会把 doctor 转到 hop,也不得用 edge 本机网络冒充 hop 出网。hop 出网 / 源站 TLS 只能由 hop 自己的 MQTT 或本机 rove-hop doctor egress 完成。

hop egress doctor

rove-hop 可另开一条与 edge 隔离的 MQTT 客户端,远程触发与 rove-hop doctor egress --json 同构的分层探测。默认关闭;不配 --mqtt-broker 时 hop 进程行为不变。doctor 不进入 splice / CONNECT 热路径。

Rove_HOP_MQTT_PASSWORD=... rove-hop --socks5 0.0.0.0:1080 \
  --mqtt-broker tcp://mqtt.example.com:1883 \
  --mqtt-hop-id rove-hop-jp \
  --mqtt-username mqtt-user

请求:

{
  "command": "hop_egress_doctor",
  "request_id": "doc-1",
  "reply_topic": "rove/replies/hop-doctor-doc-1",
  "data": {
    "target": "api.openai.com:443",
    "trace": false,
    "timeout_ms": 5000
  }
}

约束:

  • target 必填(preset 名、host:port 或 URL);远程触发不做随机 preset,避免生产 hop 被误打到公网。
  • reply_topic 必须落在 --mqtt-reply-prefix(默认 rove/replies/)下,且不能含 # / + / 空白。
  • trace 默认 falsetimeout_ms 夹在 500–30000。同时只跑一个 doctor,第二个回 throttled
  • 回包 flatten EgressDiagnosticReportkindresultdns / route / tcp / tls / http / trace,并带 event=hop_egress_doctorhop_idrequest_id
  • 回包与访问日志不含 hop 代理密码、reverse token、MQTT 密码。

用户策略查询

请求:

{
  "command": "user_policy_query",
  "request_id": "query-1",
  "reply_topic": "rove/replies/query-1",
  "data": {
    "username": "alice"
  }
}

兼容字段:

  • 用户名优先级:data.username > data.client > username > client
  • request_id 会原样带回。

成功响应:

{
  "request_id": "query-1",
  "node_id": "edge-node-01",
  "status": "ok",
  "user": {
    "username": "alice",
    "expire": "2099-12-31",
    "policy": "shared-policy",
    "up_rate": 1024,
    "down_rate": 2048,
    "max_connections": 2,
    "routing_policy": {
      "id": "shared-policy",
      "routes": [
        {"selectors": ["book:security/blocked"], "action": "block"},
        {
          "selectors": ["openai.com"],
          "action": "egress",
          "egress": {
            "id": "tokyo",
            "upstream": {
              "kind": "socks5",
              "addr": "proxy.example:1080",
              "tls": true,
              "auth": true
            }
          }
        },
        {"selectors": ["full:private.example"], "action": "direct"}
      ],
      "default_action": {
        "action": "egress",
        "egress": {
          "id": "backup",
          "upstream": {"kind": "reverse", "addr": "tokyo-hop", "tls": false, "auth": false}
        }
      }
    }
  },
  "timestamp": 1781690000
}

字段语义:

  • policy:该身份绑定的 routing policy ID(字符串)。
  • routing_policy:解析后的脱敏策略对象——policy ID、有序 routes,以及 default_action。 route 顺序与快照一致,就是 first-match-wins 的求解顺序。
  • routes[].action"egress" / "direct" / "block" 之一;只有 "egress" 会附带 egress 对象(命名 egress 的 ID 与脱敏 realization)。
  • default_action 是所有 route 都未命中时的行为,总是存在{"action": "direct"}{"action": "block"}(deny-by-default 策略),或 {"action": "egress", "egress": {...}}。 它不会因为快照里没写 default_action 而消失——缺省会显式呈现为 {"action": "direct"}, 让运维直接看到未命中时的行为,而不必从字段缺失去推断。
  • egress 引用出口链时,upstream 呈现为 {"kind": "chain", "addr": "<egress-id>", "tls": false, "auth": false, "members": [...]}members 逐个列出成员的 idprioritykindaddrtlsauth: true/false, 但永不包含成员密码、token 或认证头。
  • 用户密码、TUIC 密码、backend 用户名/密码、token 和认证头永不返回;auth 只是一个布尔标记, 表示该出口是否配置了认证。

用户不存在:

{
  "request_id": "query-1",
  "node_id": "edge-node-01",
  "status": "not_found",
  "message": "user not found",
  "timestamp": 1781690000
}

同步指令

请求:

{
  "command": "sync_users",
  "request_id": "sync-1",
  "data": {
    "syncflag": "public"
  }
}

Rust 版中,syncflag 字段仅用于兼容旧控制面消息和状态回显;实际同步行为是立即向当前 [control_plane] 拉取一次 snapshot。空 payload 或 {} 也会触发同步。

状态主题响应:

{
  "request_id": "sync-1",
  "node_id": "edge-node-01",
  "event": "sync_command",
  "status": "ok",
  "message": "snapshot applied",
  "syncflag": "public",
  "success": true,
  "updated": true,
  "already_running": false,
  "elapsed_ms": 123,
  "version": "Rove/0.1.0",
  "snapshot_version": 12,
  "snapshot_schema_version": 1,
  "timestamp": 1781690000
}

snapshot_schema_version 是当前生效快照声明的线协议结构/语义版本,与 snapshot_version (内容修订号)相互独立。当前只有一个 schema,该字段恒为 1;它存在的意义是让控制面在未来 bump schema 前,能先确认全网节点都已升级到支持新 schema 的二进制,再让 producer 输出新 schema。快照 wire contract 见快照协议

同步指令有 5 秒节流窗口;窗口内重复请求状态为 throttled,不会并发拉取控制面。 节点建立 MQTT 连接时还会发布 event: "startup":已有快照时状态为 syncedsuccess: true;尚无快照时状态为 startingsuccess: falsesnapshot_version: 0, 不会把“同步器已创建”误报成“快照已同步”。

拨测链路追踪

控制面先订阅一次性回复主题,然后发布追踪武装指令:

{
  "request_id": "probe-1",
  "reply_topic": "rove/replies/probe-1",
  "data": {
    "username": "alice",
    "target_host": "example.com",
    "target_port": 443,
    "protocol": "http",
    "ttl_secs": 30
  }
}

匹配字段都是可选的,但生产拨测建议至少传 usernametarget_hosttarget_portprotocol,避免多节点或多连接串扰。支持字段:

  • data.usernamedata.client
  • data.target_hostdata.host
  • data.target_portdata.port
  • data.protocol: httpsocks5
  • data.listener: 限定某个 listener 名称
  • data.ttl_secs: 1 到 300 秒,默认 30 秒

武装成功后立即回复:

{
  "request_id": "probe-1",
  "node_id": "edge-node-01",
  "event": "probe_trace_armed",
  "status": "ok",
  "message": "probe trace armed",
  "ttl_secs": 30,
  "timestamp": 1781690000
}

随后控制面发起真实代理拨测。节点匹配到连接后发布一次结果:

{
  "request_id": "probe-1",
  "reply_topic": "rove/replies/probe-1",
  "event": "probe_trace_result",
  "status": "error",
  "listener": "http-in",
  "protocol": "http",
  "username": "alice",
  "target_host": "example.com",
  "target_port": 443,
  "decision": "upstream",
  "failure_stage": "outbound",
  "message": "upstream connect failed",
  "snapshot_version": 12,
  "duration_ms": 35,
  "timestamp": 1781690001
}

failure_stage 取值包括:

  • parse: 协议解析或请求目标格式错误。
  • auth: 认证缺失、认证失败或账号过期。
  • policy: 命中 block 策略。
  • limit: 超过用户 max_connections 活跃隧道限制。
  • outbound: direct 或上游出口连接失败。
  • reverse_lookup: 找不到已认证的 reverse hop 会话。
  • reverse_open: edge 无法在 reverse hop 上打开目标流。
  • hop_connect: reverse hop 无法连接最终目标。
  • chain_exhausted: 出口链所有成员在隧道建立阶段全部失败(fail-closed,不回落直连)。
  • splice: 隧道建立后双向转发失败。
  • stream_io: reverse/TUIC 等流在建立后转发失败。

chain 决策的追踪结果还携带 egress(胜出成员的物理出口)、chain_member(成员 ID)与 attempts(建立尝试次数)字段;凭据永不出现。

成功结果中 statusok,通常没有 failure_stage

诊断事件会话

拨测追踪是「武装一次、匹配一条连接、回一条结果」。诊断事件会话是它的可选扩展:在一段有限的 TTL 内保持武装,对每一条匹配的代理连接持续发布结构化、脱敏的诊断事件,并在到期或取消时发布一条汇总。

安全边界(与拨测追踪一致,且更严格):

  • 默认关闭,只有收到显式命令后才会临时开启;任何状态都不落盘。
  • 连接完成路径进入受会话上限约束的同步临界区;事件通过有界通道 try_send,通道满时直接丢弃并计数(dropped_events),发布过程不 await。
  • 事件只携带拨测追踪已暴露的非敏感字段(用户名标识、目标 host/port、路由决策、失败阶段与静态描述)。用户密码、令牌、upstream 凭据永不进入诊断通道。
  • 会话数量受全局与单用户上限约束,TTL 受 max_ttl_secs 约束。

开启会话

控制面先订阅一次性回复主题,然后向 rove/diagnostics/command 发布:

{
  "command": "diagnostic_session_start",
  "request_id": "diag-1",
  "reply_topic": "rove/replies/diag-1",
  "data": {
    "username": "alice",
    "target_host": "example.com",
    "target_port": 443,
    "protocol": "http",
    "listener": "http-in",
    "event_types": ["auth", "policy", "outbound"],
    "ttl_secs": 60
  }
}

字段说明:

  • command:诊断主题专用,缺省即视为 diagnostic_session_start;取消用 diagnostic_session_cancel
  • data.username(或 data.client):必填,会话按用户名维度匹配。
  • data.target_host(或 data.host)、data.target_port(或 data.port)、data.protocolhttp/socks5)、data.listener:可选的额外过滤维度,未提供即不限制。
  • data.event_types:可选,限定需要的每连接事件类型;省略或为空数组表示全部(auth/policy/limit/outbound/splice)。无法识别的取值会被忽略,回执中 event_types 会回显实际生效的集合。summary 为生命周期事件,始终发布,不受此过滤影响。
  • data.ttl_secs:会话存活时间,钳制到 [1, max_ttl_secs];省略时使用 default_ttl_secs
  • request_id:会话标识,用于续期与取消;省略时自动生成 diag-<timestamp>。对同一 request_id 重复开启表示续期,不会新增会话计数。

武装成功后立即回复:

{
  "request_id": "diag-1",
  "node_id": "edge-node-01",
  "event": "diagnostic_session_started",
  "status": "ok",
  "message": "diagnostic session armed",
  "ttl_secs": 60,
  "event_types": ["auth", "outbound", "policy"],
  "timestamp": 1781690000
}

超过全局或单用户会话上限时,回复 eventdiagnostic_session_rejectedstatusthrottledreply_topic 非法(不以前缀开头或含通配符)时直接忽略,不回复;缺少 username 时回复 statusbad_request

每连接事件

会话存活期间,节点对每条匹配连接发布一条事件到 reply_topic

{
  "request_id": "diag-1",
  "node_id": "edge-node-01",
  "event": "diagnostic_event",
  "event_type": "outbound",
  "status": "error",
  "listener": "http-in",
  "protocol": "http",
  "username": "alice",
  "target_host": "example.com",
  "target_port": 443,
  "decision": "upstream",
  "failure_stage": "outbound",
  "message": "upstream connect failed",
  "snapshot_version": 12,
  "duration_ms": 35,
  "timestamp": 1781690001
}

event_type 取值:

  • auth:认证缺失、失败或账号过期。
  • policy:命中 block 策略。
  • limit:超过连接数上限。
  • outbound:direct、上游出口、chain 或 reverse 出口建立失败;failure_stage 会进一步区分 outboundchain_exhaustedreverse_lookupreverse_openhop_connect
  • splice:隧道建立后转发失败(failure_stagesplicestream_io),或成功隧道(statusok)。

协议解析阶段(parse)不产生诊断事件,仍由一次性拨测追踪覆盖。

汇总与取消

到期时节点自动发布汇总并清除会话;控制面也可主动取消:

{
  "command": "diagnostic_session_cancel",
  "request_id": "diag-1"
}

无论到期还是取消,都会向会话记录的 reply_topic 发布一条汇总:

{
  "request_id": "diag-1",
  "node_id": "edge-node-01",
  "event": "diagnostic_summary",
  "status": "ok",
  "matched_events": 12,
  "dropped_events": 0,
  "ttl_secs": 60,
  "timestamp": 1781690060
}
  • matched_events:成功投递的事件数。
  • dropped_events:因通道满而被丢弃的事件数;持续非零说明事件速率超过 channel_capacity,应缩小过滤范围或调大容量。

访问日志

结构化访问日志是老版本 GOST 详细流量日志的直接替代品:每条完成的连接 —— 无论成功,还是在认证 / 策略 / 上游连接 / 隧道转发哪个阶段失败 —— 都会落一行 JSON。它独立于运行日志 log.level 和 MQTT 诊断:哪怕把 log.level 调到 error 且从未开启 MQTT,访问日志依然完整记录。运维可以直接对着日志文件 grep 排查 「某用户 / 某目标连不上」这类故障。

默认开启、按天轮转、保留 7 天。

两种记录形状

同一个文件里混着两类记录,用 kind 区分:

jq 'select(.kind=="connection")' logs/access.2026-07-01   # 每条连接
jq 'select(.kind=="stats")'      logs/access.2026-07-01   # 每 60 秒心跳

kind:"connection" —— 每条连接一行

字段含义
timestamp完成时间
node_id节点标识
listener入口名
protocolhttp / socks5 / sni
client_addr客户端来源 ip:port;reverse ingress 下由已认证 relay 可信传递
client_addr_sourcereverse ingress 流量为 reverse_ingress;普通本地 accept 时省略
relay_addr / relay_instance_id实际隧道对端与 relay 实例
tunnel_session_idRove↔relay QUIC 会话 ID
ingress_id / flow_idTCP 连接或 UDP flow 的跨 relay/Rove 关联 ID
username认证用户名
target_host / target_port兼容既有目标字段;成功/出站失败时是拨号目标,前置拒绝时是请求目标
requested_host / requested_port客户端在代理协议中声明的目标
ingress_mode / origin_id仅网关:入口模式和服务端声明的 origin 标识。T1 为 sni 与允许的 DNS 名;不含客户端路径、请求头或凭据。
sniffed_host / sniff_protocol从有界首包识别出的域名及 tls / http 来源;未匹配时省略
sniff_outcomematched / unsupported / timeout / malformed / limit_exceeded / incomplete;即使 T1 在取得目标前断开,也会保留该结果
effective_policy_host当前策略候选 host;observe-only 阶段与 requested host 相同
policy_id做出该决策的 routing policy id。省略表示压根没有查到策略——未知用户,或用户指向了快照未定义的 policy;这与「策略主动拒绝」是两类事件
matched_route命中路由在该 policy routes 数组中的下标(从 0 开始)。省略表示没有任何 route 命中,由 default_action 决定
decisiondirect / block / upstream:<addr> / reverse:<hop_id> / chain:<chain_id>(携带具体上游地址或逻辑出口链,不只是类别;不含上游密码)
egress仅 chain 决策:胜出成员的物理出口标识(如 reverse:h1 / upstream:10.2.2.1:1080
chain_member仅 chain 决策:胜出成员的稳定 ID
attempts仅 chain 决策:隧道建立尝试次数(全部失败时同样记录)
result成功 / 失败
failure_stage失败阶段(如 auth / policy / dns / dial / tls / outbound / hop_connect / chain_exhausted / splice
message补充信息
snapshot_version当时生效的快照版本
duration_ms连接时长
bytes_up / bytes_down上/下行字节数

永不包含密码等凭据。

kind:"stats" —— 每 60 秒一行心跳

按 listener 聚合(不按用户,避免用户数增长带来无界基数):

字段含义
listener入口名
active_connections当前该入口仍在隧道转发阶段的连接数
bytes_up_total / bytes_down_total自进程启动以来累计字节
bytes_up_delta / bytes_down_delta相对上一次 60 秒 tick 的增量
sniff_*_total按 listener 聚合的六类识别结果累计值;不以域名作 label,基数固定

这是老版本 Go ObserverEvent 周期性 stats 事件最接近的对应物。即使一段时间没有新连接完成(因此没有 connection 行),这行心跳也能证明该 listener 仍在正常工作,而不是进程假死或某入口静默失效。

Sniffing 默认关闭。开启后也只保存规范化域名、协议来源和结果枚举,不保存 URL、HTTP 路径、header 集合、body 或 TLS payload;ECH、QUIC/HTTP3 与 UDP 不在当前可见范围。

配置

[access_log]
enable = true            # 关闭后完全不产生访问日志(不建议在生产关闭)
dir = "./logs"           # 文件名形如 access.2026-07-01
file_prefix = "access"
retention_days = 7       # 按文件名日期清理,与 mtime 无关
channel_capacity = 8192  # 写入队列容量
  • 非阻塞热路径:一次记录只是把结构体投进有界 mpsc 队列,队列打满直接丢弃并计数,绝不阻塞代理转发。 后台任务单独消费、写文件、(可选)转发 syslog。
  • 当发生丢弃时,后台每 60 秒检查一次,若有新增丢弃就输出一条含增量与累计总数的警告,提醒你调大 channel_capacity
  • 按天轮转由 tracing-appender 完成;每小时按文件名日期清理超过 retention_days 的旧文件。

转发到远程 syslog(可选)

[access_log.syslog]
enable = false
address = "syslog.example.com:514"
protocol = "udp"    # udp | tcp
facility = "local0"
tag = "rove"
  • 打开后,同一条 JSON 会额外按手搓的最小 RFC 3164(<pri>timestamp node_id tag: message)转发给远程 collector。message 就是那条 JSON,下游可继续按字段检索。
  • 支持 udp(fire-and-forget)与 tcp(RFC 6587 octet-counting 分帧)。
  • HOSTNAME 字段固定填 node_id 而非 OS 主机名,便于在多节点 fleet 里按节点归集。
  • 转发失败只记一条警告,不影响本地文件写入,也不重试阻塞热路径。TCP 单条写入有 3 秒超时:远程 collector 卡死时会主动断开重连,避免拖垮本地写入。

常见用法

# 某用户最近的失败连接
jq 'select(.kind=="connection" and .username=="alice" and .result!="success")' logs/access.*

# 按目标域名统计失败
jq -r 'select(.kind=="connection" and .result!="success") | .target_host' logs/access.* | sort | uniq -c | sort -rn

# 各 listener 最新吞吐(心跳)
jq 'select(.kind=="stats")' logs/access.$(date +%F) | tail -n 20

# 某条 route 到底拦了什么:按 policy + route 下标反查
jq 'select(.policy_id=="llm-egress" and .matched_route==0)' logs/access.*

# 哪些拒绝不是策略拒绝的:policy_id 缺失 = 压根没查到策略
# (未知用户,或用户指向了快照未定义的 policy)——这类应当为 0,否则说明
# 快照下发与用户管理脱节了
jq 'select(.kind=="connection" and .decision=="block" and (has("policy_id")|not))' logs/access.*

# 有多少流量是靠 default_action 兜底走的,而不是被显式 route 命中的:
# 这个比例偏高说明策略写得太粗,出口选择实际上没有被治理
jq -r 'select(.kind=="connection") | if has("matched_route") then "routed" else "default" end' \
  logs/access.* | sort | uniq -c

SNMP 监控:MIB 参考与 Cacti 接入

Rove 内置一个只读 SNMP agent(roverove-hop 都有,默认关闭), 供 Cacti、LibreNMS、Zabbix 等标准 NMS 直接轮询每个 listener 与每个出口(egress) 的流量计数器,无需部署任何 exporter 或旁路 agent。

能力边界(铁律):

  • 只响应 GET / GETNEXT / GETBULK;收到 SET 一律返回 notWritable
  • TRAP / INFORM 永不支持——告警靠 NMS 侧阈值,不靠节点推送。
  • 支持 SNMPv2cSNMPv3 USM;不支持 SNMPv1。
  • v3 认证只支持 SHA-1 / SHA-256,加密只支持 AES-128-CFBMD5 / DES 永不支持

开启

rove(主节点):[snmp] 配置段

[snmp]
enable = true
listen = "0.0.0.0:161"        # UDP;<1024 端口需要相应权限
community = "your-secret"     # v2c community;留空则 v2c 关闭
allow_cidrs = ["10.0.0.0/8"]  # 来源白名单(默认仅 loopback)
state_path = "./data/snmp-state.json"

# 可选:SNMPv3 用户(可多个)
[[snmp.v3_users]]
username = "cacti"
auth_protocol = "sha256"      # sha1 | sha256
auth_password = "change-me-auth"
priv_protocol = "aes128"      # 留空 = 该用户不加密(authNoPriv)
priv_password = "change-me-priv"

规则(fail-closed,配置错误在启动时报错):

  • enable = true 时,communityv3_users 至少要配一个。
  • v3 用户必须有认证口令;配置了 priv_password 的用户只接受 authPriv 级别请求, 用 authNoPriv 来问会收到 usmStatsUnsupportedSecLevels Report。
  • 白名单外的来源、错误的 community、未知用户名一律静默丢弃(只递增协议计数器), 不写日志——扫描器刷不出日志风暴。

rove-hop(独立 hop 节点):命令行

快捷开启 v2c:

rove-hop --socks5 0.0.0.0:1080 \
  --snmp-listen 0.0.0.0:161 \
  --snmp-community your-secret \
  --snmp-allow 10.0.0.0/8 --snmp-allow 192.168.1.0/24

需要 SNMPv3 时,把上面的 [snmp] 段单独放进一个 TOML 文件(v3 口令不进命令行):

rove-hop --socks5 0.0.0.0:1080 --snmp-config /etc/rove/snmp.toml

--snmp-config--snmp-listen/--snmp-community/--snmp-allow 互斥。

OID 参考

企业子树基点

.1.3.6.1.4.1.32473.61

注意32473 是 RFC 5612 保留给文档示例的企业号(PEN),当前作为占位使用。 如果你的网络里有其他设备也用了这个示例 PEN,OID 会冲突;生产环境建议向 IANA 申请正式 PEN 后替换(修改 src/snmp/mod.rsENTERPRISE_BASE 常量)。

下文用 BASE 代指 .1.3.6.1.4.1.32473.61

标准 system 组(.1.3.6.1.2.1.1

OID名称类型
.1.3.6.1.2.1.1.1.0sysDescrOctetStringRove edge node, version x.y.z(hop 节点为 hop
.1.3.6.1.2.1.1.2.0sysObjectIDOIDBASE
.1.3.6.1.2.1.1.3.0sysUpTimeTimeTicks进程启动以来的时间(百分之一秒)
.1.3.6.1.2.1.1.4.0sysContactOctetString
.1.3.6.1.2.1.1.5.0sysNameOctetStringnode_id
.1.3.6.1.2.1.1.6.0sysLocationOctetString
.1.3.6.1.2.1.1.7.0sysServicesInteger72(transport + application)

标准 snmp 组(.1.3.6.1.2.1.11,agent 自身计数)

OID名称说明
.1.3.6.1.2.1.11.1.0snmpInPkts收到的 UDP 包总数(含被丢弃的)
.1.3.6.1.2.1.11.3.0snmpInBadVersions版本不支持的包数
.1.3.6.1.2.1.11.4.0snmpInBadCommunityNamescommunity 错误的包数
.1.3.6.1.2.1.11.6.0snmpInASNParseErrsBER 解析失败的包数

节点身份标量(BASE.1

OID名称类型
BASE.1.1.0geNodeIdOctetString配置的 node_id
BASE.1.2.0geNodeRoleInteger1 = edge(rove),2 = hop(rove-hop
BASE.1.3.0geVersionOctetString软件版本号

listenerTable(BASE.2.1,每监听入口一行)

行索引是长度前缀的 listener 名字:名字 web(3 字节)的索引为 3.119.101.98。 GETNEXT/GETBULK 遍历时 OID 序稳定,Cacti 用 snmp query 自动发现即可,无需手算索引。

列 OID名称类型说明
BASE.2.1.1.<idx>geListenerNameOctetStringlistener 名(配置中的 name
BASE.2.1.2.<idx>geListenerActiveGauge32当前处于隧道转发阶段的连接数
BASE.2.1.3.<idx>geListenerBytesUpCounter64进程启动以来客户端→上游累计字节
BASE.2.1.4.<idx>geListenerBytesDownCounter64进程启动以来上游→客户端累计字节

listener 在绑定端口时即注册(计数为 0),Cacti 不必等第一条连接就能发现所有行。

egressTable(BASE.3.1,每出口一行)

行索引同上(长度前缀的出口名)。出口名为策略决策结果:directupstream:<host:port>(与访问日志 decision 字段一致)。行在第一条走该出口的 连接出现时创建。

列 OID名称类型说明
BASE.3.1.1.<idx>geEgressNameOctetStringdirectupstream:<addr>
BASE.3.1.2.<idx>geEgressActiveGauge32当前经该出口转发中的连接数
BASE.3.1.3.<idx>geEgressBytesUpCounter64该出口累计上行字节
BASE.3.1.4.<idx>geEgressBytesDownCounter64该出口累计下行字节

被策略 block 的连接不产生 egress 行;所有 listener 的字节总和与所有 egress 的 字节总和一致(同一份计数从两个维度聚合)。

SNMPv3 引擎与 USM 统计(仅配置了 v3 用户时可见)

OID名称说明
.1.3.6.1.6.3.10.2.1.1.0snmpEngineID80 00 7E D9 04 + node_id(截断至 32 字节)
.1.3.6.1.6.3.10.2.1.2.0snmpEngineBoots重启计数(落盘于 state_path
.1.3.6.1.6.3.10.2.1.3.0snmpEngineTime本次启动以来秒数
.1.3.6.1.6.3.10.2.1.4.0snmpEngineMaxMessageSize最大消息尺寸
.1.3.6.1.6.3.15.1.1.1.0.6.0usmStats*6 个安全失败计数器(unsupportedSecLevels / notInTimeWindows / unknownUserNames / unknownEngineIDs / wrongDigests / decryptionErrors)

用 net-snmp 验证

# v2c 全量遍历
snmpwalk -v2c -c your-secret 10.0.0.5:161 .1.3.6.1

# GETBULK 遍历(结果应与上面完全一致)
snmpbulkwalk -v2c -c your-secret 10.0.0.5:161 .1.3.6.1.4.1.32473.61

# v3 authPriv(net-snmp 老版本不支持 -a SHA-256,用 SHA 即 SHA-1)
snmpwalk -v3 -l authPriv -u cacti \
  -a SHA-256 -A change-me-auth -x AES -X change-me-priv \
  10.0.0.5:161 .1.3.6.1.4.1.32473.61

# 单点取值:某 listener 的累计上行字节(listener 名 "web" → 索引 3.119.101.98)
snmpget -v2c -c your-secret 10.0.0.5:161 .1.3.6.1.4.1.32473.61.2.1.3.3.119.101.98

Cacti 接入步骤

  1. 建设备:Console → Create → New Device。Hostname 填节点地址; SNMP Version 选 Version 2(填 community)或 Version 3 (Auth Protocol SHA/SHA-256、Priv Protocol AES,与 [[snmp.v3_users]] 一致)。 SNMP Port 与 [snmp].listen 端口一致。保存后设备页应显示 sysDescr / sysUptime,说明连通。
  2. 建 Data Query(自动发现表行,一次即可,之后所有节点复用): Console → Data Collection → Data Queries → 新建一个 SNMP Query, XML 里 <oid_index> 指向 BASE.2.1.1(listener 名列),四个字段分别映射 BASE.2.1.1BASE.2.1.4;egress 表同理指向 BASE.3.1.*。 字节列的 Data Source 类型选 COUNTER(Counter64 需要设备 SNMP v2c/v3, Cacti 的 spine/cmd.php 原生支持),active 列选 GAUGE
  3. 挂到设备:设备页 Associated Data Queries 添加上面两个 Query, Re-index Method 选 Uptime Goes Backwards(节点重启后自动重发现)。
  4. 建图:New Graphs → 选中该设备 → 勾选要画的 listener / egress 行。 字节计数器按 COUNTER 采样后 Cacti 自动算出 bytes/s 速率曲线; 乘 8 可换算 bits/s(在 CDEF 里配 8,*)。

LibreNMS / Zabbix 用法类似:LibreNMS 加设备后用 Custom OID 或 discovery 模块 指向上述表;Zabbix 用 SNMP agent item + discovery rule(walk[BASE.2.1.1])。

安全建议

  • 默认白名单只有 loopback127.0.0.1/32::1/128)。把 NMS 的采集网段 显式加进 allow_cidrs,不要图省事配 0.0.0.0/0
  • 跨不可信网络轮询时用 SNMPv3 authPriv;v2c community 是明文的,只适合 管理网/内网。
  • community 比较是常量时间的;v3 安全失败只递增 usmStats* 计数器并按 RFC 3414 返回 Report(或静默丢弃),都不写日志——可以放心暴露给有扫描噪音的管理网。
  • SNMP 端口被占用或 agent 异常退出只影响监控本身:代理转发不受任何影响, 只在启动日志里留一条 error!
  • state_path(engineBoots 持久化)写失败也只降级为告警;但会导致重启后 boots 不递增,NMS 侧可能要重新同步时间窗。

故障排查

现象排查
snmpwalk 超时来源 IP 在 allow_cidrs 里吗?中间防火墙放行 UDP 了吗?enable = true 了吗?
v2c 超时但 v3 正常community 是否为空(空 = v2c 关闭)或不匹配?看 snmpInBadCommunityNames
v3 报 Authentication failure口令/协议与配置不一致;节点侧 usmStatsWrongDigests 会递增
v3 报 Unsupported security level用户配了 priv_password 却用 authNoPriv 来问(fail-closed 特性)
重启后 v3 需要重新同步正常:engineBoots +1,net-snmp/spine 会自动重新 discovery
egressTable 是空的还没有连接走过任何出口;发起一条经代理的连接后即出现

最佳实践场景

本章给出几种典型部署拓扑:每个场景说明什么时候用它、怎么搭、关键配置、要注意什么。可以直接照抄,也可以 组合使用。


场景一:单节点纯直连(最简单)

适用:只想要一个带用户认证 + 访问日志的应用出口,不需要分流。

client ──► rove (认证 + 直连) ──► 目标
  • 每个用户绑定一个无 route、无 default_action 的 routing policy。
  • 决策全部落到「直连」。
{
  "schema_version": 1,
  "version": 1,
  "users": { "alice": { "password": "s3cret", "policy": "open" } },
  "routing_policies": { "open": { "routes": [] } },
  "egresses": {}
}

注意:生产务必开 TLS 入口(https / socks5tls),别让用户名密码在明文 HTTP 上裸奔。


场景二:选择性分流到二级 hop

适用:大部分流量本地直连,只有部分域名/网段需要从另一个出口(hop)出去。

                    ┌─ 命中 route ─► named egress ─► 目标
client ──► rove ┤
                    └─ 其余 ─────────► 直连 ─► 目标
  1. 在受控网络里跑一个 独立 hop 节点

    rove-hop --socks5 0.0.0.0:1080 --username hop-user --password hop-pass
    
  2. 快照使用当前 routing policy schema:route 选中 named egress,未命中则直连:

    {
      "schema_version": 1,
      "version": 2,
      "users": { "alice": { "password": "s3cret", "policy": "walled" } },
      "routing_policies": {
        "walled": {
          "routes": [
            {
              "selectors": ["api.openai.com", "openai.com", "203.0.113.0/24"],
              "action": { "type": "egress", "egress": "hop-a" }
            }
          ]
        }
      },
      "egresses": {
        "hop-a": {
          "type": "upstream",
          "backend": {
            "kind": "socks5",
            "addr": "10.0.0.9:1080",
            "username": "hop-user",
            "password": "hop-pass"
          }
        }
      }
    }
    

命中 route 的走 hop,其余直连。想「全量走上游」就把 policy 的 default_action 配成 {"type":"egress","egress":"<id>"},不必再写 catch-all route;想「只放行清单内目标」就配成 {"type":"block"},未列出的目标一律拒绝。


场景三:hop 在 NAT / 防火墙后(反向 hop)

适用:出口机器在 NAT、家宽、或私有网络里,edge 无法主动拨号它。

client ──► edge (公网, QUIC 监听) ◄══ QUIC 注册 ══ hop (NAT 后)
                    │                                  │
                    └──── 反向开隧道 ───────────────────┘──► 目标
  • edge 侧开 [reverse_hop](QUIC 的 UDP 端口,记得放行 UDP)。
  • hop 侧 rove-hop --reverse-quic edge.example.com:9443 --reverse-hop-id rove-hop-jp,令牌走 Rove_HOP_REVERSE_TOKEN 环境变量。
  • 快照 named egress 写成 { "type": "upstream", "backend": { "kind": "reverse", "addr": "rove-hop-jp" } } (命名规范见 hop-id-naming)。
  • fail-closed:edge 若没有该 hop_id 的已认证会话,直接报错,绝不回落直连

完整线协议、多 edge、观测见 反向 hop 数据面


场景四:网络隔离环境(MQTT 运维)

适用:控制面不能直接访问节点(节点在隔离网段),但需要查询策略、触发同步、做拨测追踪。

control plane ──► MQTT broker ◄── rove (主动连接, 订阅主题)
  • 节点开 [mqtt],主动连 broker,订阅用户查询 / 同步指令 / 拨测追踪主题。
  • 用户策略查询返回脱敏结果(不含密码);同步指令触发一次控制面拉取并回报状态;拨测追踪只对匹配连接 回传一次阶段结果。

消息契约见 MQTT 运维通道


场景五:多节点 fleet + 统一控制面 + 监控

适用:多地边缘节点,统一下发策略,集中观测。

              ┌─ edge-tokyo   ─┐
control ──────┼─ edge-sg      ─┼──► 各自 syslog / SNMP ──► 集中监控
(静态快照)     └─ edge-fra     ─┘
  • 同一个 snapshot_url + token 服务所有节点:接口不带 node_id,所有节点命中同一 URL、收到完全相同的 响应体。控制面可以用纯静态文件 / 对象存储提供这个接口,无需任何按节点路由的后端逻辑。
  • 个别节点要不同出口 realization(如各地本地 hop)→ 用快照的 node_overrides.<node_id>.egresses 整项替换同名 egress,节点本地按自己的 node_id 合并;policy 保持全节点统一。
  • 观测:每节点开 SNMP 给 Cacti/LibreNMS 轮询流量,或把 访问日志转发 syslog 集中检索。node_id 是跨节点归集的关键维度,务必唯一稳定。

场景六:给 TLS 入口选证书

  • 公网域名:用受信任 CA(Let’s Encrypt 等)签发的证书,客户端零配置即可信任。
  • 纯内网 / 自签名:客户端需导入你的 CA。当上游 hop 是自签名时,节点侧用 Rove_EXTRA_CA_CERTS 追加信任,或在该上游单独设 skip_cert_verify=true(仅限受控网络)。
  • 证书更新后重启进程加载新证书。

场景七:Subnetra —— 打进隔离网段(spoke egress)

适用:目标在一个不对外开放的内网段里,你想让 edge 上的已认证用户「点名」访问里面的服务。 不用 TUN、不用 NET_ADMIN、不用额外守护进程——config.toml 加一段 [subnetra] 即可组网, 数据面是加密 UDP(每链路独立 PSK 的 ChaCha20-Poly1305)。

按「哪一边能开一个 UDP 口」选摆法:

摆法 A:网段可以放行一个入站 UDP 端口(最通用)

网段内跑一个 rove hub,只对 edge 放行一个 UDP 端口;edge 作 spoke 主动拨进去。 hub 在 overlay 上开的代理入口就是「二跳」:经它可达网段内任意机器。

flowchart LR
    C["客户端"] -->|"① CONNECT 10.9.0.1:8080"| E["rove edge · spoke<br/>overlay 10.9.0.2"]

    E ==>|"加密 UDP overlay<br/>网段只放行这一个 UDP 口"| H

    subgraph SEG["隔离网段 192.168.1.0/24"]
        H["rove hub · overlay 10.9.0.1<br/>overlay 上只监听代理端口 8080"]
        H -->|"② 二跳 CONNECT<br/>走 hub 自己的认证 + 分流"| T["192.168.1.50:443<br/>网段内任意服务"]
    end
  1. 网段内(hub)——即便没有任何 [[listeners]] 也能启动,代理入口在 overlay 上:

    node_id = "seg-hub-01"
    
    [subnetra]
    enable = true
    mode = "hub"
    local_id = 1
    listen = "0.0.0.0:18020"        # 数据面 UDP,网段边界只放行这一个口
    overlay_cidr = "10.9.0.1/24"
    proxy_protocol = "http"          # overlay 上的代理入口(二跳用),走完整认证/策略
    proxy_port = 8080
    
    [[subnetra.peers]]
    id = 2
    psk = "<64 hex,每条链路唯一>"
    allowed_src = "10.9.0.2/32"      # 精确到 edge 的 overlay IP
    name = "edge-spoke"              # endpoint 留空,从已认证流量学习
    
  2. edge(spoke)

    [subnetra]
    enable = true
    mode = "spoke"
    local_id = 2
    listen = "0.0.0.0:0"             # spoke 只出站,临时端口即可
    overlay_cidr = "10.9.0.2/24"
    keepalive_secs = 25              # 维持 NAT / 防火墙映射
    
    [[subnetra.peers]]
    id = 1
    psk = "<同一条链路的 64 hex>"
    allowed_src = "10.9.0.0/24"      # 整个 overlay 子网路由到 hub(spoke 的默认路由)
    endpoint = "seg-gw.example.com:18020"  # spoke 必填
    name = "seg-hub"
    
  3. edge 的快照——给分组一个 subnetra 出口,命中 overlay 网段的目标才走隧道:

    "isolated": {
      "upstream": { "kind": "subnetra" },
      "proxy": ["10.9.0.0/24"]
    }
    
  4. 客户端:目标必须是 overlay IPv4 字面量。第一跳 CONNECT 10.9.0.1:8080 到 hub 的 overlay 代理,第二跳在这条隧道里再 CONNECT 192.168.1.50:443(hub 侧同样要过快照认证)。 两层 CONNECT 需要客户端支持代理链(如 proxychains-ng),或由业务侧封装。

摆法 B:网段完全零入站(连 UDP 也不给开)

角色对调:edge 作 hub(公网 UDP 监听),网段内放一个原版 Zig spoke(有 TUN)主动拨出。 edge 的 subnetra 出口拨 spoke 的 overlay IP,就是那台机器 TUN 上的 OS 服务。

flowchart LR
    C["客户端"] -->|"CONNECT 10.9.0.3:22"| E["rove edge · hub<br/>overlay 10.9.0.1"]

    subgraph SEG["隔离网段 · 零入站"]
        Z["Zig spoke · TUN 10.9.0.3<br/>本机 ssh · DB · API 可直达"]
    end

    Z ==>|"UDP 出站拨 edge · keepalive 保活"| E
  • kind = "subnetra" 出口拨的是裸 TCP(不讲代理协议),所以「目标机器上有监听」是唯一要求: Zig 节点的 TUN IP 上任何服务都能直达。
  • Rove spoke 在 overlay 上没有任何监听(用户态栈只在 hub 模式开代理端口),所以摆法 B 的 网段侧要用带 TUN 的 Zig 节点;要够 spoke 机器之外的其他机器,在它的 TUN IP 上再放一个小代理 (如 rove-hop --socks5 10.9.0.3:1080)做二跳。
  • 与 Zig 版完全线兼容(CI 逐字节 KAT 校验),老节点不改任何东西。

两种摆法共同的注意事项

  • fail-closed:subnetra 未启用、目标不是 IPv4 字面量、或 overlay 路由不可达时直接报错, 绝不回落直连subnetra 出口不接受 username/password/tls 字段。
  • overlay 出口仅 TCP(UDP 出口目前只有反向 hop 支持)。
  • obfuscate 必须全网一致(无握手协商,一端开一端关会互不通);PSK 每条链路唯一。
  • 时钟:节点用 boot epoch 排序,跨重启时钟回拨会被对端拒收——保证 NTP 正常即可。
  • 内层 MTU 默认 1452 已自动处理(smoltcp 按此通告 MSS)。若跑在已压缩、路径固定的外层隧道 里(如载体 1360),在 [subnetra]mtu(范围 576–1452)适配即可,见 Subnetra

字段语义、路由与相容性细节见 内嵌 Subnetra 组网底座


场景八:Subnetra hub —— 隔离网段里的机器借 edge 的出口(hub inbound)

适用:方向反过来——网段里的机器想用到 edge 的代理能力(认证、分流、限速、反向 hop 出口), 但网段不允许任何入站。edge 作 hub,网段内 Zig spoke 主动拨出。

flowchart LR
    subgraph SEG["隔离网段 · 零入站"]
        A["应用<br/>代理设为 10.9.0.1:8080"] --> Z["Zig spoke · TUN<br/>overlay 10.9.0.3"]
    end

    Z ==>|"UDP 出站拨 edge"| E["rove edge · hub<br/>overlay 代理入口 10.9.0.1:8080<br/>完整认证 + 分流 + 限速"]

    E -->|"直连"| T1["公网目标"]
    E -->|"或反向 hop / 上游"| T2["其他出口"]
  • edge 的 [subnetra]mode = "hub" + proxy_protocol / proxy_port(同场景七摆法 A 的 hub 片段),overlay 代理入口跑的是 Rove 完整引擎:同一份快照认证、同样的分流 / 限速 / 访问日志, 甚至可以从这里再走反向 hop 出口。
  • spoke 机器上的应用把代理地址配成 hub 的 overlay IP:proxy_port 即可,流量经 TUN 进 mesh; 网段内其他机器想共用,把去 10.9.0.0/24 的路由指向 spoke 主机(OS 路由,超出 Rove 范围)。
  • hub 侧对该 spoke 的 allowed_src 配精确 /32,端点留空由认证流量学习;NAT 后的 spoke 靠 keepalive 维持映射。

场景九:多节点统一发布 rove-addrbook

适用:大量云厂商 IP 段、企业应用域名或自有分类需要被多个 edge 的 route selectors 复用。

可信源 ─► rove-abctl fetch/build/verify/diff ─► 制品仓库 ─► 各节点本地 book.rab
                                                     │
控制面快照(selectors: book:category)────┘
  • 地址簿与快照是两条发布链:.rab 提供分类成员,快照决定每个 policy/route 如何使用分类。
  • 首次启用先升级节点并部署有效 .rab,最后才发布带 book: selector 的快照。
  • 每次候选书先 verifyinspect/querydiff --max-shrink,再按节点/机房 canary。
  • 发布系统应核对各节点日志里的 addrbook checksum;只对比快照 version 不足以证明决策一致。
  • Docker 挂载地址簿目录,不挂单个文件;在同一目录原子 rename,坏书会保留旧书与旧快照。
  • 回滚用旧数据构建一个更高 epoch 的新工件,不直接把历史 .rab 当作新版本复制回去。

完整 manifest、六种数据源、CLI 退出码、资源上限和恢复语义见 rove-addrbook 指南


安全加固清单

  • 所有对外入口都启用 TLS(https / socks5tls),不在公网暴露明文 http / socks5
  • hop 一律设置非默认凭据(不要留 rove/rove)。
  • 令牌 / 密码 / 证书私钥通过部署系统注入,不提交进仓库;示例只用占位符。
  • 反向 hop 令牌走环境变量,不进命令行 argv。
  • SNMP 配 community 强随机值 + 收紧 allow_cidrs 白名单;对外优先用 SNMPv3(authPriv)。
  • 用 systemd 加固(非 root、NoNewPrivilegesProtectSystem=strict),低端口用 CAP_NET_BIND_SERVICE
  • 访问日志保持开启,用于事后审计;确认日志/syslog 目标本身是可信通道。
  • 定期核对:策略失败(认证/过期/block/上游失败)时节点是拒绝而非放行。
  • 地址簿工件走受认证的发布通道;.rab 内置 SHA-256 只校验完整性,不证明发布者身份。

性能与可靠性

  • 限速up_rate/down_rate 为 0 时走零开销快路;只对需要限的用户设非零值。
  • 离线韧性:确保 cache_path 落在持久卷上,控制面故障时节点仍能靠缓存服务。
  • 退避:控制面连续失败会指数退避到最高 5 分钟,避免故障时所有节点同时打满重试。
  • 本地压测:仓库自带 examples/proxy-benchmark-local.rsdocker-compose.local.yml, 可在本地拉起 edge + 多个 hop,对 HTTP/HTTPS-TLS/SOCKS5/SOCKS5-TLS 四个入口做端到端延迟、 带宽、并发梯度与限速精度测试;支持 --json-out 保存机器可读结果 (cargo run --release --example proxy-benchmark-local -- all)。
  • Subnetra 压测cargo run --release --example subnetra-benchmark-local 会在同进程内拉起 内嵌 Subnetra hub/spoke,测 spoke-egresshub-inbound 两条 Rove 业务路径。它测的是 Subnetra wire + smoltcp + Rove HTTP 代理处理的组合成本;裸 L3 数据面基准仍应使用上游 Subnetra 项目的 netns / live-overlay benchmark。

基准测试报告

Rove 自带一套纯 Rust 的端到端基准套件,直接压真实的本地 Docker 栈,覆盖 2 条接入路径 × 4 个入口 × 7 条出口模式:接入路径是本机直连 listener 或 rove-relay reverse ingress;出口是 direct / 三种上游 hop / reverse,以及两条出口链 主备故障转移模式。套件测延迟、吞吐、并发扩展性、限速精度和连接数配额。 本页给出最近一轮完整实测结果与复现方法。

数字均为单机回环上限(无丢包、零 RTT)。生产环境的实际表现取决于公网 RTT 与丢包率,横向比较各链路的相对开销比绝对值更有参考意义。

测试环境

日期2026-07-04
硬件 / OSApple M4,macOS 26.5
Docker29.4.0(Docker Desktop)
Rust1.96.0
部署docker-compose.local.yml 本地栈:1 × edge + 1 × ingress relay + 4 × hop
负载发生器examples/proxy-benchmark-local.rs(与 Rove 同一 tokio/rustls 栈)
参数每用例 2000 请求 + 100 warmup / 并发 20 / 带宽单流 256 MiB

下表是 path=local 的历史实测:60 个用例全部成功,0 失败。

方法学

  • 接入路径
    • localhttp:18080https-tls:18443socks5:11080socks5-tls:11081
    • reverse-ingresshttp:38080https-tls:38443socks5:31080socks5-tls:31081 两条路径最终进入同一组 Rove listener;TLS 都在 Rove 终止并正常校验本地 CA。
  • 出口链路direct(直连)、https / socks5 / socks5-tls(三种上游 hop)、 reverse(QUIC 反向注册 hop);另有两条出口链模式: chain(主 reverse 成员健康,首选即胜出,测 chain 的簿记开销)与 chain-failover (主 reverse 成员未注册,每条隧道都在建立期故障转移到 socks5 备份成员,测转移成本)。
  • 分相计时:每个请求拆成 connect(TCP 建连)→ tls(入口 TLS 握手)→ tunnel(CONNECT / SOCKS5 建隧道,含 edge→hop 全部握手)→ request(HTTP 往返), 可直接定位延迟花在哪一层。
  • warmup:每用例先跑 100 个不计入统计的请求,排除冷启动噪声。
  • 开环模式:支持 --rate 按固定 schedule 发起请求,消除 coordinated omission (慢响应不会拖住后续请求的发起时刻);矩阵默认闭环。
  • 策略快照docker/local/snapshot.json 使用当前 schema 的 routing_policies + named egresses,基准矩阵会经过与生产控制面相同的解码、编译和有序 route 决策链。
  • 目标服务器内建于负载发生器(宿主机 :19090),容器内经 host.docker.internal 回连。

延迟矩阵

闭环,2000 请求 / 并发 20,单位 ms。rps 为墙钟吞吐,对个别环境级 stall 敏感 (见尾部说明),横向比较以 p50 / p99 为准。

入口出口链路RPSp50p90p99
httpdirect17592.093.747.84
httphttps30386.268.5814.03
httpsocks515033.084.3012.45
httpsocks5-tls11965.968.3312.54
httpreverse14322.854.1711.22
https-tlsdirect13903.244.997.54
https-tlshttps14529.1015.5029.00
https-tlssocks512325.688.2115.70
https-tlssocks5-tls21237.9111.4915.34
https-tlsreverse14054.055.7722.70
socks5direct45623.256.2710.97
socks5https11266.559.8025.48
socks5socks515134.216.5820.01
socks5socks5-tls25317.279.9516.46
socks5reverse17403.515.0620.97
socks5-tlsdirect30493.815.5514.54
socks5-tlshttps12328.0911.0727.17
socks5-tlssocks513224.948.6114.83
socks5-tlssocks5-tls11169.0112.1522.06
socks5-tlsreverse16914.317.1220.87
  • 最快端到端链路 http → direct p50 2.09 ms;最重链路 socks5-tls → socks5-tls(双层 TLS + 两次 SOCKS5 握手)p50 9.01 ms。 每加一层加密或握手,p50 大约 +1.5~3 ms,单调可预期。
  • 全矩阵 p99 都在 30 ms 以内。

分相 p50(ms)

入口出口链路connecttlstunnelrequest
httpdirect0.061.260.72
httphttps0.124.681.36
httpsocks50.072.110.84
httpsocks5-tls0.124.661.07
httpreverse0.061.641.06
https-tlsdirect0.061.051.090.88
https-tlshttps0.091.495.391.82
https-tlssocks50.071.322.651.32
https-tlssocks5-tls0.071.225.071.25
https-tlsreverse0.070.971.511.25
socks5direct0.072.300.88
socks5https0.065.211.20
socks5socks50.073.140.93
socks5socks5-tls0.235.941.02
socks5reverse0.072.291.10
socks5-tlsdirect0.060.941.900.81
socks5-tlshttps0.061.055.331.31
socks5-tlssocks50.060.962.820.93
socks5-tlssocks5-tls0.081.246.061.18
socks5-tlsreverse0.070.892.061.07

三个值得记住的结论:

  • 入口 TLS 握手稳定在 ~1.0–1.5 ms,四个入口除此之外同档——选 TLS 入口的代价就这么多。
  • 上游 TLS hop 把 tunnel 从 ~1.3–2.3 ms 抬到 ~4.7–6.1 ms,是全链路里最贵的一层。
  • reverse 建隧道只要 ~1.5–2.3 ms,接近 direct——QUIC 反向通道是复用的, 不需要为每个请求新建 edge→hop 连接。穿 NAT 场景里它同时是低延迟优选。

带宽矩阵

单流 256 MiB,单位 MiB/s。单次测量,±20% 内属运行间波动。

入口出口链路下载上传
httpdirect16072076
httphttps753621
httpsocks5732723
httpsocks5-tls683724
httpreverse433467
https-tlsdirect746749
https-tlshttps771466
https-tlssocks51262471
https-tlssocks5-tls780621
https-tlsreverse384311
socks5direct23412250
socks5https851454
socks5socks5830922
socks5socks5-tls755736
socks5reverse462489
socks5-tlsdirect803694
socks5-tlshttps792402
socks5-tlssocks51153366
socks5-tlssocks5-tls818578
socks5-tlsreverse384415

档位一目了然:

链路档位单流吞吐
明文入口 + 直连1.6–2.3 GiB/s
任一环节带 TLS(入口或上游)~0.6–0.9 GiB/s
reverse(QUIC 反向通道)~0.3–0.5 GiB/s

带宽测试期间 edge 容器 CPU 峰值 94%(打满约一个核)——测到的是代理数据面的 真实上限,而不是客户端上限。QUIC 链路吞吐低于 TCP+TLS 属预期 (用户态 QUIC 栈 + 单容器 CPU 上限)。

并发扩展性

http → direct,逐级抬并发:

并发请求失败RPSp50 (ms)p99 (ms)
1200016380.580.74
8200056851.342.11
328000759*2.321002.52*
128200001534*3.831009.71*

1→8 并发接近线性扩展(1638→5685 RPS),p99 仍在 2 ms 档。 带 * 的行受宿主环境影响,见下节: 冷启动单独跑并发 32 的结果是 6377 RPS / p99 14.7 ms,无塌陷。

出口链(chain)故障转移验收

2026-07-11 对出口链模式的单独验收 (同栈同参数:2000 请求 / 并发 20 / warmup 100;本地快照 docker/local/snapshot.json 定义 bench-pop——健康 reverse 主 + socks5 备,与 bench-pop-failover——未注册 reverse 主 + socks5 备)。64000 请求(16 用例 × 2000 + 带宽)全部成功,0 失败。

入口chain p50 / p99chain-failover p50 / p99参照 reverse p50参照 socks5 p50
http2.33 / 3.733.17 / 5.812.723.00
https-tls3.03 / 18.883.54 / 9.634.104.29
socks53.11 / 5.123.39 / 7.183.162.88
socks5-tls4.01 / 7.634.41 / 13.194.254.21
  • chain(主成员健康):p50 与纯 reverse 基线持平(±0.5 ms 内),chain 的 簿记与决策开销在测量噪声之下。
  • chain-failover(主成员不可用,每条隧道都经历一次建立期故障转移):p50 比 直接走 socks5 备份成员多约 0.2~0.6 ms——即一次 reverse_lookup 失败的成本;带宽 与基线一致(单流 ≥ 440 MiB/s,转移只发生在建立期,数据面零开销)。
  • fail-closed 实测:停掉 socks5 备份 hop 后,chain-failover 用户的 CONNECT 返回 502 Bad Gateway(决不回落直连);恢复 hop 后新连接立即自愈;期间 chain 用户 不受影响(reverse 主成员照常服务)。

复现:--modes chain,chain-failover(已包含在默认矩阵中)。

限速精度与连接数配额

bench-limited 用户(up_rate = down_rate = 1 MiB/s,max_connections = 2)实测:

方向payload用时实测速率期望速率*误差
download8 MiB7.008 s1,197,035 B/s1,198,373 B/s-0.1%
upload8 MiB7.007 s1,197,192 B/s1,198,373 B/s-0.1%

* 令牌桶初始带 1 秒突发额度,期望速率按 payload / ((payload - rate) / rate) 修正。

连接数配额:并发发起 4 个隧道,放行 2、拒绝 2,与 max_connections=2 完全一致 (HTTP 入口拒绝时返回 429,SOCKS5 返回 rep=0x02)。

资源占用

带宽阶段 docker stats 采样:

容器CPU 均值CPU 峰值内存峰值
edge(rove-local-main74.6%94.1%10.4 MiB
hop-socks5tls24.3%51.0%6.9 MiB
hop-https12.9%39.0%6.5 MiB
hop-reverse8.9%24.2%8.8 MiB
hop-socks51.8%6.1%6.4 MiB

全栈 5 个容器内存峰值合计 < 40 MiB

macOS Docker 下的 1003ms 极值

在 macOS + Docker Desktop 上连续大量新建连接(完整矩阵连跑、并发扫描多 step 连跑)时, 部分用例的 max 会出现孤立的 ~1003 ms 极值(p99 通常不受影响,占比 < 0.1%)。 1003 ms 是 TCP SYN 重传定时器的特征值,定性为 Docker Desktop 端口转发链路 (docker-proxy / VM NAT)在连接风暴下丢首个 SYN,属宿主环境行为, 不是 Rove 数据面缺陷——同样的用例冷启动单独跑即恢复正常。

在 Linux 原生环境(无 docker-proxy 中转)复测不受此影响。压测时如需规避: 单独跑目标用例,或降低用例间的连接新建密度。

复现

# 1. 生成本地证书并起栈(1 edge + 1 ingress relay + 4 hop)
./scripts/generate-local-certs.sh
docker compose -f docker-compose.local.yml up -d

# 2. 完整矩阵:延迟 + 带宽 + 并发扫描 + 限速/配额,附容器资源采样
cargo run --release --example proxy-benchmark-local -- all --stats \
  --json-out reports/rove-proxy-bench.json

# 3. 对比本地直连 listener 与 reverse ingress 的完整矩阵
cargo run --release --example proxy-benchmark-local -- latency \
  --paths local,reverse-ingress

# 只测 relay 接入开销,出口固定 direct
cargo run --release --example proxy-benchmark-local -- latency \
  --paths reverse-ingress --modes direct

按需单独跑某一类:

cargo run --release --example proxy-benchmark-local -- latency    # 延迟矩阵(分相计时)
cargo run --release --example proxy-benchmark-local -- bandwidth  # 吞吐矩阵
cargo run --release --example proxy-benchmark-local -- sweep      # 并发梯度
cargo run --release --example proxy-benchmark-local -- limits     # 限速精度 + 连接数配额

常用选项(完整列表见 -- --help):

选项作用
--inbounds http,socks5只测部分入口
--paths local,reverse-ingress选择本地 listener / reverse ingress 接入路径;默认仅 local
--modes direct,reverse,chain,chain-failover只测部分出口链路
--requests N / --concurrency N延迟用例规模
--rate RPS开环模式(固定到达率,消除 coordinated omission)
--mib N / --streams N带宽 payload 与并发流数
--stats采样 docker stats 输出容器资源报告
--json-out PATH机器可读结果(延迟分位数、吞吐、限速误差全量)

JSON 中每条 latency/bandwidth/sweep/limit 记录都有 path 字段,可按相同 inbound + mode 对比 relay 封装、额外 QUIC hop 与 loopback connector 的成本。

QUIC 前端与 overlay 组网有各自的专属基准:

cargo run --release --example tuic-benchmark-local        # TUIC 本地 UDP:10443
cargo run --release --example tuic-benchmark-local -- \
  --path reverse-ingress                                  # TUIC 经 relay UDP:30443
cargo run --release --example subnetra-benchmark-local    # Subnetra overlay 业务路径

常见问题 FAQ

按主题整理。找不到答案时,看 故障排查 或提 Issue

基础与概念

Rove 和 GOST 是什么关系? 旧版 Rove 是 GOST 的一组控制面插件(auth/bypass/limiter/hop/observer,走 gRPC),受限于 GOST 的插件边界。 本项目是 Rust 重写,把代理数据面和策略控制收进一个二进制,不再依赖 GOST、gRPC、数据库或转发中继

它是控制面吗?能管理用户吗? 不是。节点只消费控制面下发的快照,自己不管理用户、套餐、计费。用户和策略的真相在你的控制面。

必须有控制面才能用吗? 不是必须。节点启动时先读本地缓存 cache_path,只要缓存里有用户就能工作。快速试用可以直接手写一份 snapshot.json(见 快速开始)。但生产环境推荐用控制面统一下发。

支持哪些接入方式和出口? 应用接入(listener adapter):HTTP CONNECT 隧道、明文 HTTP absolute-form 转发、SOCKS5(含 UDP), 各自可叠加 TLS(https / socks5tls);另有独立的 TUIC v5(QUIC)前端。 出口(egress):直连、HTTP 上游、SOCKS5 上游、反向 hop(QUIC)、Subnetra 加密 L3 组网, 以及按优先级故障转移的 egress chain。

Rove 能当反向代理 / API 网关用吗? 不能把它当成 nginx / Envoy 那种通用反代。规划中的能力叫应用出口网关:T1 是 SNI 透传、T2 是 按服务端声明的 origin 转发,T3(虚拟主机、ACME、后端池、WAF)明确不做。代码还没落地。 需要发布内网服务,用 反向公网入口Subnetra, 或在 Rove 前面放 nginx。详见 应用出口网关。不要把 反向 hop 理解成反向代理——那个词在本仓库里是 NAT 后的出口


部署与运行

怎么指定配置文件? rove -c config.toml--config config.toml。省略时默认读当前目录 config.toml

需要 root 吗? 不需要。只有绑定 <1024 的端口(443/161 等)需要权限,用 systemd 的 AmbientCapabilities=CAP_NET_BIND_SERVICE 授予即可,无需整个进程用 root。

有系统依赖吗? 没有。TLS 走 rustls(ring),二进制自带 CA 根证书,不依赖 OpenSSL。运行环境要求见 安装与部署

支持优雅停机 / 零停机升级吗? 当前收到退出信号后会先停止新接入,并在配置的有界窗口内排空在途连接;超时后强制结束剩余会话。升级仍建议先通过 readiness 摘流。节点 先读缓存再联网,重启后能立即恢复服务。

能在 RouterOS 容器里跑 rove-hop 吗? 可以。推荐 reverse QUIC only(NAT 后主动注册到 edge)。运维专题见 RouterOS 容器部署 rove-hop;Release 提供 rove-hop-routeros-<version>-arm64.tar.gz(手册 + .rsc + Docker-save 镜像)。 hop_id 使用前缀 rove-hop-(如 rove-hop-jp),见 命名规范

Docker 里缓存/证书怎么处理?cache_path 指向挂进容器的可写目录,证书按 [listeners.tls] 路径挂载。自定义 CA 用 Rove_EXTRA_CA_CERTS 追加。见 安装与部署 · Docker


控制面与快照

快照接口长什么样? GET {snapshot_url}?since={本地版本},带 Authorization 头。返回 200 + RawSnapshot(版本前进时)或 304 Not Modified(无变更)。接口不带 node_id,所有节点命中同一 URL、收到相同响应。完整协议见 控制面同步协议

控制面可以是静态文件吗? 可以。因为接口不按节点路由,控制面完全可以用静态文件 / 对象存储提供,无需后端逻辑。

不同节点要不同策略怎么办? 用快照里的 node_overrides(按 node_id 索引)。控制面对所有节点发同一份快照,节点用本地 node_id 自行挑出 覆盖并在本地合并。见 数据模型 · 节点级覆盖

控制面挂了会怎样? 节点继续用当前内存快照服务;坏快照 / 超大响应 / HTTP 错误 / 编译失败都不会污染当前状态。连续失败会指数退避到 最高 5 分钟再重试。

since 怎么工作? 节点带上本地版本号;version <= since304 时不重编译、不刷日志。version 必须单调递增


认证、策略与限速

一个用户最少要配哪些字段? 当前快照 schema:passwordpolicy(指向 routing_policies 中的一项)。expire 缺省为永不过期, up_rate/down_rate/max_connections 缺省为 0(不限)。

决策顺序是什么? 按 policy 的 routes 数组 first-match-wins;命中 block 拒绝,命中 egress/direct 按 action 执行;都未命中则执行 policy 的 default_action,没有 default 则直连。sniff 安全语义见 数据模型 · 决策流程

域名规则 api.openai.com 会匹配子域名吗? 会。默认是后缀匹配,同时匹配 api.openai.com*.api.openai.com。要精确匹配用 full:,关键字用 keyword:。 写在 route 的 selectors 里,语义相同。

怎么让某个用户全量走某个上游? policy 的 default_action{"type":"egress","egress":"<id>"},不必写 catch-all route。未命中其它 route 的目标都会走该出口。

怎么让某个用户只能访问白名单内的目标? route 里逐条列出允许的目标,policy 的 default_action{"type":"block"}。选择器没有 catch-all 写法,default_action 是表达 deny-by-default 的唯一方式;未列出的目标一律拒绝,而不是退化为直连。

限速精度如何? 每用户字节令牌桶(up_rate/down_rate)。两者为 0 时走 copy_bidirectional 零开销快路,不影响吞吐。

账号过期后返回什么? HTTP 入口返回 403,SOCKS5 入口拒绝。策略 block 命中同样如此。密码错误则是 407(HTTP)。


TLS 与证书

怎么开 HTTPS 入口?http 协议的 listener 加 [listeners.tls](cert + key)即升级为 HTTPS。socks5 加 TLS 段则是 socks5tls

上游 hop 是自签名证书,节点连不上? 两个选择:给节点设 Rove_EXTRA_CA_CERTS 追加信任那张 CA(推荐),或在该上游单独设 skip_cert_verify=true(仅限受控网络)。skip_cert_verify逐上游开关,不存在全局关校验。

curl 走 HTTPS 代理报证书错误? 自签名时用 --proxy-cacert ./ca.crt 指定 CA,或 --proxy-insecure 跳过(仅测试)。


出口与 hop

roverove-hop 有什么区别? rove 是主节点(连控制面、执行策略/限速)。rove-hop 是独立出口,不连控制面、不执行策略、不限速, 只做认证 + 直连。见 独立 hop 节点

hop 忘了设密码会怎样? 只要配置了任一入口监听,--username / --password(或 Rove_HOP_USERNAME / Rove_HOP_PASSWORD) 就是必填项:两项都缺、或只给一半,进程启动即失败。没有内置回退凭据——回退值会被编进每一份 发布二进制,忘记设置等同于运行一个公开口令的开放出口。反向 QUIC-only 的 hop 不监听入口,不受影响。

反向 hop 连不上,edge 端口放行了 TCP 还是不行? 反向 hop 走 QUIC = UDP。请放行 [reverse_hop].listen 对应的 UDP 端口,不是 TCP。

edge 找不到 hop 会回落直连吗? 不会。kind = "reverse" 是 fail-closed:没有该 hop_id 的已认证会话就直接报错。


可观测与监控

怎么排查「某用户连不上某站点」? grep / jq 访问日志:每条连接都有 usernametarget_hostdecisionresultfailure_stage。见 访问日志

访问日志会记密码吗? 永不。decision 携带上游地址但不含上游密码,用户密码也从不出现在日志里。

能接 Cacti / LibreNMS 吗? 能。内置只读 SNMP agent(v2c + v3 USM),暴露每 listener、每出口的活跃连接与累计字节。只支持 GET/GETNEXT/GETBULK。见 SNMP 监控

/healthz 或 Prometheus 端点吗? 有可选的 /healthz(存活)和 /readyz(快照/控制面/listener 活性/排空状态)HTTP 端点,默认关闭且只监听 127.0.0.1:9090;配置见 配置详解。Prometheus 端点仍未提供。访问日志 kind:"stats" 心跳和 SNMP 轮询继续用于流量与历史观测。


安全

会不会在失败时变成开放代理? 不会。认证失败、账号过期、block 命中、快照编译失败、上游连接失败等,默认行为一律保守拒绝。

Rove 提供公共出口或跨境接入吗? 不提供。本项目只发布软件,不运营网络:没有官方公共出口节点、没有订阅、没有流量套餐。 所有线路由部署者自行准备,并遵守部署地与流量落地地的法律法规以及与运营商、云厂商的协议。 完整说明见文档首页的 使用边界

示例配置里能放真实令牌吗? 不能。仓库和示例只允许占位符。真实令牌、密码、证书、客户策略必须由部署环境管理,不进版本库。

故障排查

按「现象」查。排查第一站永远是访问日志(默认在 ./logs/access.YYYY-MM-DD)—— 每条连接的 resultfailure_stagedecisionmessage 会直接告诉你卡在哪一步。

# 看某用户最近的失败
jq 'select(.kind=="connection" and .username=="alice" and .result!="success")' logs/access.*

客户端连不上 / 认证失败

现象排查
407 Proxy Authentication Required缺少代理凭据或用户名/密码错误。核对客户端配置与快照里的 password
403 Forbidden账号已过期,或目标命中分组 block。看日志 failure_stage / decision
SOCKS5 直接被拒同上(SOCKS5 没有 407,认证失败即拒绝)。
连接超时端口没放行;或客户端 scheme 与入口不匹配(明文入口用了 https://,或反之)。

账号明明没过期却 403? expire 是日期(如 2026-12-31)。确认控制面下发的日期格式正确、且节点时钟准确。


TLS / 证书问题

现象排查
客户端报证书不受信任自签名证书未导入 CA。curl 用 --proxy-cacert;系统/浏览器需导入你的 CA。
节点启动即失败,提示证书/私钥[listeners.tls].cert / .key 路径错误或 PEM 格式不对。
节点连上游 hop 报证书错误hop 是自签名。给节点设 Rove_EXTRA_CA_CERTS 追加 CA,或该上游设 skip_cert_verify=true(仅受控网络)。

skip_cert_verify逐上游开关,只影响出站方向,不影响入站监听的 TLS,也没有全局开关。


分流不生效

  • 本该走上游却直连了(当前快照 schema):确认目标命中了 policy routes[].selectors,且 action 指向 正确的 named egress。注意匹配规则 —— 默认是后缀匹配full: 才是精确。IP 目标要用 CIDR (如 10.0.0.0/8)。
  • 本该直连却走了上游:检查 default_action 是不是 egress(它会兜底所有未命中 route 的目标)。
  • 本该放行却被拒绝:检查 default_action 是不是 {"type":"block"}(deny-by-default 策略只 能到达 route 里列出的目标)。
  • 顺序routes 数组 first-match-wins;未命中再执行 default_action,没有则直连。见 数据模型
  • 日志里 decision 会显示实际走向:direct / block / upstream:<addr> / reverse:<hop_id> / chain:<id>

控制面同步问题

现象排查
策略改了但节点没变确认控制面 version 递增了;version <= since304 时节点不替换。
节点一直用旧缓存控制面不可达。看日志是否有拉取失败/退避;连续失败会退避到最高 5 分钟。
新快照没生效快照解码或编译失败会被丢弃、保留当前状态。看日志里的编译错误信息。
请求地址不对snapshot_url 必须是完整地址,节点只追加 ?since=,不会拼 /api/... 之类路径。

手动确认控制面响应

curl -H "Authorization: <token>" "https://control.example.com/snapshot?since=0" | jq .version

rove-addrbook 问题

现象排查
节点启动即失败[addrbook].path 运行 rove-abctl verify;再检查权限、256 MiB 上限与容器目录挂载。
新快照报 no [addrbook]快照引用了 book: selector,但节点未配置 [addrbook];配置并验证本地 .rab,或先移除该 selector。
新快照报 unknown addrbook categoryrove-abctl inspect book.rab --categories 核对分类;错误信息会带上未知分类名,未知分类会拒绝整份快照。
新书未热替换查运行日志中的 addrbook reload failed / new addrbook rejected;新书缺少当前快照引用的分类时会保留旧书。
域名未命中云厂商 IP 段域名请求不会先 DNS 解析再查 IP 分类;给地址簿补充相应域名数据源。
Docker 中始终是旧书不要 bind mount 单个文件;挂载目录并在目录内原子替换 .rab
多节点决策不同对比各节点启动/热替换日志里的 addrbook epoch 与 checksum,而不只看快照 version

构建、六种数据源、CLI 退出码、发布门禁与回滚流程见 rove-addrbook 指南


反向 hop 连不上

现象排查
hop 注册不上 edgeedge 的 [reverse_hop].listenUDP 端口,确认放行的是 UDP 不是 TCP。
认证失败hop 的 --reverse-token / Rove_HOP_REVERSE_TOKEN 要在 edge 的 [reverse_hop].tokens 里。
用户请求报错、不回落直连这是预期的 fail-closed:edge 没有该 hop_id 的已认证会话就报错。先让 hop 注册成功。
失败阶段日志 failure_stage 分为 reverse_lookup / reverse_open / hop_connect / stream_io,据此定位。
账号/节点正常但站点仍打不开CONNECT 内源站 TLS 发生在 hop 出网之后,edge 看不到。本机跑 rove-hop doctor egress <host:port> --json,或给 hop 打开 --mqtt-brokerrove/hop/<hop_id>/doctor(见 hop MQTT doctor)。

连接会因 NAT UDP 超时断开?内建 15s QUIC 保活已压在常见 NAT 超时下;仍断开则检查中间设备的 UDP 超时设置。 详见 反向 hop 数据面


SNMP 轮询不到数据

  • 确认 [snmp].enable = true,且轮询源 IP 在 allow_cidrs 白名单内(白名单外的包静默丢弃,不产生日志)。
  • v2c:community 要匹配。v3:用户、认证/加密协议与口令要对上;配了加密口令的用户只接受 authPriv
  • 只支持 GET/GETNEXT/GETBULK;SET/TRAP/INFORM、MD5/DES 不支持。
  • SNMP 端口被占用只记一条 error! 日志,代理照常服务。见 SNMP 监控

性能 / 丢日志

  • 日志里出现「access log 丢弃」警告:写入队列打满。调大 [access_log].channel_capacity(默认 8192)。 注意这是有意的保护 —— 队满宁可丢日志也绝不阻塞代理转发。
  • 吞吐达不到预期:确认没给不需要限速的用户设非零 up_rate/down_rate(0 才走零开销快路)。
  • 本地压测:docker compose -f docker-compose.local.yml up --build + cargo run --release --example proxy-benchmark-local -- all --json-out /tmp/rove-proxy-bench.json (覆盖 4 个入口 × 5 条出口链路的延迟、吞吐、并发扫描与限速精度)。
  • Subnetra 业务路径压测:cargo run --release --example subnetra-benchmark-local -- --json-out /tmp/rove-subnetra-bench.json。 该示例不需要 Docker/TUN,会在同进程内拉起 Rove hub/spoke,分别测 spoke-egresshub-inbound 的 overlay 连接、代理 CONNECT 与下载/上传吞吐。

还是不行?

  1. [log].level 临时调到 debug,复现一次。
  2. 收集对应时间段的访问日志(记得脱敏,别贴真实凭据)。
  3. rove-hop doctor egress <目标> --trace 单独诊断出口网络是否通。
  4. 带上版本、配置(占位化)、日志片段提 Issue

Rove 项目画像与方向

项目概述

Rove 是开源的应用网络优化器:一个轻量、单体、模块化的应用出口节点。 它服务的是 Agent API、投资交易、SaaS 多云出口、Webhook 固定出口、隔离网段访问等对路径、时延和策略敏感的应用流量。 节点本地完成前端接入、用户鉴权、策略决策、限速和出口连接。仓库可以包含 rove-hoprove-relay 等第一方伴生进程,但这些进程必须保持独立部署和窄边界;应用层(模型请求体、成交回报、账单)语义不进入节点热路径。

节点不持有业务真相。控制面通过 HTTP 下发编译好的用户与策略快照,节点将快照编译为进程内结构并热替换;本地缓存用于离线或控制面不可达时的热启动。当前事实主要由 README.mdCargo.tomlconfig.example.tomlsrc/ 下的实现支撑。

架构图:

client
  |
  v
optional public rove-relay -- rove-ingress/1 QUIC --> NAT-side connector
  |
  v
listeners: HTTP CONNECT / absolute-form / SOCKS5 / TUIC / T1 SNI gateway, optional TLS where applicable
  |
  v
engine: authenticate + decide over ArcSwap snapshot
  |                    ^
  |                    |
  v                    |
outbound: direct / HTTP upstream / SOCKS5 upstream
  |                    |
  v                    |
origin or upstream     |
                       |
control plane HTTP snapshot sync
  |
  v
local cache: data/snapshot.json

项目画像(目标状态)

Rove 应该成为一个可以部署在边缘节点上的自包含应用出口平面(application egress plane):为应用侧的出站流量提供身份、策略、路由、出口选择、限速与审计。配置面简单、热路径短、失败模式可预期,运维人员不需要理解一张 service / chain / hop / listener / connector 的组合图,也不需要在节点侧维护反范式的用户数据。

项目优先级是:链路正确性和策略一致性高于功能数量;离线可启动和快照热替换高于控制面实时性;节点二进制的可审计性和低依赖高于插件式扩展的灵活性。新增能力必须服务于「应用出口平面」这个核心角色,不能把节点扩张成控制面、管理后台或通用流量编排平台。

工程质量上,Rove 不能接受“先堆功能、后补测试”的开发方式。任何影响出口链路、认证、策略、限速、快照同步、MQTT 运维通道、配置解析或安全失败模式的变化,都必须先定义可失败的自动化验收,再通过最小实现让测试通过。覆盖率是项目准入门槛,不是发布后的补救项。

用户体验上,节点应该保持少量明确配置:节点身份、控制面地址与令牌、监听列表、日志等级。运行时行为应该可观察、可回退、可解释;当控制面失败、快照无效、出口不可达或认证失败时,节点应给出明确结果,而不是静默降级成绕过策略的开放代理。

当前能力清单

  • 单体 Rust 代理二进制

    Cargo.toml 定义 rove 二进制入口为 src/main.rs,Rust 最低版本为 1.88。当前实现使用 Tokio、rustls、reqwest、ArcSwap 等库,不依赖 GOST、gRPC、数据库或 OpenSSL 系统库。

  • HTTP CONNECT、absolute-form、SOCKS5 与 T1 SNI 应用出口前端接入

    src/inbound/listener.rs 根据监听配置分发 httpsocks5 与 TLS 透明的 sni 协议,并可在 HTTP/SOCKS5 监听层包裹 TLS。src/inbound/http.rs 支持 CONNECT 和明文 HTTP absolute-form;src/inbound/socks5.rs 支持 用户名密码认证后的 CONNECT 与 UDP ASSOCIATE;src/inbound/sni.rs 只接受服务端闭合白名单中的 ClientHello SNI,绑定当前快照用户后复用 policy / egress / splice,绝不终止 TLS 或按任意名称拨号。

  • NAT 后反向公网入口(reverse ingress)

    rove-relay 在公网提供独立的 rove-ingress/1 QUIC 数据面;NAT 内 Rove 通过可重复的 [[reverse_ingress]] connector 主动注册,只能把 relay 预授权的 TCP/UDP 端口映射到本机已经声明的 listener 名称。TCP 每连接一条独立 QUIC stream,UDP 保持 datagram 语义并按有界 flow 转发,可承载真实 TUIC 握手;relay 不终止用户 TLS、不持有用户证书私钥、不执行用户策略。节点 token、端口池、并发/flow 上限、动态租约 grace、MTU 与原始客户端地址关联均 fail-closed,详见 反向公网入口

  • 用户认证、过期校验、策略决策和热替换

    src/engine.rs 使用 ArcSwap<Snapshot> 提供热替换快照,认证路径按用户名查找用户并检查密码与过期日期。src/model.rs 将控制面下发的唯一一种快照 schema(schema_version: 1users + routing_policies + egresses 三张独立表)按节点 node_id 编译为运行期 SnapshotSnapshot::compile(doc, node_id))。决策按有序 route first-match-wins 选择 named egress / direct / block,未命中执行 policy 的 default_action(同一套 egress / direct / block 词汇,其中 block 表达 deny-by-default 策略),没有 default 则直连。全部 wire 结构 deny_unknown_fields,异形或含未知字段的文档整份拒收而非半懂半猜地执行。node_overrides 让控制面向所有节点发同一份快照,同时仍能给个别节点整项替换已存在的 egress realization(不能新增 node-only egress,也不能改 policy),详见 docs/snapshot-protocol.md

  • 域名与 IP 规则匹配

    src/policy/domain.rs 支持默认后缀匹配、full: 精确匹配和 keyword: 关键字匹配;src/policy/ip.rs 支持单 IP 和 CIDR。当前 cargo test 覆盖这些匹配语义,测试结果为 4 项通过。

  • rove-addrbook 版本化地址数据集(.rab + book: 规则 scheme)

    src/addrbook/ 实现稳定的 .rab 二进制格式(小端、偏移寻址、SHA-256 尾部校验、确定性构建、加载期全量不变量校验,规范见 docs/addrbook-format.md)与层级分类查询(IP 区间二分、域名 exact/后缀/关键字、google 自动展开 google/ads 等子孙)。控制面快照在 route selectors 里用 book:<category> 引用分类;同一条 route 内显式规则与 addrbook 分类按“或”组合,跨 route 的优先级由 routes 数组顺序决定。快照编译期钉住书版本,书热替换 = 重编译最近快照,成功才双双替换。fail-closed:无书或未知分类拒绝整个快照,坏工件启动即拒绝、运行期保留旧书。rove-abctl 提供 fetch/build/verify/diff/query/bench 采集构建工具链,支持 cidrs、Rove 域名规则、v2fly domain-list、AWS/Azure/GCP 官方地址段六种数据源,diff --max-shrink 作为发布异常门。控制面快照里的显式地址仅作补充,addrbook 是主要地址源。数据发布走独立于二进制版本的定期通道:.github/workflows/addrbook-release.yml 用仓库清单 addrbook/book.toml 定时构建,经 verify/diff 门/探针后发布到滚动 Release 标签 addrbook-latest;数据门禁失败只阻断数据更新,不影响代码发布。

  • direct、HTTP upstream、SOCKS5 upstream 出站连接

    src/outbound/mod.rs 支持直连、HTTP CONNECT 上游出口、SOCKS5 上游出口,并允许 upstream 连接使用 TLS。HTTP upstream 可带 Basic 认证,SOCKS5 upstream 可带用户名密码认证。每个 upstream 可通过 skip_cert_verify(默认 false)单独关闭 TLS 证书链/主机名/有效期校验,用于自签名证书或纯 IP 的 hop 节点;这是逐个 upstream 的显式开关,不影响入站监听端的 TLS 校验,也不存在全局开关。

  • 每用户字节速率限制

    src/io.rs 在双向 splice 中按用户 up_ratedown_rate 应用字节令牌桶;速率为 0 时使用 copy_bidirectional 快路。

  • 控制面轮询、快照缓存与热启动

    src/sync/mod.rs 从配置的 snapshot_url——完整地址,不拼接任何固定路径,只追加 ?since=/&since=——拉取快照。接口不带 node_id,所有节点命中同一个 URL 并收到完全相同的响应体。使用 Bearer token,支持 304 不变更语义,先读本地缓存再立即尝试一次控制面同步;远端新版本先编译验证,只有可服务的快照才会原子写回缓存并热替换引擎快照,连续同步失败会退避重试。需要按节点区分的出口(如不同边缘位置的本地 hop)由节点拿到同一份响应体后,用本地配置的 node_id 去响应体自带的 node_overrides 里自选、在本地合并,控制面不需要知道请求者是哪个节点。

  • MQTT 异步运维通道

    src/mqtt.rs 在配置启用后连接 MQTT broker,沿用旧版默认主题响应用户策略查询和同步指令;src/trace.rs 支持拨测前短 TTL 武装追踪,匹配到下一条 HTTP CONNECT 或 SOCKS5 连接后回传阶段结果;src/diagnostics.rs 在此之上提供可选的诊断事件会话:按用户维度武装有限时长会话,对每条匹配连接持续发布脱敏事件并在到期或取消时汇总,默认关闭、不落盘;连接完成路径只进入有界同步临界区,发布使用 try_send、不执行异步等待。消息契约见 docs/mqtt-integration.md

  • 结构化访问日志

    src/access_log.rs 是老版本 GOST 详细流量日志的直接替代品:每条完成的连接(成功或在任一阶段失败)落一行 kind:"connection" 的 JSONL,字段含客户端来源 client_addr、用户名、目标、决策(decision 携带具体上游地址如 upstream:10.0.0.5:1080 而非仅类别)、结果、失败阶段、字节数等;reverse ingress 流量还带 relay_instance_idtunnel_session_idingress_id/flow_id,可和 relay JSONL 联合溯源真实 IP。日志永不包含密码或 token,独立于 log.level 和 MQTT。默认开启,用 tracing-appender 按天轮转,按文件名日期保留 7 天;热路径经有界 mpsc 队列非阻塞投递,队满丢弃并计数;可选转发到远程 syslog(手搓最小 RFC 3164,支持 UDP/TCP,TCP 写入带 3 秒超时以避免卡死的远程 collector 拖垃后台写入任务)。同一条流水线每 60 秒还额外写一行 kind:"stats" 记录,按 listener(不按用户,避免无界基数)聚合当前活跃连接数与累计/增量字节数,是老版本 Go ObserverEvent 周期性 stats 事件最接近的对应物,也是连接量低谷期证明进程和 listener 仍存活的心跳信号。

  • 内置只读 SNMP agent(v2c + v3 USM)

    src/snmp/ 内嵌一个默认关闭的只读 SNMP agent(roverove-hop 都支持),供 Cacti / LibreNMS 等标准 NMS 直接轮询:手写 BER 编解码子集,暴露标准 system 组与企业子树下的 listenerTable / egressTable(每监听入口、每出口的活跃连接 Gauge32 与累计上/下行字节 Counter64,由 src/stats.rs 在热路径原子维护)。SNMPv2c 走 community(常量时间比较)+ 来源 CIDR 白名单;SNMPv3 USM 支持 SHA-1/SHA-256 认证、AES-128-CFB 加密、密钥本地化、engineBoots 落盘和完整的 discovery/Report 流程,安全策略 fail-closed(用户必须有认证;配了加密就强制 authPriv)。只实现 GET/GETNEXT/GETBULK,SET/TRAP/INFORM、MD5/DES 永不支持;SNMP 故障(端口占用、畸形包)不影响代理转发。MIB 与 Cacti 接入见 docs/snmp-cacti.md

  • 扁平 TOML 配置

    src/config.rsconfig.example.toml 表明节点配置集中在节点 ID、控制面、监听和日志等级。TLS 由监听项内的默认证书和私钥路径开启;TCP listener 还可声明额外的 SNI → 证书映射,在同一 IP:port 和单个 Rove 进程内服务多个独立证书,未知或缺失 SNI 回退默认证书。

  • 内嵌 Subnetra 组网底座(hub / spoke)

    src/subnetra/ 原生实现 Subnetra v1 线格协议,作为可插拔的轻量 Layer-3 加密隧道底座——无需单独部署守护进程、无需 TUN(规范 §1 允许用户态 IP 栈)。数据面(crypto/wire/replay/session/peer/reactor)实现 BLAKE2b-256 keyed KDF + ChaCha20-Poly1305、20 字节小端头、头部混淆、64 位滑动防重放、认证前不改状态的 epoch 前向排序、内源过滤、认证后端点学习与最长前缀路由(hub 可 relay、禁反射);netstack/(smoltcp,Medium::Ip)在 overlay 上终结内层 TCP,产出 AsyncRead/AsyncWrite 流接入现有代理机制。mode = "hub" 在 overlay 上跑 HTTP/SOCKS 代理入口(可无 TCP 监听);mode = "spoke" 作为 upstream.kind = "subnetra" 出口把流量打进隔离网段,fail-closed 不回落直连。与现有 Zig 版 subnetra 线兼容,由 tests/subnetra_conformance.rs 对参考 KAT 向量逐字节校验。配置见 docs/subnetra.md

非目标(铁律)

  • 不回到 GOST 插件运行时。

    Rove 的核心价值是把数据面和策略控制收进一个可审计二进制;重新依赖 GOST、gRPC 插件或外部代理编排图会破坏项目边界。

  • 不在节点内实现控制面或管理后台。

    节点只消费编译快照,不成为用户、套餐、计费、审计、租户或 Web 管理的真相来源。

  • 不引入控制面长连接推送通道(SSE / WebSocket)。

    推送通道会让节点的策略生效路径依赖与控制面之间的长连接状态,耦合性太强,破坏“HTTP 拉取 + 本地缓存 + 离线可服务“这一松耦合契约。低延迟同步需求已由 MQTT 同步指令覆盖:控制面发一条 sync_command,节点立即拉取一次快照——传输通道(MQTT broker)与真相来源(快照 URL)保持分离。轮询间隔可配短至秒级,无需第二条推送协议。

  • 不把节点做成「先堆协议、再补正确性」的厨房水槽。

    当前可靠热路径是 HTTP CONNECT、SOCKS5、TUIC 与 T1 SNI 网关,它们是 listener adapter,不是产品本身。 新的接入方式必须先证明自己服务的是应用入口,并且有独立的认证命名空间、fail-closed 失败路径和 E2E,才能加到 identity → policy → route → egress 主干上。 不得为了协议清单去引入消费级代理生态的协议或客户端一键配置。

  • 不把出口平面做成通用反向代理或 API 网关。

    Rove 可以在入站侧增加以服务端声明的 origin 为准的网关型 listener(例如按 SNI 路由的 TLS 透传入口),把它们当作又一种 listener adapter 复用同一条 identity -> policy -> route -> egress 主干。但 origin 必须由节点本地配置或快照声明,绝不能由客户端的 Host、URL 或路径 表达——那等于把出口平面退化成开放正向代理和 SSRF 入口。请求改写、鉴权卸载、限流计费、 证书签发、L7 负载均衡策略、虚拟主机管理这类 API 网关职责不属于 Rove。

  • 不把 AI Provider 或交易柜台的 L7 业务语义嵌入节点热路径。

    Agent API 与投资交易是一等场景,但优化的是路径、出口、认证、限速和可观测性。 OpenAI / Anthropic 的请求体、SSE、API key 账本,以及券商成交、持仓、资金划转语义, 不属于 Rove 节点。若未来需要此类能力,必须由独立进程承载,不能假设与节点同机。

  • 不允许策略失败时开放放行。

    认证失败、账号过期、block 命中、快照编译失败或上游连接失败时,默认行为必须保持保守,不能为了可用性绕过访问控制。

  • 不在仓库里固化生产密钥、真实节点令牌或客户策略数据。

    这是公开仓库的铁律。示例只能使用占位值;data/logs/、真实证书、token、内部控制面 地址不得入库。每次推送、打 tag、发 Release / crate 前必须跑 scripts/check-public-tree.sh。 详见 AGENT.mdSECURITY.md

  • 不把方向文档当作日常任务看板。

    本文档维护目标画像和边界,不记录每个提交、每个 PR 或每次发布状态。

  • 不接受无测试驱动的功能开发。

    新增或变更功能必须先落下能表达预期行为和失败边界的自动化测试,再实现代码;修复缺陷必须先复现缺陷并让测试失败。没有对应测试的代码改动只能进入探索分支,不能合入主线。

  • 不允许覆盖率低于 80%。

    仓库主线的 Rust 代码行覆盖率必须保持不低于 80%,CI 使用 cargo llvm-cov --fail-under-lines 80 执行门禁。低于该门槛时,不得发布新功能;安全失败模式、认证、策略决策、快照编译、协议握手、上游连接和限速路径不得用整体覆盖率达标掩盖局部无测试。

方向与意图

  • 建立测试基线和 TDD 门禁

    当前测试覆盖仍然不足,项目需要把测试从“局部验证”提升为“开发驱动”。目标状态是每个功能入口都有先失败、后实现、再回归的测试证据;覆盖率报告能在本地和 CI 中稳定产出;低于 80% 或关键路径缺测会阻断合入和发布。

  • 补齐运行健康与可观测性:已交付主动健康探针

    已交付独立、默认回环监听的 /healthz/readyz:编排系统可区分进程存活、未加载快照、无活跃数据面 listener、已加载的快照/schema 版本、控制面持续不可达和停机排空;响应不包含 URL、令牌、用户密码或策略内容。显式配置的 TCP/TUIC listener 会在后台服务启动前完成绑定和 TLS 校验,失败时节点非零退出;运行期无活跃 listener 时 readiness 返回 503。网络隔离场景仍可通过 MQTT 节点状态和拨测追踪确认故障点;结构化访问日志与 kind:"stats"、SNMP 继续承担历史连接和流量观测。Prometheus 指标端点仍按真实需求评估。

  • TCP 首包运营识别:已交付 observe-only 与 HTTP/SOCKS5/TUIC route

    HTTP CONNECT、SOCKS5 CONNECT 与 TUIC TCP Connect 可按 listener 显式启用有界首包观察,从 TLS ClientHello 或 HTTP/1 请求提取规范化域名。observe 不改变握手/拨号时序;route 在拨号前执行 requested + sniffed 双候选策略:任一 block 即拒绝,仅当 requested 为 IP 时允许 sniffed 域名选择 proxy 出口,实际目标永远不改写。HTTP/SOCKS5 的 route 必须先向客户端确认隧道(200 / SOCKS5 成功) 客户端才会发送首包,再决定出口。访问日志区分 requested/sniffed/effective/target identity,周期 stats 只按 listener 记录固定结果枚举,域名不进入指标 label。默认关闭,不保存 URL、header 集合或 payload;ECH、QUIC/HTTP3 与 UDP sniff 仍是后续独立方向。

  • 加强认证与策略安全硬度

    密码比较、快照编译失败处理、日志脱敏、TLS 证书加载错误和认证失败反馈都应保持可审计、保守和低泄露。README 中已列出密码常量时间比较,这是值得优先收敛的安全方向。

  • 让停机和升级更适合生产节点:已交付有界排空

    节点同时接收 SIGINTSIGTERM,收到信号后立即停止 TCP、TUIC 与 Subnetra hub 的新接入,并在 [shutdown].grace_period_secs 的有界窗口内排空在途连接;完成或超时均有明确日志,超时后强制终止剩余会话并以退出码 0 结束。tests/shutdown_integration.rs 同时守护窗口内传输完成与超时必退语义。

  • HTTP 应用入口:已交付 absolute-form 明文转发

    普通 HTTP 绝对形式 GET/POST 已与 CONNECT 共用认证、过期、策略、连接数限制、限速、出口选择和访问日志语义;转发前移除代理凭据与逐跳头,改写为 origin-form,并以单请求关闭控制边界。CONNECT 仍是 HTTPS 主路径;透明代理、缓存、内容改写和浏览器网关不在范围内。

  • 移动端 TLS 入口:已交付 TUIC v5 前端接入

    TUIC v5 前端已落地为 TUIC v5 前端接入(QUIC/TLS 1.3)。节点仍本地执行用户过期、策略分流、限速和连接数限制;身份归属只做查表(快照按协议命名空间 frontends.<协议>uuid -> username 索引,前端凭据独立于登录密码),不从报文还原用户名、不复用登录口令。frontends 结构让后续 listener adapter 按协议命名空间纯加法接入。

    TCP Connect 复用现有出口并按用户限速;UDP Packet 走反向 hop 的 UDP 出口。Web fallback 伪装、TUIC over 其它传输等仍需分别证明真实应用入口需求,不搭车。

  • NAT 后公网接入:已交付 reverse ingress

    公网 rove-relay 与 NAT 内 connector 已提供统一节点认证、预授权固定/动态端口、原生 TCP/UDP、 多 relay 独立会话、结构化集中观测和真实客户端 IP 关联。该能力严格属于接入基础设施,不进入用户策略 快照;relay 不成为用户认证/策略控制面,也不允许指定任意内网目标。UDP 保证 1200B 内层 datagram, 编码后超出 Quinn 路径能力时明确丢弃并计数,不通过可靠 stream 模拟 UDP,不实现通用分片重组。

  • UDP relay:已交付 reverse/2(client→server)与 SOCKS5 UDP ASSOCIATE

    UDP 出口已落地为 reverse/2 UDP relay:经反向 hop 出口、维持认证/策略/可观测一致语义,适用于 WebRTC→SFU、实时 API、游戏连专用服务器等 client→server 场景(EIM + address-restricted、不分片、不做 full-cone/P2P)。前端侧 TUICSOCKS5 UDP ASSOCIATE 均已接到此出口。UDP 分片、full-cone 打洞等仍只在明确的真实业务场景下才进入范围。

  • 收敛限速精度和突发行为

    当前令牌桶已经提供每用户字节速率限制;后续方向是让突发容量、双向统计和误差表现更可解释。任何调整都必须以不破坏代理吞吐和连接稳定性为前提。

  • 应用场景优先:Agent API 与投资交易

    文档、示例和基准应覆盖「按模型/供应商选出口」「交易与行情固定出口」「Webhook 回源 IP 稳定」这类路径问题。 不在节点内解析业务 payload。

  • 新 listener adapter 必须先证明应用入口需求

    frontends.<协议> 只是加法挂钩,不是「协议动物园」的开工许可。候选接入方式必须先回答: 它服务哪一类应用入口、认证如何 fail-closed、对现有 HTTP/SOCKS5/TUIC 热路径是否零回归。 消费级代理生态的协议、订阅和一键客户端配置不在范围内。

  • 应用出口网关:T1 SNI 透传已交付,T2 声明式 origin,T3 不做

    产品边界与术语见 应用出口网关。T1 已复用受限 ClientHello sniff / PrefixedIo / splice,不终止 TLS,且以 listener 绑定的有效快照身份和精确 origins 白名单 fail-closed;T2 可以做, 但 origin 必须由服务端声明,绝不能来自客户端 Host / URL。 通用反代、证书签发、后端池、WAF 不是 Rove 的事——发布内网服务走 reverse ingress / Subnetra。 新入口不要再叫 reverse:仓库里 reverse hop 与 reverse ingress 已经各占一次。

验收流程与标准

任何整体功能、方向能力或可发布变更,必须经过同一套可复现验收流程。验收材料应能说明“需求是什么、先写了哪些失败测试、实现后哪些测试通过、覆盖率是多少、哪些风险仍未关闭”。

各一级能力与测试锚点的对应关系由 验收矩阵(业务能力覆盖矩阵)维护,五条硬性不变量为:每个一级功能至少一条 Happy Path;每个高风险功能至少一条失败路径;每个涉及凭据/身份的功能至少验证两种角色结局;每个修改系统状态的操作至少验证一次失败后的恢复或回滚;每次新增一级业务功能必须同步新增对应 E2E 并在矩阵中登记。矩阵中的 ⚠️ 缺口在对应方向标记完成前必须先关闭。

  1. 需求验收定义

    开发前必须把目标行为、失败边界、兼容性要求和不可接受行为写清楚。涉及安全失败模式时,要明确认证失败、账号过期、策略拒绝、快照无效、控制面不可达、上游失败等场景的期望结果。

  2. TDD 证据

    功能实现前必须提交或保留能失败的测试用例,证明测试确实覆盖新增行为或缺陷复现。测试通过后,不能删除或弱化这些用例来迁就实现。

  3. 自动化测试门禁

    合入前至少要通过格式化、静态检查、单元测试、集成测试和关键链路回归测试;具体命令以仓库 CI 为准。CI 未覆盖的验收项不得只写在 PR 描述里,必须补成可执行脚本、测试或明确的人工验收记录。

  4. 覆盖率门禁

    覆盖率必须不低于 80%,统计范围以仓库内 Rust 业务代码为准,排除生成物、示例配置和部署产物。覆盖率报告必须能在本地和 CI 复现;如果不同工具口径不一致,以 CI 中固定工具的结果为准。

  5. 关键路径专项验收

    HTTP CONNECT、SOCKS5、TLS 监听、T1 SNI 透明网关、认证失败、账号过期、block、direct、HTTP upstream、SOCKS5 upstream、限速、快照编译、热替换、缓存热启动、控制面 304、MQTT 查询和同步指令、MQTT 拨测追踪与诊断事件会话、reverse ingress 的认证/租约/TCP/UDP/TUIC/MTU/恢复、访问日志记录与轮转保留清理,都必须有自动化验收覆盖。缺少其中任一项时,相关方向不得标记完成。

    如果新增 listener adapter,完成前还必须覆盖:有效凭据归属到正确用户、未知凭据保守拒绝、目标解析失败拒绝、block 命中拒绝、direct 转发成功、HTTP/SOCKS5 upstream 转发成功、过期用户拒绝、限速生效、连接数限制生效、TLS listener 配置错误拒绝启动,以及至少一次真实客户端兼容拨测。

  6. 发布前验收

    发布前必须确认测试全绿、覆盖率达标、配置示例不含真实凭据、日志不泄露密码或令牌、失败模式默认保守、README 与路线图没有把未完成能力写成已完成事实。

完成的样子

Rove 达到目标状态时,应表现为一个小而稳的应用出口节点:启动配置清楚,快照同步和缓存语义可靠,核心协议链路有自动化验证守护,安全失败模式明确,运维可以通过健康和指标判断节点状态。

  • 核心出口路径被自动化回归守住。

    HTTP CONNECT、SOCKS5、认证失败、账号过期、block、direct、HTTP upstream、SOCKS5 upstream、限速和快照热替换等路径不应只依赖手工冒烟;回归必须能在本地或 CI 中被拦下,且整体覆盖率持续不低于 80%。

  • 生产部署能判断节点状态。

    节点应暴露足够的健康、版本、快照和错误状态,让编排系统可以区分未加载快照、控制面暂时不可达、配置错误、上游错误和正常服务中。

  • 安全边界清楚且默认保守。

    密码、令牌、证书路径、快照内容和用户策略不会被无意写入日志或文档;任何策略或认证不确定状态都不会变成开放代理。

  • 方向扩展不破坏项目形状。

    新增协议、指标、推送或限速能力后,项目仍然保持单体节点、控制面快照消费方、少量配置和可审计热路径这几个基本特征。

  • 文档与代码保持一致。

    当 README、配置示例或实现行为发生变化时,本文档的当前能力和非目标要同步校正;不确定的能力应标为待核验,而不是写成已完成事实。

验收矩阵(业务能力覆盖矩阵)

本文档是 docs/roadmap.md「验收流程与标准」的执行载体:把路线图里的每个一级业务能力映射到 可执行的自动化测试锚点,回答“这条能力靠什么证据算验收通过“。矩阵与代码同仓演进, 引用的测试必须真实存在;测试重命名或删除时必须同步修订本表。

硬性不变量(合入门禁)

以下五条对本仓库是硬性规定(同时固化在 AGENT.md),不满足时相关变更不得合入主线:

  1. 每个一级功能至少有一条 Happy Path 自动化验收。
  2. 每个高风险功能至少覆盖一条失败路径。 凡涉及认证、策略决策、加密、快照/状态写入、 出站选择的能力一律视为高风险;失败路径必须验证保守行为(fail-closed),不能只测成功。
  3. 每个涉及凭据/身份的功能至少验证两种角色。 Rove 没有传统 RBAC,“角色“指身份结局: 例如 合法用户 vs 过期用户、正确密码 vs 错误密码、正确 token/community vs 错误值。
  4. 每个会修改系统状态的操作至少验证一次失败后的恢复或回滚。 例如无效快照不得覆盖缓存、 连接数拒绝后配额释放、日志轮转清理、engineBoots 跨重启持久化。
  5. 每次新增一级业务功能,必须同步新增对应的 E2E(tests/ 下集成测试),并新增本表一行。 只有单元测试不算完成;缺 E2E 的功能只能停留在探索分支。

矩阵总览

图例:✅ 已有自动化验收(锚点见下文明细)· ⚠️ 已识别缺口或待核验 · — 该维度不适用(须给出理由)。

一级能力Happy Path失败路径角色/凭据恢复/回滚E2E(tests/
HTTP CONNECT 前端proxy_integration
HTTP absolute-form 前端— 单请求无持久状态proxy_integration
SOCKS5 前端(含 UDP ASSOCIATE)proxy_integration / socks5_udp_integration
TLS 监听(含 SNI 多证书)— 无用户角色— 无状态写入tls_sni_integration
T1 SNI 透明应用出口网关✅ 绑定身份有效 / 未知 / 过期✅ 连接配额拒绝后释放sni_gateway_integration
认证与过期校验— 只读决策✅ 经 proxy_integration 链路
策略决策与规则匹配— 只读决策proxy_integration(block/direct)
出站(direct / HTTP / SOCKS5 upstream)✅ dns/dial/tls 分阶段✅ 经 proxy_integration / reverse_hop_integration
每用户限速— 令牌桶无失败分支— 无独立状态写入proxy_integration
连接数限制proxy_integration
控制面快照同步 / 缓存 / 热替换snapshot_sync_integration
健康探针与 listener readiness— 无凭据health_integration
MQTT 运维通道(查询/同步/拨测/诊断)mqtt_integration
结构化访问日志— 无角色proxy_integration
TCP 首包 observe-only 识别— 不涉及新凭据— 只读观察proxy_integration / tuic_integration
TUIC sniff route 双候选策略✅ requested/sniffed 身份结局— 只读决策tuic_integration
HTTP/SOCKS5 sniff route 双候选策略✅ requested/sniffed 身份结局— 只读决策proxy_integration
SNMP agent(v2c + v3 USM)snmp_integration
反向 hop(QUIC)+ reverse/2 UDPreverse_hop_integration
反向公网入口(TCP/UDP)reverse_ingress_integration
TUIC v5 前端— 无状态写入tuic_integration
Subnetra hub / spoke✅ 密钥即身份— fail-closed 即恢复语义subnetra_* 四个套件
独立 hop 二进制(rove-hop)rove_hop_integration
hop MQTT egress doctor— 只读一次探测,无状态写入hop_mqtt_integration
优雅停机(SIGINT/SIGTERM)— 无身份语义shutdown_integration
配置解析(TOML)— 无身份语义— 解析不修改外部状态— 单元层验收即可
rove-addrbook(.rab 数据集 + book: 规则)✅ 未知分类/缺书拒快照— 无独立用户角色,沿用 policy/route✅ 坏工件保旧书、坏书换失败不动快照addrbook_integration(含 golden 向量)
Snapshot routing schema(routing policy + named egress)✅ 未知字段/缺失引用/坏 override 拒绝✅ policy 与未知用户 fail-closed✅ 坏快照不替换内存快照/cachesnapshot_routing_integration / snapshot_validator_integration

测试锚点明细

锚点格式为 文件::测试名。以下按能力列出各维度的证据。

HTTP CONNECT 前端

  • Happy Path:tests/proxy_integration.rs::http_connect_direct_tunnels_bytes
  • 失败路径:src/inbound/http.rs::rejects_method_missing_auth_bad_auth_and_bad_targettests/proxy_integration.rs::http_connect_blocked_by_policy_returns_403_without_dialing_out
  • 角色:合法用户成功(Happy Path)vs 过期用户拒绝 src/inbound/http.rs::rejects_expired_users_before_policy、错误凭据拒绝(同上失败用例)
  • 恢复/回滚:tests/proxy_integration.rs::http_connect_max_connections_rejects_second_tunnel_then_releases

HTTP absolute-form 前端

  • Happy Path:tests/proxy_integration.rs::http_absolute_get_forwards_origin_form_and_strips_proxy_headershttp_absolute_post_forwards_body_sent_with_request_head
  • 失败路径 / 角色:tests/proxy_integration.rs::http_absolute_request_requires_auth_before_dialing_origin 对照有效凭据的 Happy Path,验证缺失或错误认证不会先拨号。
  • 恢复/回滚:单请求转发不持久化状态,不适用。

SOCKS5 前端(含 UDP ASSOCIATE)

  • Happy Path:tests/proxy_integration.rs::socks5_connect_direct_tunnels_bytestests/socks5_udp_integration.rs::socks5_udp_associate_relays_through_hop_to_echo
  • 失败路径:src/inbound/socks5.rs::rejects_auth_failure / rejects_unsupported_command / rejects_target_blocked_by_policy / rejects_when_upstream_connect_fails
  • 角色:正确用户名密码 vs 错误凭据(rejects_auth_failure
  • 恢复/回滚:tests/proxy_integration.rs::socks5_max_connections_rejects_second_tunnel_then_releases

TLS 监听

  • Happy Path:src/inbound/listener.rs::run_accepts_and_dispatches_http_over_real_tlstests/tls_sni_integration.rs::single_tls_listener_selects_certificates_by_sni_and_tunnels_http_connect 通过真实 rove 进程验证同一 IP:port 按两个 SNI 返回不同叶证书,两条连接随后都能完成 HTTP CONNECT 并双向传输字节;未命中的 SNI 回退默认证书。
  • 失败路径:src/inbound/listener.rs::run_reports_bind_errors_for_invalid_addresssrc/tls.rs::cert_and_key_loaders_report_missing_or_empty_filestests/tls_sni_integration.rs::duplicate_sni_mapping_fails_startupcertificate_that_does_not_cover_sni_fails_startupcertificate_without_server_names_fails_startup 验证重复域名、证书 SAN 不匹配和空名称列表均使真实进程 fail-closed 非零退出。
  • 本地 Docker 验收:./scripts/accept-local-tls-sni.sh 在主机 18443 端口验证两个 SNI 返回各自证书,并通过两条 HTTPS 代理连接分别完成 HTTP CONNECT。

T1 SNI 透明应用出口网关

  • Happy Path:tests/sni_gateway_integration.rs::sni_gateway_transparently_tunnels_allowed_sni_through_selected_egresssni_gateway_transparently_tunnels_allowed_sni_through_socks5_egresssni_gateway_transparently_tunnels_direct_egress_on_the_listener_port 分别以真实 TLS ClientHello 验证 HTTP upstream、SOCKS5 upstream 和 direct 路径均完整回放已读字节并双向传输。
  • 失败路径:sni_gateway_rejects_unlisted_sni_and_non_tls_before_dialing_egresssni_gateway_rejects_unknown_or_expired_bound_identity_before_dialingsni_gateway_honors_a_block_policy_before_dialing_egress 验证白名单外、非 TLS / 无 SNI、未知或过期身份、 以及 block 均在 egress 拨号前关闭。src/inbound/sni.rs::gateway_only_accepts_a_matched_tls_sni 覆盖 ClientHello 分类的最小门槛。
  • 角色/凭据:有效 listener 绑定用户成功;未知和过期的绑定用户拒绝,见同一集成测试。
  • 限速与恢复:sni_gateway_applies_the_bound_identity_down_rate 验证快照 down_rate 生效; sni_gateway_releases_a_connection_limit_after_the_tunnel_closes 验证超出上限拒绝,并在首条隧道关闭后释放配额。
  • 配置与审计:src/config.rs::sni_listener_requires_a_bound_identity_and_closed_dns_origins 拒绝空身份、空/无效/重复 origin 和 TLS 误配;src/access_log.rs::pre_target_rejection_keeps_the_bounded_sniff_outcome 验证在目标尚未确定时的 SNI 拒绝仍保留有界嗅探结果。

认证与过期校验

  • Happy Path:src/inbound/http.rs::authenticates_username_and_password_containing_special_characters
  • 失败路径 / 角色:src/inbound/http.rs::rejects_expired_users_before_policy(过期用户)、 错误密码拒绝(HTTP/SOCKS5 失败用例);常量时间比较 src/engine.rs::constant_time_eq_matches_string_equality

策略决策与规则匹配

  • Happy Path:src/policy/domain.rs::suffix_matches_subdomains / full_and_keywordsrc/policy/ip.rs::single_and_cidr / many_exact_hosts_keep_or_semanticssrc/model.rs::first_match_keeps_declaration_order_on_overlap / indexed_first_match_agrees_with_linear_scan(索引与线性扫描对同一 host 集同结果)、 tests/snapshot_routing_integration.rs::overlapping_routes_resolve_in_declaration_order
  • 失败路径(fail-closed):src/model.rs::decide_blocks_for_an_unknown_user_or_a_dangling_policytests/proxy_integration.rs::http_connect_blocked_by_policy_returns_403_without_dialing_out
  • 角色:block policy 用户被拒 vs direct policy 用户放行(proxy_integration 中不同 policy 的引擎构造)
  • 复杂度回归:src/model.rs::many_full_routes_miss_stays_sublinear (2000 条 full: 路由 × 20000 次未命中必须低于 80 ms,锁住 O(n) 回潮)

出站(direct / HTTP upstream / SOCKS5 upstream)

  • Happy Path:src/outbound/mod.rs::direct_connect_tunnels_bytes / http_upstream_connects_with_basic_auth_and_tunnels / socks5_upstream_connects_with_auth_and_tunnels
  • 失败路径:http_upstream_refusal_is_reportedsocks5_upstream_failures_are_reportedtls_upstream_with_self_signed_cert_is_rejected_by_default(默认拒绝自签名); 访问日志分阶段 tests/proxy_integration.rs::http_connect_unresolvable_host_records_dns_stage / http_connect_refused_port_records_dial_stage / http_connect_upstream_tls_handshake_failure_records_tls_stage
  • 角色:upstream Basic 认证 / SOCKS5 用户名密码(Happy Path 用例内验证)
  • 恢复/回滚:tls_upstream_with_skip_cert_verify_accepts_self_signed_cert (逐 upstream 显式开关,验证默认关、显式开两种状态)

每用户限速

  • Happy Path(不限速快路):src/io.rs::splice_reports_byte_counts_on_unthrottled_fast_path
  • 限速生效:tests/proxy_integration.rs::http_connect_down_rate_throttles_target_to_client_bytes / http_connect_up_rate_throttles_client_to_target_bytes
  • 角色:限速用户 vs 零速率用户走 64 KiB 无限速快路(两组用例对照)

连接数限制

  • 全维度:tests/proxy_integration.rs::http_connect_max_connections_rejects_second_tunnel_then_releases / socks5_max_connections_rejects_second_tunnel_then_releases(拒绝即失败路径,释放即恢复)

控制面快照同步 / 缓存 / 热替换

  • Happy Path:src/sync/mod.rs::sync_once_applies_remote_snapshot_and_saves_cacheload_cache_accepts_valid_snapshot
  • 失败路径:sync_once_rejects_invalid_remote_snapshot_without_overwriting_cacheload_cache_reports_invalid_snapshot_compile_errorload_cache_rejects_oversized_file
  • 恢复/回滚:无效快照不覆盖缓存(同上)、temp-then-rename 原子写 save_cache_round_trips_valid_snapshot 与私有权限 save_cache_writes_private_file_permissions、 304/旧版本不替换 sync_once_treats_304_and_stale_versions_as_no_update
  • 编译门禁:src/model.rs::compile_rejects_a_user_bound_to_an_unknown_policycompile_* 系列、 node_overrides 覆盖 compile_applies_node_specific_egress_override_for_matching_node_id
  • 双角色:合法 token 同步成功(Happy Path 系列)vs 控制面 401/403 拒绝 token 时 fail-closed —— 同步失败、继续热服务已加载快照、缓存文件逐字节不变 sync_once_rejected_token_fails_closed_without_touching_cache
  • 进程级 E2E:tests/snapshot_sync_integration.rs 拉起真实 rove 进程:远程快照热替换后 block 生效、坏快照不覆盖缓存、401 保持旧快照继续服务。

Snapshot routing schema

  • Happy Path:tests/snapshot_routing_integration.rs 覆盖 egress A/B、单 backend/chain、direct、 block、default egress/direct、overlap/order、IP/CIDR、book 与 sniff safety。
  • 失败路径:同文件 strict_rejections_fail_closednode_override_introducing_new_egress_fails_closedsrc/sync/mod.rs::sync_once_rejects_missing_egress_refs_without_replacing_snapshot_or_cache
  • inspection:src/mqtt.rs::user_policy_query_exposes_routes_and_named_egresses_without_credentials
  • public validator:tests/snapshot_validator_integration.rs 覆盖 file/stdin、node override、 addrbook、decode/compile/read/arguments 失败阶段与凭据安全 JSON。

健康探针与 listener readiness

  • Happy Path:tests/health_integration.rs::health_endpoints_report_snapshot_and_sustained_control_plane_failure 验证已加载快照时 /healthz/readyz 的响应。
  • 失败路径:同一 E2E 验证控制面持续不可达后 /readyz=503tests/health_integration.rs::configured_listener_bind_failure_exits_nonzero 验证显式 listener 端口冲突时节点启动失败。
  • 恢复/生命周期:src/health.rs::readiness_tracks_required_data_plane_livenessreadiness_distinguishes_starting_ready_unreachable_and_draining

MQTT 运维通道

  • Happy Path:src/mqtt.rs::user_policy_query_replies_without_passwordssync_command_accepts_empty_payload_and_syncflag_aliases
  • 失败路径:rejects_bad_reply_topicsuser_policy_query_reports_missing_usersync_command_throttle_allows_first_and_rejects_second
  • 拨测/诊断:probe_trace_command_arms_valid_requests_and_rejects_bad_onessrc/trace.rs::armed_probe_reports_once_on_matchsrc/diagnostics.rs::record_publishes_events_only_for_matching_active_sessions / events_never_leak_credentials / event_type_from_candidate_maps_stages_and_skips_parse
  • 恢复/回滚:src/diagnostics.rs::sweep_expired_emits_summaries_and_clears_sessionscancel_unknown_session_returns_nonestart_enforces_global_and_per_user_caps
  • 进程级 E2E:tests/mqtt_integration.rs 经真实 TCP MQTT broker 拉起 rove,用户策略查询 回包不含密码。

结构化访问日志

  • Happy Path:tests/proxy_integration.rs::access_log_file_records_bytes_for_successful_http_tunnel、 stats 记录 src/access_log.rs::access_log_stats_record_json_uses_kind_stats_and_gauge_fields
  • 失败路径:队列饱和丢弃计数 record_drops_and_counts_when_channel_saturated、 syslog 卡死超时 syslog_tcp_send_times_out_on_stalled_peer_instead_of_hanging_forever
  • 脱敏:record_from_candidate_carries_bytes_and_never_leaks_secrets
  • 恢复/回滚:轮转清理 sweep_removes_files_older_than_retention_and_keeps_recentsweep_on_missing_directory_is_a_no_op

TCP 首包 observe-only 识别

  • Happy Path:tests/proxy_integration.rs::http_connect_observe_sniff_records_host_without_changing_tunnel_bytessocks5_connect_observe_sniff_records_host_without_changing_tunnel_bytestests/tuic_integration.rs::tuic_connect_observe_sniff_records_host_without_changing_stream_bytes
  • 失败路径:tests/proxy_integration.rs::socks5_connect_observe_sniff_forwards_unsupported_payload_and_counts_outcome 验证不可识别 payload 原样转发并记录 unsupportedsrc/sniff.rspassive_observer_* 单测覆盖 timeout、limit、incomplete、畸形与精确回放。
  • 配置边界:src/config.rs::listener_sniff_defaults_off_and_parses_observe_bounds / listener_sniff_rejects_invalid_limits_and_modes
  • 隐私/基数:src/access_log.rs::record_from_candidate_carries_bytes_and_never_leaks_secrets / src/stats.rs::sniff_outcomes_are_counted_per_listener_without_domain_labels

TUIC sniff route 双候选策略

  • Happy Path:tests/tuic_integration.rs::tuic_route_unmatched_sniff_replays_captured_prefix_to_requested_iptuic_route_sniffed_proxy_selects_egress_but_dials_requested_ip
  • 失败路径(fail-closed):tests/tuic_integration.rs::tuic_route_sniffed_block_prevents_requested_ip_dial 验证 sniffed block 在任何目标拨号前拒绝;tests/snapshot_routing_integration.rs::requested_block_vetoes_before_sniffed_host / requested_ip_uses_non_block_sniffed_action_first 覆盖 requested block / sniffed block 任一命中即 block、IP 目标按 sniffed 域名选出口、显式域名不被 sniffed egress route 改路。
  • 捕获边界:src/sniff.rs::prefix_capture_returns_match_and_every_consumed_byte / prefix_capture_times_out_without_waiting_for_stream_eof / prefix_capture_enforces_limit_and_preserves_captured_byte
  • 配置边界:src/config.rs::tuic_listener_accepts_sniff_route_mode

HTTP/SOCKS5 sniff route 双候选策略

  • Happy Path:tests/proxy_integration.rs::http_connect_route_unmatched_sniff_replays_captured_prefix_to_requested_iphttp_connect_route_sniffed_proxy_selects_egress_but_dials_requested_ipsocks5_connect_route_sniffed_proxy_selects_egress_but_dials_requested_ip
  • 失败路径(fail-closed):http_connect_route_sniffed_block_prevents_requested_ip_dialsocks5_connect_route_sniffed_block_prevents_requested_ip_dial 验证 sniffed block 在任何目标拨号前拒绝;与 TUIC 共用 decide_with_sniff 双候选规则。
  • 配置边界:src/config.rs::listener_sniff_accepts_route_mode

SNMP agent(v2c + v3 USM)

  • Happy Path:tests/snmp_integration.rs::getnext_walk_and_getbulk_walk_return_the_same_treebyte_counters_are_monotonic_as_traffic_accumulates
  • 失败路径:wrong_community_gets_no_answer_over_udpsource_addresses_outside_the_allowlist_get_no_answerport_conflict_surfaces_as_bind_error_not_panic(SNMP 故障不影响转发)
  • 角色:正确 vs 错误 community;v3 discovery v3_discovery_over_udp_returns_unknown_engine_ids_report; fail-closed 校验 src/config.rs::snmp_validate_enforces_fail_closed_rules
  • 恢复/回滚:src/snmp/usm.rs::engine_boots_increment_across_restarts_and_reset_on_engine_change

反向 hop(QUIC)+ reverse/2 UDP relay

  • Happy Path:tests/reverse_hop_integration.rs::reverse_tunnel_transfers_bytes_both_directionsudp_association_relays_through_hop_to_echo
  • 失败路径:open_without_registered_hop_fails_closedregistration_with_wrong_token_is_rejectedhop_target_connect_failure_is_isolated_to_one_streamudp_open_fails_closed_for_hop_without_udp_capudp_open_fails_closed_without_reverse_plane
  • 角色:正确注册 token vs 错误 token
  • 恢复/回滚:duplicate_hop_id_is_rejected_under_reject_policyreplace_policy_swaps_in_the_new_session
  • 日志脱敏:hop_access_log_records_reverse_decision_without_secrets

反向公网入口(TCP/UDP)

  • Happy Path:tests/reverse_ingress_integration.rs::relay_forwards_tcp_and_1200_byte_udp_with_client_metadata 同时覆盖真实 QUIC 会话、TCP 双向流、UDP datagram、1200B MTU 保证值与真实客户端地址; relay_preserves_end_to_end_tls_termination_at_rove 证明 relay 不终止用户 TLS; relay_carries_a_real_tuic_quic_handshake_over_udp 验证真实 TUIC/QUIC 握手穿过 UDP relay。
  • 失败路径:src/ingress/frame.rs::duplicate_and_unknown_headers_fail_closedreader_stops_before_raw_payload_and_enforces_limittests/reverse_ingress_integration.rs::relay_rejects_bad_token_unknown_listener_and_unauthorized_port
  • 角色/凭据:上述 E2E 覆盖正确 token 与错误 token;relay 配置要求每 node 独立凭据。
  • 恢复/回滚:tests/reverse_ingress_integration.rs::dynamic_tcp_lease_restores_the_same_port_within_grace 验证 session 断开释放 socket,并在 grace 窗口内恢复同一动态端口;connector 使用有界指数退避且不缓存公网流量。
  • 关联与脱敏:src/access_log.rs::reverse_ingress_metadata_is_correlation_safe_and_secret_free

TUIC v5 前端

  • Happy Path:tests/tuic_integration.rs::tuic_connect_tcp_relays_to_echotuic_packet_relays_udp_through_reverse_hop
  • 失败路径 / 角色:tuic_bad_token_closes_connectionsrc/engine.rs::authenticate_tuic_fails_closed_on_bad_inputs vs authenticate_tuic_accepts_correct_uuid_and_token

Subnetra hub / spoke

  • Happy Path:tests/subnetra_netstack.rs::tcp_stream_flows_both_ways_over_the_overlaytests/subnetra_http_over_overlay.rs::http_connect_is_proxied_over_the_subnetra_overlaytests/subnetra_egress.rs::outbound_subnetra_upstream_dials_over_the_overlay
  • 失败路径(fail-closed 不回落直连): tests/subnetra_egress.rs::outbound_subnetra_rejects_non_overlay_host_when_enabled
  • 线兼容 KAT:tests/subnetra_conformance.rs 全套(逐字节向量校验)
  • 压力/恢复:tests/subnetra_netstack.rs::concurrent_connect_burst_beyond_listen_backlog_succeedsbulk_transfer_survives_flow_control

独立 hop 二进制(rove-hop)

  • Happy Path:tests/rove_hop_integration.rs::https_forward_proxy_tunnels_after_trusted_tls_and_cleans_upsocks5_forward_proxy_tunnels_after_authentication
  • 失败路径:tests/rove_hop_integration.rs::https_forward_proxy_rejects_untrusted_tls_bad_credentials_and_failed_upstream 覆盖未受信任 TLS、错误凭据的 407 和出站连接失败的 502。
  • 角色:同一失败路径覆盖有效 gate-service 凭据与错误凭据。
  • 恢复/回滚:https_forward_proxy_tunnels_after_trusted_tls_and_cleans_upsocks5_forward_proxy_tunnels_after_authentication 均验证客户端关闭后目标连接被关闭,不遗留出站隧道。

hop MQTT egress doctor

  • Happy Path:tests/hop_mqtt_integration.rs::hop_mqtt_doctor_reports_tls_failure_after_tcp_ok_without_leaking_secrets 经真实 rove-hop 进程 + 假 MQTT broker + 明文 TCP 目标,断言回包与 doctor egress --json 同构(dns/route/tcp/tls/http),且 tcp.status=oktls.status=failed
  • 失败路径:hop_mqtt_doctor_rejects_missing_target_without_running_probetargetbad_request 且不跑探测;src/hop_mqtt.rs 丢弃前缀外 / 含通配符的 reply_topic
  • 角色/凭据:同一 Happy Path 断言回包不含 hop 代理密码与 MQTT 密码。
  • 恢复/回滚:doctor 是只读一次探测,不写快照或热路径状态;并发第二请求回 throttled,不排队打爆 hop。
  • 默认关闭:src/bin/rove-hop.rs::mqtt_doctor_defaults_off_and_parses_broker

优雅停机

  • Happy Path:tests/shutdown_integration.rs::node_exits_cleanly_on_sigterm / node_exits_cleanly_on_sigint
  • 停止接收与恢复语义:tests/shutdown_integration.rs::sigterm_stops_accepting_and_allows_inflight_tunnel_to_finish 验证停止新接入后在途隧道仍可完成。
  • 有界失败路径:tests/shutdown_integration.rs::graceful_shutdown_forces_exit_after_drain_timeout 验证超过窗口后强制结束且进程按时退出。

配置解析(TOML)

  • Happy Path / 默认值:src/config.rs::load_applies_defaults_and_effective_mqtt_valuesload_custom_listener_mqtt_and_log_settings
  • 失败路径:src/config.rs::load_reports_read_and_parse_errorsload_rejects_zero_health_and_shutdown_timeoutsload_accepts_full_snmp_config_and_rejects_invalid_onessnmp_validate_enforces_fail_closed_rules

rove-addrbook(.rab 地址数据集 + book: 规则 scheme)

  • Happy Path:tests/addrbook_integration.rs::http_connect_to_book_blocked_category_is_rejected 经真实 HTTP CONNECT 链路验证 book:blocked-nets 分类阻断(403); http_connect_passes_when_book_category_not_selected 验证未选中分类不泄漏进判定、 隧道端到端可通字节;book_domain_block_applies_to_requested_host 验证域名类分类命中。
  • 失败路径(fail-closed):snapshot_with_unknown_book_category_is_rejected 验证未知分类 拒绝整个快照;snapshot_with_book_rules_but_no_book_is_rejected 验证配置了 book: 规则 但节点无 [addrbook] 时快照编译失败;startup_with_unloadable_artifact_is_a_hard_error 验证 缺失/损坏工件启动即拒绝。
  • 角色/凭据:无独立用户角色——addrbook 只提供地址数据,判定归属沿用 policy/route (proxy_integration 已覆盖 policy/用户角色结局)。
  • 恢复/回滚:corrupt_artifact_on_reload_keeps_previous_book 验证单字节损坏被校验和 拦下且旧书继续服务;addrbook_swap_recompiles_snapshot_atomically_and_rejects_bad_books 验证换书 = 重编译最近快照原子替换(旧规则语义消失、新语义生效),且新书缺分类时 换书失败、书与快照都保持不动。
  • 协议稳定性:golden_vector_matches_deterministic_rebuildtests/fixtures/addrbook/ 重建并与提交入库的 tests/vectors/addrbook_v1.rab 逐字节比对 + 钉住 SHA-256—— 编码器输出漂移即测试失败(格式破坏门禁,见 docs/addrbook-format.md)。
  • 单元层:src/addrbook/format.rs 覆盖编解码 roundtrip、确定性、坏 magic/版本/校验和、 section 重叠/缺失、字符串池放大、伪造大计数/堆预算、语义违规拒绝与单字节变异不 panic;src/addrbook/book.rs 覆盖层级子孙展开(含同前缀 兄弟隔离)、三种域名匹配、双栈 IP 区间;src/addrbook/builder.rs 覆盖重叠 CIDR 扫描线 合并、位图并集与 mapped IPv6 规范化;src/addrbook/sources.rs 覆盖六种数据源解析 (v2fly 全局 affiliation 先于选择性 include、目录逃逸/展开预算拒绝、空源拒绝、 Provider 缺字段/坏 CIDR 拒绝); src/policy/mod.rs 覆盖显式规则与 book: 规则组合、selector 共享语义。

维护规则

  • 新增一级能力:先在本表加一行(允许先全 ⚠️ 表达 TDD 红灯状态),实现完成时五个维度 必须落到真实锚点,且 E2E 列必须指向 tests/ 下的集成测试。
  • 修改既有能力:若行为、失败边界或角色语义变化,同一 PR 内更新对应行。
  • 测试重命名/删除:同一 PR 内修订本表引用,禁止留下悬空锚点。
  • 消除缺口:表中 ⚠️ 项是显式技术债;对应方向在 docs/roadmap.md 标记完成前, 必须先把 ⚠️ 转为 ✅。
  • 诚实原则:不确定是否覆盖的维度写 ⚠️ 待核验,不许写成 ✅; 必须附不适用理由。