feat(mobile): add embedded runtime and managed network updates (#2532)

* feat(mobile): add embedded iOS runtime API

Add a thin panic-safe C ABI crate for embedding no-TUN instances
on iOS. Expose lifecycle, status, JSON-RPC, string ownership, and
error handling.

Build device and simulator XCFramework static libraries on macOS.
Add exact named-instance deletion to the iOS and Android wrappers.
Cover wrapper lifecycle and the port-forward patch flow on host
targets.

* fix(gateway): recover TCP port-forward listeners

Release an unusable TCP port-forward listener after an accept
failure. Retry binding until the forward is cancelled. Keep the old
listener released while rebinding so mobile sockets can recover.

Expose opt-in iOS diagnostics for listener and connection events.
Trace configuration removal and adapter shutdown. Add tests for
recovery, release-before-rebind, and cancellation.

* feat(web): persist incremental managed config patches

Add a revision-CAS PATCH contract for managed configs while keeping
the existing Full PUT path for compatibility and recovery.

Apply Full and Patch mutations with their revision in one SQLite
transaction. Reject ownership conflicts and invalidate revisions on
alternate web-owned writes.

Document limits, failure semantics, rollout order, and verification.
Cover delta updates, conflicts, idempotency, and transaction rollback.

* feat(web): apply managed config patches to live sessions

Carry Patch fences and touched instance IDs into live sessions.
Reconcile only those instances when the applied revision matches the
Patch base. Fall back to Full reconciliation for gaps and restarts.

Invalidate the applied revision around every direct runtime mutation.
Fence revision advancement with the runtime cache epoch so stale
reconcile rounds cannot overwrite a newer invalidation.

Require deletion responses to confirm each requested instance before
advancing the revision. Raise the managed PUT and PATCH body limit to
32 MiB and return typed conflicts for publisher recovery.

* fix(core): retry transient accepted TCP errors

Keep TCP tunnel listeners alive when an accepted socket fails during
upgrade with a retryable connection-state error.

Share the retryable I/O classifier with the socket listener. Cover a
rejected connection followed by success and propagation of permanent
errors.

* feat(core): add internal Peer Relay edge projection

Derive the local advertised OSPF row from physical adjacency and transport-authenticated credential relay coverage. Keep full local adjacency only in the temporary SPF snapshot so direct destinations retain a fallback route.

Leave Peer Relay disabled at the public configuration seam. A follow-up change can expose the preference without coupling route projection to credential reauthorization.

feat(config): expose Peer Relay routing preference

Add prefer_peer_relay to public protobuf, TOML, management patch, and
hosted runtime surfaces.

Read the preference from live peer context so runtime config updates take
effect. Refresh authenticated peer metadata when the option is enabled.

Cover dynamic enable and disable in a five-node, dual-admin credential
topology, including forwarded relay coverage and local fallback.
This commit is contained in:
KKRainbow
2026-08-28 00:43:26 +08:00
committed by GitHub
parent abf03ca521
commit 4a10d1c2b9
38 changed files with 5614 additions and 554 deletions
@@ -73,6 +73,14 @@ object EasyTierJNI {
*/
@JvmStatic external fun retainNetworkInstance(instanceNames: Array<String>?): Int
/**
* 停止指定的网络实例,其他实例不受影响
* @param instanceName 要停止的实例名称,不存在时为 no-op
* @return 0 表示成功,-1 表示失败
* @throws RuntimeException 当操作失败时抛出异常
*/
@JvmStatic external fun deleteNetworkInstance(instanceName: String): Int
/**
* 收集网络信息
* @param maxLength 最大返回条目数
@@ -9,6 +9,7 @@
//! - `parseConfig(config)`: validate TOML config text.
//! - `runNetworkInstance(config)`: start a local network instance.
//! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest.
//! - `deleteNetworkInstance(instanceName)`: stop exactly one named instance.
//! - `listInstances()`: return running instance names and IDs as JSON.
//! - `collectNetworkInfos()`: return running instance info as a JSON string.
//! - `callJsonRpc(...)`: call an exposed EasyTier RPC service with JSON payload.
@@ -106,6 +107,23 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance(
network_api::retain_network_instance_jni(env, class, instance_names)
}
/// Stop exactly one named network instance without affecting other instances.
///
/// Java signature:
/// `EasyTierJNI.deleteNetworkInstance(instanceName: String): Int`
///
/// An unknown name is a no-op. On failure this returns `-1` and throws
/// `RuntimeException`.
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_deleteNetworkInstance(
env: JNIEnv,
class: JClass,
instance_name: JString,
) -> jint {
logger::init();
network_api::delete_network_instance_jni(env, class, instance_name)
}
/// Collect running network instance information.
///
/// Java signature:
@@ -2,8 +2,8 @@ use std::{ffi::CStr, ptr};
use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap};
use easytier_ffi::{
KeyValuePair, collect_network_infos, free_string, list_instance, parse_config,
retain_network_instance, run_network_instance, set_tun_fd,
KeyValuePair, collect_network_infos, delete_network_instance, free_string, list_instance,
parse_config, retain_network_instance, run_network_instance, set_tun_fd,
};
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
@@ -76,6 +76,30 @@ pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config:
}
}
pub(crate) fn delete_network_instance_jni(
mut env: JNIEnv,
_class: JClass,
instance_name: JString,
) -> jint {
let instance_name = match jstring_to_cstring(&mut env, &instance_name) {
Ok(name) => name,
Err(error) => {
throw_exception(&mut env, &format!("Invalid instance name: {error}"));
return -1;
}
};
let instance_names = [instance_name.as_ptr()];
unsafe {
let result = delete_network_instance(instance_names.as_ptr(), instance_names.len());
if result != 0
&& let Some(error) = get_last_error()
{
throw_exception(&mut env, &error);
}
result
}
}
pub(crate) fn retain_network_instance_jni(
mut env: JNIEnv,
_class: JClass,
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "easytier-ios"
version = "0.1.0"
edition.workspace = true
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
serde_json = "1.0"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
"c-abi",
] }
[dev-dependencies]
uuid = "1"
tokio = { version = "1", features = ["io-util"] }
easytier-core = { path = "../../easytier-core" }
easytier-ffi = { path = "../easytier-ffi", default-features = false, features = [
"c-abi",
"ffi-dataplane",
] }
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
#
# Build the easytier-ios static library slices for the Flutter iOS client.
#
# This script only runs on macOS: it needs the Apple SDK (aarch64-apple-ios*,
# x86_64-apple-ios targets) plus `lipo`. Run it from the EasyTier repository
# root or from this crate directory.
#
# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
# ./build-xcframework.sh
#
# Output (workspace target directory + ./xcframework/sim):
# target/aarch64-apple-ios/release/libeasytier_ios.a (device)
# xcframework/sim/libeasytier_ios.a (simulator, lipo merged)
set -euo pipefail
CRATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# The crate lives in a workspace; build artifacts land in the workspace root
# target directory regardless of the current directory.
WORKSPACE_ROOT="$(cd "${CRATE_DIR}/../.." && pwd)"
TARGET_DIR="${WORKSPACE_ROOT}/target"
OUT_DIR="${CRATE_DIR}/xcframework"
if [[ "$(uname)" != "Darwin" ]]; then
echo "error: build-xcframework.sh must run on macOS (needs Apple SDK, lipo)" >&2
exit 1
fi
cd "${WORKSPACE_ROOT}"
# The Rust iOS targets emit a `___chkstk_darwin` stack-probe call but do not
# link the compiler-rt archive that provides it. Point the linker at the
# matching device or simulator archive shipped inside the Xcode toolchain.
CLANG_BIN="$(xcrun --find clang)" # .../Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
TOOLCHAIN_USR="${CLANG_BIN%/bin/clang}" # .../XcodeDefault.xctoolchain/usr
CLANG_RT_DIR="$(cd "${TOOLCHAIN_USR}/lib/clang" && cd "$(ls | sort -V | tail -1)/lib/darwin" && pwd)"
CLANG_RT_RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-L${CLANG_RT_DIR}"
echo "==> using libclang_rt from ${CLANG_RT_DIR}"
# kcp-sys's bindgen rejects the `-sim` in the aarch64-apple-ios-sim target
# triple; give bindgen an explicit simulator target so the C bindings build.
SIM_SDK="$(xcrun --sdk iphonesimulator --show-sdk-path)"
echo "==> building aarch64-apple-ios (device)"
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.ios" \
cargo build -p easytier-ios --release --target aarch64-apple-ios
echo "==> building aarch64-apple-ios-sim (Apple Silicon simulator)"
BINDGEN_EXTRA_CLANG_ARGS="--target=arm64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
cargo build -p easytier-ios --release --target aarch64-apple-ios-sim
echo "==> building x86_64-apple-ios (Intel simulator)"
BINDGEN_EXTRA_CLANG_ARGS="--target=x86_64-apple-ios17.0-simulator -isysroot ${SIM_SDK}" \
RUSTFLAGS="${CLANG_RT_RUSTFLAGS} -C link-arg=-lclang_rt.iossim" \
cargo build -p easytier-ios --release --target x86_64-apple-ios
rm -rf "${OUT_DIR}"
mkdir -p "${OUT_DIR}/sim"
echo "==> lipo: merge simulator slices"
lipo -create \
"${TARGET_DIR}/aarch64-apple-ios-sim/release/libeasytier_ios.a" \
"${TARGET_DIR}/x86_64-apple-ios/release/libeasytier_ios.a" \
-output "${OUT_DIR}/sim/libeasytier_ios.a"
echo "==> done:"
echo " device: ${TARGET_DIR}/aarch64-apple-ios/release/libeasytier_ios.a"
echo " simulator: ${OUT_DIR}/sim/libeasytier_ios.a"
@@ -0,0 +1,138 @@
/**
* @file easytier-ios.h
* @brief iOS-facing C ABI for EasyTier.
*
* This library embeds EasyTier into an iOS app without a TUN device or
* NEPacketTunnel: it manages EasyTier instances and bridges to the EasyTier
* management RPC surface. Loopback port forwarding into the virtual network
* is configured through easytier_ios_call_json_rpc() with
* api.config.ConfigRpcService/PatchConfig port-forward patches; there is no
* built-in forwarder.
*
* Error handling: functions returning `int` return 0 on success and -1 on
* failure; functions returning `char *` return NULL on failure. Call
* easytier_ios_last_error() on the same thread to retrieve details.
*
* Threading: all functions are safe to call from any thread. The last-error
* buffer is thread-local, so query it on the thread that received the
* failure.
*/
#ifndef EASYTIER_IOS_H
#define EASYTIER_IOS_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/**
* @brief Enable diagnostic EasyTier logging to stderr.
*
* Installs a narrow global tracing subscriber that records port-forward
* lifecycle events. Repeated calls are idempotent.
*
* @return 0 on success, -1 if another tracing subscriber was installed first.
*/
int easytier_ios_enable_diagnostic_logging(void);
/**
* @brief Start one EasyTier network instance from a TOML config string.
*
* The config's `instance_name` must be unique among instances started
* through this library.
*
* @param toml Non-null pointer to a NUL-terminated UTF-8 TOML config string.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_run_instance(const char *toml);
/**
* @brief Keep the named instances and stop all others.
*
* @param names_json Null, empty, or a NUL-terminated JSON array of instance
* name strings. Null / empty / `[]` stops every running
* instance.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_retain_instances(const char *names_json);
/**
* @brief Stop exactly one named instance without affecting other instances.
*
* An unknown name is a no-op.
*
* @param instance_name Non-null NUL-terminated instance name.
* @return 0 on success, -1 on failure.
*/
int easytier_ios_delete_instance(const char *instance_name);
/**
* @brief Collect running instance information as a JSON object.
*
* The result maps each instance name to its running info JSON object.
*
* @param max_length Maximum number of instances to report.
* @return A newly allocated NUL-terminated JSON string on success, NULL on
* failure.
*
* @ownership The caller owns the returned string and must release it with
* easytier_ios_free_string().
*/
char *easytier_ios_collect_network_infos(int max_length);
/**
* @brief Call an exposed EasyTier management RPC method using protobuf JSON.
*
* `service_name` is the protobuf service name (e.g.
* "api.config.ConfigRpcService"), `method_name` the RPC method name (e.g.
* "PatchConfig"). `payload_json` must contain the protobuf JSON request,
* including any `instance` selector required by the target RPC.
*
* Port forwarding into the virtual network is driven through this bridge
* with api.config.ConfigRpcService/PatchConfig port-forward patches.
*
* @param service_name Non-null NUL-terminated RPC service name.
* @param method_name Non-null NUL-terminated RPC method name.
* @param payload_json Non-null NUL-terminated protobuf JSON request body.
* @return A newly allocated NUL-terminated JSON response string on success,
* NULL on failure.
*
* @ownership The caller owns the returned string and must release it with
* easytier_ios_free_string().
*/
char *easytier_ios_call_json_rpc(const char *service_name,
const char *method_name,
const char *payload_json);
/**
* @brief Return the last error message on this thread.
*
* Combines wrapper-side errors recorded by this library with the
* easytier-ffi last FFI error.
*
* @return A newly allocated NUL-terminated string, or NULL when there is no
* recorded error.
*
* @ownership The caller owns the returned string and must release it with
* easytier_ios_free_string().
*/
char *easytier_ios_last_error(void);
/**
* @brief Release a string returned by this library.
*
* Use this for strings returned by easytier_ios_collect_network_infos(),
* easytier_ios_call_json_rpc() and easytier_ios_last_error(). Passing NULL
* is a no-op. The string must not be used after this call.
*
* @param s NULL, or a string previously returned by this library.
*/
void easytier_ios_free_string(char *s);
#ifdef __cplusplus
}
#endif
#endif /* EASYTIER_IOS_H */
@@ -0,0 +1,70 @@
use std::{
cell::RefCell,
ffi::{CStr, CString, c_char},
ptr,
};
thread_local! {
// Thread-local last error for the easytier-ios C ABI. Wrapper-side
// argument/JSON failures are recorded here; easytier-ffi records
// instance/RPC failures in its own buffer. `last_error` merges both.
static LAST_ERROR: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn set_error(message: &str) {
LAST_ERROR.with(|cell| {
let mut buffer = cell.borrow_mut();
buffer.clear();
buffer.extend_from_slice(message.as_bytes());
});
}
pub(crate) fn clear_error() {
LAST_ERROR.with(|cell| cell.borrow_mut().clear());
}
fn thread_local_error() -> Option<String> {
LAST_ERROR.with(|cell| {
let buffer = cell.borrow();
if buffer.is_empty() {
None
} else {
Some(String::from_utf8_lossy(&buffer).into_owned())
}
})
}
fn ffi_error() -> Option<String> {
unsafe {
let mut error_ptr: *const c_char = ptr::null();
easytier_ffi::get_error_msg(&mut error_ptr);
if error_ptr.is_null() {
None
} else {
let error_str = CStr::from_ptr(error_ptr).to_string_lossy().into_owned();
easytier_ffi::free_string(error_ptr);
Some(error_str)
}
}
}
/// Merge both error layers: this wrapper's own thread-local buffer and
/// easytier-ffi's last FFI error.
pub(crate) fn last_error() -> Option<String> {
match (ffi_error(), thread_local_error()) {
(Some(ffi_error), Some(local_error)) => Some(format!("{local_error}; {ffi_error}")),
(Some(ffi_error), None) => Some(ffi_error),
(None, Some(local_error)) => Some(local_error),
(None, None) => None,
}
}
/// Copy the merged last error into a newly allocated C string (null when
/// there is no error). The caller owns the result and must release it with
/// `easytier_ios_free_string`.
pub(crate) fn last_error_raw() -> *mut c_char {
match last_error().and_then(|message| CString::new(message).ok()) {
Some(message) => message.into_raw(),
None => ptr::null_mut(),
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
use std::ffi::CString;
/// Build a NUL-terminated C string from a Rust string for FFI calls.
pub(crate) fn cstring_for(value: &str, what: &str) -> std::io::Result<CString> {
CString::new(value).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("{what} contains a null byte"),
)
})
}