跳转到内容

移动端库(etemenanki-ffi)

源码文件:40 个 · 核对版本 Etemenanki 555b7df
  • Etemenanki/ffi/Cargo.toml
  • Etemenanki/ffi/src/lib.rs
  • Etemenanki/ffi/src/proxy.rs
  • Etemenanki/ffi/src/client.rs
  • Etemenanki/ffi/src/platform.rs
  • Etemenanki/ffi/src/types.rs
  • Etemenanki/ffi/src/error.rs
  • Etemenanki/ffi/bindgen/Cargo.toml
  • Etemenanki/ffi/bindgen/src/main.rs
  • Etemenanki/ffi/build-android.sh
  • Etemenanki/ffi/build-ios.sh
  • Etemenanki/ffi/shell.nix
  • Etemenanki/ffi/README.md
  • Etemenanki/ffi/tests/proxy.rs
  • Etemenanki/Cargo.toml
  • Etemenanki/Cargo.lock
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/routes.rs
  • Etemenanki/app/src/lower.rs
  • Etemenanki/app/src/subscribe.rs
  • Etemenanki/app/src/api.rs
  • Etemenanki/webclient/src/lib.rs
  • Etemenanki/webclient/src/router.rs
  • Etemenanki/supervisor/src/supervisor.rs
  • Etemenanki/supervisor/src/build/apply.rs
  • Etemenanki/supervisor/src/build/validate.rs
  • Etemenanki/supervisor/src/topology/inbound/mod.rs
  • Etemenanki/supervisor/src/system/listener.rs
  • Etemenanki/supervisor/src/track/mod.rs
  • Etemenanki/supervisor/src/track/sampler.rs
  • Etemenanki/supervisor/src/entity/id.rs
  • Etemenanki/environment/src/dial/socket.rs
  • Etemenanki/protocols/src/tun/config.rs
  • Etemenanki/protocols/src/tun/device.rs
  • Etemenanki/protocols/src/tun/inbound.rs
  • Etemenanki/concepts/src/net.rs
  • Etemenanki/supervisor/tests/socket_policy.rs
  • Etemenanki/environment/tests/unit/dial/socket.rs
  • Etemenanki/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 获取(按用户的用量计费)。
  • 文件夹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 二进制
    • build-android.sh
    • build-ios.sh
    • shell.nix
    • README.md

两个 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)。

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。

ffi/src/proxy.rs
#[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 只在一次应用成功之后(或重载发现文件未变时)才被替换,所以它只会保存已经应用的文件。

ffi/src/platform.rs
#[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:这里没有任何东西比较文件的版本。

记录 字段 类型 含义
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 都不导出。

ffi/src/error.rs
#[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。

ffi/src/client.rs
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 依次执行:

  1. 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>。这是第一步,所以无效的描述符在运行时存在之前就会失败。

  2. dispatch(&config_toml, platform) 构建日志(见日志桥接)。

  3. runtime(&log) 构建代理的运行时(见运行时);构建失败为 Io。

  4. config::sources_given(config, subscribe) 把这些字节与配置的解析结果一起配成 Sources。当配置能解析、但两者不一致时,它以 InvalidInput 失败,变为 Config:

    • the config has a [subscribe] section, but no subscribe file was given
    • a subscribe file was given, but the config has no [subscribe] section to pick its routes in

    解析错误不在这里抛出:它随 sources 一起传递,由 Core::start 报告。

  5. Supervisor::builder().socket_options(platform::socket_options(platform)),builder 的其他设置都保持默认:没有用量 sink,默认的一秒采样器 tick,以及默认的 ApplyOptions。

  6. 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 级别在 target etemenanki_app::instance 上记录 config loaded: <summary>。它的 LoadError 转换为 FfiError。

  7. 组装代理: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。

client::lowering(tun) 返回 etemenanki-app 的 Lowering 类型,即 Arc<dyn Fn(&Config) -> io::Result<Built> + Send + Sync>,闭包捕获了 Arc<OwnedFd>。Core 在每次启动、重载和路由切换时调用它:

ffi/src/client.rs
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 })
})
}
  1. lower(cfg) 是桌面端 app 的 lowering,原样使用:它接受的每个键、抛出的每个错误在这里同样适用(从 TOML 到 spec)。
  2. refuse_servers 按顺序检查 spec 的每个入站,拒绝第一个为他人提供服务的入站。
  3. supply_tun 把 TUN 入站绑定到传入的描述符。
  4. [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。

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。

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。

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)在扩展打开的描述符中找到。嵌入指南涵盖了这两种情况。

ffi/src/platform.rs
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 服务。
ffi/src/proxy.rs
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 上下文。

ffi/src/proxy.rs
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。Line visitor 原样追加 message 字段(一个 &str 值,或格式化参数的 Debug 形式,后者打印的文本不带引号)。其他字符串字段追加为 key=value,其他任何字段追加为 key= 后跟该值的 Debug 形式。tracing 的宏最先记录 message,所以它排在各字段之前。有字段但没有 message 的事件变成 <target>: 后面直接跟 key=value,冒号后有两个空格。例如,一次重载以 etemenanki_app::instance: config reloaded: <summary> 的形式到达平台。
  • 哪些事件。 代理的运行时线程上的事件,以及 start 运行其 block_on 期间调用线程上的事件。其他线程保留它们原有的默认 dispatcher。其中包括 poll stop 的 future 的线程,所以 stop 自己可能发出的那一行(停止)不会到达平台。
