Mobile library (etemenanki-ffi)
Source files: 40 · checked against 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 embeds Etemenanki in an Android or iOS app as a client. The platform’s VPN API creates and configures a TUN interface and hands the app a file descriptor for it. The app passes that descriptor, the same TOML config the desktop app runs (and its subscribe file), and a small callback object to Proxy::start. The library then serves the device through the config’s outbounds and routes, on a tokio runtime of its own, until Proxy::stop. Kotlin and Swift bindings are generated from the library by UniFFI.
This page is for contributors who change ffi/. It covers the crate layout and build, the exported API with its exact semantics, the Platform callbacks and how protect becomes the supervisor’s socket policy, how a config is lowered for a phone, the TUN descriptor’s ownership, the runtime and the log bridge, errors and panic catching, the edits lock, stopping, and the host tests. The library reuses the desktop app’s loading and route-switching code unchanged; that code is described on etemenanki-app: from TOML to a spec, etemenanki-app: running, reloading and shutting down and The REST API. How an app integrates the library is described in the embedding guide.
Responsibilities
Section titled “Responsibilities”| Component | File → symbol | Owns |
|---|---|---|
| Crate root | ffi/src/lib.rs |
The module list, the re-exports that form the Rust API, and uniffi::setup_scaffolding!(), which generates the UniFFI scaffolding for the namespace etemenanki_ffi. |
| The proxy | ffi/src/proxy.rs → Proxy |
One running proxy: its runtime, its Core, the files running now, the edits lock, the log dispatch, panic catching, stop and Drop. |
| Client lowering | ffi/src/client.rs → lowering, refuse_servers, supply_tun, TUN_TAG |
Turning a config into a client’s spec: the app’s lowering, then refusing server inbounds and binding the TUN inbound to the supplied descriptor. |
| Platform | ffi/src/platform.rs → Platform, LogLevel, socket_options, PlatformLog |
The foreign callback trait, the socket hook that calls protect, and the tracing layer that calls log. |
| Records | ffi/src/types.rs |
The app-facing copies of the route view, the apply report, the traffic snapshot and live connections, converted at the boundary. |
| Errors | ffi/src/error.rs → FfiError |
The one error type every call returns, and its mapping from LoadError and ControlError. |
| Binding generator | ffi/bindgen → uniffi-bindgen |
The UniFFI command-line generator, built at the UniFFI version the library uses. |
What it reuses rather than re-implements:
| Concern | Reused from | Described on |
|---|---|---|
| Merging the subscribe file, lowering, starting, reloading, dedup by bytes | app/src/instance.rs → Core; app/src/config.rs → sources_given, effective; app/src/lower.rs → lower |
From TOML to a spec, Running and reloading |
| The route view and editing a pick | app/src/routes.rs → Snapshot |
The REST API |
The route view’s types and ControlError |
etemenanki-webclient |
The REST API |
| Traffic, live flows and killing one | Supervisor::tracker → Tracker |
Tracking flows, stats and speed limits |
| Serving the TUN device | TunSource::Fd in the supervisor, tun::adopt in the protocols crate |
Serving inbounds, TUN |
What it does not do:
- Write files. A route switch returns the edited config as a string, and the app stores it (
RouteChange::config_toml). Paths inside the config still name files on the device’s filesystem, read as on the desktop: a certificate, key orca_fileby the app’s lowering (a missing one isIo), and the[route]geodata files by the supervisor when it builds the route (one a rule needs that cannot be read isConfig,building route failed: …). - Read the subscribe file from a path. Its contents come as the second argument of
startandreload; apathin[subscribe]is not read. - Serve
[api]. The lowering drops it; routes, traffic and connections are calls onProxyinstead. - Serve others. It is a client. An inbound serving a server protocol (Trojan, VLESS, VMess, Shadowsocks, Shadowsocks 2022, Hysteria 2) is refused wherever it listens, loopback included, and so is a SOCKS or HTTP inbound that other hosts can reach.
- Create, address or route the interface. The platform does that; the config’s
tuninbound contributes only its tag, MTU, UDP and flow settings and sniffing. - Expose everything the supervisor tracks. There are no flow events, no session list, no close-by-selector and no per-user figures. The REST API has the first three; per-user usage is the supervisor’s Rust API alone (Per-user usage accounting).
Crate layout
Section titled “Crate layout”Directoryffi/
- Cargo.toml
etemenanki-ffi, publish = false Directorysrc/
- lib.rs
- proxy.rs
- client.rs
- platform.rs
- types.rs
- error.rs
Directorytests/
- proxy.rs host tests over a socketpair
Directorybindgen/
- Cargo.toml
etemenanki-ffi-bindgen, publish = false Directorysrc/
- main.rs the
uniffi-bindgenbinary
- main.rs the
- Cargo.toml
- build-android.sh
- build-ios.sh
- shell.nix
- README.md
- Cargo.toml
Both crates are workspace members (Cargo.toml → members lists ffi and ffi/bindgen), at version 0.1.0, and both set publish = false: the library is built per target by the scripts beside it, and nothing depends on it as a crate.
ffi/Cargo.toml builds three crate types from one library named etemenanki_ffi:
| Crate type | Used for |
|---|---|
cdylib |
Android’s libetemenanki_ffi.so, and the library the Kotlin bindings are generated from |
staticlib |
iOS’s libetemenanki_ffi.a, wrapped into an XCFramework |
lib |
The Rust host tests in ffi/tests |
Its dependencies:
| Dependency | Requirement | Why |
|---|---|---|
etemenanki-app |
path, 2.1.1 |
Core, the config and its lowering, routes::Snapshot |
etemenanki-concepts |
path, 2.0.0 |
DialNetwork and Remote, for ConnectionInfo |
etemenanki-environment |
path, 3.0.0 |
SocketOptions, for the protect hook |
etemenanki-protocols |
path, 4.0.0, features hysteria and tun |
The TUN defaults, and the two features the app’s outbounds and the device need |
etemenanki-supervisor |
path, 0.3.0 |
Supervisor, the TUN bind types, the tracker, ApplyReport |
etemenanki-webclient |
path, 0.1.0 |
The route view and the errors a route switch is refused with |
uniffi |
0.32.2 |
Scaffolding and derives |
tokio |
workspace | The proxy’s runtime, and tokio::sync::Mutex for the edits lock |
futures |
workspace | FutureExt::catch_unwind in spawn_on, the oneshot channel stop waits on, and executor::block_on in the tests |
compact_str |
workspace | CompactString, the key type of the tracker’s per-tag maps, which the TrafficSnapshot conversion names |
parking_lot |
workspace | The running and files mutexes |
tracing |
workspace | Events, Dispatch and the per-thread default dispatcher |
tracing-subscriber |
workspace (feature env-filter) |
registry, EnvFilter, and the Layer trait PlatformLog implements |
thiserror |
workspace | The Error and Display derive of FfiError |
etherparse (dev) |
0.20 |
Builds and parses the IP packets the tests push through the device |
The dependency direction is one way: etemenanki-ffi depends on the app’s library crate and on the supervisor, and nothing in the workspace depends on it. The library crates are published to a private Cargo registry; the two FFI crates are not.
The one feature, vendored-openssl, forwards to etemenanki-protocols/vendored-openssl (openssl/vendored): OpenSSL is built from source, for targets that have no system OpenSSL to link. Android and iOS have none, so both build scripts enable it. Hysteria 2 does not use OpenSSL; its QUIC stack uses rustls with ring (workspace Cargo.toml).
Key types
Section titled “Key types”What UniFFI exports
Section titled “What UniFFI exports”| Rust item | UniFFI derive or attribute | In the bindings |
|---|---|---|
Proxy |
#[derive(uniffi::Object)], methods under #[uniffi::export] |
A class. start is a named constructor (#[uniffi::constructor]); reload, set_route and stop are async (Kotlin suspend, Swift async). |
Platform |
#[uniffi::export(foreign)] |
An interface the app implements in Kotlin or Swift. |
LogLevel, Inherit, TargetKind, Network |
uniffi::Enum |
Enums. Inherit::Group carries a named field name. |
RouteView, DefaultRoute, RouteGroup, RouteTarget, ApplyReport, RouteChange, Rates, TagRates, TrafficSnapshot, ConnectionInfo |
uniffi::Record |
Data classes and structs, copied across the boundary. |
FfiError |
uniffi::Error |
The error every call throws. Kotlin sees it as FfiException. |
TUN_TAG |
none | A Rust constant only; the bindings do not see it. |
UniFFI renames members to each language’s conventions, so set_route is setRoute, close_connection is closeConnection and config_toml is 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);}Every method may be called from any thread. A call that fails returns an FfiError; except after Panic, what runs is left as it was.
| Method | Runs on | Semantics |
|---|---|---|
start |
The calling thread, blocking (Runtime::block_on) until the first apply has finished |
Serves tun_fd as config_toml, with subscribe_toml exactly when the config has a [subscribe]. Returns the proxy, or refuses the config. See Starting. |
reload |
The proxy’s runtime | Runs new files in place of the running ones; what an apply does to live connections is described on Reconcile. Files that do not apply are refused and what runs is left as it was. Files byte for byte equal to the last ones Core recorded are not tried again: the result is unchanged when those applied, or Config with the refusal recorded for them. See Reloading. |
set_route |
The proxy’s runtime | Points a route group, or the default route with "default", at a target and applies that. Returns the report and the config with the pick in it, comments and formatting kept. See Switching a route. |
routes |
The calling thread | The route groups of the running files, their picks, what each inherits and where its traffic goes, the default route, and every target a pick may name. |
traffic |
The calling thread | The tracker’s snapshot at the last sampler tick. |
connections |
The calling thread | Every live flow, sorted by id, which is oldest first. |
close_connection |
The calling thread | Kills the live flow id; returns whether a flow with that id was live. Other flows, even those sharing its carrier, keep running. |
stop |
The proxy’s runtime, then a thread of its own | Stops accepting, gives live connections STOP_GRACE, closes the rest, then shuts the runtime down with RUNTIME_GRACE, releasing the descriptor’s duplicates and the runtime’s threads. A second call does nothing. See Stopping. |
The three locks:
| Lock | Kind | Held | Guards |
|---|---|---|---|
Proxy::running |
parking_lot::Mutex |
Only to clone the runtime handle or the Arc<State> out, or to take Running in stop and Drop |
Whether the proxy runs. None means stopped: every call but stop then returns FfiError::Stopped. |
State::edits |
tokio::sync::Mutex<()> |
Across the whole of a reload, a set_route, and the supervisor shutdown in stop |
One edit at a time. A route switch reads the running files, edits them and applies them; without this lock a concurrent reload could replace the files between the read and the apply, and the switch would then apply an edit of stale files. Tokio’s mutex queues waiters in order. |
State::files |
parking_lot::Mutex |
Only to clone or replace the Arc<Sources> |
The files running now: the config and subscribe bytes of the last successful start, reload or switch. |
State::files is separate from Core’s own record of the last files it tried (Core::last). That record can hold files that were refused, while a route switch must edit the files that run. files is replaced only after an apply succeeds (or a reload finds the files unchanged), so it only ever holds files that applied.
Platform and LogLevel
Section titled “Platform and 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 }The app implements Platform in Kotlin or Swift. Its methods are called from the proxy’s own threads, any of them, at any time until stop returns, and log is also called on the thread calling start while start runs (The log bridge). The implementation must therefore be thread-safe. It must not throw: UniFFI turns an exception from a callback that declares no error into a Rust panic (Callback interface failure: <exception>).
| Method | Called | The app should |
|---|---|---|
protect(fd) |
For every socket a dialer opens toward the network (the system resolver’s sockets are not offered; see The socket-protect hook), after it is created and before it is bound or connected | Keep the socket out of the tunnel and return true: on Android, VpnService.protect(fd). Where the tunnel already excludes the app’s own traffic, as a packet tunnel does on iOS, return true. Returning false closes the socket unused and fails the dial it was for. |
log(level, message) |
For every tracing event at or above the config’s [log] level, on the thread that emitted it |
Hand the line to the platform’s log. |
Both are called synchronously: log runs inside the event’s dispatch and protect inside the dial, so a slow implementation holds up the thread, and the task, that made the call. A panic in either, a thrown exception included, unwinds through the code that made the call:
- in
logon the thread callingstart, or inside the task of areloadorset_route(which logsconfig reloaded: …), the call returns it asFfiError::Panic; - anywhere else it ends the runtime task that emitted the event or opened the socket, and no call returns it. That task may be a flow’s, a probe’s, or the supervisor’s own actor, which runs every apply after the first and logs
applied: <n> built, <n> reused, <n> swapped, <n> drainedatdebugin each.
LogLevel maps tracing::Level one to one: ERROR → Error, WARN → Warn, INFO → Info, DEBUG → Debug, TRACE → Trace (impl From<&Level> for LogLevel).
Records
Section titled “Records”The records in ffi/src/types.rs are copies of Rust types, converted when a call returns. None of them holds a reference into the proxy.
Routes
Section titled “Routes”| Record | Field | Type | Meaning |
|---|---|---|---|
RouteView |
default |
DefaultRoute |
The route taking traffic no group matches; switched as the group named "default" |
groups |
Vec<RouteGroup> |
The subscribe file’s route groups, in the order they are matched | |
targets |
Vec<RouteTarget> |
What a pick may name, in this order: the config’s outbounds, its balancers, the subscribe file’s nodes, then the built-in direct and blackhole, each unless an outbound, balancer or node already has that name |
|
DefaultRoute |
pick |
Option<String> |
The target the config picks, if any: [subscribe.routes] default, else [route] default |
target |
Option<String> |
Where unmatched traffic goes: the pick, else the config’s first outbound, else the subscribe file’s first node | |
RouteGroup |
name |
String |
|
pick |
Option<String> |
The target the user picked, if any | |
inherit |
Inherit |
What the group uses while nothing is picked: Default, Direct, Blackhole or Group { name } |
|
target |
Option<String> |
Where the group’s traffic goes: the pick, else what it inherits. None only for a group that inherits in a cycle or from a group that does not exist. |
|
RouteTarget |
name |
String |
|
kind |
TargetKind |
Node (a subscribe file’s node), Outbound (the config’s own), Balancer, Direct or Blackhole (the built-ins) |
impl From<web::RouteView> for RouteView copies the webclient’s view field by field. It drops the view’s etag: nothing here compares versions of a file.
What an apply did
Section titled “What an apply did”| Record | Field | Type | Meaning |
|---|---|---|---|
ApplyReport |
unchanged |
bool |
Nothing was applied: the files are the ones already running, or the pick was already made |
reused |
Vec<String> |
Resources kept as they were | |
built |
Vec<String> |
Resources built from the config: new, or changed | |
swapped |
Vec<String> |
Inbounds serving new connections with a new handler | |
drained |
Vec<String> |
Outbound versions taken out of service | |
rebound |
Vec<String> |
Inbounds whose listener was bound again | |
restarted |
Vec<String> |
Inbounds restarted, ending their live connections | |
removed |
Vec<String> |
Inbounds taken out of the config | |
RouteChange |
report |
ApplyReport |
What the switch did |
config_toml |
String |
The config with the pick in it, for the app to store in place of its copy, so the pick survives a restart |
Two conversions fill ApplyReport. From<web::ReloadReport> turns Unchanged into ApplyReport::unchanged() (every list empty, unchanged: true) and Applied(report) into the next one. From<supervisor::ApplyReport> sets unchanged: false and turns each list into strings with ToString: reused and built hold Resources (inbound <tag>, outbound <id>, balancer <tag>, user set <tag>, route, dns), drained holds OutboundIds, and the other four hold inbound tags. What each list means for live connections is on Reconcile. restarted stays empty here: every apply runs with the default ApplyOptions (the builder’s for the first, Supervisor::apply for the rest), which refuse a Disrupt step (Reloading).
Traffic
Section titled “Traffic”| Record | Field | Type | From StatsSnapshot / TagStats |
|---|---|---|---|
TrafficSnapshot |
tick |
u64 |
tick: ticks so far, 0 before the first |
interval_ms |
u64 |
interval in milliseconds, saturating at u64::MAX; the time the rates are over |
|
total |
Rates |
total: every flow together |
|
inbounds |
Vec<TagRates> |
inbounds, in tag order: every inbound that has carried a flow |
|
outbounds |
Vec<TagRates> |
outbounds, in tag order: every outbound that has carried a flow. The DNS the proxy answers itself is the empty tag. |
|
TagRates |
tag, rates |
String, Rates |
One map entry |
Rates |
up, down |
u64 |
Payload toward and back from destinations, in total |
up_rate, down_rate |
u64 |
Bytes per second over the last tick | |
flows |
u64 |
Flows live at the tick (usize widened) |
The snapshot’s users are left out.
Connections
Section titled “Connections”impl From<&FlowEntry> for ConnectionInfo:
| Field | Type | From the FlowEntry |
|---|---|---|
id |
u64 |
id().get(); the value close_connection takes |
network |
Network |
Udp when destination().network is DialNetwork::Udp; Tcp for Tcp, Unknown and Unix |
inbound |
String |
The tag of the inbound it entered at |
source |
Option<String> |
The client’s IP address, without a port, when the inbound knows it |
host |
String |
The destination’s domain or IP address; for UDP, the first packet’s |
port |
u16 |
The destination port |
sniffed |
Option<String> |
The domain read from its first bytes, if any |
outbound |
String |
outbound().tag, without the version; a balancer’s flow names the member it picked |
rule |
Option<u32> |
The index of the route rule that matched; None for the default route, or for a DNS query the proxy answers itself |
started_at_ms |
u64 |
Unix milliseconds, computed as SystemTime::now() minus the flow’s elapsed monotonic time (unix_ms); 0 if that lies before the epoch, u64::MAX if the milliseconds do not fit a u64 |
up, down |
u64 |
Payload bytes toward and back from the destination |
The flow’s session, user, user label and plane epoch are not exported.
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 },}| Variant | When | What runs |
|---|---|---|
Config |
The config or subscribe file does not parse, does not merge or lower, or describes something the supervisor refuses (geodata it cannot read included) | Unchanged |
ServerInbound |
The config has a server-protocol inbound, or a SOCKS or HTTP inbound other hosts can reach | Unchanged |
UnknownGroup |
set_route named a route group the files do not have |
Unchanged |
Io |
Something outside the config failed: the runtime could not be built, the descriptor could not be duplicated, a certificate, key or CA file the config names could not be read, a local port could not be bound, or the supervisor had already shut down (an edit queued behind stop) |
Unchanged |
Stopped |
The proxy was stopped, or its runtime dropped the call’s task | Stopped |
Panic |
A bug: the call panicked inside the library and the panic was caught | Any state; stopping the proxy is the safe course |
Config and Io print their message alone, so the app sees the underlying text unchanged. The texts the supervisor produces are catalogued on Validation.
The mapping, in ffi/src/error.rs:
| From | Case | FfiError |
|---|---|---|
LoadError::Config(io) |
io wraps a client::ServerInbound (found with io.get_ref() and downcast_ref) |
ServerInbound { tag, protocol } |
LoadError::Config(io) |
kind InvalidData or InvalidInput |
Config (through ControlError::Invalid) |
LoadError::Config(io) |
any other kind, such as NotFound for a ca_file that is missing |
Io (through ControlError::Failed) |
LoadError::Apply |
ApplyError::Bind or ApplyError::Stopped |
Io |
LoadError::Apply |
any other ApplyError |
Config |
ControlError::UnknownGroup(group) |
UnknownGroup { group } |
|
ControlError::Invalid(message) |
Config { message } |
|
ControlError::Stale { .. } |
cannot happen here: there is no ETag to be stale against | Config with its text |
ControlError::Failed(message) |
Io { message } |
|
io::Error from the runtime builder or duplicate |
FfiError::io |
Io with the error’s Display |
running is None, or the task was cancelled |
Stopped |
|
| A caught panic payload | FfiError::panic |
Panic |
LoadError → ControlError is the app’s own conversion (app/src/instance.rs), shared with the REST API; the FFI adds only the ServerInbound case in front of it.
FfiError::panic(payload) takes the message from a &str payload, then from a String payload, and otherwise uses a panic with no message.
TUN_TAG
Section titled “TUN_TAG”pub const TUN_TAG: &str = "tun";The tag of the TUN inbound the lowering adds to a config that has none.
Data flow
Section titled “Data flow”flowchart LR app["Kotlin or Swift app"] -->|"UniFFI bindings"| proxy["Proxy"] proxy --> running["Running: Runtime and Arc State"] running --> core["app instance::Core"] core --> lowering["client::lowering"] core --> sup["Supervisor"] sup --> tun["tun inbound on TunSource::Fd"] sup --> outs["outbounds and resolvers"] outs -->|"SocketHook"| protect["Platform::protect"] running -->|"PlatformLog on every runtime thread"| log["Platform::log"] proxy -->|"routes, traffic, connections"| tracker["Tracker and running files"]
Starting
Section titled “Starting”sequenceDiagram participant A as App thread 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: block_on under with_default(log) C->>C: config::effective merges the subscribe file C->>L: lower, refuse_servers, supply_tun L-->>C: Built with api None C->>S: start(spec) with the protect hook S->>S: bind the tun inbound with tun::adopt S-->>C: Supervisor and ApplyReport C-->>P: Core, after logging config loaded P-->>A: Arc Proxy
start wraps start_on in guard, which catches a panic. start_on runs, in order:
-
duplicate(tun_fd). A negative value fails withIo,<fd> is not a file descriptor. Otherwise the descriptor is borrowed (BorrowedFd::borrow_raw, under the rule that the caller keeps it open for the duration of the call) and duplicated withtry_clone_to_owned, which usesfcntl(F_DUPFD_CLOEXEC), so the duplicate is close-on-exec; an OS error becomesIo. The duplicate goes into anArc<OwnedFd>. This is the first step, so a bad descriptor fails before a runtime exists. -
dispatch(&config_toml, platform)builds the log (see The log bridge). -
runtime(&log)builds the proxy’s runtime (see The runtime); a build failure isIo. -
config::sources_given(config, subscribe)pairs the bytes intoSourceswith the config’s parse. When the config parses and the two disagree, it fails withInvalidInput, which becomesConfig: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
A parse error is not raised here: it travels with the sources, and
Core::startreports it. -
Supervisor::builder().socket_options(platform::socket_options(platform)), with every other builder setting at its default: no usage sink, the default one-second sampler tick, and defaultApplyOptions. -
Core::start(builder, Ok((files.clone(), parsed)), client::lowering(tun)), run withruntime.block_oninsidetracing::dispatcher::with_default(&log, …), so events on the calling thread during the start reach the platform too.Core::startmerges the subscribe file (config::effective), calls the lowering, starts the supervisor (the first apply runs insideSupervisorBuilder::start), and logsconfig loaded: <summary>atinfoon targetetemenanki_app::instance. ItsLoadErrorconverts toFfiError. -
The proxy is assembled:
Running { runtime, state: Arc<State> { core, edits, files } }insiderunning, returned asArc<Proxy>.
start is synchronous and blocks its caller until the first apply has bound every listener and adopted the device. It uses Runtime::block_on, which tokio does not allow from inside another runtime’s async context; a Rust caller in that position gets the resulting panic back as FfiError::Panic.
When start fails, everything it built is owned by locals of start_on and dropped with them: the runtime, the lowering closure and its descriptor duplicate, and whatever the supervisor had started. Core::start does not log its own failure, unlike Core::reload_with: the error reaches the app only as the returned FfiError.
Client lowering
Section titled “Client lowering”client::lowering(tun) returns the app’s Lowering type, Arc<dyn Fn(&Config) -> io::Result<Built> + Send + Sync>, closing over the Arc<OwnedFd>. Core calls it on every start, reload and route switch:
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)is the desktop app’s lowering, unchanged: every key it accepts and every error it raises apply here too (From TOML to a spec).refuse_serverschecks every inbound of the spec, in order, and refuses the first that serves others.supply_tunbinds the TUN inbound to the supplied descriptor.[api]is logged atinfo(the config's [api] is not served here: the app reads routes and traffic through this library) and dropped:Built::apiis alwaysNone. The app’sapi::optionsis never called, so thelistenvalue is not parsed and the rule that a non-local API needs asecretis not applied. The table is still deserialised intoApiConfig, which denies unknown fields, so an unknown key or a wrong type in[api]is still refused when the config parses.
The steps stop at the first error. A config that fails the app’s lowering is refused with that error even when it also has a server inbound: a VLESS inbound over TLS whose tls.cert_file does not exist is refused as Io by the lowering (inbound <tag>: cannot read tls.cert_file "<path>": <error>), before refuse_servers could report ServerInbound.
Refusing server inbounds
Section titled “Refusing server inbounds”InboundProtocolSpec |
Allowed when | protocol in the error |
|---|---|---|
Tun |
always | |
Socks |
the bind is local | socks beyond this device |
Http |
the bind is local | http beyond this device |
Trojan |
never | trojan |
Vless |
never | vless |
Vmess |
never | vmess |
Shadowsocks |
never | shadowsocks |
Ss2022 |
never | shadowsocks 2022 |
Hysteria2 |
never | hysteria2 |
is_local(bind) holds for BindSpec::Unix, and for BindSpec::Tcp whose host is the string localhost or parses as a loopback IP address (127.0.0.0/8 or ::1). It is purely syntactic: no name is resolved. BindSpec::Udp and BindSpec::Tun are never local. The app’s lowering binds an inbound with no listen to 127.0.0.1, so a SOCKS or HTTP inbound without listen is allowed.
A refusal is an io::Error of kind InvalidInput carrying a ServerInbound { tag, protocol } as its inner error, so the boundary can find it again (FfiError). Its own Display reads inbound "<tag>" serves <protocol>, a server protocol; a client runs only a tun inbound and local socks or http inbounds; that is the text that appears in the reload log line. The app receives FfiError::ServerInbound, whose text is inbound "<tag>" serves <protocol>, a server protocol; only a tun inbound and local socks or http inbounds run in a client.
Binding the TUN inbound
Section titled “Binding the TUN inbound”supply_tun(spec, tun) looks for inbounds whose protocol is Tun:
| The spec has | Result |
|---|---|
| No TUN inbound | One is appended: tag TUN_TAG (tun), bind TunSource::Fd with DEFAULT_MTU (1500), sniff: true, and TunSpec { udp: true, udp_idle_timeout: DEFAULT_UDP_IDLE_TIMEOUT (60 s), max_flows: DEFAULT_MAX_FLOWS (65 536) }, no user set, no user-removal policy. When another inbound of the config (a local SOCKS or HTTP one) is already tagged tun, the supervisor refuses the spec with duplicate inbound tag tun, which is Config. |
One, bound to TunSource::Create(device) |
Its bind is replaced by TunSource::Fd with the config’s device.mtu. When the config gave a name, an address or a route, it logs at warn: inbound <tag>: the platform owns the tun interface, so its name, addresses and routes in the config are ignored. The warning comes from every lowering of such a config: at start, and again at each reload and route switch that lowers it. |
One, bound to TunSource::Fd(device) |
Rebound to this descriptor with that mtu. The app’s lowering never produces this; the branch is defensive. |
| One, bound to anything else | InvalidInput: inbound <tag>: a tun inbound bound to <bind> (defensive as well) |
| More than one | InvalidInput: the config has more than one tun inbound; the platform supplies one device |
The config’s tag, mtu, udp, udp_idle_timeout, max_flows and sniffing therefore apply; name, address and routes do not. The mtu must match the MTU the platform configured, which the library cannot see. The config’s listen or port on a tun inbound is still refused by the app’s lowering before supply_tun runs: inbound <tag>: tun owns a network interface and has no listener; remove listen/port.
Because the bind is replaced before the supervisor validates the spec, the supervisor’s platform check for created devices (which refuses routes everywhere but Linux) never sees it. Validation still requires the MTU to be at least 1280: inbound <tag>: tun mtu must be at least 1280, naming the config’s own tun inbound (the one the lowering appends always has 1500). See Validation.
On Android and iOS, tun::open refuses to create a device (tun devices are created by the system VPN API here; adopt its descriptor). The FFI never reaches it, since every TUN inbound it passes on is a TunSource::Fd.
The TUN descriptor
Section titled “The TUN descriptor”The descriptor the app passes stays the app’s. The proxy works on duplicates only:
flowchart LR appfd["tun_fd, the app's"] -->|"duplicate: try_clone_to_owned"| arc["Arc OwnedFd in the lowering closure"] arc -->|"cloned Arc"| spec["SuppliedTun in every spec"] spec -->|"listener bind: tun::adopt"| dev["device kept by the listener"] dev -->|"try_clone"| first["duplicate the stack reads"]
- The lowering closure holds one duplicate for the proxy’s lifetime and puts a clone of the same
Arcinto every spec it builds.SuppliedTuncompares equal when the raw descriptor number and the MTU are equal, so every spec the proxy builds names the same device, and an apply keeps it (Serving inbounds). - When the listener is bound,
system/listener.rs→bindcallsetemenanki_protocols::tun::adopt(fd), which duplicates the descriptor again and sets it non-blocking, then keeps that duplicate and hands a further duplicate to the stack. It logsinbound <tag> serves a supplied tun deviceatinfo. A duplicate shares its original’s file status flags, so adopting makes the app’s own descriptor non-blocking too. - On macOS and iOS builds the stack is configured with
packet_information(true): theutundevice carries a 4-byte protocol header on each packet, which the stack reads and writes. On Linux and Android it reads bare IP packets (TUN). stopreleases every duplicate: the listener’s and the stack’s with the supervisor’s shutdown, and the lowering closure’s with theCore. The platform keeps the interface for as long as the app keeps its own descriptor open.
How an app obtains the descriptor is platform code outside the library: on Android, the ParcelFileDescriptor that VpnService.Builder.establish() returns, with setMtu equal to the config’s tun MTU; on iOS, the packet tunnel’s utun control socket, found among the extension’s open descriptors by its com.apple.net.utun_control control id (CTLIOCGINFO). The embedding guide covers both.
The socket-protect hook
Section titled “The socket-protect hook”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", )) } }))}The policy has only a hook: no source address, interface, packet mark or keepalive. SupervisorBuilder::socket_options stores it for the supervisor’s lifetime, and the supervisor passes it to every builder that opens an outbound socket: each dial of a proxy outbound over TCP and its transports, a direct flow’s TCP dial and UDP sockets, a SOCKS outbound’s UDP relay socket, a Hysteria 2 or WireGuard outbound’s socket, every query a resolver sends from this host, and balancer health probes. The full table is on The supervisor.
SocketOptions::apply runs the hook last, after the interface and mark (which are unset here), on the raw socket2::Socket, before the dialer binds or connects it. protect gets the socket’s raw descriptor. A false becomes PermissionDenied, the platform refused to protect the socket, which apply returns, and the dial fails with it: nothing leaves on a socket the hook refused. The field reference and platform support of SocketOptions are on Dialers.
Two kinds of socket do not pass through the hook:
- Listener sockets. A local SOCKS or HTTP inbound’s listener is opened by the supervisor’s listener code, not by a dialer; it accepts connections from this device and needs no protection.
- The system resolver.
getaddrinfoopens its sockets inside the C library, where no policy reaches them. The config’s[dns] backenddefaults tosystem. On Android, audp,tlsorhttpsbackend, or excluding the app from its own VPN withaddDisallowedApplication, keeps the proxy’s lookups out of its own tunnel; an iOS packet tunnel already excludes them. See Name resolution and the DNS service.
The runtime
Section titled “The runtime”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)}Each proxy owns one multi-thread tokio runtime, with I/O and timers enabled, tokio’s default number of worker threads, and every thread named etemenanki. On start, each thread (workers and blocking-pool threads alike) makes the proxy’s Dispatch its thread default and parks the DefaultGuard in the thread-local LOG, one guard per runtime thread; on stop, it drops the guard. The library installs no global subscriber.
The async methods do their work on this runtime, not on the caller’s executor, with one exception: stop does part of its work in the caller’s future (Stopping). spawn(call) clones the runtime handle and the Arc<State> under running, builds the call’s future inside guard, and spawns it with spawn_on; the caller’s future only awaits the tokio JoinHandle. Kotlin coroutines, Swift concurrency and the tests’ futures::executor::block_on can all poll a JoinHandle, so no tokio context is needed on the calling side.
The log bridge
Section titled “The log bridge”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)), )}- Filter. The config’s
[log] levelis used as anEnvFilterdirective string, as the desktop app uses it; a config without one, or one that does not parse, getsinfo. Unlike the desktop app,RUST_LOGis not consulted.EnvFilter::newparses leniently: a directive that does not parse is skipped (tracing-subscriber printsignoring `<directive>`: <error>to standard error), and a string left with no directive at all enableserroronly. So[log] levelnever makesstartfail. The filter is built once, instart: a reload that changes[log] leveldoes not change what reaches the platform. A new proxy applies it. - Layer.
PlatformLogimplementstracing_subscriber::Layer::on_event. For each event it builds one line and callsplatform.log(level, line)on the emitting thread, before the event’s dispatch returns. There is no formatting layer: no timestamp and no span fields. - Line format.
<target>: <message>followed bykey=valuefor every other field. TheLinevisitor appends themessagefield as is (a&strvalue, or theDebugform of formatted arguments, which prints the text without quotes). It appends another string field askey=value, and any other field askey=followed by the value’sDebugform.tracing’s macros record the message first, so it comes before the fields. An event with fields but no message becomes<target>:followed directly bykey=value, with two spaces after the colon. A reload, for example, reaches the platform asetemenanki_app::instance: config reloaded: <summary>. - Which events. Events on the proxy’s runtime threads, and events on the calling thread while
startruns itsblock_on. Other threads keep whatever default dispatcher they had. That includes the thread pollingstop’s future, so the linestopitself may emit (Stopping) does not reach the platform.
Reloading
Section titled “Reloading”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 holds the edits lock, pairs the strings exactly as start does, and hands them to Core::reload_with, the same function the desktop app’s file watcher and REST API use (Running and reloading). Core::reload_with serialises reloads under its own lock, compares the bytes with the last files it recorded, merges and lowers (here through the client lowering), and applies the spec with Supervisor::apply, which uses the default ApplyOptions. Reload::report then turns its result into a ReloadReport. state.files is replaced only when that succeeds. An apply that succeeds also publishes the build’s API options, which are always None here.
| The files | reload returns |
Logged by Core (error unless noted) |
|---|---|---|
| Disagree about the subscribe file | Config |
reload: <error> |
Byte for byte the files Core last recorded, which applied |
Ok, unchanged: true |
nothing |
Byte for byte the files Core last recorded, with a refusal recorded for them |
Config: <reason> (the files have not changed since this was found). The variant is always Config, whatever the first refusal was: files first refused as ServerInbound or Io come back as Config with that text. |
nothing |
| Do not parse, merge or lower, or have a server inbound | Config, ServerInbound, or Io for a certificate, key or CA file that cannot be read |
reload: <error>; keeping the running config |
| Change the TUN inbound | Config with the ApplyError::Disruptive text |
reload refused, keeping the running config: inbound <tag>: <reason>, which would end its live connections; restart to apply it |
| Fail to bind a listener | Io |
reload: inbound <tag>: binding <bind> failed: <error>; keeping the running config |
Find the supervisor shut down (an edit queued behind stop) |
Io: the supervisor has shut down |
reload: the supervisor has shut down; keeping the running config |
| Refused by the supervisor otherwise | Config |
reload: <error>; keeping the running config |
| Apply | Ok with the report |
config reloaded: <summary> at info |
A TUN change is refused here as in the desktop app, and a restart applies it: stop the proxy and start a new one with the new files. The <summary> lists each non-empty group of the report as built [a, b], then swapped, drained, rebound, restarted, removed and reused, joined by ; , or nothing to run.
Core::start records the start files as the last ones tried, so a reload with the very files the proxy started with reports unchanged.
Switching a route
Section titled “Switching a route”sequenceDiagram
participant A as App
participant T as task on the proxy runtime
participant R as routes::Snapshot
participant C as Core::reload_with
A->>T: set_route(group, target)
T->>T: lock edits, clone the running files
T->>R: Snapshot::of, then pick(group, target)
alt pick already made
R-->>T: None
T-->>A: RouteChange unchanged, current config
else edited
R-->>T: edited config bytes
T->>C: reload_with the edited config and the same subscribe file
C-->>T: Reload, turned into a report
T->>T: replace the running files
T-->>A: RouteChange with the report and the edited config
end
- Take the edits lock and clone
state.files. Snapshot::of(files, config::parse_bytes(&files.config)): parse the running config and subscribe file. A failure isControlError::Invalid.snapshot.pick(&group, &target), which is the REST API’s own check and edit (app/src/routes.rs):- a
groupother thandefaultthat is not one of the view’s groups isUnknownGroup:no route group named "<group>"; - a
targetthat is not one of the view’s targets isConfig:no node, outbound or balancer is named "<target>"; the route view lists them; - otherwise the config is edited with
toml_edit(routes.rs→edit): the keygroupin[subscribe.routes]when the config has a[subscribe], else[route] default. A missing[subscribe.routes]or[route]table is created, as an inline table when its parent is inline (child), and a table that existed only as the parent of subtables, such as a[route]known only from[[route.rule]], is made explicit (set_implicit(false)). A switch can therefore add a table header as well as change a value. The replaced value keeps its surrounding comments and spacing (set_stringcopies its decor), and the rest of the file is kept. Two defensive errors, bothConfig, cover a document of an unexpected shape:the parent of "<key>" is not a tableandthe table holding the routes is not a table.
- a
- When the edit leaves the bytes as they were (
pickreturnsNone), no apply happens: the result isRouteChange { report: ApplyReport::unchanged(), config_toml: <the running config> }. - Otherwise the edited config and the unchanged subscribe file go to
Core::reload_withas already-read sources. The apply is the check: a pick that does not make a valid config is refused like any reload, and what runs is left as it was. - On success
state.filesbecomes the edited files, and the call returns the report with the edited config asconfig_toml(String::from_utf8_lossy; the bytes came from aString, so nothing is lost).
The desktop app’s PUT /v1/routes/{group} uses the same Snapshot::pick, but around a file on disk: it compares an If-Match ETag with the file’s (Stale, 409), checks the edit with instance::check_bytes, reads the file again and refuses with Stale when it changed in the meantime, writes it atomically, and only then does the router reload. Here the running files are edited in memory, the apply is the check, nothing is written, and the app stores config_toml so the pick survives a restart. The desktop path is described on The REST API.
Reading routes, traffic and connections
Section titled “Reading routes, traffic and connections”The four synchronous calls run on the calling thread under guard, clone the Arc<State> out of running, and never go through the supervisor’s actor:
routesparses the running files on every call (Snapshot::of) and convertssnapshot.view(). It reads the files, not the supervisor.state.filesonly ever holds files that applied, and the subscribe merge refuses a route group nameddefaultand a pick that names an unknown group or target (app/src/subscribe.rs→apply), so every pickroutesshows is part of files that applied. During a switch it shows the files from before or after it, depending on whetherstate.fileshas been replaced yet.traffictakes a freshTrackerhandle (Core::trackerclones the supervisor’s on each call), clones itswatchreceiver (stats()), and converts the currentArc<StatsSnapshot>while holdingborrow(). The sampler replaces it once a tick, one second by default; the FFI keeps that default. Before the first tick,tickis0.connectionstakestracker().flows(), sorts it byFlowId(sort_unstable_by_key) and converts each entry. Flow ids are handed out in increasing order, so the list is oldest first.close_connection(id)callsTracker::kill(FlowId::new(id)), which sets the flow’s kill flag and wakes it; its next poll fails in either direction. It returns whether a flow with that id is still in the registry, which includes a flow already killed that has not deregistered yet. See Tracking flows, stats and speed limits.
Stopping
Section titled “Stopping”sequenceDiagram participant A as App participant P as Proxy::stop participant T as task on the proxy runtime participant O as thread of its own A->>P: stop() P->>P: take Running out of running P->>T: spawn: lock edits, Core::shutdown(STOP_GRACE) T-->>P: done, or a panic that is logged P->>O: Runtime::shutdown_timeout(RUNTIME_GRACE) O-->>P: oneshot once the runtime is gone P-->>A: returns
- Mark it stopped.
self.running.lock().take(). Whenrunningis alreadyNone,stopreturns at once. This happens before the first await, so from here on every other call returnsStopped, and a secondstop, even one that runs while the first is still stopping, returns at once. - Shut the supervisor down. A task on the proxy’s runtime takes the edits lock, so a reload or route switch under way finishes first, then runs
state.core.shutdown(STOP_GRACE): the supervisor stops accepting, gives live connections up to 2 seconds to finish, then closes what is left (The supervisor). An edit queued behind this one runs against a stopped supervisor: an apply fails withIo(the supervisor has shut down, see Reloading), unless the runtime’s shutdown drops the edit’s task first, in which case the call returnsStopped. - A panic in that task is not returned (
stopreturns nothing).stopemitsstopping the proxy panicked; releasing what is leftwithtracing::error!, but in its own future, on the thread that polls it: the foreign executor’s thread, or the tests’block_on. The library sets no dispatcher there, so only a subscriber the caller installed on that thread sees the line;Platform::logdoes not. AJoinErrorthat is not a panic is ignored. The runtime shutdown follows either way. - Shut the runtime down.
Runtime::shutdown_timeout(RUNTIME_GRACE)blocks, so it runs on a thread of its own (std::thread::spawn), off the caller’s executor, which may be the app’s main thread. It drops the tasks left on the runtime and waits up to 3 seconds for its threads to finish. The thread then sends on afutures::channel::oneshot, whichstopawaits;stopreturns when that resolves, whether the thread sent on it or dropped it (let _ = done.await).
stop has no Result: it cannot fail. The Arc<State> moves into the shutdown task and is dropped with it, taking the Core, the lowering closure and its descriptor duplicate; the supervisor’s shutdown and the runtime’s shutdown release the listener’s and the stack’s duplicates. The host test that checks the device is closed once stop returns is listed under Tests.
Dropping the last reference to a Proxy without stop stops it at once: Drop takes Running and calls Runtime::shutdown_background, with no grace for live connections and no wait for the runtime’s threads.
In the bindings, the foreign object holds an Arc<Proxy>, released by Kotlin’s destroy() (also close(), since the class is AutoCloseable, or UniFFI’s cleaner once the object is unreachable) and by Swift’s deinit. Kotlin’s destroy() frees the Rust side once the calls in flight on that object have returned. Releasing the last reference without stop is the Drop above, so stop() belongs before destroy(), the order the crate’s README example uses.
Invariants
Section titled “Invariants”| Invariant | Enforced by | Pinned by |
|---|---|---|
The app’s descriptor stays the app’s; the proxy serves duplicates and holds none after stop returns |
duplicate; tun::adopt; the State dropped in the shutdown task; the runtime shut down before stop returns |
a_tun_device_carries_tcp_and_udp_to_the_outbound: with the test’s own descriptor closed, the device stays open while the proxy runs and is closed after stop |
Every socket a dialer opens is offered to protect before it connects; the system resolver’s sockets are the exception |
platform::socket_options → SupervisorBuilder::socket_options |
a_tun_device_carries_tcp_and_udp_to_the_outbound (one protect for the TCP dial, at least two once UDP has flowed); direct_flows_are_dialed_on_hooked_sockets, a_socks_upstream_is_dialed_on_hooked_sockets (supervisor/tests/socket_policy.rs) |
| A socket the platform refuses carries nothing | The hook returns PermissionDenied; SocketOptions::apply fails the dial |
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); no FFI test |
| A client runs no server-protocol inbound, wherever it listens, and no SOCKS or HTTP inbound other hosts can reach | refuse_servers on every lowering |
a_server_inbound_is_refused (on loopback), only_a_local_socks_inbound_is_served |
| A local SOCKS or HTTP inbound is served | is_local |
only_a_local_socks_inbound_is_served (127.0.0.1) |
| Exactly one TUN device, the platform’s | supply_tun |
A config without a tun inbound is served on TUN_TAG: a_tun_device_carries_tcp_and_udp_to_the_outbound; more than one has no test |
[api] is never served, and nothing is written |
Built { api: None }; no file API in the crate |
By construction |
| A route switch changes only the pick, and keeps comments and formatting | routes::Snapshot::pick with toml_edit |
set_route_and_reload_switch_like_the_rest_api |
| A switch that changes nothing applies nothing | pick returns None |
set_route_and_reload_switch_like_the_rest_api (the same switch twice: unchanged, same config) |
| Files already running are not applied again | Core::reload_with compares Sources byte for byte |
set_route_and_reload_switch_like_the_rest_api (reloading the stored files is unchanged) |
| Refused files leave what runs as it was | Core::reload_with; state.files replaced only on success |
set_route_and_reload_switch_like_the_rest_api (a refused reload keeps the pick) |
One edit at a time, and stop waits for the one under way |
State::edits |
No dedicated test |
After stop, every call fails with Stopped, and stop again does nothing |
running taken first |
a_tun_device_carries_tcp_and_udp_to_the_outbound |
| No Rust panic unwinds into the app from a call | guard, spawn_on, FfiError::panic |
a_panic_is_returned_not_unwound |
| The log reaches the platform | dispatch, runtime, with_default in start |
a_tun_device_carries_tcp_and_udp_to_the_outbound (config loaded in the platform’s log) |
What reaches it is filtered by [log] level |
The EnvFilter in dispatch |
No test |
Failure paths and cancellation
Section titled “Failure paths and cancellation”| Call | Fails with | When |
|---|---|---|
start |
Io |
tun_fd is negative (<fd> is not a file descriptor), cannot be duplicated, the runtime cannot be built, a certificate, key or CA file the config names cannot be read, or a listener cannot be bound |
Config |
The files disagree about the subscribe file, the config does not parse, merge or lower, a second TUN inbound, or the supervisor refuses the spec (a geodata file it cannot read included) | |
ServerInbound |
An inbound serves others | |
Panic |
A panic on the calling thread: in the library’s own code, or in the app’s Platform::log for an event emitted there during the start. A panic in protect happens where a dial runs, in a task on the runtime; it ends that task, and no call returns it. |
|
reload |
as in Reloading | |
set_route |
UnknownGroup, Config, Io |
An unknown group; an unknown target; an edit or apply that fails as a reload would |
routes, traffic, connections, close_connection |
Stopped, Config (routes only), Panic |
|
every call but stop |
Stopped |
After stop or during it |
Panics are caught at three places:
guard(call)runs a closure undercatch_unwind(AssertUnwindSafe(call)). It coversstartand the four synchronous calls, and inspawnit covers building the call’s future and spawning it.spawn_on(handle, task)wraps the spawned future inAssertUnwindSafe(task).catch_unwind(), so a panic insidereloadorset_routebecomes the task’sErr(FfiError::Panic).spawnmaps aJoinErrorthat still carries a panic toPanic(e.into_panic()), and any otherJoinError, a task cancelled because the runtime was shut down, toStopped.
Catching needs unwinding, so the library must be built with panic = "unwind", Rust’s default; the workspace’s release profile sets only lto = true. With panic = "abort", a panic would end the app’s process.
UniFFI’s scaffolding catches panics at the boundary too: rust_call wraps every exported call, and the poll of an async call’s future, in catch_unwind. It reports a caught panic as an internal error of the bindings (Kotlin’s InternalException, a private error type in Swift), not as FfiError. guard and spawn_on exist so that a panic reaches the app as the typed FfiError::Panic, carrying the payload’s message.
A caught panic says nothing about the state it left: FfiError::Panic asks the app to stop the proxy. stop and Drop have no guard, so in the bindings only UniFFI’s catch covers them; a panic in the supervisor shutdown task is handled inside stop, as described in Stopping.
Cancellation:
- Dropping the future of
reloadorset_routedoes not stop the work. Dropping a tokioJoinHandledetaches its task, which runs to the end on the proxy’s runtime, holding the edits lock until it does. The app does not see the result. stoptakesRunningbefore its first await, so the proxy counts as stopped from then on, whatever happens to the future. What else a droppedstopfuture does depends on when it is dropped:- while it waits for the supervisor shutdown task, the future still owns the
Runtime, which is dropped on the thread that drops the future. Tokio’sDropfor a runtime cancels the tasks left on it, the supervisor shutdown among them, so live connections do not get the rest ofSTOP_GRACE, and then waits for the runtime’s threads to finish without theRUNTIME_GRACEbound; - once it waits on the oneshot, the runtime belongs to the shutdown thread, which finishes on its own.
- while it waits for the supervisor shutdown task, the future still owns the
startcannot be cancelled; it is synchronous.- Connections end on
stopby the supervisor’s grace and close. What an apply does to live connections is described on Reconcile.
Limits
Section titled “Limits”| Constant or bound | Value | Defined in | Meaning |
|---|---|---|---|
STOP_GRACE |
2 s | ffi/src/proxy.rs |
How long live connections get to finish when the proxy stops |
RUNTIME_GRACE |
3 s | ffi/src/proxy.rs |
How long the runtime’s threads get to finish what is left after that |
| Sampler tick | 1 s | the supervisor’s default, kept | How often traffic changes |
DEFAULT_MTU |
1500 | protocols/src/tun/config.rs |
The MTU of the added TUN inbound, and of a config’s tun inbound without mtu |
MIN_TUN_MTU |
1280 | supervisor/src/build/validate.rs |
The smallest MTU a spec may give the device |
DEFAULT_UDP_IDLE_TIMEOUT |
60 s | protocols/src/tun/config.rs |
UDP idle timeout of the added TUN inbound |
DEFAULT_MAX_FLOWS |
65 536 | protocols/src/tun/config.rs |
Live flows across the added TUN inbound’s device |
| TUN devices | 1 per proxy | client.rs → supply_tun |
The platform supplies one device |
| Edits | 1 at a time per proxy | State::edits |
Reloads, route switches and the supervisor shutdown in stop queue in order |
| Runtime threads | tokio’s default worker count, plus its blocking pool | proxy.rs → runtime |
All named etemenanki |
On a phone, the TUN inbound’s max_flows caps the number of live flows the device serves: it sizes a semaphore in the TUN inbound (protocols/src/tun/inbound.rs), and a flow past the cap is dropped (TUN). DEFAULT_MAX_FLOWS is sized as a file-descriptor guardrail, since each flow may cost the outbound a socket (protocols/src/tun/config.rs); it is not a memory bound. The limits of the rest of the workspace are collected on Limits, timeouts and memory.
Building
Section titled “Building”The scripts build release libraries with vendored-openssl and generate the bindings from the built library with the workspace’s own uniffi-bindgen.
The binding generator
Section titled “The binding generator”fn main() { uniffi::uniffi_bindgen_main()}etemenanki-ffi-bindgen builds one binary, uniffi-bindgen, from uniffi 0.32.2 with the cli feature. The library requires the same uniffi = "0.32.2", and both crates share the workspace’s Cargo.lock, which resolves uniffi to 0.32.2. Bindings generated by another UniFFI version do not match the library, which is why the generator lives in the workspace rather than being installed separately. It runs in library mode: it reads the interface from the metadata in a built library’s symbols.
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 and ffi/build-android.sh
Section titled “Android: ffi/shell.nix and ffi/build-android.sh”ffi/shell.nix is a nix-shell for cross-building. It pins a nixpkgs revision (a tarball URL with its hash), accepts the Android SDK license and allows unfree packages in that nixpkgs, and composes an Android SDK with only NDK 28.2.13676358 (r28c): no platforms, build tools, CMake or emulator, since those belong to the app’s build. The mkShellNoCC shell provides cargo-ndk, perl and make (the last two for the vendored OpenSSL build) and sets ANDROID_HOME, ANDROID_SDK_ROOT, ANDROID_NDK_HOME and ANDROID_NDK_ROOT. Rust is not provided: the rustup toolchain on PATH is used, with the three Android targets added once. A different nixpkgs can be passed with --arg pkgs, provided it accepts the license and allows unfree.
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):
- Changes to the repository root and resolves
OUT_DIR(defaulttarget/ffi/android) to an absolute path withrealpath -m. - Checks its tools:
error: cargo-ndk not found; run inside 'nix-shell ffi/shell.nix', orerror: ANDROID_NDK_HOME is not set; run inside 'nix-shell ffi/shell.nix', each printed to standard error with exit status 1. - Removes
OUT_DIR/jniLibsandOUT_DIR/kotlin. - Runs
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_PLATFORMsets the minimum API level the libraries link against; cargo-ndk’s default is 21, the lowest Rust supports. - Deletes every file under
jniLibsother thanlibetemenanki_ffi.so: cargo-ndk copies everycdylibthe build produced, and some dependencies (boringtun, tun-rs) declare one of their own. - Generates the Kotlin bindings from the
arm64-v8alibrary (any ABI carries the same metadata) with--language kotlin --no-format, since ktlint is not assumed to be installed. - Prints where the output went:
Android libraries: <OUT_DIR>/jniLibsandKotlin bindings: <OUT_DIR>/kotlin.
The output is OUT_DIR/jniLibs/{arm64-v8a,armeabi-v7a,x86_64}/libetemenanki_ffi.so and OUT_DIR/kotlin/uniffi/etemenanki_ffi/etemenanki_ffi.kt. The libraries are left unstripped, because the generator reads the metadata from their symbol tables; the app’s Gradle build strips what it packages. The app copies jniLibs/ into its module’s src/main/ and the Kotlin file into its sources, and depends on JNA (net.java.dev.jna:jna:<version>@aar), through which the bindings load the library.
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) runs on macOS with Xcode only:
- Refuses other systems (
error: iOS builds need macOS with Xcode (the iOS SDKs and xcodebuild)) and a missingxcodebuild(error: xcodebuild not found; install Xcode and run 'xcode-select --install'), each printed to standard error with exit status 1. - Changes to the repository root and resolves
OUT_DIR(defaulttarget/ffi/ios) and the target directory (CARGO_TARGET_DIR, defaulttarget) to absolute paths by creating them and takingpwd -P, because macOSrealpathhas no-m. - Removes an old
EtemenankiFFI.xcframework,swift/andheaders/, and recreates the last two. - Builds
libetemenanki_ffi.awithcargo build --release -p etemenanki-ffi --features vendored-openssl --target <triple>, once foraarch64-apple-iosand once foraarch64-apple-ios-sim, so each lands in<target dir>/<triple>/release/. - Generates the Swift bindings from the device slice’s static library,
<target dir>/aarch64-apple-ios/release/libetemenanki_ffi.a, which writesetemenanki_ffi.swift,etemenanki_ffiFFI.handetemenanki_ffiFFI.modulemap. - Moves the header and the modulemap into
headers/, the modulemap renamedmodule.modulemapso that Xcode finds the module. - Runs
xcodebuild -create-xcframeworkwith each slice’s library and theheaders/directory, producingOUT_DIR/EtemenankiFFI.xcframework, then removesheaders/. - Prints where the output went:
XCFramework: <path>andSwift bindings: <OUT_DIR>/swift/etemenanki_ffi.swift.
The output is the XCFramework (the static library, C header and modulemap for devices and the arm64 simulator) and OUT_DIR/swift/etemenanki_ffi.swift, which imports the etemenanki_ffiFFI module the modulemap declares. Both go into the packet tunnel extension’s target.
cargo test -p etemenanki-ffiffi/tests/proxy.rs drives the proxy as the bindings do: synchronous methods from the test’s thread, async ones on an executor that is not tokio (futures::executor::block_on). The TUN device is a datagram socketpair (UnixDatagram::pair), which, like a TUN descriptor, carries one packet per read and write. The test keeps one end (wire, with a read timeout of TIMEOUT = 5 s), hands the other end’s descriptor to Proxy::start, writes IPv4 packets built with etherparse into its end, and reads what the proxy’s stack answers, standing in for the operating system.
The harness:
| Helper | What it does |
|---|---|
CLIENT |
10.0.0.2, the source address of every packet the test writes |
Recorder |
A Platform that records every descriptor protect is given, returns true, and keeps every log line with its level |
Panicking |
A Platform whose log panics with the log failed on: <message> |
Device |
The test’s end (wire) and the end whose descriptor the proxy is given (tun); recv reads one packet into a 2048-byte buffer and fails the test when none arrives within TIMEOUT |
Device::tcp_exchange |
A hand-written TCP handshake from CLIENT:<port> (SYN, wait for SYN-ACK, ACK, one data segment), then reads the reply in order, acknowledging each segment |
Device::udp_exchange |
Sends one UDP datagram from CLIENT:<port> and returns the reply’s payload, checking its ports |
tcp_of |
The TCP segment of a packet, if it holds one (SlicedPacket::from_ip) |
tcp_ack |
Builds a bare ACK from CLIENT |
tcp_echo, udp_echo |
Echo servers on 127.0.0.1, port 0 |
eventually |
Polls a condition every 20 ms, failing after TIMEOUT |
DIRECT |
A config with [log] level = "debug", one freedom outbound direct and [route] default = "direct", and no inbound |
SUBSCRIBE |
A subscribe file with one SOCKS node upstream on 127.0.0.1 (its PORT replaced by the test) and a route group Web that inherits direct and matches port 80 |
SUBSCRIBED |
A config at [log] level = "info" with the direct outbound, a comment # The app's picks., an empty [subscribe] and [subscribe.routes] holding Web = "direct" # plain web goes direct |
| Test | Behaviour it pins |
|---|---|
a_tun_device_carries_tcp_and_udp_to_the_outbound |
TCP through the device reaches an echo server over direct and comes back, with exactly one protect for the TCP dial; UDP does the same, with at least two protect calls in total. The TCP flow is listed with host 127.0.0.1, the echo port, outbound direct, inbound TUN_TAG, source 10.0.0.2 and at least 15 bytes up. close_connection returns true, the flow leaves the list, and a second close returns false. A traffic sample arrives with at least 19 bytes up in total. The platform’s log contains config loaded. With the test’s own descriptor closed, a send on its end still succeeds; after stop it fails with ConnectionRefused. After stop, connections and reload return Stopped, and a second stop does nothing. |
a_server_inbound_is_refused |
A config with a VLESS inbound on 127.0.0.1, port 1, is refused with ServerInbound { tag: "vl", protocol: "vless" }: even a loopback server protocol is refused. The port is never bound, because the refusal comes first. |
only_a_local_socks_inbound_is_served |
A SOCKS inbound on 0.0.0.0 is refused with ServerInbound naming its tag; the same inbound on 127.0.0.1 starts, and stops |
set_route_and_reload_switch_like_the_rest_api |
A stand-in for the SOCKS upstream accepts connections, reports each on a channel and holds it open (mem::forget), so a flow sent to it waits on its greeting. With SUBSCRIBE pointed at it and SUBSCRIBED: routes shows one group picking direct and lists upstream. set_route("Web", "upstream") applies (unchanged false), returns the config with Web = "upstream" # plain web goes direct and the # The app's picks. comment kept, and the group’s target becomes upstream. A port-80 flow through the device then dials the upstream. The same switch again is unchanged with the same config; group Nope is UnknownGroup; target nowhere is Config. Reloading the returned config with the subscribe file is unchanged; reloading DIRECT with a subscribe file is Config and the pick stays upstream; reloading DIRECT alone applies and leaves no groups. |
a_panic_is_returned_not_unwound |
With Panicking as the platform and DIRECT at level info, start returns Panic whose message contains the log failed |
Other tests that cover code the library relies on:
| Test | File | Behaviour it pins |
|---|---|---|
given_sources_must_agree_on_a_subscribe_file |
app/tests/unit/config.rs |
sources_given accepts a [subscribe] without path when a subscribe file is given, and refuses a missing file or a missing section with 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 |
The socket policy reaches direct TCP and UDP sockets and a SOCKS upstream’s sockets, and a refusing hook fails the dial |
hook_runs_on_apply_and_its_error_propagates |
environment/tests/unit/dial/socket.rs |
SocketOptions::apply runs the hook once and returns its error |
Nothing tests more than one TUN inbound, the warning for a tun inbound’s name, address or routes, a set_route to default, an [api] section, the edits lock, an edit queued behind stop, Drop without stop, protect returning false through the FFI, or the [log] level filter. A change to those paths should add one. The harnesses of the other crates are described on Testing.