移动端库(etemenanki-ffi)
源码文件:40 个 · 核对版本 Etemenanki 555b7df
Etemenanki/ffi/Cargo.tomlEtemenanki/ffi/src/lib.rsEtemenanki/ffi/src/proxy.rsEtemenanki/ffi/src/client.rsEtemenanki/ffi/src/platform.rsEtemenanki/ffi/src/types.rsEtemenanki/ffi/src/error.rsEtemenanki/ffi/bindgen/Cargo.tomlEtemenanki/ffi/bindgen/src/main.rsEtemenanki/ffi/build-android.shEtemenanki/ffi/build-ios.shEtemenanki/ffi/shell.nixEtemenanki/ffi/README.mdEtemenanki/ffi/tests/proxy.rsEtemenanki/Cargo.tomlEtemenanki/Cargo.lockEtemenanki/app/src/instance.rsEtemenanki/app/src/config.rsEtemenanki/app/src/routes.rsEtemenanki/app/src/lower.rsEtemenanki/app/src/subscribe.rsEtemenanki/app/src/api.rsEtemenanki/webclient/src/lib.rsEtemenanki/webclient/src/router.rsEtemenanki/supervisor/src/supervisor.rsEtemenanki/supervisor/src/build/apply.rsEtemenanki/supervisor/src/build/validate.rsEtemenanki/supervisor/src/topology/inbound/mod.rsEtemenanki/supervisor/src/system/listener.rsEtemenanki/supervisor/src/track/mod.rsEtemenanki/supervisor/src/track/sampler.rsEtemenanki/supervisor/src/entity/id.rsEtemenanki/environment/src/dial/socket.rsEtemenanki/protocols/src/tun/config.rsEtemenanki/protocols/src/tun/device.rsEtemenanki/protocols/src/tun/inbound.rsEtemenanki/concepts/src/net.rsEtemenanki/supervisor/tests/socket_policy.rsEtemenanki/environment/tests/unit/dial/socket.rsEtemenanki/app/tests/unit/config.rs
etemenanki-ffi 把 Etemenanki 作为客户端嵌入 Android 或 iOS app。平台的 VPN API 创建并配置一个 TUN 接口,再把它的文件描述符交给 app。app 把这个描述符、与桌面端 etemenanki-app 运行的同一份 TOML 配置(及其订阅文件),以及一个小小的回调对象传给 Proxy::start。随后,库在自己的 tokio 运行时上,经由配置中的出站和路由为这个设备提供服务,直到 Proxy::stop。Kotlin 和 Swift 绑定由 UniFFI 从库生成。
本页面向修改 ffi/ 的贡献者。内容包括:crate 布局与构建,导出的 API 及其确切语义,Platform 回调以及 protect 如何成为 supervisor(监管器)的 socket 策略,配置如何针对手机做 lowering(降为 spec),TUN 描述符的所有权,运行时与日志桥接,错误与 panic 捕获,edits 锁,停止,以及主机测试。库原样复用桌面端 app 的加载和路由切换代码;这些代码见 etemenanki-app:从 TOML 到 spec、etemenanki-app:运行、重载与关停 和 REST API。app 如何集成这个库见嵌入指南。
| 组件 | 文件 → 符号 | 负责 |
|---|---|---|
| crate 根 | ffi/src/lib.rs |
模块列表、构成 Rust API 的重新导出,以及 uniffi::setup_scaffolding!(),它为命名空间 etemenanki_ffi 生成 UniFFI 脚手架代码。 |
| 代理 | ffi/src/proxy.rs → Proxy |
一个正在运行的代理:它的运行时、它的 Core、正在运行的文件、edits 锁、日志 dispatch、panic 捕获、stop 和 Drop。 |
| 客户端 lowering | ffi/src/client.rs → lowering、refuse_servers、supply_tun、TUN_TAG |
把配置变成客户端的 spec(期望状态):先做 etemenanki-app 的 lowering,再拒绝服务端入站,并把 TUN 入站绑定到传入的描述符。 |
| 平台 | ffi/src/platform.rs → Platform、LogLevel、socket_options、PlatformLog |
外部(foreign)回调 trait、调用 protect 的 socket 钩子,以及调用 log 的 tracing layer。 |
| 记录类型 | ffi/src/types.rs |
面向 app 的路由视图、应用报告、流量快照和存活连接的副本,在边界处转换。 |
| 错误 | ffi/src/error.rs → FfiError |
每个调用都返回的唯一错误类型,以及它从 LoadError 和 ControlError 的映射。 |
| 绑定生成器 | ffi/bindgen → uniffi-bindgen |
UniFFI 的命令行生成器,按库所用的 UniFFI 版本构建。 |
它复用、而不重新实现的部分:
| 关注点 | 复用自 | 说明见 |
|---|---|---|
| 合并订阅文件、lowering、启动、重载、按字节去重 | app/src/instance.rs → Core;app/src/config.rs → sources_given、effective;app/src/lower.rs → lower |
从 TOML 到 spec、运行与重载 |
| 路由视图与编辑选择 | app/src/routes.rs → Snapshot |
REST API |
路由视图的类型与 ControlError |
etemenanki-webclient |
REST API |
| 流量、存活流以及强制关闭其中一条 | Supervisor::tracker → Tracker |
流跟踪、统计与限速 |
| 为 TUN 设备提供服务 | supervisor 中的 TunSource::Fd,protocols crate 中的 tun::adopt |
监听器与服务循环、TUN |
它不做的事:
- 写文件。 路由切换以字符串形式返回编辑后的配置,由 app 保存(
RouteChange::config_toml)。配置中的路径仍然指向设备文件系统上的文件,读取方式与桌面端相同:证书、私钥或ca_file由 etemenanki-app 的 lowering 读取(缺失时为Io),[route]的 geodata 文件由 supervisor 在构建路由时读取(某条规则需要但无法读取的文件为Config,building route failed: …)。 - 从路径读取订阅文件。 它的内容作为
start和reload的第二个参数传入;[subscribe]中的path不会被读取。 - 提供
[api]服务。 lowering 会丢弃它;路由、流量和连接改为Proxy上的调用。 - 为他人提供服务。 它是客户端。提供服务端协议(Trojan、VLESS、VMess、Shadowsocks、Shadowsocks 2022、Hysteria 2)的入站无论监听在哪里都会被拒绝,loopback 也不例外;其他主机可以访问的 SOCKS 或 HTTP 入站同样会被拒绝。
- 创建接口、为它分配地址或配置路由。 这些由平台完成;配置中的
tun入站只贡献它的 tag、MTU、UDP 与流设置,以及嗅探。 - 暴露 supervisor 跟踪的一切。 没有流事件、没有会话列表、没有按选择器关闭,也没有按用户的数据。REST API 提供前三项;按用户的用量只能通过 supervisor 的 Rust API 获取(按用户的用量计费)。
crate 布局
Section titled “crate 布局”文件夹ffi/
- Cargo.toml
etemenanki-ffi, publish = false 文件夹src/
- lib.rs
- proxy.rs
- client.rs
- platform.rs
- types.rs
- error.rs
文件夹tests/
- proxy.rs 基于 socketpair 的主机测试
文件夹bindgen/
- Cargo.toml
etemenanki-ffi-bindgen, publish = false 文件夹src/
- main.rs
uniffi-bindgen二进制
- main.rs
- Cargo.toml
- build-android.sh
- build-ios.sh
- shell.nix
- README.md
- Cargo.toml
两个 crate 都是工作区成员(Cargo.toml → members 列出了 ffi 和 ffi/bindgen),版本都是 0.1.0,并且都设置了 publish = false:库由旁边的脚本按目标逐个构建,没有任何东西把它当作 crate 来依赖。
ffi/Cargo.toml 从一个名为 etemenanki_ffi 的库构建三种 crate 类型:
| crate 类型 | 用途 |
|---|---|
cdylib |
Android 的 libetemenanki_ffi.so,也是生成 Kotlin 绑定所依据的库 |
staticlib |
iOS 的 libetemenanki_ffi.a,封装进一个 XCFramework |
lib |
ffi/tests 中的 Rust 主机测试 |
它的依赖:
| 依赖 | 版本要求 | 原因 |
|---|---|---|
etemenanki-app |
path,2.1.1 |
Core、配置及其 lowering、routes::Snapshot |
etemenanki-concepts |
path,2.0.0 |
DialNetwork 和 Remote,用于 ConnectionInfo |
etemenanki-environment |
path,3.0.0 |
SocketOptions,用于 protect 钩子 |
etemenanki-protocols |
path,4.0.0,features hysteria 和 tun |
TUN 的默认值,以及 etemenanki-app 的出站和设备所需的这两个 feature |
etemenanki-supervisor |
path,0.3.0 |
Supervisor、TUN 绑定类型、tracker、ApplyReport |
etemenanki-webclient |
path,0.1.0 |
路由视图,以及拒绝路由切换时使用的错误 |
uniffi |
0.32.2 |
脚手架与 derive |
tokio |
workspace | 代理的运行时,以及用作 edits 锁的 tokio::sync::Mutex |
futures |
workspace | spawn_on 中的 FutureExt::catch_unwind、stop 等待的 oneshot 通道,以及测试中的 executor::block_on |
compact_str |
workspace | CompactString,tracker 按 tag 分组的 map 的键类型,TrafficSnapshot 的转换中用到了它 |
parking_lot |
workspace | running 和 files 这两个互斥锁 |
tracing |
workspace | 事件、Dispatch 以及每线程的默认 dispatcher |
tracing-subscriber |
workspace(feature env-filter) |
registry、EnvFilter,以及 PlatformLog 实现的 Layer trait |
thiserror |
workspace | FfiError 的 Error 与 Display derive |
etherparse(dev) |
0.20 |
构建并解析测试经由设备推送的 IP 包 |
依赖方向是单向的:etemenanki-ffi 依赖 app 的库 crate 和 supervisor,工作区中没有任何东西依赖它。库 crate 发布到一个私有 Cargo registry;这两个 FFI crate 不发布。
唯一的 feature vendored-openssl 转发到 etemenanki-protocols/vendored-openssl(openssl/vendored):从源码构建 OpenSSL,供没有系统 OpenSSL 可链接的目标使用。Android 和 iOS 都没有,所以两个构建脚本都启用了它。Hysteria 2 不使用 OpenSSL;它的 QUIC 协议栈使用基于 ring 的 rustls(工作区 Cargo.toml)。
UniFFI 导出了什么
Section titled “UniFFI 导出了什么”| Rust 项 | UniFFI derive 或属性 | 在绑定中 |
|---|---|---|
Proxy |
#[derive(uniffi::Object)],方法位于 #[uniffi::export] 之下 |
一个类。start 是具名构造函数(#[uniffi::constructor]);reload、set_route 和 stop 是异步的(Kotlin 的 suspend,Swift 的 async)。 |
Platform |
#[uniffi::export(foreign)] |
app 用 Kotlin 或 Swift 实现的接口。 |
LogLevel、Inherit、TargetKind、Network |
uniffi::Enum |
枚举。Inherit::Group 带一个具名字段 name。 |
RouteView、DefaultRoute、RouteGroup、RouteTarget、ApplyReport、RouteChange、Rates、TagRates、TrafficSnapshot、ConnectionInfo |
uniffi::Record |
数据类和结构体,跨边界复制。 |
FfiError |
uniffi::Error |
每个调用抛出的错误。Kotlin 中表现为 FfiException。 |
TUN_TAG |
无 | 仅是一个 Rust 常量;绑定看不到它。 |
UniFFI 按各语言的惯例重命名成员,所以 set_route 是 setRoute,close_connection 是 closeConnection,config_toml 是 configToml。
#[derive(uniffi::Object)]pub struct Proxy { running: Mutex<Option<Running>>, // parking_lot::Mutex}
struct Running { runtime: Runtime, state: Arc<State>,}
struct State { core: Core, /// Held across a reload or a route switch, so one edit runs at a time. edits: tokio::sync::Mutex<()>, /// The files running now: what a route switch edits. files: Mutex<Arc<Sources>>, // parking_lot::Mutex}
const STOP_GRACE: Duration = Duration::from_secs(2);const RUNTIME_GRACE: Duration = Duration::from_secs(3);
#[uniffi::export]impl Proxy { #[uniffi::constructor] pub fn start( config_toml: String, subscribe_toml: Option<String>, tun_fd: i32, platform: Arc<dyn Platform>, ) -> Result<Arc<Self>, FfiError>; pub async fn reload( &self, config_toml: String, subscribe_toml: Option<String>, ) -> Result<ApplyReport, FfiError>; pub async fn set_route(&self, group: String, target: String) -> Result<RouteChange, FfiError>; pub fn routes(&self) -> Result<RouteView, FfiError>; pub fn traffic(&self) -> Result<TrafficSnapshot, FfiError>; pub fn connections(&self) -> Result<Vec<ConnectionInfo>, FfiError>; pub fn close_connection(&self, id: u64) -> Result<bool, FfiError>; pub async fn stop(&self);}每个方法都可以从任意线程调用。失败的调用返回一个 FfiError;除 Panic 之外,正在运行的内容保持原样。
| 方法 | 运行于 | 语义 |
|---|---|---|
start |
调用线程,阻塞(Runtime::block_on)直到第一次应用(apply)完成 |
按 config_toml 为 tun_fd 提供服务,当且仅当配置有 [subscribe] 时带上 subscribe_toml。返回代理,或拒绝该配置。见启动。 |
reload |
代理的运行时 | 用新文件替换正在运行的文件;一次应用对存活连接做什么见规划并应用变更。无法应用的文件会被拒绝,正在运行的内容保持原样。与 Core 最后记录的文件逐字节相同的文件不会再试一次:如果那些文件已应用,结果为 unchanged;如果为它们记录了拒绝,结果为 Config。见重载。 |
set_route |
代理的运行时 | 把一个路由组(或用 "default" 指代的默认路由)指向一个目标并应用。返回报告,以及包含这个选择的配置,注释和格式保持不变。见切换路由。 |
routes |
调用线程 | 正在运行的文件中的路由组、它们的选择、各自继承什么以及流量去往哪里,默认路由,以及选择可以指定的每个目标。 |
traffic |
调用线程 | tracker 在上一个采样器 tick 时的快照。 |
connections |
调用线程 | 每条存活流,按 id 排序,也就是最早的在前。 |
close_connection |
调用线程 | 强制关闭(kill)存活流 id;返回具有该 id 的流是否存活。其他流,即使与它共享同一个承载连接,也继续运行。 |
stop |
代理的运行时,然后是一个独立线程 | 停止接受连接,给存活连接 STOP_GRACE 的时间,关闭剩下的连接,然后以 RUNTIME_GRACE 关停运行时,释放描述符的副本和运行时的线程。第二次调用什么也不做。见停止。 |
三把锁:
| 锁 | 类型 | 持有时机 | 保护 |
|---|---|---|---|
Proxy::running |
parking_lot::Mutex |
只在把运行时句柄或 Arc<State> 克隆出来时,或在 stop 和 Drop 中取出 Running 时 |
代理是否在运行。None 表示已停止:此后除 stop 之外的每个调用都返回 FfiError::Stopped。 |
State::edits |
tokio::sync::Mutex<()> |
贯穿整个 reload、set_route,以及 stop 中的 supervisor 关停 |
一次只进行一个编辑。路由切换读取正在运行的文件、编辑它们,再应用;没有这把锁,并发的重载可能在读取和应用之间替换文件,切换随后就会应用一份针对过时文件的编辑。tokio 的互斥锁按顺序为等待者排队。 |
State::files |
parking_lot::Mutex |
只在克隆或替换 Arc<Sources> 时 |
正在运行的文件:最近一次成功的启动、重载或切换所用的配置字节和订阅文件字节。 |
State::files 与 Core 自己对最近尝试过的文件的记录(Core::last)是分开的。那份记录可能保存着被拒绝的文件,而路由切换必须编辑正在运行的文件。files 只在一次应用成功之后(或重载发现文件未变时)才被替换,所以它只会保存已经应用的文件。
Platform 与 LogLevel
Section titled “Platform 与 LogLevel”#[uniffi::export(foreign)]pub trait Platform: Send + Sync { fn protect(&self, fd: i32) -> bool; fn log(&self, level: LogLevel, message: String);}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]pub enum LogLevel { Error, Warn, Info, Debug, Trace }app 用 Kotlin 或 Swift 实现 Platform。它的方法在代理自己的线程上被调用,可能是其中任何一个线程,在 stop 返回之前随时可能发生;start 运行期间,log 还会在调用 start 的线程上被调用(日志桥接)。因此实现必须是线程安全的。它不能抛出异常:对于没有声明错误的回调,UniFFI 会把其中抛出的异常变成 Rust panic(Callback interface failure: <exception>)。
| 方法 | 调用时机 | app 应当 |
|---|---|---|
protect(fd) |
拨号器向网络打开的每个 socket(系统解析器的 socket 不在其列,见 socket 保护钩子),在它创建之后、绑定或连接之前 | 让这个 socket 不走隧道,并返回 true:在 Android 上是 VpnService.protect(fd)。如果隧道本来就排除了 app 自己的流量,例如 iOS 上的 packet tunnel,直接返回 true。返回 false 会让这个 socket 未经使用就被关闭,并让它所服务的那次拨号失败。 |
log(level, message) |
每个级别不低于配置中 [log] level 的 tracing 事件,在发出该事件的线程上 |
把这一行交给平台的日志。 |
两者都是同步调用的:log 在事件的 dispatch 内部运行,protect 在拨号内部运行,所以慢的实现会拖住发起调用的线程以及任务。任何一个中的 panic(包括抛出的异常)都会穿过发起调用的代码展开:
- 如果发生在调用
start的线程上的log中,或发生在reload或set_route的任务内部(它会记录config reloaded: …),调用会把它作为FfiError::Panic返回; - 如果发生在其他任何地方,它会结束发出该事件或打开该 socket 的运行时任务,没有任何调用会返回它。这个任务可能属于某条流、某次探测,或 supervisor 自己的 actor;actor 运行第一次之后的每次应用,并在每次应用中以
debug级别记录applied: <n> built, <n> reused, <n> swapped, <n> drained。
LogLevel 与 tracing::Level 一一对应:ERROR → Error,WARN → Warn,INFO → Info,DEBUG → Debug,TRACE → Trace(impl From<&Level> for LogLevel)。
ffi/src/types.rs 中的记录类型是 Rust 类型的副本,在调用返回时转换。它们都不持有指向代理内部的引用。
| 记录 | 字段 | 类型 | 含义 |
|---|---|---|---|
RouteView |
default |
DefaultRoute |
接收没有被任何路由组匹配的流量的路由;作为名为 "default" 的路由组来切换 |
groups |
Vec<RouteGroup> |
订阅文件的路由组,按匹配顺序排列 | |
targets |
Vec<RouteTarget> |
选择可以指定的目标,顺序为:配置的出站、它的负载均衡器、订阅文件的节点,最后是内置的 direct 和 blackhole(如果已有出站、负载均衡器或节点使用了这个名称,对应的内置项就不列出) |
|
DefaultRoute |
pick |
Option<String> |
配置选择的目标(如果有):[subscribe.routes] default,否则 [route] default |
target |
Option<String> |
未被匹配的流量去往哪里:该选择,否则配置的第一个出站,否则订阅文件的第一个节点 | |
RouteGroup |
name |
String |
|
pick |
Option<String> |
用户选择的目标(如果有) | |
inherit |
Inherit |
没有选择时该组使用什么:Default、Direct、Blackhole 或 Group { name } |
|
target |
Option<String> |
该组的流量去往哪里:该选择,否则它继承的对象。只有在继承成环、或继承自不存在的路由组时才为 None。 |
|
RouteTarget |
name |
String |
|
kind |
TargetKind |
Node(订阅文件的节点)、Outbound(配置自己的出站)、Balancer、Direct 或 Blackhole(内置项) |
impl From<web::RouteView> for RouteView 逐字段复制 webclient 的视图。它丢弃视图的 etag:这里没有任何东西比较文件的版本。
一次应用做了什么
Section titled “一次应用做了什么”| 记录 | 字段 | 类型 | 含义 |
|---|---|---|---|
ApplyReport |
unchanged |
bool |
什么都没有应用:这些文件就是正在运行的文件,或者这个选择已经做过了 |
reused |
Vec<String> |
原样保留的资源 | |
built |
Vec<String> |
根据配置构建的资源:新的,或有变化的 | |
swapped |
Vec<String> |
改用新的 handler(处理器)服务新连接的入站 | |
drained |
Vec<String> |
退出服务的出站版本 | |
rebound |
Vec<String> |
监听器被重新绑定的入站 | |
restarted |
Vec<String> |
被重启、存活连接随之结束的入站 | |
removed |
Vec<String> |
从配置中移除的入站 | |
RouteChange |
report |
ApplyReport |
这次切换做了什么 |
config_toml |
String |
包含这个选择的配置,供 app 保存以替换它手上的副本,使选择在重启后依然有效 |
有两个转换负责填充 ApplyReport。From<web::ReloadReport> 把 Unchanged 变成 ApplyReport::unchanged()(每个列表为空,unchanged: true),把 Applied(report) 交给下一个转换。From<supervisor::ApplyReport> 设置 unchanged: false,并用 ToString 把每个列表变成字符串:reused 和 built 中是 Resource(inbound <tag>、outbound <id>、balancer <tag>、user set <tag>、route、dns),drained 中是 OutboundId,其余四个列表中是入站 tag。每个列表对存活连接意味着什么见规划并应用变更。restarted 在这里始终为空:每次应用都使用默认的 ApplyOptions(第一次用 builder 的,其余用 Supervisor::apply 的),它会拒绝中断性步骤(Disrupt)(重载)。
| 记录 | 字段 | 类型 | 来自 StatsSnapshot / TagStats |
|---|---|---|---|
TrafficSnapshot |
tick |
u64 |
tick:到目前为止的 tick 数,第一个 tick 之前为 0 |
interval_ms |
u64 |
以毫秒计的 interval,饱和于 u64::MAX;即速率所覆盖的时长 |
|
total |
Rates |
total:所有流合计 |
|
inbounds |
Vec<TagRates> |
inbounds,按 tag 排序:每个承载过流的入站 |
|
outbounds |
Vec<TagRates> |
outbounds,按 tag 排序:每个承载过流的出站。代理自己应答的 DNS 使用空 tag。 |
|
TagRates |
tag、rates |
String、Rates |
map 中的一项 |
Rates |
up、down |
u64 |
发往目的地和从目的地返回的有效载荷总量 |
up_rate、down_rate |
u64 |
上一个 tick 内的每秒字节数 | |
flows |
u64 |
该 tick 时存活的流数(由 usize 扩宽) |
快照中的 users 被省略。
impl From<&FlowEntry> for ConnectionInfo:
| 字段 | 类型 | 来自 FlowEntry |
|---|---|---|
id |
u64 |
id().get();即 close_connection 接受的值 |
network |
Network |
destination().network 为 DialNetwork::Udp 时为 Udp;为 Tcp、Unknown 和 Unix 时为 Tcp |
inbound |
String |
它进入时所经入站的 tag |
source |
Option<String> |
客户端的 IP 地址,不含端口,在入站知道它时提供 |
host |
String |
目的地的域名或 IP 地址;UDP 时为第一个包的目的地 |
port |
u16 |
目的端口 |
sniffed |
Option<String> |
从它最初几个字节中读到的域名(如果有) |
outbound |
String |
outbound().tag,不含版本;负载均衡器的流给出它选中的成员 |
rule |
Option<u32> |
匹配的路由规则的索引;默认路由,或代理自己应答的 DNS 查询,为 None |
started_at_ms |
u64 |
Unix 毫秒时间,由 SystemTime::now() 减去该流经过的单调时间计算而来(unix_ms);结果早于 epoch 时为 0,毫秒数放不进 u64 时为 u64::MAX |
up、down |
u64 |
发往目的地和从目的地返回的有效载荷字节数 |
流的会话、用户、用户 label 和 plane(数据平面)epoch 都不导出。
FfiError
Section titled “FfiError”#[derive(Debug, thiserror::Error, uniffi::Error)]pub enum FfiError { #[error("{message}")] Config { message: String }, #[error( "inbound {tag:?} serves {protocol}, a server protocol; only a tun inbound and \ local socks or http inbounds run in a client" )] ServerInbound { tag: String, protocol: String }, #[error("no route group named {group:?}")] UnknownGroup { group: String }, #[error("{message}")] Io { message: String }, #[error("the proxy is stopped")] Stopped, #[error("internal error: {message}")] Panic { message: String },}| 变体 | 何时 | 正在运行的内容 |
|---|---|---|
Config |
配置或订阅文件无法解析、无法合并或无法降为 spec,或者描述了 supervisor 拒绝的内容(包括它无法读取的 geodata) | 不变 |
ServerInbound |
配置有一个服务端协议入站,或有一个其他主机可以访问的 SOCKS 或 HTTP 入站 | 不变 |
UnknownGroup |
set_route 指定了文件中不存在的路由组 |
不变 |
Io |
配置之外的某件事失败了:运行时无法构建、描述符无法复制、配置指定的证书、私钥或 CA 文件无法读取、某个本地端口无法绑定,或者 supervisor 已经关停(排在 stop 之后的编辑) |
不变 |
Stopped |
代理已停止,或者它的运行时丢弃了该调用的任务 | 已停止 |
Panic |
一个 bug:调用在库内部 panic,且该 panic 被捕获 | 任意状态;停止代理是稳妥的做法 |
Config 和 Io 只打印它们的消息,所以 app 看到的是原样的底层文本。supervisor 产生的文本收录在校验与应用错误。
映射位于 ffi/src/error.rs:
| 来源 | 情形 | FfiError |
|---|---|---|
LoadError::Config(io) |
io 包装了一个 client::ServerInbound(通过 io.get_ref() 和 downcast_ref 找到) |
ServerInbound { tag, protocol } |
LoadError::Config(io) |
kind 为 InvalidData 或 InvalidInput |
Config(经由 ControlError::Invalid) |
LoadError::Config(io) |
其他任何 kind,例如缺失的 ca_file 对应的 NotFound |
Io(经由 ControlError::Failed) |
LoadError::Apply |
ApplyError::Bind 或 ApplyError::Stopped |
Io |
LoadError::Apply |
其他任何 ApplyError |
Config |
ControlError::UnknownGroup(group) |
UnknownGroup { group } |
|
ControlError::Invalid(message) |
Config { message } |
|
ControlError::Stale { .. } |
在这里不会发生:没有可以用来判断过时的 ETag | 带其文本的 Config |
ControlError::Failed(message) |
Io { message } |
|
来自运行时 builder 或 duplicate 的 io::Error |
FfiError::io |
以该错误的 Display 为文本的 Io |
running 为 None,或任务被取消 |
Stopped |
|
| 捕获到的 panic payload | FfiError::panic |
Panic |
LoadError → ControlError 是 etemenanki-app 自己的转换(app/src/instance.rs),与 REST API 共用;FFI 只在它前面加了 ServerInbound 这一种情形。
FfiError::panic(payload) 先尝试从 &str payload 取消息,再尝试 String payload,否则使用 a panic with no message。
TUN_TAG
Section titled “TUN_TAG”pub const TUN_TAG: &str = "tun";lowering 为没有 TUN 入站的配置添加的 TUN 入站所使用的 tag。
flowchart LR app["Kotlin 或 Swift app"] -->|"UniFFI 绑定"| proxy["Proxy"] proxy --> running["Running:Runtime 与 Arc State"] running --> core["app instance::Core"] core --> lowering["client::lowering"] core --> sup["Supervisor"] sup --> tun["TunSource::Fd 上的 tun 入站"] sup --> outs["出站与解析器"] outs -->|"SocketHook"| protect["Platform::protect"] running -->|"每个运行时线程上的 PlatformLog"| log["Platform::log"] proxy -->|"routes、traffic、connections"| tracker["Tracker 与正在运行的文件"]
sequenceDiagram participant A as app 线程 participant P as Proxy::start_on participant C as Core::start participant L as client::lowering participant S as SupervisorBuilder::start A->>P: start(config, subscribe, tun_fd, platform) P->>P: duplicate(tun_fd)、dispatch、runtime P->>P: config::sources_given P->>C: 在 with_default(log) 下 block_on C->>C: config::effective 合并订阅文件 C->>L: lower、refuse_servers、supply_tun L-->>C: Built,api 为 None C->>S: 带 protect 钩子 start(spec) S->>S: 用 tun::adopt 绑定 tun 入站 S-->>C: Supervisor 与 ApplyReport C-->>P: 记录 config loaded 后返回 Core P-->>A: Arc Proxy
start 把 start_on 包在捕获 panic 的 guard 中。start_on 依次执行:
-
duplicate(tun_fd)。 负值以Io失败:<fd> is not a file descriptor。否则,借用该描述符(BorrowedFd::borrow_raw,依据的规则是调用者在调用期间保持它打开),并用try_clone_to_owned复制它;后者使用fcntl(F_DUPFD_CLOEXEC),所以副本带有 close-on-exec 标志。操作系统错误变为Io。副本放进一个Arc<OwnedFd>。这是第一步,所以无效的描述符在运行时存在之前就会失败。 -
dispatch(&config_toml, platform)构建日志(见日志桥接)。 -
runtime(&log)构建代理的运行时(见运行时);构建失败为Io。 -
config::sources_given(config, subscribe)把这些字节与配置的解析结果一起配成Sources。当配置能解析、但两者不一致时,它以InvalidInput失败,变为Config:the config has a [subscribe] section, but no subscribe file was givena subscribe file was given, but the config has no [subscribe] section to pick its routes in
解析错误不在这里抛出:它随 sources 一起传递,由
Core::start报告。 -
Supervisor::builder().socket_options(platform::socket_options(platform)),builder 的其他设置都保持默认:没有用量 sink,默认的一秒采样器 tick,以及默认的ApplyOptions。 -
Core::start(builder, Ok((files.clone(), parsed)), client::lowering(tun)),在tracing::dispatcher::with_default(&log, …)内部用runtime.block_on运行,所以启动期间调用线程上的事件也会到达平台。Core::start合并订阅文件(config::effective),调用 lowering,启动 supervisor(第一次应用在SupervisorBuilder::start内部运行),并以info级别在 targetetemenanki_app::instance上记录config loaded: <summary>。它的LoadError转换为FfiError。 -
组装代理:
Running { runtime, state: Arc<State> { core, edits, files } }放进running,以Arc<Proxy>返回。
start 是同步的,会阻塞调用者,直到第一次应用绑定了所有监听器并接管(adopt)了设备。它使用 Runtime::block_on,而 tokio 不允许在另一个运行时的异步上下文中调用它;处在这种位置的 Rust 调用者会以 FfiError::Panic 的形式拿回由此产生的 panic。
start 失败时,它构建的一切都归 start_on 的局部变量所有,并随之被 drop:运行时、lowering 闭包及其描述符副本,以及 supervisor 已经启动的任何东西。与 Core::reload_with 不同,Core::start 不记录自己的失败:错误只以返回的 FfiError 到达 app。
客户端 lowering
Section titled “客户端 lowering”client::lowering(tun) 返回 etemenanki-app 的 Lowering 类型,即 Arc<dyn Fn(&Config) -> io::Result<Built> + Send + Sync>,闭包捕获了 Arc<OwnedFd>。Core 在每次启动、重载和路由切换时调用它:
pub(crate) fn lowering(tun: Arc<OwnedFd>) -> Lowering { Arc::new(move |cfg: &Config| { let mut spec = lower(cfg)?; refuse_servers(&spec)?; supply_tun(&mut spec, &tun)?; if cfg.api.is_some() { tracing::info!( "the config's [api] is not served here: the app reads routes and traffic \ through this library" ); } Ok(Built { spec, api: None }) })}lower(cfg)是桌面端 app 的 lowering,原样使用:它接受的每个键、抛出的每个错误在这里同样适用(从 TOML 到 spec)。refuse_servers按顺序检查 spec 的每个入站,拒绝第一个为他人提供服务的入站。supply_tun把 TUN 入站绑定到传入的描述符。[api]以info级别记录一行(the config's [api] is not served here: the app reads routes and traffic through this library)后被丢弃:Built::api始终为None。etemenanki-app 的api::options从不被调用,所以listen值不会被解析,“非本地的 API 必须有secret”这条规则也不会生效。这个表仍会被反序列化为拒绝未知字段的ApiConfig,所以[api]中的未知键或错误类型在配置解析时仍会被拒绝。
这些步骤在第一个错误处停止。未通过 etemenanki-app 的 lowering 的配置会以该错误被拒绝,即使它同时也有服务端入站:一个 tls.cert_file 不存在的 VLESS over TLS 入站会被 lowering 以 Io 拒绝(inbound <tag>: cannot read tls.cert_file "<path>": <error>),refuse_servers 没有机会报告 ServerInbound。
拒绝服务端入站
Section titled “拒绝服务端入站”InboundProtocolSpec |
允许的条件 | 错误中的 protocol |
|---|---|---|
Tun |
始终允许 | |
Socks |
绑定是本地的 | socks beyond this device |
Http |
绑定是本地的 | http beyond this device |
Trojan |
从不 | trojan |
Vless |
从不 | vless |
Vmess |
从不 | vmess |
Shadowsocks |
从不 | shadowsocks |
Ss2022 |
从不 | shadowsocks 2022 |
Hysteria2 |
从不 | hysteria2 |
is_local(bind) 对 BindSpec::Unix 成立;对 BindSpec::Tcp,当主机是字符串 localhost、或按 IP 地址解析后是 loopback 地址(127.0.0.0/8 或 ::1)时成立。它纯粹是语法判断:不做任何名称解析。BindSpec::Udp 和 BindSpec::Tun 从不算本地。etemenanki-app 的 lowering 把没有 listen 的入站绑定到 127.0.0.1,所以没有 listen 的 SOCKS 或 HTTP 入站是允许的。
拒绝是一个 kind 为 InvalidInput 的 io::Error,其内部错误是一个 ServerInbound { tag, protocol },这样边界处可以把它重新找出来(FfiError)。它自己的 Display 为 inbound "<tag>" serves <protocol>, a server protocol; a client runs only a tun inbound and local socks or http inbounds;重载日志行中出现的就是这段文本。app 收到的是 FfiError::ServerInbound,其文本为 inbound "<tag>" serves <protocol>, a server protocol; only a tun inbound and local socks or http inbounds run in a client。
绑定 TUN 入站
Section titled “绑定 TUN 入站”supply_tun(spec, tun) 查找协议为 Tun 的入站:
| spec 中有 | 结果 |
|---|---|
| 没有 TUN 入站 | 追加一个:tag 为 TUN_TAG(tun),绑定为使用 DEFAULT_MTU(1500)的 TunSource::Fd,sniff: true,TunSpec { udp: true, udp_idle_timeout: DEFAULT_UDP_IDLE_TIMEOUT (60 s), max_flows: DEFAULT_MAX_FLOWS (65 536) },没有用户集,没有用户移除策略。如果配置中另一个入站(本地 SOCKS 或 HTTP 入站)已经使用了 tag tun,supervisor 会以 duplicate inbound tag tun 拒绝这个 spec,即 Config。 |
一个,绑定为 TunSource::Create(device) |
它的绑定被替换为带配置中 device.mtu 的 TunSource::Fd。如果配置给出了 name、address 或 route,会以 warn 级别记录:inbound <tag>: the platform owns the tun interface, so its name, addresses and routes in the config are ignored。每次对这类配置做 lowering 都会产生这条警告:start 时一次,之后每次对它做 lowering 的重载和路由切换时再各一次。 |
一个,绑定为 TunSource::Fd(device) |
以那个 mtu 重新绑定到这个描述符。etemenanki-app 的 lowering 从不产生这种情况;这个分支是防御性的。 |
| 一个,绑定为其他任何东西 | InvalidInput:inbound <tag>: a tun inbound bound to <bind>(同样是防御性的) |
| 多于一个 | InvalidInput:the config has more than one tun inbound; the platform supplies one device |
因此,配置中的 tag、mtu、udp、udp_idle_timeout、max_flows 和 sniffing 生效;name、address 和 routes 不生效。mtu 必须与平台配置的 MTU 一致,而库看不到后者。tun 入站上配置的 listen 或 port 仍会在 supply_tun 运行之前被 etemenanki-app 的 lowering 拒绝:inbound <tag>: tun owns a network interface and has no listener; remove listen/port。
由于绑定在 supervisor 校验 spec 之前就被替换,supervisor 针对所创建设备的平台检查(在 Linux 以外的所有平台上拒绝 routes)永远看不到它。校验仍要求 MTU 至少为 1280:inbound <tag>: tun mtu must be at least 1280,指向的是配置自己的 tun 入站(lowering 追加的那个始终是 1500)。见校验与应用错误。
在 Android 和 iOS 上,tun::open 拒绝创建设备(tun devices are created by the system VPN API here; adopt its descriptor)。FFI 永远不会走到那里,因为它传下去的每个 TUN 入站都是 TunSource::Fd。
TUN 描述符
Section titled “TUN 描述符”app 传入的描述符始终归 app 所有。代理只操作副本:
flowchart LR appfd["tun_fd,归 app 所有"] -->|"复制:try_clone_to_owned"| arc["lowering 闭包中的 Arc OwnedFd"] arc -->|"克隆的 Arc"| spec["每个 spec 中的 SuppliedTun"] spec -->|"监听器绑定:tun::adopt"| dev["监听器持有的设备"] dev -->|"try_clone"| first["协议栈读取的副本"]
- lowering 闭包在代理的整个生命周期内持有一个副本,并把同一个
Arc的克隆放进它构建的每个 spec。SuppliedTun在原始描述符编号和 MTU 都相等时比较为相等,所以代理构建的每个 spec 都指向同一个设备,应用时会保留它(监听器与服务循环)。 - 绑定监听器时,
system/listener.rs→bind调用etemenanki_protocols::tun::adopt(fd),它再复制一次描述符并设为非阻塞,然后保留这个副本,再把另一个副本交给协议栈。它以info级别记录inbound <tag> serves a supplied tun device。副本与原描述符共享文件状态标志,所以 adopt 也会让 app 自己的描述符变为非阻塞。 - 在 macOS 和 iOS 构建中,协议栈配置为
packet_information(true):utun设备在每个包前带一个 4 字节的协议头,协议栈读写时会处理它。在 Linux 和 Android 上,协议栈读取裸 IP 包(TUN)。 stop释放所有副本:监听器和协议栈的副本随 supervisor 关停释放,lowering 闭包的副本随Core释放。只要 app 保持自己的描述符打开,平台就保留这个接口。
app 如何获得描述符属于库之外的平台代码:在 Android 上,是 VpnService.Builder.establish() 返回的 ParcelFileDescriptor,其中 setMtu 应等于配置中 tun 的 MTU;在 iOS 上,是 packet tunnel 的 utun 控制 socket,通过它的 com.apple.net.utun_control 控制 id(CTLIOCGINFO)在扩展打开的描述符中找到。嵌入指南涵盖了这两种情况。
socket 保护钩子
Section titled “socket 保护钩子”pub(crate) fn socket_options(platform: Arc<dyn Platform>) -> SocketOptions { SocketOptions::default().with_hook(Arc::new(move |socket| { if platform.protect(socket.as_raw_fd()) { Ok(()) } else { Err(io::Error::new( io::ErrorKind::PermissionDenied, "the platform refused to protect the socket", )) } }))}这个策略只有一个钩子:没有源地址、接口、包标记或 keepalive。SupervisorBuilder::socket_options 在 supervisor 的整个生命周期内保存它,supervisor 把它传给每个打开出站 socket 的 builder:代理出站经由 TCP 及其传输层的每次拨号、直连流的 TCP 拨号和 UDP socket、SOCKS 出站的 UDP 中继 socket、Hysteria 2 或 WireGuard 出站的 socket、解析器从本机发出的每个查询,以及负载均衡器的健康探测。完整的表见 supervisor 概览。
SocketOptions::apply 在原始的 socket2::Socket 上最后运行钩子,排在接口和标记之后(这里两者都未设置),并在拨号器绑定或连接它之前。protect 拿到的是 socket 的原始描述符。false 变成 PermissionDenied(the platform refused to protect the socket),由 apply 返回,拨号随之失败:被钩子拒绝的 socket 上不会有任何数据发出。SocketOptions 的字段参考和平台支持见拨号器与 socket 策略。
有两类 socket 不经过钩子:
- 监听器 socket。 本地 SOCKS 或 HTTP 入站的监听器由 supervisor 的监听器代码打开,而不是由拨号器打开;它接受来自本设备的连接,不需要保护。
- 系统解析器。
getaddrinfo在 C 库内部打开它的 socket,任何策略都触及不到。配置的[dns] backend默认为system。在 Android 上,改用udp、tls或https后端,或者用addDisallowedApplication把 app 排除在它自己的 VPN 之外,可以让代理的查询不进入它自己的隧道;iOS 的 packet tunnel 本来就排除了这些查询。见名称解析与 DNS 服务。
thread_local! { /// The proxy's log, as the default of each of its runtime's threads. static LOG: RefCell<Option<DefaultGuard>> = const { RefCell::new(None) };}
fn runtime(log: &Dispatch) -> Result<Runtime, FfiError> { let log = log.clone(); tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_name("etemenanki") .on_thread_start(move || { let guard = tracing::dispatcher::set_default(&log); LOG.with(|slot| *slot.borrow_mut() = Some(guard)); }) .on_thread_stop(|| { LOG.with(|slot| slot.borrow_mut().take()); }) .build() .map_err(FfiError::io)}每个代理拥有一个多线程 tokio 运行时,启用了 I/O 和计时器,使用 tokio 默认的工作线程数,每个线程都命名为 etemenanki。线程启动时,每个线程(工作线程和阻塞池线程都一样)把代理的 Dispatch 设为自己的线程默认值,并把 DefaultGuard 存放在线程局部变量 LOG 中,每个运行时线程一个 guard;线程停止时,它 drop 这个 guard。库不安装全局 subscriber。
异步方法在这个运行时上完成工作,而不是在调用者的执行器上,只有一个例外:stop 有一部分工作在调用者的 future 中完成(停止)。spawn(call) 在 running 锁下克隆出运行时句柄和 Arc<State>,在 guard 内构建调用的 future,并用 spawn_on 派生它;调用者的 future 只等待 tokio 的 JoinHandle。Kotlin 协程、Swift 并发和测试中的 futures::executor::block_on 都能 poll JoinHandle,所以调用方不需要 tokio 上下文。
fn dispatch(config_toml: &str, platform: Arc<dyn Platform>) -> Dispatch { let level = config::parse_bytes(config_toml.as_bytes()) .ok() .and_then(|cfg| cfg.log.level) .unwrap_or_else(|| "info".to_owned()); Dispatch::new( tracing_subscriber::registry() .with(EnvFilter::new(level)) .with(PlatformLog(platform)), )}- 过滤器。 配置的
[log] level被当作EnvFilter指令字符串使用,与桌面端 app 的用法相同;没有这一项的配置,或无法解析的配置,得到info。与桌面端 app 不同,这里不参考RUST_LOG。EnvFilter::new的解析是宽松的:无法解析的指令会被跳过(tracing-subscriber 向标准错误打印ignoring `<directive>`: <error>),一条指令都不剩的字符串只启用error。所以[log] level永远不会让start失败。过滤器只在start中构建一次:改变[log] level的重载不会改变到达平台的内容,新的代理才会应用它。 - Layer。
PlatformLog实现tracing_subscriber::Layer::on_event。对每个事件,它构建一行文本,并在发出事件的线程上、在事件的 dispatch 返回之前调用platform.log(level, line)。没有格式化 layer:没有时间戳,也没有 span 字段。 - 行格式。
<target>: <message>,后面为其余每个字段追加key=value。Linevisitor 原样追加message字段(一个&str值,或格式化参数的Debug形式,后者打印的文本不带引号)。其他字符串字段追加为key=value,其他任何字段追加为key=后跟该值的Debug形式。tracing的宏最先记录 message,所以它排在各字段之前。有字段但没有 message 的事件变成<target>:后面直接跟key=value,冒号后有两个空格。例如,一次重载以etemenanki_app::instance: config reloaded: <summary>的形式到达平台。 - 哪些事件。 代理的运行时线程上的事件,以及
start运行其block_on期间调用线程上的事件。其他线程保留它们原有的默认 dispatcher。其中包括 pollstop的 future 的线程,所以stop自己可能发出的那一行(停止)不会到达平台。
pub async fn reload(&self, config_toml: String, subscribe_toml: Option<String>) -> Result<ApplyReport, FfiError>{ self.spawn(move |state| async move { let _edit = state.edits.lock().await; let given = config::sources_given( config_toml.into_bytes(), subscribe_toml.map(String::into_bytes), ); let files = given.as_ref().ok().map(|(files, _)| files.clone()); let report = state.core.reload_with(|| given).await?.report()?; if let Some(files) = files { *state.files.lock() = Arc::new(files); } Ok(report.into()) }) .await}reload 持有 edits 锁,像 start 一样把两个字符串配对,然后交给 Core::reload_with,桌面端 app 的文件监视器和 REST API 用的也是这个函数(运行与重载)。Core::reload_with 在它自己的锁下串行执行重载,把字节与它最后记录的文件比较,合并并做 lowering(这里经由客户端 lowering),再用 Supervisor::apply 应用 spec,后者使用默认的 ApplyOptions。随后 Reload::report 把结果变成 ReloadReport。只有这一步成功时 state.files 才会被替换。成功的应用还会发布这次构建得到的 API 选项,在这里它始终为 None。
| 文件 | reload 返回 |
Core 记录的日志(除注明外均为 error) |
|---|---|---|
| 在订阅文件上不一致 | Config |
reload: <error> |
与 Core 最后记录的文件逐字节相同,且那些文件已应用 |
Ok,unchanged: true |
无 |
与 Core 最后记录的文件逐字节相同,且为它们记录了拒绝 |
Config:<reason> (the files have not changed since this was found)。无论第一次拒绝是什么,变体始终是 Config:第一次以 ServerInbound 或 Io 被拒绝的文件,这次会以带相同文本的 Config 返回。 |
无 |
| 无法解析、合并或降为 spec,或有服务端入站 | Config、ServerInbound,或对无法读取的证书、私钥或 CA 文件返回 Io |
reload: <error>; keeping the running config |
| 改变了 TUN 入站 | 带 ApplyError::Disruptive 文本的 Config |
reload refused, keeping the running config: inbound <tag>: <reason>, which would end its live connections; restart to apply it |
| 无法绑定某个监听器 | Io |
reload: inbound <tag>: binding <bind> failed: <error>; keeping the running config |
发现 supervisor 已关停(排在 stop 之后的编辑) |
Io:the supervisor has shut down |
reload: the supervisor has shut down; keeping the running config |
| 因其他原因被 supervisor 拒绝 | Config |
reload: <error>; keeping the running config |
| 应用成功 | 带报告的 Ok |
info 级别的 config reloaded: <summary> |
这里和桌面端 app 一样拒绝 TUN 变更,重启才能应用它:停止代理,再用新文件启动一个新代理。<summary> 以 built [a, b] 的形式列出报告中每个非空的分组,依次是 built、swapped、drained、rebound、restarted、removed 和 reused,用 ; 连接;什么都没有时为 nothing to run。
Core::start 把启动时的文件记录为最近尝试的文件,所以用代理启动时的那组文件做 reload 会报告 unchanged。
sequenceDiagram
participant A as app
participant T as 代理运行时上的任务
participant R as routes::Snapshot
participant C as Core::reload_with
A->>T: set_route(group, target)
T->>T: 锁住 edits,克隆正在运行的文件
T->>R: Snapshot::of,然后 pick(group, target)
alt 已经是这个选择
R-->>T: None
T-->>A: RouteChange 为 unchanged,附当前配置
else 已编辑
R-->>T: 编辑后的配置字节
T->>C: 用编辑后的配置和同一个订阅文件 reload_with
C-->>T: Reload,转换为报告
T->>T: 替换正在运行的文件
T-->>A: 带报告和编辑后配置的 RouteChange
end
- 获取 edits 锁,克隆
state.files。 Snapshot::of(files, config::parse_bytes(&files.config)):解析正在运行的配置和订阅文件。失败为ControlError::Invalid。snapshot.pick(&group, &target),即 REST API 自己的检查与编辑(app/src/routes.rs):- 不是
default、也不在视图路由组之中的group为UnknownGroup:no route group named "<group>"; - 不在视图目标之中的
target为Config:no node, outbound or balancer is named "<target>"; the route view lists them; - 否则用
toml_edit编辑配置(routes.rs→edit):配置有[subscribe]时编辑[subscribe.routes]中的键group,否则编辑[route] default。缺失的[subscribe.routes]或[route]表会被创建,父表是内联表时就创建为内联表(child);只作为子表的父表而存在的表,例如只通过[[route.rule]]出现的[route],会被变成显式表(set_implicit(false))。因此一次切换除了改变一个值,还可能添加一个表头。被替换的值保留其周围的注释和空白(set_string复制它的 decor),文件的其余部分保持不变。两个防御性错误(都是Config)处理形状出乎意料的文档:the parent of "<key>" is not a table和the table holding the routes is not a table。
- 不是
- 如果编辑后字节没有变化(
pick返回None),就不应用:结果为RouteChange { report: ApplyReport::unchanged(), config_toml: <the running config> }。 - 否则,编辑后的配置和未变的订阅文件作为已读取的 sources 交给
Core::reload_with。应用本身就是检查:一个不能构成有效配置的选择会像任何重载一样被拒绝,正在运行的内容保持原样。 - 成功时
state.files变为编辑后的文件,调用返回报告,并以编辑后的配置作为config_toml(String::from_utf8_lossy;这些字节来自一个String,所以不会丢失任何内容)。
桌面端 app 的 PUT /v1/routes/{group} 使用同一个 Snapshot::pick,但作用于磁盘上的文件:它把 If-Match ETag 与文件的 ETag 比较(Stale,409),用 instance::check_bytes 检查编辑,再次读取文件并在其间有变化时以 Stale 拒绝,原子地写入文件,最后才由 router 执行重载。这里则是在内存中编辑正在运行的文件,应用本身就是检查,不写任何东西,由 app 保存 config_toml,使选择在重启后依然有效。桌面端的路径见 REST API。
读取路由、流量和连接
Section titled “读取路由、流量和连接”四个同步调用在调用线程上、在 guard 下运行,从 running 中克隆出 Arc<State>,从不经过 supervisor 的 actor:
routes每次调用都解析正在运行的文件(Snapshot::of),并转换snapshot.view()。它读取的是文件,而不是 supervisor。state.files只会保存已应用的文件,而订阅合并会拒绝名为default的路由组,以及指向未知路由组或目标的选择(app/src/subscribe.rs→apply),所以routes显示的每个选择都属于已应用的文件。切换进行期间,它显示切换之前或之后的文件,取决于state.files是否已被替换。traffic取一个新的Tracker句柄(Core::tracker在每次调用时克隆 supervisor 的 tracker),克隆它的watch接收端(stats()),并在持有borrow()期间转换当前的Arc<StatsSnapshot>。采样器每个 tick 替换它一次,默认为一秒;FFI 保留这个默认值。第一个 tick 之前,tick为0。connections取tracker().flows(),按FlowId排序(sort_unstable_by_key),并转换每个条目。流 id 按递增顺序分配,所以列表中最早的在前。close_connection(id)调用Tracker::kill(FlowId::new(id)),它设置流的强制关闭标志并唤醒该流;流的下一次 poll 无论哪个方向都会失败。它返回具有该 id 的流是否仍在注册表中,这包括已被强制关闭、但尚未注销的流。见流跟踪、统计与限速。
sequenceDiagram participant A as app participant P as Proxy::stop participant T as 代理运行时上的任务 participant O as 独立线程 A->>P: stop() P->>P: 从 running 中取出 Running P->>T: spawn:锁住 edits,Core::shutdown(STOP_GRACE) T-->>P: 完成,或一个被记录下来的 panic P->>O: Runtime::shutdown_timeout(RUNTIME_GRACE) O-->>P: 运行时结束后发送 oneshot P-->>A: 返回
- 标记为已停止。
self.running.lock().take()。如果running已经是None,stop立即返回。这发生在第一个 await 之前,所以从此以后,其他每个调用都返回Stopped;第二次stop,即使在第一次仍在停止的过程中运行,也会立即返回。 - 关停 supervisor。 代理运行时上的一个任务获取 edits 锁,所以正在进行的重载或路由切换会先完成,然后运行
state.core.shutdown(STOP_GRACE):supervisor 停止接受连接,给存活连接最多 2 秒完成,然后关闭剩下的连接(supervisor 概览)。排在它之后的编辑会面对一个已停止的 supervisor:应用以Io失败(the supervisor has shut down,见重载),除非运行时的关停先 drop 了该编辑的任务,那样的话调用返回Stopped。 - 该任务中的 panic 不会被返回(
stop不返回任何东西)。stop用tracing::error!发出stopping the proxy panicked; releasing what is left,但这发生在它自己的 future 中,在 poll 它的线程上:外部执行器的线程,或测试中的block_on。库没有在那里设置 dispatcher,所以只有调用者在那个线程上安装的 subscriber 才能看到这一行;Platform::log看不到。不是 panic 的JoinError会被忽略。无论哪种情况,接下来都会关停运行时。 - 关停运行时。
Runtime::shutdown_timeout(RUNTIME_GRACE)会阻塞,所以它在一个独立线程上运行(std::thread::spawn),不占用调用者的执行器,后者可能是 app 的主线程。它 drop 运行时上剩余的任务,并最多等待 3 秒让运行时的线程结束。然后这个线程在一个futures::channel::oneshot上发送,stop等待它;无论线程是发送了还是把它 drop 了,只要它有了结果,stop就返回(let _ = done.await)。
stop 没有 Result:它不会失败。Arc<State> 移入关停任务并随之被 drop,带走 Core、lowering 闭包及其描述符副本;supervisor 的关停和运行时的关停释放监听器和协议栈的副本。检查 stop 返回后设备已关闭的主机测试列在测试中。
不调用 stop 就 drop 对 Proxy 的最后一个引用,会立即停止它:Drop 取出 Running 并调用 Runtime::shutdown_background,不给存活连接宽限时间,也不等待运行时的线程。
在绑定中,外部对象持有一个 Arc<Proxy>,由 Kotlin 的 destroy()(也可以是 close(),因为该类实现了 AutoCloseable;或者在对象不可达后由 UniFFI 的 cleaner 调用)和 Swift 的 deinit 释放。Kotlin 的 destroy() 会在该对象上进行中的调用都返回之后再释放 Rust 一侧。不调用 stop 就释放最后一个引用,就是上面说的 Drop,所以 stop() 应当在 destroy() 之前调用,crate 的 README 示例就是这个顺序。
| 不变量 | 由谁保证 | 由哪个测试固定 |
|---|---|---|
app 的描述符始终归 app 所有;代理只为副本提供服务,stop 返回后不再持有任何副本 |
duplicate;tun::adopt;在关停任务中被 drop 的 State;在 stop 返回前关停的运行时 |
a_tun_device_carries_tcp_and_udp_to_the_outbound:测试自己的描述符关闭后,设备在代理运行期间仍保持打开,stop 之后关闭 |
拨号器打开的每个 socket 在连接之前都会交给 protect;系统解析器的 socket 是例外 |
platform::socket_options → SupervisorBuilder::socket_options |
a_tun_device_carries_tcp_and_udp_to_the_outbound(TCP 拨号一次 protect,UDP 通过后总数至少两次);direct_flows_are_dialed_on_hooked_sockets、a_socks_upstream_is_dialed_on_hooked_sockets(supervisor/tests/socket_policy.rs) |
| 平台拒绝的 socket 不承载任何数据 | 钩子返回 PermissionDenied;SocketOptions::apply 让拨号失败 |
a_refusing_hook_fails_the_dial(supervisor/tests/socket_policy.rs)、hook_runs_on_apply_and_its_error_propagates(environment/tests/unit/dial/socket.rs);没有 FFI 测试 |
| 客户端不运行任何服务端协议入站(无论监听在哪里),也不运行其他主机可以访问的 SOCKS 或 HTTP 入站 | 每次 lowering 中的 refuse_servers |
a_server_inbound_is_refused(在 loopback 上)、only_a_local_socks_inbound_is_served |
| 本地 SOCKS 或 HTTP 入站会被提供服务 | is_local |
only_a_local_socks_inbound_is_served(127.0.0.1) |
| 恰好一个 TUN 设备,即平台提供的那个 | supply_tun |
没有 tun 入站的配置在 TUN_TAG 上提供服务:a_tun_device_carries_tcp_and_udp_to_the_outbound;多于一个的情况没有测试 |
从不提供 [api] 服务,也不写任何东西 |
Built { api: None };crate 中没有文件 API |
由构造保证 |
| 路由切换只改变选择,并保留注释和格式 | 基于 toml_edit 的 routes::Snapshot::pick |
set_route_and_reload_switch_like_the_rest_api |
| 什么都不改变的切换什么都不应用 | pick 返回 None |
set_route_and_reload_switch_like_the_rest_api(同一个切换做两次:unchanged,配置相同) |
| 已在运行的文件不会再次应用 | Core::reload_with 逐字节比较 Sources |
set_route_and_reload_switch_like_the_rest_api(重载保存下来的文件为 unchanged) |
| 被拒绝的文件让正在运行的内容保持原样 | Core::reload_with;state.files 只在成功时替换 |
set_route_and_reload_switch_like_the_rest_api(被拒绝的重载保留了选择) |
一次一个编辑,且 stop 会等待正在进行的那个 |
State::edits |
没有专门的测试 |
stop 之后,每个调用都以 Stopped 失败,再次 stop 什么也不做 |
首先取出 running |
a_tun_device_carries_tcp_and_udp_to_the_outbound |
| 没有 Rust panic 会从调用中展开进 app | guard、spawn_on、FfiError::panic |
a_panic_is_returned_not_unwound |
| 日志到达平台 | dispatch、runtime、start 中的 with_default |
a_tun_device_carries_tcp_and_udp_to_the_outbound(平台日志中有 config loaded) |
到达平台的内容按 [log] level 过滤 |
dispatch 中的 EnvFilter |
没有测试 |
失败路径与取消
Section titled “失败路径与取消”| 调用 | 失败于 | 何时 |
|---|---|---|
start |
Io |
tun_fd 为负(<fd> is not a file descriptor)或无法复制、运行时无法构建、配置指定的证书、私钥或 CA 文件无法读取,或某个监听器无法绑定 |
Config |
文件在订阅文件上不一致;配置无法解析、合并或降为 spec;有第二个 TUN 入站;或 supervisor 拒绝该 spec(包括它无法读取的 geodata 文件) | |
ServerInbound |
某个入站为他人提供服务 | |
Panic |
调用线程上的 panic:在库自己的代码中,或者在启动期间该线程上发出的事件所调用的 app Platform::log 中。protect 中的 panic 发生在拨号运行的地方,即运行时上的某个任务中;它结束那个任务,没有任何调用会返回它。 |
|
reload |
见重载 | |
set_route |
UnknownGroup、Config、Io |
未知的路由组;未知的目标;像重载一样失败的编辑或应用 |
routes、traffic、connections、close_connection |
Stopped、Config(仅 routes)、Panic |
|
除 stop 外的每个调用 |
Stopped |
stop 之后或期间 |
panic 在三个地方被捕获:
guard(call)在catch_unwind(AssertUnwindSafe(call))下运行一个闭包。它覆盖start和四个同步调用;在spawn中,它覆盖构建调用的 future 和派生它的过程。spawn_on(handle, task)把派生的 future 包在AssertUnwindSafe(task).catch_unwind()中,所以reload或set_route内部的 panic 会变成任务的Err(FfiError::Panic)。spawn把仍携带 panic 的JoinError映射为Panic(e.into_panic()),把其他任何JoinError(即因运行时关停而被取消的任务)映射为Stopped。
捕获需要展开(unwind),所以库必须以 panic = "unwind" 构建,这是 Rust 的默认值;工作区的 release profile 只设置了 lto = true。如果是 panic = "abort",panic 会结束 app 的进程。
UniFFI 的脚手架也会在边界处捕获 panic:rust_call 把每个导出的调用,以及对异步调用 future 的每次 poll,都包在 catch_unwind 中。它把捕获到的 panic 报告为绑定的内部错误(Kotlin 的 InternalException,Swift 中的一个私有错误类型),而不是 FfiError。guard 和 spawn_on 的存在,就是为了让 panic 以带类型的 FfiError::Panic 到达 app,并带上 payload 中的消息。
捕获到的 panic 无法说明它留下了什么状态:FfiError::Panic 要求 app 停止代理。stop 和 Drop 没有 guard,所以在绑定中只有 UniFFI 的捕获覆盖它们;supervisor 关停任务中的 panic 在 stop 内部处理,如停止所述。
取消:
- drop
reload或set_route的 future 不会停止工作。drop 一个 tokioJoinHandle会让它的任务脱离(detach),任务在代理的运行时上一直运行到结束,并在结束前一直持有 edits 锁。app 看不到结果。 stop在第一个 await 之前取出Running,所以无论这个 future 之后怎样,从那时起代理都算已停止。被 drop 的stopfuture 还会做什么,取决于它何时被 drop:- 如果它正在等待 supervisor 关停任务,future 仍拥有
Runtime,后者会在 drop 这个 future 的线程上被 drop。tokio 对运行时的Drop会取消其上剩余的任务,其中包括 supervisor 关停任务,所以存活连接得不到STOP_GRACE的剩余时间;随后它等待运行时的线程结束,且不受RUNTIME_GRACE的上限约束; - 如果它已经在 oneshot 上等待,运行时就归关停线程所有,后者会自行完成。
- 如果它正在等待 supervisor 关停任务,future 仍拥有
start无法取消;它是同步的。- 连接 在
stop时经过 supervisor 的宽限期后被关闭。一次应用对存活连接做什么见规划并应用变更。
| 常量或上限 | 值 | 定义位置 | 含义 |
|---|---|---|---|
STOP_GRACE |
2 秒 | ffi/src/proxy.rs |
代理停止时,存活连接能用来完成的时间 |
RUNTIME_GRACE |
3 秒 | ffi/src/proxy.rs |
在那之后,运行时的线程能用来完成剩余工作的时间 |
| 采样器 tick | 1 秒 | supervisor 的默认值,保持不变 | traffic 多久变化一次 |
DEFAULT_MTU |
1500 | protocols/src/tun/config.rs |
追加的 TUN 入站的 MTU,也是没有 mtu 的配置 tun 入站的 MTU |
MIN_TUN_MTU |
1280 | supervisor/src/build/validate.rs |
spec 可以给设备的最小 MTU |
DEFAULT_UDP_IDLE_TIMEOUT |
60 秒 | protocols/src/tun/config.rs |
追加的 TUN 入站的 UDP 空闲超时 |
DEFAULT_MAX_FLOWS |
65 536 | protocols/src/tun/config.rs |
追加的 TUN 入站所在设备上的存活流数 |
| TUN 设备 | 每个代理 1 个 | client.rs → supply_tun |
平台提供一个设备 |
| 编辑 | 每个代理同一时间 1 个 | State::edits |
重载、路由切换和 stop 中的 supervisor 关停按顺序排队 |
| 运行时线程 | tokio 默认的工作线程数,外加它的阻塞池 | proxy.rs → runtime |
全部命名为 etemenanki |
在手机上,TUN 入站的 max_flows 限制设备所服务的存活流数量:它决定 TUN 入站中一个信号量的大小(protocols/src/tun/inbound.rs),超出上限的流会被丢弃(TUN)。DEFAULT_MAX_FLOWS 的大小是按文件描述符护栏来定的,因为每条流都可能让出站花费一个 socket(protocols/src/tun/config.rs);它不是内存上限。工作区其余部分的限制汇总在限制、超时与内存一页中。
脚本以 vendored-openssl 构建 release 库,并用工作区自己的 uniffi-bindgen 从构建出的库生成绑定。
fn main() { uniffi::uniffi_bindgen_main()}etemenanki-ffi-bindgen 从带 cli feature 的 uniffi 0.32.2 构建一个二进制 uniffi-bindgen。库要求同样的 uniffi = "0.32.2",两个 crate 共用工作区的 Cargo.lock,它把 uniffi 解析为 0.32.2。由其他 UniFFI 版本生成的绑定与库不匹配,这就是生成器放在工作区内、而不是单独安装的原因。它以库模式运行:从构建出的库的符号中的元数据读取接口。
cargo build -p etemenanki-fficargo run -p etemenanki-ffi-bindgen -- generate \ --library target/debug/libetemenanki_ffi.so --language kotlin --out-dir out/kotlincargo run -p etemenanki-ffi-bindgen -- generate \ --library target/debug/libetemenanki_ffi.so --language swift --out-dir out/swiftAndroid:ffi/shell.nix 与 ffi/build-android.sh
Section titled “Android:ffi/shell.nix 与 ffi/build-android.sh”ffi/shell.nix 是用于交叉构建的 nix-shell。它固定了一个 nixpkgs 版本(一个带哈希的 tarball URL),在这个 nixpkgs 中接受 Android SDK 许可并允许非自由软件包,并组合出一个只含 NDK 28.2.13676358(r28c)的 Android SDK:没有 platforms、build tools、CMake 或模拟器,因为这些属于 app 自己的构建。mkShellNoCC shell 提供 cargo-ndk、perl 和 make(后两者用于 vendored OpenSSL 的构建),并设置 ANDROID_HOME、ANDROID_SDK_ROOT、ANDROID_NDK_HOME 和 ANDROID_NDK_ROOT。它不提供 Rust:使用 PATH 上的 rustup 工具链,并需要一次性添加三个 Android 目标。可以用 --arg pkgs 传入另一个 nixpkgs,前提是它接受该许可并允许非自由软件包。
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-androidnix-shell ffi/shell.nix --run ffi/build-android.sh [OUT_DIR]ffi/build-android.sh(set -euo pipefail):
- 切换到仓库根目录,并用
realpath -m把OUT_DIR(默认target/ffi/android)解析为绝对路径。 - 检查所需工具:缺失时打印
error: cargo-ndk not found; run inside 'nix-shell ffi/shell.nix'或error: ANDROID_NDK_HOME is not set; run inside 'nix-shell ffi/shell.nix',都输出到标准错误,退出状态为 1。 - 删除
OUT_DIR/jniLibs和OUT_DIR/kotlin。 - 运行
cargo ndk -t arm64-v8a -t armeabi-v7a -t x86_64 -o OUT_DIR/jniLibs build --release -p etemenanki-ffi --features vendored-openssl。CARGO_NDK_PLATFORM设置库链接时针对的最低 API level;cargo-ndk 的默认值是 21,也是 Rust 支持的最低值。 - 删除
jniLibs下除libetemenanki_ffi.so以外的所有文件:cargo-ndk 会复制构建产生的每个cdylib,而某些依赖(boringtun、tun-rs)声明了自己的cdylib。 - 从
arm64-v8a的库生成 Kotlin 绑定(任何 ABI 都带有相同的元数据),使用--language kotlin --no-format,因为不假定装有 ktlint。 - 打印输出位置:
Android libraries: <OUT_DIR>/jniLibs和Kotlin bindings: <OUT_DIR>/kotlin。
输出为 OUT_DIR/jniLibs/{arm64-v8a,armeabi-v7a,x86_64}/libetemenanki_ffi.so 和 OUT_DIR/kotlin/uniffi/etemenanki_ffi/etemenanki_ffi.kt。库不做 strip,因为生成器从它们的符号表读取元数据;app 的 Gradle 构建会 strip 它打包的内容。app 把 jniLibs/ 复制到自己模块的 src/main/ 下,把 Kotlin 文件复制到自己的源码中,并依赖 JNA(net.java.dev.jna:jna:<version>@aar),绑定通过 JNA 加载库。
iOS:ffi/build-ios.sh
Section titled “iOS:ffi/build-ios.sh”rustup target add aarch64-apple-ios aarch64-apple-ios-simffi/build-ios.sh [OUT_DIR]ffi/build-ios.sh(set -euo pipefail)只能在装有 Xcode 的 macOS 上运行:
- 拒绝其他系统(
error: iOS builds need macOS with Xcode (the iOS SDKs and xcodebuild)),也拒绝缺失xcodebuild的环境(error: xcodebuild not found; install Xcode and run 'xcode-select --install'),都输出到标准错误,退出状态为 1。 - 切换到仓库根目录,并通过创建目录再取
pwd -P的方式,把OUT_DIR(默认target/ffi/ios)和目标目录(CARGO_TARGET_DIR,默认target)解析为绝对路径,因为 macOS 的realpath没有-m。 - 删除旧的
EtemenankiFFI.xcframework、swift/和headers/,并重新创建后两者。 - 用
cargo build --release -p etemenanki-ffi --features vendored-openssl --target <triple>构建libetemenanki_ffi.a,aarch64-apple-ios和aarch64-apple-ios-sim各一次,所以每个都落在<target dir>/<triple>/release/中。 - 从设备切片的静态库
<target dir>/aarch64-apple-ios/release/libetemenanki_ffi.a生成 Swift 绑定,写出etemenanki_ffi.swift、etemenanki_ffiFFI.h和etemenanki_ffiFFI.modulemap。 - 把头文件和 modulemap 移到
headers/,modulemap 改名为module.modulemap,让 Xcode 能找到这个模块。 - 用每个切片的库和
headers/目录运行xcodebuild -create-xcframework,生成OUT_DIR/EtemenankiFFI.xcframework,然后删除headers/。 - 打印输出位置:
XCFramework: <path>和Swift bindings: <OUT_DIR>/swift/etemenanki_ffi.swift。
输出为 XCFramework(面向设备和 arm64 模拟器的静态库、C 头文件和 modulemap)以及 OUT_DIR/swift/etemenanki_ffi.swift,后者导入 modulemap 声明的 etemenanki_ffiFFI 模块。两者都放进 packet tunnel 扩展的 target。
cargo test -p etemenanki-ffiffi/tests/proxy.rs 像绑定那样驱动代理:同步方法从测试线程调用,异步方法在一个不是 tokio 的执行器上运行(futures::executor::block_on)。TUN 设备用一个数据报 socketpair(UnixDatagram::pair)代替,它和 TUN 描述符一样,每次读写承载一个包。测试保留一端(wire,读超时为 TIMEOUT = 5 秒),把另一端的描述符交给 Proxy::start,向自己这一端写入用 etherparse 构建的 IPv4 包,并读取代理协议栈的应答,扮演操作系统的角色。
测试工具(harness):
| 辅助项 | 作用 |
|---|---|
CLIENT |
10.0.0.2,测试写入的每个包的源地址 |
Recorder |
一个 Platform:记录 protect 收到的每个描述符并返回 true,保留每一行日志及其级别 |
Panicking |
一个 Platform:其 log 以 the log failed on: <message> panic |
Device |
测试这一端(wire),以及把描述符交给代理的那一端(tun);recv 把一个包读入 2048 字节的缓冲区,如果在 TIMEOUT 内没有包到达,就让测试失败 |
Device::tcp_exchange |
从 CLIENT:<port> 手写一次 TCP 握手(SYN,等待 SYN-ACK,ACK,一个数据段),然后按顺序读取应答,逐段确认 |
Device::udp_exchange |
从 CLIENT:<port> 发送一个 UDP 数据报,返回应答的有效载荷,并检查其端口 |
tcp_of |
包中的 TCP 段,如果有的话(SlicedPacket::from_ip) |
tcp_ack |
构建一个来自 CLIENT 的纯 ACK |
tcp_echo、udp_echo |
位于 127.0.0.1、端口 0 的回显服务器 |
eventually |
每 20 毫秒轮询一次条件,超过 TIMEOUT 则失败 |
DIRECT |
一个配置:[log] level = "debug",一个 freedom 出站 direct,[route] default = "direct",没有入站 |
SUBSCRIBE |
一个订阅文件:一个位于 127.0.0.1 的 SOCKS 节点 upstream(其 PORT 由测试替换),以及一个继承 direct 并匹配 80 端口的路由组 Web |
SUBSCRIBED |
一个配置:[log] level = "info",带 direct 出站、注释 # The app's picks.、一个空的 [subscribe],以及包含 Web = "direct" # plain web goes direct 的 [subscribe.routes] |
| 测试 | 它固定的行为 |
|---|---|
a_tun_device_carries_tcp_and_udp_to_the_outbound |
经由设备的 TCP 通过 direct 到达回显服务器并返回,TCP 拨号恰好触发一次 protect;UDP 同样如此,protect 调用总数至少两次。该 TCP 流在列表中显示为 host 127.0.0.1、回显端口、出站 direct、入站 TUN_TAG、源 10.0.0.2,上行至少 15 字节。close_connection 返回 true,该流离开列表,第二次关闭返回 false。收到的流量样本总上行至少 19 字节。平台日志包含 config loaded。测试自己的描述符关闭后,在它这一端的发送仍然成功;stop 之后则以 ConnectionRefused 失败。stop 之后,connections 和 reload 返回 Stopped,第二次 stop 什么也不做。 |
a_server_inbound_is_refused |
一个在 127.0.0.1 端口 1 上带 VLESS 入站的配置被以 ServerInbound { tag: "vl", protocol: "vless" } 拒绝:即使是 loopback 上的服务端协议也会被拒绝。该端口从未被绑定,因为拒绝在先。 |
only_a_local_socks_inbound_is_served |
0.0.0.0 上的 SOCKS 入站被以指明其 tag 的 ServerInbound 拒绝;同样的入站放在 127.0.0.1 上可以启动,也可以停止 |
set_route_and_reload_switch_like_the_rest_api |
一个 SOCKS 上游的替身接受连接,在一个通道上报告每个连接并保持其打开(mem::forget),所以发往它的流会停在等待它的问候(greeting)上。使用指向它的 SUBSCRIBE 和 SUBSCRIBED:routes 显示一个选择 direct 的路由组,并列出 upstream。set_route("Web", "upstream") 应用成功(unchanged 为 false),返回的配置中有 Web = "upstream" # plain web goes direct,并保留了注释 # The app's picks.,该组的目标变为 upstream。随后经由设备的一条 80 端口的流会拨号到这个上游。同样的切换再做一次为 unchanged,配置相同;路由组 Nope 为 UnknownGroup;目标 nowhere 为 Config。带订阅文件重载返回的配置为 unchanged;带订阅文件重载 DIRECT 为 Config,选择仍为 upstream;单独重载 DIRECT 应用成功,之后不再有路由组。 |
a_panic_is_returned_not_unwound |
以 Panicking 作为平台、DIRECT 的级别设为 info 时,start 返回 Panic,其消息包含 the log failed |
其他覆盖库所依赖代码的测试:
| 测试 | 文件 | 它固定的行为 |
|---|---|---|
given_sources_must_agree_on_a_subscribe_file |
app/tests/unit/config.rs |
给出订阅文件时,sources_given 接受没有 path 的 [subscribe];缺少文件或缺少该节时以 InvalidInput 拒绝 |
direct_flows_are_dialed_on_hooked_sockets、a_socks_upstream_is_dialed_on_hooked_sockets、a_refusing_hook_fails_the_dial |
supervisor/tests/socket_policy.rs |
socket 策略作用于直连的 TCP 和 UDP socket 以及 SOCKS 上游的 socket,拒绝的钩子会让拨号失败 |
hook_runs_on_apply_and_its_error_propagates |
environment/tests/unit/dial/socket.rs |
SocketOptions::apply 运行钩子一次,并返回它的错误 |
以下情况都没有测试:多于一个 TUN 入站、tun 入站的 name、address 或 routes 引发的警告、指向 default 的 set_route、[api] 一节、edits 锁、排在 stop 之后的编辑、不调用 stop 的 Drop、经由 FFI 的 protect 返回 false,以及 [log] level 过滤器。修改这些路径时应当补上测试。其他 crate 的测试工具见测试。