ffi/src/proxy.rs
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
  1. 获取 edits 锁,克隆 state.files。
  2. Snapshot::of(files, config::parse_bytes(&files.config)):解析正在运行的配置和订阅文件。失败为 ControlError::Invalid。
  3. 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。
  4. 如果编辑后字节没有变化(pick 返回 None),就不应用:结果为 RouteChange { report: ApplyReport::unchanged(), config_toml: <the running config> }。
  5. 否则,编辑后的配置和未变的订阅文件作为已读取的 sources 交给 Core::reload_with。应用本身就是检查:一个不能构成有效配置的选择会像任何重载一样被拒绝,正在运行的内容保持原样。
  6. 成功时 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。

四个同步调用在调用线程上、在 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: 返回
  1. 标记为已停止。 self.running.lock().take()。如果 running 已经是 None,stop 立即返回。这发生在第一个 await 之前,所以从此以后,其他每个调用都返回 Stopped;第二次 stop,即使在第一次仍在停止的过程中运行,也会立即返回。
  2. 关停 supervisor。 代理运行时上的一个任务获取 edits 锁,所以正在进行的重载或路由切换会先完成,然后运行 state.core.shutdown(STOP_GRACE):supervisor 停止接受连接,给存活连接最多 2 秒完成,然后关闭剩下的连接(supervisor 概览)。排在它之后的编辑会面对一个已停止的 supervisor:应用以 Io 失败(the supervisor has shut down,见重载),除非运行时的关停先 drop 了该编辑的任务,那样的话调用返回 Stopped。
  3. 该任务中的 panic 不会被返回(stop 不返回任何东西)。stop 用 tracing::error! 发出 stopping the proxy panicked; releasing what is left,但这发生在它自己的 future 中,在 poll 它的线程上:外部执行器的线程,或测试中的 block_on。库没有在那里设置 dispatcher,所以只有调用者在那个线程上安装的 subscriber 才能看到这一行;Platform::log 看不到。不是 panic 的 JoinError 会被忽略。无论哪种情况,接下来都会关停运行时。
  4. 关停运行时。 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 没有测试
调用 失败于 何时
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 一个 tokio JoinHandle 会让它的任务脱离(detach),任务在代理的运行时上一直运行到结束,并在结束前一直持有 edits 锁。app 看不到结果。
  • stop 在第一个 await 之前取出 Running,所以无论这个 future 之后怎样,从那时起代理都算已停止。被 drop 的 stop future 还会做什么,取决于它何时被 drop:
    • 如果它正在等待 supervisor 关停任务,future 仍拥有 Runtime,后者会在 drop 这个 future 的线程上被 drop。tokio 对运行时的 Drop 会取消其上剩余的任务,其中包括 supervisor 关停任务,所以存活连接得不到 STOP_GRACE 的剩余时间;随后它等待运行时的线程结束,且不受 RUNTIME_GRACE 的上限约束;
    • 如果它已经在 oneshot 上等待,运行时就归关停线程所有,后者会自行完成。
  • 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 从构建出的库生成绑定。

ffi/bindgen/src/main.rs
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-ffi
cargo run -p etemenanki-ffi-bindgen -- generate \
--library target/debug/libetemenanki_ffi.so --language kotlin --out-dir out/kotlin
cargo run -p etemenanki-ffi-bindgen -- generate \
--library target/debug/libetemenanki_ffi.so --language swift --out-dir out/swift

Android: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-android
nix-shell ffi/shell.nix --run ffi/build-android.sh [OUT_DIR]

ffi/build-android.sh(set -euo pipefail):

  1. 切换到仓库根目录,并用 realpath -m 把 OUT_DIR(默认 target/ffi/android)解析为绝对路径。
  2. 检查所需工具:缺失时打印 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。
  3. 删除 OUT_DIR/jniLibs 和 OUT_DIR/kotlin。
  4. 运行 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 支持的最低值。
  5. 删除 jniLibs 下除 libetemenanki_ffi.so 以外的所有文件:cargo-ndk 会复制构建产生的每个 cdylib,而某些依赖(boringtun、tun-rs)声明了自己的 cdylib。
  6. 从 arm64-v8a 的库生成 Kotlin 绑定(任何 ABI 都带有相同的元数据),使用 --language kotlin --no-format,因为不假定装有 ktlint。
  7. 打印输出位置: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 加载库。

终端窗口
rustup target add aarch64-apple-ios aarch64-apple-ios-sim
ffi/build-ios.sh [OUT_DIR]

ffi/build-ios.sh(set -euo pipefail)只能在装有 Xcode 的 macOS 上运行:

  1. 拒绝其他系统(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。
  2. 切换到仓库根目录,并通过创建目录再取 pwd -P 的方式,把 OUT_DIR(默认 target/ffi/ios)和目标目录(CARGO_TARGET_DIR,默认 target)解析为绝对路径,因为 macOS 的 realpath 没有 -m。
  3. 删除旧的 EtemenankiFFI.xcframework、swift/ 和 headers/,并重新创建后两者。
  4. 用 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/ 中。
  5. 从设备切片的静态库 <target dir>/aarch64-apple-ios/release/libetemenanki_ffi.a 生成 Swift 绑定,写出 etemenanki_ffi.swift、etemenanki_ffiFFI.h 和 etemenanki_ffiFFI.modulemap。
  6. 把头文件和 modulemap 移到 headers/,modulemap 改名为 module.modulemap,让 Xcode 能找到这个模块。
  7. 用每个切片的库和 headers/ 目录运行 xcodebuild -create-xcframework,生成 OUT_DIR/EtemenankiFFI.xcframework,然后删除 headers/。
  8. 打印输出位置: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-ffi

ffi/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 的测试工具见测试。