diff --git a/Cargo.lock b/Cargo.lock index 00993791..edf7cb46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2385,6 +2385,7 @@ version = "0.1.0" dependencies = [ "android_logger", "easytier", + "easytier-ffi", "jni", "log", "once_cell", @@ -2396,13 +2397,17 @@ dependencies = [ name = "easytier-ffi" version = "0.1.0" dependencies = [ + "async-trait", "dashmap", "easytier", + "log", "once_cell", + "percent-encoding", "serde", "serde_json", "tokio", "tokio-util", + "url", "uuid", ] diff --git a/easytier-contrib/easytier-android-jni/Cargo.toml b/easytier-contrib/easytier-android-jni/Cargo.toml index 5f069daa..f810eaac 100644 --- a/easytier-contrib/easytier-android-jni/Cargo.toml +++ b/easytier-contrib/easytier-android-jni/Cargo.toml @@ -13,4 +13,5 @@ log = "0.4" android_logger = "0.13" serde = { version = "1.0.220", features = ["derive"] } serde_json = "1.0" -easytier = { path = "../../easytier" } \ No newline at end of file +easytier = { path = "../../easytier" } +easytier-ffi = { path = "../easytier-ffi", default-features = false } diff --git a/easytier-contrib/easytier-android-jni/build.rs b/easytier-contrib/easytier-android-jni/build.rs new file mode 100644 index 00000000..e0b17701 --- /dev/null +++ b/easytier-contrib/easytier-android-jni/build.rs @@ -0,0 +1,17 @@ +use std::{env, path::PathBuf}; + +fn main() { + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if !matches!(target_os.as_str(), "android" | "linux") { + return; + } + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let exports = manifest_dir.join("exports.map"); + println!("cargo:rerun-if-changed={}", exports.display()); + println!( + "cargo:rustc-cdylib-link-arg=-Wl,--version-script={}", + exports.display() + ); + println!("cargo:rustc-cdylib-link-arg=-Wl,--exclude-libs,ALL"); +} diff --git a/easytier-contrib/easytier-android-jni/exports.map b/easytier-contrib/easytier-android-jni/exports.map new file mode 100644 index 00000000..3f2b15f1 --- /dev/null +++ b/easytier-contrib/easytier-android-jni/exports.map @@ -0,0 +1,6 @@ +{ + global: + Java_com_easytier_jni_EasyTierJNI_*; + local: + *; +}; diff --git a/easytier-contrib/easytier-android-jni/kotlin/com/easytier/jni/EasyTierJNI.kt b/easytier-contrib/easytier-android-jni/kotlin/com/easytier/jni/EasyTierJNI.kt index 8f1b0efe..9b6995a3 100644 --- a/easytier-contrib/easytier-android-jni/kotlin/com/easytier/jni/EasyTierJNI.kt +++ b/easytier-contrib/easytier-android-jni/kotlin/com/easytier/jni/EasyTierJNI.kt @@ -1,5 +1,9 @@ package com.easytier.jni +fun interface ConfigServerEventCallback { + fun onEvent(eventJson: String) +} + /** EasyTier JNI 接口类 提供 Android 应用调用 EasyTier 网络功能的接口 */ object EasyTierJNI { @@ -33,6 +37,35 @@ object EasyTierJNI { */ @JvmStatic external fun runNetworkInstance(config: String): Int + /** + * 启动配置服务器客户端 + * @param url 配置服务器 URL + * @param hostname 主机名,传入 null 使用系统主机名 + * @param machineId 稳定机器 ID,由调用方负责持久化 + * @param secureMode 是否启用 secure mode + * @param callback 远程配置应用/删除事件回调 + * @return 0 表示成功,-1 表示失败 + * @throws RuntimeException 当客户端启动失败时抛出异常 + */ + @JvmStatic + external fun startConfigServerClient( + url: String, + hostname: String?, + machineId: String, + secureMode: Boolean, + callback: ConfigServerEventCallback? + ): Int + + /** + * 停止配置服务器客户端 + * @return 0 表示成功,-1 表示失败 + * @throws RuntimeException 当客户端停止失败时抛出异常 + */ + @JvmStatic external fun stopConfigServerClient(): Int + + /** 查询配置服务器客户端是否已连接 */ + @JvmStatic external fun isConfigServerClientConnected(): Boolean + /** * 保留指定的网络实例,停止其他实例 * @param instanceNames 要保留的实例名称数组,传入 null 或空数组将停止所有实例 @@ -44,7 +77,7 @@ object EasyTierJNI { /** * 收集网络信息 * @param maxLength 最大返回条目数 - * @return 包含网络信息的字符串数组,每个元素格式为 "key=value" + * @return 包含网络信息的 JSON 字符串 * @throws RuntimeException 当操作失败时抛出异常 */ @JvmStatic external fun collectNetworkInfos(maxLength: Int): String? diff --git a/easytier-contrib/easytier-android-jni/src/callback.rs b/easytier-contrib/easytier-android-jni/src/callback.rs new file mode 100644 index 00000000..b598391d --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/callback.rs @@ -0,0 +1,124 @@ +use std::{ + ffi::{CStr, c_char, c_void}, + sync::{Arc, Mutex, MutexGuard}, +}; + +use easytier_ffi::ConfigServerEventCallback; +use jni::JNIEnv; +use jni::objects::{GlobalRef, JObject, JValue}; +use once_cell::sync::Lazy; + +use crate::error; + +pub(crate) struct JniConfigServerCallback { + java_vm: jni::JavaVM, + callback: GlobalRef, +} + +static CONFIG_SERVER_CALLBACK: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +pub(crate) fn lock_callback_storage() +-> Result>>, String> { + CONFIG_SERVER_CALLBACK + .lock() + .map_err(|e| format!("Failed to lock config server callback: {}", e)) +} + +pub(crate) fn new_callback( + env: &mut JNIEnv, + callback: &JObject, +) -> Result, String> { + let java_vm = env + .get_java_vm() + .map_err(|e| format!("Failed to get JavaVM: {:?}", e))?; + let callback = env + .new_global_ref(callback) + .map_err(|e| format!("Failed to create callback global ref: {:?}", e))?; + Ok(Arc::new(JniConfigServerCallback { java_vm, callback })) +} + +pub(crate) fn callback_fn( + callback: &Option>, +) -> ConfigServerEventCallback { + callback + .as_ref() + .map(|_| config_server_event_callback as unsafe extern "C" fn(*const c_char, *mut c_void)) +} + +pub(crate) fn user_data(callback: &Option>) -> *mut c_void { + callback + .as_ref() + .map(|callback| Arc::as_ptr(callback) as *mut c_void) + .unwrap_or(std::ptr::null_mut()) +} + +impl JniConfigServerCallback { + fn clear_pending_exception( + env: &mut JNIEnv, + context: &str, + error: &dyn std::fmt::Debug, + ) -> String { + match env.exception_check() { + Ok(true) => { + if let Err(clear_err) = env.exception_clear() { + return format!( + "{}: {:?}; failed to clear pending Java exception: {:?}", + context, error, clear_err + ); + } + } + Ok(false) => {} + Err(check_err) => { + return format!( + "{}: {:?}; failed to check pending Java exception: {:?}", + context, error, check_err + ); + } + } + + format!("{}: {:?}", context, error) + } + + fn on_event(&self, event_json: *const c_char) -> Result<(), String> { + let event_json = unsafe { CStr::from_ptr(event_json) } + .to_str() + .map_err(|e| format!("Invalid config server event JSON: {:?}", e))?; + let mut env = self + .java_vm + .attach_current_thread() + .map_err(|e| format!("Failed to attach callback thread: {:?}", e))?; + let event_json = env.new_string(event_json).map_err(|e| { + Self::clear_pending_exception(&mut env, "Failed to create event string", &e) + })?; + + if let Err(e) = env.call_method( + self.callback.as_obj(), + "onEvent", + "(Ljava/lang/String;)V", + &[JValue::from(&event_json)], + ) { + return Err(Self::clear_pending_exception( + &mut env, + "Failed to call config server callback", + &e, + )); + } + Ok(()) + } +} + +unsafe extern "C" fn config_server_event_callback( + event_json: *const c_char, + user_data: *mut c_void, +) { + if event_json.is_null() || user_data.is_null() { + return; + } + + let callback = unsafe { &*(user_data as *const JniConfigServerCallback) }; + + if let Err(error) = callback.on_event(event_json) { + error::set_callback_error(error); + } +} diff --git a/easytier-contrib/easytier-android-jni/src/config_server_api.rs b/easytier-contrib/easytier-android-jni/src/config_server_api.rs new file mode 100644 index 00000000..8590a120 --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/config_server_api.rs @@ -0,0 +1,140 @@ +use std::ptr; + +use easytier_ffi::{ + in_config_server_callback, is_config_server_client_connected, start_config_server_client, + stop_config_server_client, +}; +use jni::JNIEnv; +use jni::objects::{JClass, JObject, JString}; +use jni::sys::{JNI_FALSE, JNI_TRUE, jboolean, jint}; + +use crate::{ + callback, error, + strings::{jstring_to_cstring, optional_jstring_to_cstring}, +}; + +pub(crate) fn start_config_server_client_jni( + env: &mut JNIEnv, + config_server_url: JString, + hostname: JString, + machine_id: JString, + secure_mode: jboolean, + callback_obj: JObject, +) -> jint { + if in_config_server_callback() { + error::throw_exception( + env, + "Cannot start config server client from config server callback", + ); + return -1; + } + + let config_server_url = match jstring_to_cstring(env, &config_server_url) { + Ok(cstr) => cstr, + Err(e) => { + error::throw_exception(env, &format!("Invalid config server URL: {}", e)); + return -1; + } + }; + let hostname = match optional_jstring_to_cstring(env, &hostname) { + Ok(cstr) => cstr, + Err(e) => { + error::throw_exception(env, &format!("Invalid hostname: {}", e)); + return -1; + } + }; + let machine_id = match jstring_to_cstring(env, &machine_id) { + Ok(cstr) => cstr, + Err(e) => { + error::throw_exception(env, &format!("Invalid machine ID: {}", e)); + return -1; + } + }; + + let callback_ref = if callback_obj.is_null() { + None + } else { + match callback::new_callback(env, &callback_obj) { + Ok(state) => Some(state), + Err(e) => { + error::throw_exception(env, &e); + return -1; + } + } + }; + + let mut callback_guard = match callback::lock_callback_storage() { + Ok(guard) => guard, + Err(e) => { + error::throw_exception(env, &e); + return -1; + } + }; + if callback_guard.is_none() { + error::clear_callback_error(); + } + + let callback_fn = callback::callback_fn(&callback_ref); + let user_data = callback::user_data(&callback_ref); + let result = unsafe { + start_config_server_client( + config_server_url.as_ptr(), + hostname + .as_ref() + .map(|value| value.as_ptr()) + .unwrap_or(ptr::null()), + machine_id.as_ptr(), + secure_mode == JNI_TRUE, + callback_fn, + user_data, + ) + }; + if result != 0 { + if let Some(error_msg) = error::get_last_error() { + error::throw_exception(env, &error_msg); + } + return result; + } + + *callback_guard = callback_ref; + result +} + +pub(crate) fn stop_config_server_client_jni(mut env: JNIEnv, _class: JClass) -> jint { + if in_config_server_callback() { + let result = stop_config_server_client(); + if result != 0 + && let Some(error_msg) = error::get_last_error() + { + error::throw_exception(&mut env, &error_msg); + } + return result; + } + + let mut callback_guard = match callback::lock_callback_storage() { + Ok(guard) => guard, + Err(e) => { + error::throw_exception(&mut env, &e); + return -1; + } + }; + + let result = stop_config_server_client(); + if result != 0 { + if let Some(error_msg) = error::get_last_error() { + error::throw_exception(&mut env, &error_msg); + } + return result; + } + + *callback_guard = None; + result +} + +pub(crate) fn is_config_server_client_connected_jni(_env: JNIEnv, _class: JClass) -> jboolean { + if is_config_server_client_connected() != 0 { + JNI_TRUE + } else { + JNI_FALSE + } +} diff --git a/easytier-contrib/easytier-android-jni/src/error.rs b/easytier-contrib/easytier-android-jni/src/error.rs new file mode 100644 index 00000000..e3f8443a --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/error.rs @@ -0,0 +1,74 @@ +use std::{ + ffi::{CStr, c_char}, + ptr, + sync::Mutex, +}; + +use easytier_ffi::{free_string, get_error_msg}; +use jni::JNIEnv; +use jni::objects::JClass; +use jni::sys::jstring; +use once_cell::sync::Lazy; + +static JNI_CALLBACK_ERROR: Lazy>> = Lazy::new(|| Mutex::new(None)); + +pub(crate) fn set_callback_error(error: String) { + log::error!("{}", error); + if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() { + *guard = Some(error); + } +} + +pub(crate) fn clear_callback_error() { + if let Ok(mut guard) = JNI_CALLBACK_ERROR.lock() { + *guard = None; + } +} + +fn take_callback_error() -> Option { + JNI_CALLBACK_ERROR + .lock() + .ok() + .and_then(|mut guard| guard.take()) +} + +fn get_ffi_last_error() -> Option { + unsafe { + let mut error_ptr: *const c_char = ptr::null(); + get_error_msg(&mut error_ptr); + if error_ptr.is_null() { + None + } else { + let error_cstr = CStr::from_ptr(error_ptr); + let error_str = error_cstr.to_string_lossy().into_owned(); + free_string(error_ptr); + Some(error_str) + } + } +} + +pub(crate) fn get_last_error() -> Option { + match (get_ffi_last_error(), take_callback_error()) { + (Some(ffi_error), Some(callback_error)) => Some(format!( + "{}; config server callback error: {}", + ffi_error, callback_error + )), + (Some(ffi_error), None) => Some(ffi_error), + (None, Some(callback_error)) => Some(callback_error), + (None, None) => None, + } +} + +pub(crate) fn throw_exception(env: &mut JNIEnv, message: &str) { + let _ = env.throw_new("java/lang/RuntimeException", message); +} + +pub(crate) fn get_last_error_jni(env: JNIEnv, _class: JClass) -> jstring { + match get_last_error() { + Some(error) => match env.new_string(&error) { + Ok(jstr) => jstr.into_raw(), + Err(_) => ptr::null_mut(), + }, + None => ptr::null_mut(), + } +} diff --git a/easytier-contrib/easytier-android-jni/src/lib.rs b/easytier-contrib/easytier-android-jni/src/lib.rs index a9f3731e..7760b0ae 100644 --- a/easytier-contrib/easytier-android-jni/src/lib.rs +++ b/easytier-contrib/easytier-android-jni/src/lib.rs @@ -1,319 +1,203 @@ -use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap}; +//! JNI facade for Android callers of EasyTier. +//! +//! This file intentionally lists every Java-visible native method exported by +//! `libeasytier_android_jni.so`. The implementation details live in sibling +//! modules so this facade stays readable as an API map. +//! +//! Network management APIs: +//! - `setTunFd(instanceName, fd)`: attach an Android TUN fd to an instance. +//! - `parseConfig(config)`: validate TOML config text. +//! - `runNetworkInstance(config)`: start a local network instance. +//! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest. +//! - `collectNetworkInfos()`: return running instance info as a JSON string. +//! +//! Config server client APIs: +//! - `startConfigServerClient(url, hostname, machineId, secureMode, callback)`: +//! start the managed remote config client. +//! - `stopConfigServerClient()`: stop the managed client and release its Java callback. +//! - `isConfigServerClientConnected()`: return whether the managed client is connected. +//! +//! Error API: +//! - `getLastError()`: return the latest FFI/JNI error string for the calling thread. + +mod callback; +mod config_server_api; +mod error; +mod logger; +mod network_api; +mod strings; + use jni::JNIEnv; -use jni::objects::{JClass, JObjectArray, JString}; -use jni::sys::{jint, jstring}; -use once_cell::sync::Lazy; -use std::ffi::{CStr, CString}; -use std::ptr; +use jni::objects::{JClass, JObject, JObjectArray, JString}; +use jni::sys::{jboolean, jint, jstring}; -// 定义 KeyValuePair 结构体 -#[repr(C)] -#[derive(Clone, Copy)] -pub struct KeyValuePair { - pub key: *const std::ffi::c_char, - pub value: *const std::ffi::c_char, -} - -// 声明外部 C 函数 -unsafe extern "C" { - fn set_tun_fd(inst_name: *const std::ffi::c_char, fd: std::ffi::c_int) -> std::ffi::c_int; - fn get_error_msg(out: *mut *const std::ffi::c_char); - fn free_string(s: *const std::ffi::c_char); - fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int; - fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int; - fn retain_network_instance( - inst_names: *const *const std::ffi::c_char, - length: usize, - ) -> std::ffi::c_int; - fn collect_network_infos(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int; -} - -// 初始化 Android 日志 -static LOGGER_INIT: Lazy<()> = Lazy::new(|| { - android_logger::init_once( - android_logger::Config::default() - .with_max_level(log::LevelFilter::Debug) - .with_tag("EasyTier-JNI"), - ); -}); - -// 辅助函数:从 Java String 转换为 CString -fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result { - let java_str = env - .get_string(jstr) - .map_err(|e| format!("Failed to get string: {:?}", e))?; - let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?; - CString::new(rust_str).map_err(|_| "String contains null byte".to_string()) -} - -// 辅助函数:获取错误消息 -fn get_last_error() -> Option { - unsafe { - let mut error_ptr: *const std::ffi::c_char = ptr::null(); - get_error_msg(&mut error_ptr); - if error_ptr.is_null() { - None - } else { - let error_cstr = CStr::from_ptr(error_ptr); - let error_str = error_cstr.to_string_lossy().into_owned(); - free_string(error_ptr); - Some(error_str) - } - } -} - -// 辅助函数:抛出 Java 异常 -fn throw_exception(env: &mut JNIEnv, message: &str) { - let _ = env.throw_new("java/lang/RuntimeException", message); -} - -/// 设置 TUN 文件描述符 +/// Attach a TUN file descriptor to an EasyTier network instance. +/// +/// Java signature: +/// `EasyTierJNI.setTunFd(instanceName: String, fd: Int): Int` +/// +/// `instanceName` must name an instance known to the shared FFI instance cache. +/// The `fd` must be a valid Android TUN file descriptor. On failure this +/// returns `-1` and throws `RuntimeException` with the FFI error message when +/// one is available. #[unsafe(no_mangle)] pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_setTunFd( - mut env: JNIEnv, - _class: JClass, + env: JNIEnv, + class: JClass, inst_name: JString, fd: jint, ) -> jint { - Lazy::force(&LOGGER_INIT); - - let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) { - Ok(cstr) => cstr, - Err(e) => { - throw_exception(&mut env, &format!("Invalid instance name: {}", e)); - return -1; - } - }; - - unsafe { - let result = set_tun_fd(inst_name_cstr.as_ptr(), fd); - if result != 0 - && let Some(error) = get_last_error() - { - throw_exception(&mut env, &error); - } - result - } + logger::init(); + network_api::set_tun_fd_jni(env, class, inst_name, fd) } -/// 解析配置 +/// Validate a TOML network config string. +/// +/// Java signature: +/// `EasyTierJNI.parseConfig(config: String): Int` +/// +/// This only validates the config text; it does not start or mutate any +/// instance. On failure this returns `-1` and throws `RuntimeException`. #[unsafe(no_mangle)] pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_parseConfig( - mut env: JNIEnv, - _class: JClass, + env: JNIEnv, + class: JClass, config: JString, ) -> jint { - Lazy::force(&LOGGER_INIT); - - let config_cstr = match jstring_to_cstring(&mut env, &config) { - Ok(cstr) => cstr, - Err(e) => { - throw_exception(&mut env, &format!("Invalid config string: {}", e)); - return -1; - } - }; - - unsafe { - let result = parse_config(config_cstr.as_ptr()); - if result != 0 - && let Some(error) = get_last_error() - { - throw_exception(&mut env, &error); - } - result - } + logger::init(); + network_api::parse_config_jni(env, class, config) } -/// 运行网络实例 +/// Start one local EasyTier network instance from TOML config text. +/// +/// Java signature: +/// `EasyTierJNI.runNetworkInstance(config: String): Int` +/// +/// The instance name in the config must be unique in the FFI instance cache. +/// On failure this returns `-1` and throws `RuntimeException`. #[unsafe(no_mangle)] pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_runNetworkInstance( - mut env: JNIEnv, - _class: JClass, + env: JNIEnv, + class: JClass, config: JString, ) -> jint { - Lazy::force(&LOGGER_INIT); - - let config_cstr = match jstring_to_cstring(&mut env, &config) { - Ok(cstr) => cstr, - Err(e) => { - throw_exception(&mut env, &format!("Invalid config string: {}", e)); - return -1; - } - }; - - unsafe { - let result = run_network_instance(config_cstr.as_ptr()); - if result != 0 - && let Some(error) = get_last_error() - { - throw_exception(&mut env, &error); - } - result - } + logger::init(); + network_api::run_network_instance_jni(env, class, config) } -/// 保持网络实例 +/// Retain the named network instances and stop all other instances. +/// +/// Java signature: +/// `EasyTierJNI.retainNetworkInstance(instanceNames: Array?): Int` +/// +/// Passing `null` or an empty array stops all instances. Null elements inside a +/// non-empty array are skipped. On failure this returns `-1` and throws +/// `RuntimeException`. #[unsafe(no_mangle)] pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_retainNetworkInstance( - mut env: JNIEnv, - _class: JClass, + env: JNIEnv, + class: JClass, instance_names: JObjectArray, ) -> jint { - Lazy::force(&LOGGER_INIT); - - // 处理 null 数组的情况 - if instance_names.is_null() { - unsafe { - let result = retain_network_instance(ptr::null(), 0); - if result != 0 - && let Some(error) = get_last_error() - { - throw_exception(&mut env, &error); - } - return result; - } - } - - // 获取数组长度 - let array_length = match env.get_array_length(&instance_names) { - Ok(len) => len as usize, - Err(e) => { - throw_exception(&mut env, &format!("Failed to get array length: {:?}", e)); - return -1; - } - }; - - // 如果数组为空,停止所有实例 - if array_length == 0 { - unsafe { - let result = retain_network_instance(ptr::null(), 0); - if result != 0 - && let Some(error) = get_last_error() - { - throw_exception(&mut env, &error); - } - return result; - } - } - - // 转换 Java 字符串数组为 C 字符串数组 - let mut c_strings = Vec::with_capacity(array_length); - let mut c_string_ptrs = Vec::with_capacity(array_length); - - for i in 0..array_length { - let java_string = match env.get_object_array_element(&instance_names, i as i32) { - Ok(obj) => obj, - Err(e) => { - throw_exception( - &mut env, - &format!("Failed to get array element {}: {:?}", i, e), - ); - return -1; - } - }; - - if java_string.is_null() { - continue; // 跳过 null 元素 - } - - let jstring = JString::from(java_string); - let c_string = match jstring_to_cstring(&mut env, &jstring) { - Ok(cstr) => cstr, - Err(e) => { - throw_exception( - &mut env, - &format!("Invalid instance name at index {}: {}", i, e), - ); - return -1; - } - }; - - c_string_ptrs.push(c_string.as_ptr()); - c_strings.push(c_string); // 保持 CString 的所有权 - } - - unsafe { - let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len()); - if result != 0 - && let Some(error) = get_last_error() - { - throw_exception(&mut env, &error); - } - result - } + logger::init(); + network_api::retain_network_instance_jni(env, class, instance_names) } -/// 收集网络信息 +/// Collect running network instance information. +/// +/// Java signature: +/// `EasyTierJNI.collectNetworkInfos(maxLength: Int): String?` +/// +/// Returns a JSON string containing `NetworkInstanceRunningInfoMap`, or null if +/// collection fails. `maxLength` limits how many FFI entries are collected. On +/// failure this throws `RuntimeException` when an error message is available. #[unsafe(no_mangle)] pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos( - mut env: JNIEnv, - _class: JClass, + env: JNIEnv, + class: JClass, + max_length: jint, ) -> jstring { - Lazy::force(&LOGGER_INIT); - - const MAX_INFOS: usize = 100; - let mut infos = vec![ - KeyValuePair { - key: ptr::null(), - value: ptr::null(), - }; - MAX_INFOS - ]; - - unsafe { - let count = collect_network_infos(infos.as_mut_ptr(), MAX_INFOS); - if count < 0 { - if let Some(error) = get_last_error() { - throw_exception(&mut env, &error); - } - return ptr::null_mut(); - } - - let mut ret = NetworkInstanceRunningInfoMap::default(); - - // 使用 serde_json 构建 JSON - for info in infos.iter().take(count as usize) { - let key_ptr = info.key; - let val_ptr = info.value; - if key_ptr.is_null() || val_ptr.is_null() { - break; - } - - let key = CStr::from_ptr(key_ptr).to_string_lossy(); - let val = CStr::from_ptr(val_ptr).to_string_lossy(); - let value = match serde_json::from_str::(val.as_ref()) { - Ok(v) => v, - Err(_) => { - throw_exception(&mut env, "Failed to parse JSON"); - continue; - } - }; - ret.map.insert(key.to_string(), value); - } - - let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string()); - - match env.new_string(&json_str) { - Ok(jstr) => jstr.into_raw(), - Err(_) => { - throw_exception(&mut env, "Failed to create JSON string"); - ptr::null_mut() - } - } - } + logger::init(); + network_api::collect_network_infos_jni(env, class, max_length) } -/// 获取最后的错误信息 +/// Return the latest FFI/JNI error string for the calling thread. +/// +/// Java signature: +/// `EasyTierJNI.getLastError(): String?` +/// +/// This combines the FFI thread-local error with any pending config-server Java +/// callback error. It returns null when no error is available. #[unsafe(no_mangle)] pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_getLastError( env: JNIEnv, - _class: JClass, + class: JClass, ) -> jstring { - match get_last_error() { - Some(error) => match env.new_string(&error) { - Ok(jstr) => jstr.into_raw(), - Err(_) => ptr::null_mut(), - }, - None => ptr::null_mut(), - } + error::get_last_error_jni(env, class) +} + +/// Start the managed config-server client. +/// +/// Java signature: +/// `EasyTierJNI.startConfigServerClient(url, hostname, machineId, secureMode, callback): Int` +/// +/// JNI only converts Java values and keeps the Java callback alive. The FFI +/// layer owns singleton lifecycle, config-server/data-plane mutual exclusion, +/// remote instance tracking, and callback event timing. If `callback` is +/// non-null, each remote apply/delete event is delivered to +/// `ConfigServerEventCallback.onEvent(eventJson)`. +/// +/// On failure this returns `-1` and throws `RuntimeException`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_startConfigServerClient( + mut env: JNIEnv, + _class: JClass, + config_server_url: JString, + hostname: JString, + machine_id: JString, + secure_mode: jboolean, + callback: JObject, +) -> jint { + logger::init(); + config_server_api::start_config_server_client_jni( + &mut env, + config_server_url, + hostname, + machine_id, + secure_mode, + callback, + ) +} + +/// Stop the managed config-server client. +/// +/// Java signature: +/// `EasyTierJNI.stopConfigServerClient(): Int` +/// +/// The FFI layer performs the actual stop and managed instance cleanup. JNI +/// releases the Java callback reference after FFI stop succeeds. On failure +/// this returns `-1` and throws `RuntimeException`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_stopConfigServerClient( + env: JNIEnv, + class: JClass, +) -> jint { + logger::init(); + config_server_api::stop_config_server_client_jni(env, class) +} + +/// Report whether the managed config-server client is connected. +/// +/// Java signature: +/// `EasyTierJNI.isConfigServerClientConnected(): Boolean` +/// +/// Returns `JNI_TRUE` only when the FFI config-server client exists and reports +/// connected. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_isConfigServerClientConnected( + env: JNIEnv, + class: JClass, +) -> jboolean { + logger::init(); + config_server_api::is_config_server_client_connected_jni(env, class) } diff --git a/easytier-contrib/easytier-android-jni/src/logger.rs b/easytier-contrib/easytier-android-jni/src/logger.rs new file mode 100644 index 00000000..20c149b4 --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/logger.rs @@ -0,0 +1,13 @@ +use once_cell::sync::Lazy; + +static LOGGER_INIT: Lazy<()> = Lazy::new(|| { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Debug) + .with_tag("EasyTier-JNI"), + ); +}); + +pub(crate) fn init() { + Lazy::force(&LOGGER_INIT); +} diff --git a/easytier-contrib/easytier-android-jni/src/network_api.rs b/easytier-contrib/easytier-android-jni/src/network_api.rs new file mode 100644 index 00000000..b7300b86 --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/network_api.rs @@ -0,0 +1,210 @@ +use std::{ffi::CStr, ptr}; + +use easytier::proto::api::manage::{NetworkInstanceRunningInfo, NetworkInstanceRunningInfoMap}; +use easytier_ffi::{ + KeyValuePair, collect_network_infos, parse_config, retain_network_instance, + run_network_instance, set_tun_fd, +}; +use jni::JNIEnv; +use jni::objects::{JClass, JObjectArray, JString}; +use jni::sys::{jint, jstring}; + +use crate::{ + error::{get_last_error, throw_exception}, + strings::jstring_to_cstring, +}; + +pub(crate) fn set_tun_fd_jni( + mut env: JNIEnv, + _class: JClass, + inst_name: JString, + fd: jint, +) -> jint { + let inst_name_cstr = match jstring_to_cstring(&mut env, &inst_name) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid instance name: {}", e)); + return -1; + } + }; + unsafe { + let result = set_tun_fd(inst_name_cstr.as_ptr(), fd); + if result != 0 + && let Some(error) = get_last_error() + { + throw_exception(&mut env, &error); + } + result + } +} + +pub(crate) fn parse_config_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint { + let config_cstr = match jstring_to_cstring(&mut env, &config) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid config string: {}", e)); + return -1; + } + }; + unsafe { + let result = parse_config(config_cstr.as_ptr()); + if result != 0 + && let Some(error) = get_last_error() + { + throw_exception(&mut env, &error); + } + result + } +} + +pub(crate) fn run_network_instance_jni(mut env: JNIEnv, _class: JClass, config: JString) -> jint { + let config_cstr = match jstring_to_cstring(&mut env, &config) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid config string: {}", e)); + return -1; + } + }; + unsafe { + let result = run_network_instance(config_cstr.as_ptr()); + 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, + instance_names: JObjectArray, +) -> jint { + if instance_names.is_null() { + return retain_all(&mut env); + } + + let array_length = match env.get_array_length(&instance_names) { + Ok(len) => len as usize, + Err(e) => { + throw_exception(&mut env, &format!("Failed to get array length: {:?}", e)); + return -1; + } + }; + + if array_length == 0 { + return retain_all(&mut env); + } + + let mut c_strings = Vec::with_capacity(array_length); + let mut c_string_ptrs = Vec::with_capacity(array_length); + + for i in 0..array_length { + let java_string = match env.get_object_array_element(&instance_names, i as i32) { + Ok(obj) => obj, + Err(e) => { + throw_exception( + &mut env, + &format!("Failed to get array element {}: {:?}", i, e), + ); + return -1; + } + }; + + if java_string.is_null() { + continue; + } + + let jstring = JString::from(java_string); + let c_string = match jstring_to_cstring(&mut env, &jstring) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception( + &mut env, + &format!("Invalid instance name at index {}: {}", i, e), + ); + return -1; + } + }; + + c_string_ptrs.push(c_string.as_ptr()); + c_strings.push(c_string); + } + + unsafe { + let result = retain_network_instance(c_string_ptrs.as_ptr(), c_string_ptrs.len()); + if result != 0 + && let Some(error) = get_last_error() + { + throw_exception(&mut env, &error); + } + result + } +} + +fn retain_all(env: &mut JNIEnv) -> jint { + unsafe { + let result = retain_network_instance(ptr::null(), 0); + if result != 0 + && let Some(error) = get_last_error() + { + throw_exception(env, &error); + } + result + } +} + +pub(crate) fn collect_network_infos_jni( + mut env: JNIEnv, + _class: JClass, + max_length: jint, +) -> jstring { + let max_length = max_length.max(0) as usize; + let mut infos = vec![ + KeyValuePair { + key: ptr::null(), + value: ptr::null(), + }; + max_length + ]; + + unsafe { + let count = collect_network_infos(infos.as_mut_ptr(), max_length); + if count < 0 { + if let Some(error) = get_last_error() { + throw_exception(&mut env, &error); + } + return ptr::null_mut(); + } + + let mut ret = NetworkInstanceRunningInfoMap::default(); + for info in infos.iter().take(count as usize) { + let key_ptr = info.key; + let val_ptr = info.value; + if key_ptr.is_null() || val_ptr.is_null() { + break; + } + + let key = CStr::from_ptr(key_ptr).to_string_lossy(); + let val = CStr::from_ptr(val_ptr).to_string_lossy(); + let value = match serde_json::from_str::(val.as_ref()) { + Ok(v) => v, + Err(_) => { + throw_exception(&mut env, "Failed to parse JSON"); + continue; + } + }; + ret.map.insert(key.to_string(), value); + } + + let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string()); + match env.new_string(&json_str) { + Ok(jstr) => jstr.into_raw(), + Err(_) => { + throw_exception(&mut env, "Failed to create JSON string"); + ptr::null_mut() + } + } + } +} diff --git a/easytier-contrib/easytier-android-jni/src/strings.rs b/easytier-contrib/easytier-android-jni/src/strings.rs new file mode 100644 index 00000000..af97814d --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/strings.rs @@ -0,0 +1,23 @@ +use std::ffi::CString; + +use jni::JNIEnv; +use jni::objects::JString; + +pub(crate) fn jstring_to_cstring(env: &mut JNIEnv, jstr: &JString) -> Result { + let java_str = env + .get_string(jstr) + .map_err(|e| format!("Failed to get string: {:?}", e))?; + let rust_str = java_str.to_str().map_err(|_| "Invalid UTF-8".to_string())?; + CString::new(rust_str).map_err(|_| "String contains null byte".to_string()) +} + +pub(crate) fn optional_jstring_to_cstring( + env: &mut JNIEnv, + jstr: &JString, +) -> Result, String> { + if jstr.is_null() { + return Ok(None); + } + + jstring_to_cstring(env, jstr).map(Some) +} diff --git a/easytier-contrib/easytier-ffi/Cargo.toml b/easytier-contrib/easytier-ffi/Cargo.toml index 83e3c755..1b1cb41f 100644 --- a/easytier-contrib/easytier-ffi/Cargo.toml +++ b/easytier-contrib/easytier-ffi/Cargo.toml @@ -4,10 +4,11 @@ version = "0.1.0" edition.workspace = true [lib] -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [features] -default = ["ffi-dataplane"] +default = ["c-abi", "ffi-dataplane"] +c-abi = [] ffi-dataplane = ["easytier/ffi-dataplane"] [dependencies] @@ -15,9 +16,13 @@ easytier = { path = "../../easytier" } once_cell = "1.18.0" dashmap = "6.0" +tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] } +async-trait = "0.1" +log = "0.4" +percent-encoding = "2.3" +url = "2" serde = { version = "1.0", features = ["derive"] } serde_json = "1" uuid = "1.17.0" -tokio = { version = "1", features = ["rt-multi-thread", "io-util", "time", "sync", "macros"] } tokio-util = "0.7" diff --git a/easytier-contrib/easytier-ffi/examples/example_ffi_call.c b/easytier-contrib/easytier-ffi/examples/example_ffi_call.c new file mode 100644 index 00000000..637e1de6 --- /dev/null +++ b/easytier-contrib/easytier-ffi/examples/example_ffi_call.c @@ -0,0 +1,100 @@ +#include +#include +#include +#include +#include // for sleep + +// FFI struct and function declarations +typedef struct { + const char* key; + const char* value; +} KeyValuePair; + +typedef void (*config_server_event_callback)( + const char* event_json, + void* user_data +); + +extern int parse_config(const char* cfg_str); +extern int run_network_instance(const char* cfg_str); +extern void get_error_msg(const char** out); +extern void free_string(const char* s); +extern int collect_network_infos(KeyValuePair* infos, size_t max_length); +extern int start_config_server_client( + const char* config_server_url, + const char* hostname, + const char* machine_id, + bool secure_mode, + config_server_event_callback callback, + void* user_data +); +extern int stop_config_server_client(void); +extern int is_config_server_client_connected(void); + +static void on_config_server_event(const char* event_json, void* user_data) { + (void)user_data; + printf("config server event: %s\n", event_json); +} + +int main() { + const char* config = "inst_name = \"test\"\nnetwork = \"test_network\"\n"; + int ret; + + // 调用 parse_config + ret = parse_config(config); + if (ret != 0) { + const char* err = NULL; + get_error_msg(&err); + if (err) { + printf("parse_config error: %s\n", err); + free_string(err); + } + return 1; + } + printf("parse_config success\n"); + + // 调用 run_network_instance + ret = run_network_instance(config); + if (ret != 0) { + const char* err = NULL; + get_error_msg(&err); + if (err) { + printf("run_network_instance error: %s\n", err); + free_string(err); + } + return 1; + } + printf("run_network_instance success\n"); + + // 周期性调用 collect_network_infos 并打印 + const size_t max_infos = 8; + KeyValuePair* infos = (KeyValuePair*)malloc(sizeof(KeyValuePair) * max_infos); + if (!infos) { + fprintf(stderr, "malloc failed\n"); + return 1; + } + + for (int i = 0; i < 5; ++i) { // 循环5次作为示例 + memset(infos, 0, sizeof(KeyValuePair) * max_infos); + int count = collect_network_infos(infos, max_infos); + if (count < 0) { + const char* err = NULL; + get_error_msg(&err); + if (err) { + printf("collect_network_infos error: %s\n", err); + free_string(err); + } + break; + } + printf("collect_network_infos: %d instance(s)\n", count); + for (int j = 0; j < count; ++j) { + printf(" [%d] key: %s\n value: %s\n", j, infos[j].key, infos[j].value); + free_string(infos[j].key); + free_string(infos[j].value); + } + sleep(1); + } + free(infos); + + return 0; +} diff --git a/easytier-contrib/easytier-ffi/src/config_server.rs b/easytier-contrib/easytier-ffi/src/config_server.rs new file mode 100644 index 00000000..185d82a2 --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/config_server.rs @@ -0,0 +1,565 @@ +use std::{ + cell::Cell, + collections::HashSet, + ffi::{CString, c_char, c_int, c_void}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, +}; + +use easytier::{ + common::{ + MachineIdOptions, + config::{ConfigLoader as _, TomlConfigLoader}, + }, + tunnel::TunnelScheme, + web_client::{WebClient, WebClientHooks, run_web_client}, +}; +use uuid::Uuid; + +use crate::{ + data_plane::remove_data_plane_handles_by_instance_ids, + error::set_error_msg, + state::{ + ASYNC_RUNTIME, INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP, + lock_remote_instance_mutation, remove_instance_name_ids, + }, + strings::{c_str_to_string, optional_c_str_to_string}, + types::ConfigServerEventCallback, +}; + +thread_local! { + static IN_CONFIG_SERVER_CALLBACK: Cell = const { Cell::new(false) }; +} + +static CONFIG_SERVER_CLIENT: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(None)); +static CONFIG_SERVER_CLIENT_ACTIVE: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| AtomicBool::new(false)); +static CONFIG_SERVER_CLIENT_STOPPING: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| AtomicBool::new(false)); +static LAST_CONFIG_SERVER_CALLBACK_ERROR: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(None)); + +pub(crate) struct ConfigServerCallbackScope; + +impl ConfigServerCallbackScope { + pub(crate) fn enter() -> Self { + IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(true)); + Self + } +} + +impl Drop for ConfigServerCallbackScope { + fn drop(&mut self) { + IN_CONFIG_SERVER_CALLBACK.with(|in_callback| in_callback.set(false)); + } +} + +pub fn in_config_server_callback() -> bool { + IN_CONFIG_SERVER_CALLBACK.with(Cell::get) +} + +fn config_server_machine_id_options(machine_id: String) -> MachineIdOptions { + MachineIdOptions { + explicit_machine_id: Some(machine_id), + state_dir: None, + } +} + +pub fn validate_config_server_client_options( + config_server_url_s: &str, + machine_id: &str, +) -> Result<(), String> { + if machine_id.trim().is_empty() { + return Err("machine_id is empty".to_string()); + } + + let config_server_url = match url::Url::parse(config_server_url_s) { + Ok(url) => url, + Err(_) => format!( + "udp://config-server.easytier.cn:22020/{}", + config_server_url_s + ) + .parse() + .map_err(|err| format!("failed to parse config server URL: {}", err))?, + }; + + TunnelScheme::try_from(&config_server_url).map_err(|_| { + format!( + "unsupported config server scheme: {}", + config_server_url.scheme() + ) + })?; + + let token = config_server_url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .map(|segment| percent_encoding::percent_decode_str(segment).decode_utf8()) + .transpose() + .map_err(|err| format!("failed to decode config server token: {}", err))? + .map(|token| token.to_string()) + .unwrap_or_default(); + + if token.is_empty() { + return Err("empty token".to_string()); + } + + Ok(()) +} + +struct ManagedConfigServerClient { + client: WebClient, + hooks: Arc, +} + +pub(crate) struct ManagedConfigServerClientHooks { + pub(crate) instance_ids: Mutex>, + callback_delivery: Mutex<()>, + stopping: AtomicBool, + callback: ConfigServerEventCallback, + user_data: usize, +} + +impl ManagedConfigServerClientHooks { + pub(crate) fn new(callback: ConfigServerEventCallback, user_data: *mut c_void) -> Self { + Self { + instance_ids: Mutex::new(HashSet::new()), + callback_delivery: Mutex::new(()), + stopping: AtomicBool::new(false), + callback, + user_data: user_data as usize, + } + } + + #[cfg(test)] + pub(crate) fn tracked_instance_ids(&self) -> Vec { + self.instance_ids + .lock() + .map(|guard| guard.iter().copied().collect()) + .unwrap_or_default() + } + + fn remove_tracked_instance_ids(&self, ids: &[Uuid]) -> Result, String> { + let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?; + Ok(ids + .iter() + .filter_map(|id| guard.remove(id).then_some(*id)) + .collect()) + } + + fn validate_instance_name(&self, inst_name: &str, inst_id: Uuid) -> Result<(), String> { + if let Some(existing_id) = INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id) + && existing_id != inst_id + { + return Err(format!("instance name {} already exists", inst_name)); + } + + Ok(()) + } + + fn commit_instance_name(&self, inst_name: String, inst_id: Uuid) -> Result<(), String> { + INSTANCE_NAME_ID_MAP.retain(|_, existing_id| *existing_id != inst_id); + self.validate_instance_name(&inst_name, inst_id)?; + INSTANCE_NAME_ID_MAP.insert(inst_name, inst_id); + Ok(()) + } + + pub(crate) fn start_stopping(&self) -> Vec { + let _delivery_guard = if in_config_server_callback() { + None + } else { + self.callback_delivery.lock().ok() + }; + let mut guard = match self.instance_ids.lock() { + Ok(guard) => guard, + Err(_) => return Vec::new(), + }; + self.stopping.store(true, Ordering::Release); + guard.drain().collect() + } + + pub(crate) fn note_callback_error(&self, error: String) { + log::warn!("config server event callback failed: {}", error); + if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() { + *guard = Some(error); + } + } + + fn emit_event_with_delivery_locked( + &self, + event: &str, + instance_id: Uuid, + ) -> Result<(), String> { + if self.stopping.load(Ordering::Acquire) { + return Ok(()); + } + + let Some(callback) = self.callback else { + return Ok(()); + }; + + let event_json = serde_json::json!({ + "event": event, + "success": true, + "instance_id": instance_id.to_string(), + "error": null, + }) + .to_string(); + let event_json = CString::new(event_json).map_err(|err| err.to_string())?; + let _callback_scope = ConfigServerCallbackScope::enter(); + unsafe { + callback(event_json.as_ptr(), self.user_data as *mut c_void); + } + Ok(()) + } + + fn emit_event(&self, event: &str, instance_id: Uuid) -> Result<(), String> { + let _delivery_guard = self + .callback_delivery + .lock() + .map_err(|err| err.to_string())?; + self.emit_event_with_delivery_locked(event, instance_id) + } + + fn wait_for_callback_delivery(&self) { + if in_config_server_callback() { + return; + } + + if let Ok(guard) = self.callback_delivery.lock() { + drop(guard); + } + } +} + +#[async_trait::async_trait] +impl WebClientHooks for ManagedConfigServerClientHooks { + fn manages_remote_config_instances(&self) -> bool { + true + } + + async fn pre_run_network_instance(&self, cfg: &TomlConfigLoader) -> Result<(), String> { + if self.stopping.load(Ordering::Acquire) { + return Err("config server client is stopping".to_string()); + } + + let inst_name = cfg.get_inst_name(); + let inst_id = cfg.get_id(); + + self.validate_instance_name(&inst_name, inst_id) + } + + async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> { + let _delivery_guard = self + .callback_delivery + .lock() + .map_err(|err| err.to_string())?; + let Some(inst_name) = INSTANCE_MANAGER.get_instance_name(id) else { + if !self.stopping.load(Ordering::Acquire) { + return Err(format!("instance {} not found after start", id)); + } + return Ok(()); + }; + + { + let _mutation_guard = INSTANCE_MUTATION_LOCK + .lock() + .map_err(|err| err.to_string())?; + if INSTANCE_MANAGER.get_instance_name(id).is_none() { + if !self.stopping.load(Ordering::Acquire) { + return Err(format!("instance {} not found after start", id)); + } + return Ok(()); + } + + let should_delete = { + let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?; + if self.stopping.load(Ordering::Acquire) { + true + } else { + guard.insert(*id); + false + } + }; + + if should_delete { + if let Err(err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) { + return Err(err.to_string()); + } + remove_instance_name_ids(&[*id]); + return Ok(()); + } + + if self.stopping.load(Ordering::Acquire) { + self.remove_tracked_instance_ids(&[*id])?; + remove_instance_name_ids(&[*id]); + return Ok(()); + } + + if let Err(err) = self.commit_instance_name(inst_name.clone(), *id) { + self.remove_tracked_instance_ids(&[*id])?; + if let Err(delete_err) = INSTANCE_MANAGER.delete_network_instance(vec![*id]) { + return Err(format!( + "{}; failed to delete duplicate instance: {}", + err, delete_err + )); + } + return Err(err); + } + + if self.stopping.load(Ordering::Acquire) { + self.remove_tracked_instance_ids(&[*id])?; + remove_instance_name_ids(&[*id]); + return Ok(()); + } + if INSTANCE_MANAGER.get_instance_name(id).is_none() { + self.remove_tracked_instance_ids(&[*id])?; + remove_instance_name_ids(&[*id]); + return Err(format!( + "instance {} was removed before post-run completed", + id + )); + } + } + + remove_data_plane_handles_by_instance_ids(&[*id]); + + if let Err(err) = self.emit_event_with_delivery_locked("run_network_instance", *id) { + self.note_callback_error(err); + } + Ok(()) + } + + async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> { + let removed_ids = { + let _mutation_guard = INSTANCE_MUTATION_LOCK + .lock() + .map_err(|err| err.to_string())?; + let removed_ids = self.remove_tracked_instance_ids(ids)?; + remove_instance_name_ids(ids); + remove_data_plane_handles_by_instance_ids(&removed_ids); + removed_ids + }; + + for id in removed_ids { + if let Err(err) = self.emit_event("delete_network_instance", id) { + self.note_callback_error(err); + } + } + Ok(()) + } +} + +pub(crate) fn remove_config_server_tracked_instance_ids(ids: &[Uuid]) { + if ids.is_empty() { + return; + } + + if let Ok(guard) = CONFIG_SERVER_CLIENT.lock() + && let Some(managed) = guard.as_ref() + && let Err(err) = managed.hooks.remove_tracked_instance_ids(ids) + { + log::warn!("failed to remove config server tracked ids: {}", err); + } +} + +pub(crate) fn wait_for_config_server_delivery() { + let hooks = CONFIG_SERVER_CLIENT + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|managed| managed.hooks.clone())); + if let Some(hooks) = hooks { + hooks.wait_for_callback_delivery(); + } +} + +pub(crate) fn last_callback_error() -> Option { + LAST_CONFIG_SERVER_CALLBACK_ERROR + .lock() + .ok() + .and_then(|guard| guard.clone()) +} + +pub(crate) fn clear_last_callback_error() { + if let Ok(mut guard) = LAST_CONFIG_SERVER_CALLBACK_ERROR.lock() { + *guard = None; + } +} + +#[cfg(feature = "ffi-dataplane")] +pub(crate) fn is_config_server_active_or_stopping() -> bool { + CONFIG_SERVER_CLIENT_ACTIVE.load(Ordering::Acquire) + || CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) +} + +#[cfg(test)] +pub(crate) fn set_active_for_test(active: bool) { + CONFIG_SERVER_CLIENT_ACTIVE.store(active, Ordering::Release); +} + +/// # Safety +/// Start the config server client. +/// +/// `config_server_url` must be a valid null-terminated UTF-8 string. +/// `hostname` may be null; if non-null it must be a valid null-terminated UTF-8 string. +/// `machine_id` must be a valid null-terminated UTF-8 string. +/// `event_json` passed to `callback` is valid only during that callback invocation. +pub(crate) unsafe fn start_config_server_client( + config_server_url: *const c_char, + hostname: *const c_char, + machine_id: *const c_char, + secure_mode: bool, + callback: ConfigServerEventCallback, + user_data: *mut c_void, +) -> c_int { + if in_config_server_callback() { + set_error_msg("cannot start config server client from config server callback"); + return -1; + } + + let config_server_url = match unsafe { c_str_to_string(config_server_url, "config_server_url") } + { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + let hostname = match unsafe { optional_c_str_to_string(hostname, "hostname") } { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + let machine_id = match unsafe { c_str_to_string(machine_id, "machine_id") } { + Err(err) => { + set_error_msg(&err); + return -1; + } + Ok(value) => value, + }; + if let Err(err) = validate_config_server_client_options(&config_server_url, &machine_id) { + set_error_msg(&err); + return -1; + } + + let mut guard = match CONFIG_SERVER_CLIENT.lock() { + Ok(guard) => guard, + Err(err) => { + set_error_msg(&format!("failed to lock config server client: {}", err)); + return -1; + } + }; + + if guard.is_some() { + set_error_msg("config server client already exists"); + return -1; + } + if CONFIG_SERVER_CLIENT_STOPPING.load(Ordering::Acquire) { + set_error_msg("config server client is stopping"); + return -1; + } + clear_last_callback_error(); + + #[cfg(feature = "ffi-dataplane")] + let _data_plane_usage_guard = match crate::data_plane::lock_for_config_server_start() { + Ok(guard) => guard, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + + CONFIG_SERVER_CLIENT_ACTIVE.store(true, Ordering::Release); + let hooks = Arc::new(ManagedConfigServerClientHooks::new(callback, user_data)); + let client = match ASYNC_RUNTIME.block_on(run_web_client( + &config_server_url, + config_server_machine_id_options(machine_id), + hostname, + secure_mode, + INSTANCE_MANAGER.clone(), + Some(hooks.clone()), + )) { + Ok(client) => client, + Err(err) => { + CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release); + set_error_msg(&format!("failed to start config server client: {}", err)); + return -1; + } + }; + + *guard = Some(ManagedConfigServerClient { client, hooks }); + 0 +} + +pub(crate) fn stop_config_server_client() -> c_int { + if in_config_server_callback() { + set_error_msg("cannot stop config server client from config server callback"); + return -1; + } + + let mut guard = match CONFIG_SERVER_CLIENT.lock() { + Ok(guard) => guard, + Err(err) => { + set_error_msg(&format!("failed to lock config server client: {}", err)); + return -1; + } + }; + + let Some(managed) = guard.as_ref() else { + CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release); + return 0; + }; + if CONFIG_SERVER_CLIENT_STOPPING.swap(true, Ordering::AcqRel) { + set_error_msg("config server client is stopping"); + return -1; + } + let hooks = managed.hooks.clone(); + let managed = guard.take().expect("config server client exists"); + drop(guard); + + let _remote_mutation_guard = lock_remote_instance_mutation(); + let tracked_ids = hooks.start_stopping(); + drop(managed); + + let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() { + Ok(guard) => guard, + Err(err) => { + hooks.wait_for_callback_delivery(); + CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release); + CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release); + set_error_msg(&format!("failed to lock instance mutation: {}", err)); + return -1; + } + }; + let delete_result = INSTANCE_MANAGER.delete_network_instance(tracked_ids.clone()); + if delete_result.is_ok() { + remove_instance_name_ids(&tracked_ids); + remove_data_plane_handles_by_instance_ids(&tracked_ids); + } + drop(_mutation_guard); + hooks.wait_for_callback_delivery(); + CONFIG_SERVER_CLIENT_ACTIVE.store(false, Ordering::Release); + CONFIG_SERVER_CLIENT_STOPPING.store(false, Ordering::Release); + + if let Err(err) = delete_result { + set_error_msg(&format!( + "failed to delete config server instances: {}", + err + )); + return -1; + } + 0 +} + +pub(crate) fn is_config_server_client_connected() -> c_int { + CONFIG_SERVER_CLIENT + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|managed| managed.client.is_connected())) + .map(i32::from) + .unwrap_or(0) +} diff --git a/easytier-contrib/easytier-ffi/src/data_plane.rs b/easytier-contrib/easytier-ffi/src/data_plane.rs new file mode 100644 index 00000000..d96c7c9e --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/data_plane.rs @@ -0,0 +1,854 @@ +#[cfg(feature = "ffi-dataplane")] +use std::{ + future::Future, + net::{IpAddr, SocketAddr}, + sync::{ + Arc, RwLock, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; + +#[cfg(feature = "ffi-dataplane")] +use dashmap::DashMap; +#[cfg(feature = "ffi-dataplane")] +use easytier::launcher::{DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket}; +#[cfg(feature = "ffi-dataplane")] +use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}; +#[cfg(feature = "ffi-dataplane")] +use tokio_util::sync::CancellationToken; +#[cfg(feature = "ffi-dataplane")] +use uuid::Uuid; + +#[cfg(feature = "ffi-dataplane")] +use crate::{ + config_server::{in_config_server_callback, is_config_server_active_or_stopping}, + error::{free_string, set_error_msg}, + state::{INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP}, +}; + +#[cfg(feature = "ffi-dataplane")] +static NEXT_DATA_PLANE_HANDLE: AtomicU64 = AtomicU64::new(1); +#[cfg(feature = "ffi-dataplane")] +static DATA_PLANE_HANDLES: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(DashMap::new); +#[cfg(feature = "ffi-dataplane")] +static DATA_PLANE_USAGE_LOCK: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| RwLock::new(())); + +#[cfg(feature = "ffi-dataplane")] +struct DataPlaneHandle { + instance_id: uuid::Uuid, + runtime: tokio::runtime::Handle, + // Cancelled by close() to wake any in-flight op on this handle. + close_token: CancellationToken, + resource: DataPlaneResource, +} + +#[cfg(feature = "ffi-dataplane")] +struct TcpHalves { + read: tokio::sync::Mutex>, + write: tokio::sync::Mutex>, +} + +#[cfg(feature = "ffi-dataplane")] +enum DataPlaneResource { + Tcp(Arc), + TcpListener(Arc>), + Udp(Arc), +} + +// Several helper functions for FFI data plane operations to facilitate logic reuse. + +#[cfg(feature = "ffi-dataplane")] +fn next_handle() -> u64 { + NEXT_DATA_PLANE_HANDLE.fetch_add(1, Ordering::Relaxed) +} + +#[cfg(feature = "ffi-dataplane")] +fn timeout_duration(timeout_ms: u64) -> Duration { + Duration::from_millis(timeout_ms) +} + +#[cfg(feature = "ffi-dataplane")] +unsafe fn cstr_to_string(ptr: *const std::ffi::c_char, name: &str) -> Option { + if ptr.is_null() { + set_error_msg(&format!("{} is null", name)); + return None; + } + Some( + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(), + ) +} + +#[cfg(feature = "ffi-dataplane")] +fn get_instance_id(inst_name: &str) -> Option { + INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id.value()) +} + +#[cfg(feature = "ffi-dataplane")] +fn parse_socket_addr(host: &str, port: u16) -> Option { + let ip = match host.parse::() { + Ok(ip) => ip, + Err(e) => { + set_error_msg(&format!("failed to parse ip address: {}", e)); + return None; + } + }; + Some(SocketAddr::new(ip, port)) +} + +/// Encode an IP address for FFI return. Returns `*mut c_char` to match +/// `CString::into_raw`; caller releases it via `free_string`. +#[cfg(feature = "ffi-dataplane")] +fn into_ffi_ip_cstring(ip: IpAddr) -> Option<*mut std::ffi::c_char> { + match std::ffi::CString::new(ip.to_string()) { + Ok(s) => Some(s.into_raw()), + Err(e) => { + set_error_msg(&format!("failed to encode ip: {}", e)); + None + } + } +} + +#[cfg(feature = "ffi-dataplane")] +fn get_runtime_handle( + inst_id: &uuid::Uuid, + deadline: std::time::Instant, +) -> Option { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let Some(rt) = INSTANCE_MANAGER.data_plane_wait_runtime_handle(inst_id, remaining) else { + set_error_msg("instance runtime is not ready"); + return None; + }; + Some(rt) +} + +#[cfg(feature = "ffi-dataplane")] +fn insert_tcp_stream_handle( + instance_id: uuid::Uuid, + runtime: tokio::runtime::Handle, + stream: DataPlaneTcpStream, +) -> u64 { + let (rd, wr) = tokio::io::split(stream); + let handle = next_handle(); + DATA_PLANE_HANDLES.insert( + handle, + DataPlaneHandle { + instance_id, + runtime, + close_token: CancellationToken::new(), + resource: DataPlaneResource::Tcp(Arc::new(TcpHalves { + read: tokio::sync::Mutex::new(rd), + write: tokio::sync::Mutex::new(wr), + })), + }, + ); + handle +} + +#[cfg(feature = "ffi-dataplane")] +fn get_tcp_stream( + handle: u64, +) -> Option<(Arc, tokio::runtime::Handle, CancellationToken)> { + let Some(h) = DATA_PLANE_HANDLES.get(&handle) else { + set_error_msg("tcp stream handle not found"); + return None; + }; + match &h.resource { + DataPlaneResource::Tcp(halves) => { + Some((halves.clone(), h.runtime.clone(), h.close_token.clone())) + } + DataPlaneResource::TcpListener(_) | DataPlaneResource::Udp(_) => { + set_error_msg("handle is not a tcp stream"); + None + } + } +} + +#[cfg(feature = "ffi-dataplane")] +fn get_tcp_listener( + handle: u64, +) -> Option<( + Arc>, + tokio::runtime::Handle, + CancellationToken, + uuid::Uuid, +)> { + let Some(h) = DATA_PLANE_HANDLES.get(&handle) else { + set_error_msg("tcp listener handle not found"); + return None; + }; + match &h.resource { + DataPlaneResource::TcpListener(listener) => Some(( + listener.clone(), + h.runtime.clone(), + h.close_token.clone(), + h.instance_id, + )), + DataPlaneResource::Tcp(_) | DataPlaneResource::Udp(_) => { + set_error_msg("handle is not a tcp listener"); + None + } + } +} + +#[cfg(feature = "ffi-dataplane")] +fn get_udp_socket( + handle: u64, +) -> Option<( + Arc, + tokio::runtime::Handle, + CancellationToken, +)> { + let Some(h) = DATA_PLANE_HANDLES.get(&handle) else { + set_error_msg("udp socket handle not found"); + return None; + }; + match &h.resource { + DataPlaneResource::Udp(socket) => { + Some((socket.clone(), h.runtime.clone(), h.close_token.clone())) + } + DataPlaneResource::Tcp(_) | DataPlaneResource::TcpListener(_) => { + set_error_msg("handle is not a udp socket"); + None + } + } +} + +#[cfg(feature = "ffi-dataplane")] +pub(crate) fn remove_data_plane_handles_by_instance_ids(ids: &[Uuid]) { + if ids.is_empty() { + return; + } + + DATA_PLANE_HANDLES.retain(|_, handle| { + if ids.contains(&handle.instance_id) { + handle.close_token.cancel(); + false + } else { + true + } + }); +} + +#[cfg(not(feature = "ffi-dataplane"))] +pub(crate) fn remove_data_plane_handles_by_instance_ids(_ids: &[uuid::Uuid]) {} + +#[cfg(feature = "ffi-dataplane")] +fn data_plane_rejected() -> bool { + if in_config_server_callback() { + set_error_msg("cannot use data plane from config server callback"); + true + } else if is_config_server_active_or_stopping() { + set_error_msg("cannot use data plane while config server client is active"); + true + } else { + false + } +} + +#[cfg(feature = "ffi-dataplane")] +fn enter_data_plane_operation() -> Option> { + if data_plane_rejected() { + return None; + } + + let guard = match DATA_PLANE_USAGE_LOCK.read() { + Ok(guard) => guard, + Err(err) => { + set_error_msg(&format!("failed to lock data plane usage: {}", err)); + return None; + } + }; + if data_plane_rejected() { + return None; + } + Some(guard) +} + +/// Run an IO op on the resource's owning runtime, supporting +/// timeout and cancellation. +#[cfg(feature = "ffi-dataplane")] +async fn run_with_cancel( + close_token: &CancellationToken, + timeout_ms: u64, + error_prefix: &str, + op: F, +) -> Option> +where + F: Future>, +{ + tokio::select! { + biased; + _ = close_token.cancelled() => { + set_error_msg(&format!("{}: handle closed", error_prefix)); + None + } + res = tokio::time::timeout(timeout_duration(timeout_ms), op) => match res { + Ok(r) => Some(r), + Err(_) => { + set_error_msg(&format!("{} timed out", error_prefix)); + None + } + } + } +} + +#[cfg(feature = "ffi-dataplane")] +pub(crate) fn lock_for_config_server_start() +-> Result, String> { + let guard = DATA_PLANE_USAGE_LOCK + .write() + .map_err(|err| format!("failed to lock data plane usage: {}", err))?; + if !DATA_PLANE_HANDLES.is_empty() { + return Err("cannot start config server client while data plane is in use".to_string()); + } + Ok(guard) +} +/// # Safety +/// Open a TCP stream through an EasyTier instance data plane. Returns 0 on +/// failure. On success, writes the local socket address chosen for this +/// connection into `out_local_ip` (a heap-allocated C string the caller must +/// release via `free_string`) and `out_local_port`. Both out pointers must be +/// non-null. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_tcp_connect( + inst_name: *const std::ffi::c_char, + dst_ip: *const std::ffi::c_char, + dst_port: std::ffi::c_ushort, + timeout_ms: u64, + out_local_ip: *mut *const std::ffi::c_char, + out_local_port: *mut std::ffi::c_ushort, +) -> u64 { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return 0, + }; + if out_local_ip.is_null() || out_local_port.is_null() { + set_error_msg("output pointer is null"); + return 0; + } + let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else { + return 0; + }; + let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else { + return 0; + }; + let Some(inst_id) = get_instance_id(&inst_name) else { + set_error_msg("instance not found"); + return 0; + }; + let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else { + return 0; + }; + let deadline = std::time::Instant::now() + timeout_duration(timeout_ms); + let Some(runtime) = get_runtime_handle(&inst_id, deadline) else { + return 0; + }; + + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let result = + runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_connect(&inst_id, dst_addr, remaining)); + match result { + Ok(stream) => { + let local_addr = stream.local_addr(); + let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { + return 0; + }; + let handle = insert_tcp_stream_handle(inst_id, runtime, stream); + unsafe { + *out_local_ip = local_ip as *const std::ffi::c_char; + *out_local_port = local_addr.port(); + } + handle + } + Err(e) => { + set_error_msg(&format!("failed to connect tcp data plane: {}", e)); + 0 + } + } +} + +/// # Safety +/// Bind a TCP listener through an EasyTier instance data plane. Returns 0 on +/// failure. The local address actually bound is written into `out_local_ip` / +/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_tcp_bind( + inst_name: *const std::ffi::c_char, + local_port: std::ffi::c_ushort, + timeout_ms: u64, + out_local_ip: *mut *const std::ffi::c_char, + out_local_port: *mut std::ffi::c_ushort, +) -> u64 { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return 0, + }; + if out_local_ip.is_null() || out_local_port.is_null() { + set_error_msg("output pointer is null"); + return 0; + } + let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else { + return 0; + }; + let Some(inst_id) = get_instance_id(&inst_name) else { + set_error_msg("instance not found"); + return 0; + }; + let deadline = std::time::Instant::now() + timeout_duration(timeout_ms); + let Some(runtime) = get_runtime_handle(&inst_id, deadline) else { + return 0; + }; + + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let result = + runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_bind(&inst_id, local_port, remaining)); + match result { + Ok(listener) => { + let local_addr = listener.local_addr(); + let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { + return 0; + }; + let handle = next_handle(); + DATA_PLANE_HANDLES.insert( + handle, + DataPlaneHandle { + instance_id: inst_id, + runtime, + close_token: CancellationToken::new(), + resource: DataPlaneResource::TcpListener(Arc::new(tokio::sync::Mutex::new( + listener, + ))), + }, + ); + unsafe { + *out_local_ip = local_ip as *const std::ffi::c_char; + *out_local_port = local_addr.port(); + } + handle + } + Err(e) => { + set_error_msg(&format!("failed to bind tcp data plane: {}", e)); + 0 + } + } +} + +/// # Safety +/// Accept one connection from a TCP data-plane listener. Returns a TCP stream +/// handle, or 0 on failure. Local and peer addresses are written into out +/// parameters; returned IP strings must be released via `free_string`. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_tcp_accept( + handle: u64, + timeout_ms: u64, + out_local_ip: *mut *const std::ffi::c_char, + out_local_port: *mut std::ffi::c_ushort, + out_peer_ip: *mut *const std::ffi::c_char, + out_peer_port: *mut std::ffi::c_ushort, +) -> u64 { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return 0, + }; + if out_local_ip.is_null() + || out_local_port.is_null() + || out_peer_ip.is_null() + || out_peer_port.is_null() + { + set_error_msg("output pointer is null"); + return 0; + } + let Some((listener, runtime, close_token, instance_id)) = get_tcp_listener(handle) else { + return 0; + }; + + let ret = runtime.block_on(async move { + let mut listener = listener.lock().await; + run_with_cancel( + &close_token, + timeout_ms, + "tcp data plane accept", + listener.accept(), + ) + .await + }); + + match ret { + Some(Ok((stream, peer_addr))) => { + let local_addr = stream.local_addr(); + let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { + return 0; + }; + let Some(peer_ip) = into_ffi_ip_cstring(peer_addr.ip()) else { + free_string(local_ip); + return 0; + }; + let stream_handle = insert_tcp_stream_handle(instance_id, runtime, stream); + unsafe { + *out_local_ip = local_ip as *const std::ffi::c_char; + *out_local_port = local_addr.port(); + *out_peer_ip = peer_ip as *const std::ffi::c_char; + *out_peer_port = peer_addr.port(); + } + stream_handle + } + Some(Err(e)) => { + set_error_msg(&format!("failed to accept tcp data plane: {}", e)); + 0 + } + None => 0, + } +} + +/// # Safety +/// Read from a TCP data-plane stream. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_tcp_read( + handle: u64, + buf: *mut std::ffi::c_uchar, + len: u32, + timeout_ms: u64, +) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + if buf.is_null() { + set_error_msg("buf is null"); + return -1; + } + let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else { + return -1; + }; + // Safety: caller-owned buffer outlives this blocking call. + let buf = unsafe { std::slice::from_raw_parts_mut(buf, len as usize) }; + runtime.block_on(async move { + let mut rd = halves.read.lock().await; + match run_with_cancel( + &close_token, + timeout_ms, + "failed to read tcp data plane", + rd.read(buf), + ) + .await + { + Some(Ok(n)) => n as std::ffi::c_int, + Some(Err(e)) => { + set_error_msg(&format!("failed to read tcp data plane: {}", e)); + -1 + } + None => -1, + } + }) +} + +/// # Safety +/// Write to a TCP data-plane stream. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_tcp_write( + handle: u64, + buf: *const std::ffi::c_uchar, + len: u32, + timeout_ms: u64, +) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + if buf.is_null() { + set_error_msg("buf is null"); + return -1; + } + let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else { + return -1; + }; + let total = len as usize; + // Safety: caller-owned buffer outlives this blocking call. + let buf = unsafe { std::slice::from_raw_parts(buf, total) }; + runtime.block_on(async move { + let mut wr = halves.write.lock().await; + // Use `write_all` to honor `net.Conn::Write` semantics on the Go side + // (must write everything or return an error); single `write()` can + // silently short-write and corrupt streams that the caller assumes are + // fully written. + match run_with_cancel( + &close_token, + timeout_ms, + "failed to write tcp data plane", + wr.write_all(buf), + ) + .await + { + Some(Ok(())) => total as std::ffi::c_int, + Some(Err(e)) => { + set_error_msg(&format!("failed to write tcp data plane: {}", e)); + -1 + } + None => -1, + } + }) +} + +#[cfg(feature = "ffi-dataplane")] +pub(crate) fn data_plane_tcp_close(handle: u64) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| { + matches!(e.resource, DataPlaneResource::Tcp(_)) + }) else { + set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) { + "handle is not a tcp stream" + } else { + "tcp stream handle not found" + }); + return -1; + }; + h.close_token.cancel(); + if let DataPlaneResource::Tcp(halves) = h.resource { + // Best-effort half-close; if write half is in use, the in-flight call + // observes the cancel token and releases the lock shortly after. + h.runtime.spawn(async move { + if let Ok(mut wr) = halves.write.try_lock() { + let _ = wr.shutdown().await; + } + }); + } + 0 +} + +#[cfg(feature = "ffi-dataplane")] +pub(crate) fn data_plane_tcp_listener_close(handle: u64) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| { + matches!(e.resource, DataPlaneResource::TcpListener(_)) + }) else { + set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) { + "handle is not a tcp listener" + } else { + "tcp listener handle not found" + }); + return -1; + }; + h.close_token.cancel(); + 0 +} + +/// # Safety +/// Bind a UDP socket through an EasyTier instance data plane. Returns 0 on +/// failure. The local address actually bound (which may differ from the +/// requested port when `local_port == 0`) is written into `out_local_ip` / +/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_udp_bind( + inst_name: *const std::ffi::c_char, + local_port: std::ffi::c_ushort, + timeout_ms: u64, + out_local_ip: *mut *const std::ffi::c_char, + out_local_port: *mut std::ffi::c_ushort, +) -> u64 { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return 0, + }; + if out_local_ip.is_null() || out_local_port.is_null() { + set_error_msg("output pointer is null"); + return 0; + } + let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else { + return 0; + }; + let Some(inst_id) = get_instance_id(&inst_name) else { + set_error_msg("instance not found"); + return 0; + }; + let deadline = std::time::Instant::now() + timeout_duration(timeout_ms); + let Some(runtime) = get_runtime_handle(&inst_id, deadline) else { + return 0; + }; + + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let result = + runtime.block_on(INSTANCE_MANAGER.data_plane_udp_bind(&inst_id, local_port, remaining)); + match result { + Ok(socket) => { + let local_addr = socket.local_addr(); + let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { + return 0; + }; + let handle = next_handle(); + DATA_PLANE_HANDLES.insert( + handle, + DataPlaneHandle { + instance_id: inst_id, + runtime, + close_token: CancellationToken::new(), + resource: DataPlaneResource::Udp(Arc::new(socket)), + }, + ); + unsafe { + *out_local_ip = local_ip as *const std::ffi::c_char; + *out_local_port = local_addr.port(); + } + handle + } + Err(e) => { + set_error_msg(&format!("failed to bind udp data plane: {}", e)); + 0 + } + } +} + +/// # Safety +/// Send a datagram through a UDP data-plane socket. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_udp_send_to( + handle: u64, + dst_ip: *const std::ffi::c_char, + dst_port: std::ffi::c_ushort, + buf: *const std::ffi::c_uchar, + len: u32, + timeout_ms: u64, +) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + if buf.is_null() { + set_error_msg("buf is null"); + return -1; + } + let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else { + return -1; + }; + let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else { + return -1; + }; + let Some((socket, runtime, close_token)) = get_udp_socket(handle) else { + return -1; + }; + let total = len as usize; + // Safety: caller-owned buffer outlives this blocking call. + let buf = unsafe { std::slice::from_raw_parts(buf, total) }; + runtime.block_on(async move { + match run_with_cancel( + &close_token, + timeout_ms, + "failed to send udp data plane", + socket.send_to(buf, dst_addr), + ) + .await + { + Some(Ok(n)) => n as std::ffi::c_int, + Some(Err(e)) => { + set_error_msg(&format!("failed to send udp data plane: {}", e)); + -1 + } + None => -1, + } + }) +} + +/// # Safety +/// Receive a datagram from a UDP data-plane socket. +#[cfg(feature = "ffi-dataplane")] +pub(crate) unsafe fn data_plane_udp_recv_from( + handle: u64, + buf: *mut std::ffi::c_uchar, + len: u32, + out_ip: *mut *const std::ffi::c_char, + out_port: *mut std::ffi::c_ushort, + timeout_ms: u64, +) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + if buf.is_null() || out_ip.is_null() || out_port.is_null() { + set_error_msg("output pointer is null"); + return -1; + } + let Some((socket, runtime, close_token)) = get_udp_socket(handle) else { + return -1; + }; + let total = len as usize; + // Safety: caller-owned buffer outlives this blocking call. + let buf = unsafe { std::slice::from_raw_parts_mut(buf, total) }; + let ret = runtime.block_on(run_with_cancel( + &close_token, + timeout_ms, + "udp data plane receive", + socket.recv_from(buf), + )); + + match ret { + Some(Ok((n, addr))) => { + // The returned ip pointer must be released by the caller via + // `free_string` (which calls `CString::from_raw`, matching + // `CString::into_raw` here). + let Some(ip_cstr) = into_ffi_ip_cstring(addr.ip()) else { + return -1; + }; + unsafe { + *out_ip = ip_cstr as *const std::ffi::c_char; + *out_port = addr.port() as std::ffi::c_ushort; + } + n as std::ffi::c_int + } + Some(Err(e)) => { + set_error_msg(&format!("failed to receive udp data plane: {}", e)); + -1 + } + None => -1, + } +} + +#[cfg(feature = "ffi-dataplane")] +pub(crate) fn data_plane_udp_close(handle: u64) -> std::ffi::c_int { + let _data_plane_usage_guard = match enter_data_plane_operation() { + Some(guard) => guard, + None => return -1, + }; + let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| { + matches!(e.resource, DataPlaneResource::Udp(_)) + }) else { + set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) { + "handle is not a udp socket" + } else { + "udp socket handle not found" + }); + return -1; + }; + h.close_token.cancel(); + 0 +} + +#[cfg(all(test, feature = "ffi-dataplane"))] +mod tests { + use super::*; + use std::{sync::mpsc, time::Duration}; + + #[test] + fn config_server_start_waits_for_data_plane_operation() { + let read_guard = DATA_PLANE_USAGE_LOCK.read().unwrap(); + let (done_tx, done_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _write_guard = lock_for_config_server_start().unwrap(); + done_tx.send(()).unwrap(); + }); + + assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err()); + drop(read_guard); + done_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + waiter.join().unwrap(); + } +} diff --git a/easytier-contrib/easytier-ffi/src/error.rs b/easytier-contrib/easytier-ffi/src/error.rs new file mode 100644 index 00000000..af51bf6b --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/error.rs @@ -0,0 +1,65 @@ +use std::{ + cell::RefCell, + ffi::{CString, c_char}, +}; + +thread_local! { + // # Thread Safety + // set_error_msg and get_error_msg must be called on the same thread to + // get correct error. And since `Handle::block_on` polls the top-level + // future on the calling thread, set_error_msg always runs on the same + // thread as the corresponding get_error_msg. + static ERROR_MSG: RefCell> = const { RefCell::new(Vec::new()) }; +} + +pub(crate) fn set_error_msg(msg: &str) { + ERROR_MSG.with(|cell| { + let mut buf = cell.borrow_mut(); + buf.clear(); + buf.extend_from_slice(msg.as_bytes()); + }); +} + +fn thread_local_error_msg() -> Option { + ERROR_MSG.with(|cell| { + let buf = cell.borrow(); + if buf.is_empty() { + None + } else { + Some(String::from_utf8_lossy(&buf).into_owned()) + } + }) +} + +pub(crate) unsafe fn get_error_msg(out: *mut *const c_char) { + let msg = match ( + thread_local_error_msg(), + crate::config_server::last_callback_error(), + ) { + (Some(error), Some(callback_error)) => Some(format!( + "{}; config server callback error: {}", + error, callback_error + )), + (Some(error), None) => Some(error), + (None, Some(callback_error)) => { + Some(format!("config server callback error: {}", callback_error)) + } + (None, None) => None, + }; + let cstr = msg.and_then(|msg| CString::new(msg).ok()); + unsafe { + *out = match cstr { + Some(s) => s.into_raw() as *const c_char, + None => std::ptr::null(), + }; + } +} + +pub(crate) fn free_string(s: *const c_char) { + if s.is_null() { + return; + } + unsafe { + let _ = CString::from_raw(s as *mut c_char); + } +} diff --git a/easytier-contrib/easytier-ffi/src/instance_api.rs b/easytier-contrib/easytier-ffi/src/instance_api.rs new file mode 100644 index 00000000..420967f3 --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/instance_api.rs @@ -0,0 +1,236 @@ +use std::ffi::{c_char, c_int}; + +use easytier::common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader}; + +use crate::{ + config_server::{ + in_config_server_callback, remove_config_server_tracked_instance_ids, + wait_for_config_server_delivery, + }, + data_plane::remove_data_plane_handles_by_instance_ids, + error::set_error_msg, + state::{ + INSTANCE_MANAGER, INSTANCE_MUTATION_LOCK, INSTANCE_NAME_ID_MAP, instance_name_exists, + lock_remote_instance_mutation, + }, + types::KeyValuePair, +}; + +/// # Safety +/// Set the tun fd +pub(crate) unsafe fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int { + let inst_name = unsafe { + assert!(!inst_name.is_null()); + std::ffi::CStr::from_ptr(inst_name) + .to_string_lossy() + .into_owned() + }; + if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) { + return -1; + } + + let inst_id = *INSTANCE_NAME_ID_MAP + .get(&inst_name) + .as_ref() + .unwrap() + .value(); + + match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) { + Ok(_) => 0, + Err(_) => -1, + } +} + +/// # Safety +/// Parse the config +pub(crate) unsafe fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { + let cfg_str = unsafe { + assert!(!cfg_str.is_null()); + std::ffi::CStr::from_ptr(cfg_str) + .to_string_lossy() + .into_owned() + }; + + if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) { + set_error_msg(&format!("failed to parse config: {:?}", e)); + return -1; + } + + 0 +} + +/// # Safety +/// Run the network instance +pub(crate) unsafe fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { + if in_config_server_callback() { + set_error_msg("cannot run network instance from config server callback"); + return -1; + } + + let cfg_str = unsafe { + assert!(!cfg_str.is_null()); + std::ffi::CStr::from_ptr(cfg_str) + .to_string_lossy() + .into_owned() + }; + let cfg = match TomlConfigLoader::new_from_str(&cfg_str) { + Ok(cfg) => cfg, + Err(e) => { + set_error_msg(&format!("failed to parse config: {}", e)); + return -1; + } + }; + + let inst_name = cfg.get_inst_name(); + + wait_for_config_server_delivery(); + let _remote_mutation_guard = lock_remote_instance_mutation(); + let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() { + Ok(guard) => guard, + Err(err) => { + set_error_msg(&format!("failed to lock instance mutation: {}", err)); + return -1; + } + }; + + if instance_name_exists(&inst_name) { + set_error_msg("instance already exists"); + return -1; + } + + let instance_id = + match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) { + Ok(id) => id, + Err(e) => { + set_error_msg(&format!("failed to start instance: {}", e)); + return -1; + } + }; + + INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id); + + 0 +} + +/// # Safety +/// Retain the network instance +pub(crate) unsafe fn retain_network_instance( + inst_names: *const *const std::ffi::c_char, + length: usize, +) -> std::ffi::c_int { + if in_config_server_callback() { + set_error_msg("cannot retain network instances from config server callback"); + return -1; + } + + wait_for_config_server_delivery(); + let _remote_mutation_guard = lock_remote_instance_mutation(); + let _mutation_guard = match INSTANCE_MUTATION_LOCK.lock() { + Ok(guard) => guard, + Err(err) => { + set_error_msg(&format!("failed to lock instance mutation: {}", err)); + return -1; + } + }; + + if length == 0 { + let removed_ids = INSTANCE_MANAGER.list_network_instance_ids(); + if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) { + set_error_msg(&format!("failed to delete instances: {}", e)); + return -1; + } + remove_config_server_tracked_instance_ids(&removed_ids); + remove_data_plane_handles_by_instance_ids(&removed_ids); + INSTANCE_NAME_ID_MAP.clear(); + return 0; + } + + let inst_names = unsafe { + assert!(!inst_names.is_null()); + std::slice::from_raw_parts(inst_names, length) + .iter() + .map(|&name| { + assert!(!name.is_null()); + std::ffi::CStr::from_ptr(name) + .to_string_lossy() + .into_owned() + }) + .collect::>() + }; + + let removed_ids = INSTANCE_MANAGER + .list_network_instance_ids() + .into_iter() + .filter(|id| { + INSTANCE_MANAGER + .get_instance_name(id) + .is_none_or(|name| !inst_names.contains(&name)) + }) + .collect::>(); + + if let Err(e) = INSTANCE_MANAGER.delete_network_instance(removed_ids.clone()) { + set_error_msg(&format!("failed to delete instances: {}", e)); + return -1; + } + + remove_config_server_tracked_instance_ids(&removed_ids); + remove_data_plane_handles_by_instance_ids(&removed_ids); + INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k)); + + 0 +} + +/// # Safety +/// Collect the network infos +pub(crate) unsafe fn collect_network_infos( + infos: *mut KeyValuePair, + max_length: usize, +) -> std::ffi::c_int { + if in_config_server_callback() { + set_error_msg("cannot collect network infos from config server callback"); + return -1; + } + + if max_length == 0 { + return 0; + } + + let infos = unsafe { + assert!(!infos.is_null()); + std::slice::from_raw_parts_mut(infos, max_length) + }; + + let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() { + Ok(infos) => infos, + Err(e) => { + set_error_msg(&format!("failed to collect network infos: {}", e)); + return -1; + } + }; + + let mut index = 0; + for (instance_id, value) in collected_infos.iter() { + if index >= max_length { + break; + } + let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else { + continue; + }; + // convert value to json string + let value = match serde_json::to_string(&value) { + Ok(value) => value, + Err(e) => { + set_error_msg(&format!("failed to serialize instance info: {}", e)); + return -1; + } + }; + + infos[index] = KeyValuePair { + key: std::ffi::CString::new(key).unwrap().into_raw(), + value: std::ffi::CString::new(value).unwrap().into_raw(), + }; + index += 1; + } + + index as std::ffi::c_int +} diff --git a/easytier-contrib/easytier-ffi/src/lib.rs b/easytier-contrib/easytier-ffi/src/lib.rs index a912c591..acb22b54 100644 --- a/easytier-contrib/easytier-ffi/src/lib.rs +++ b/easytier-contrib/easytier-ffi/src/lib.rs @@ -1,1008 +1,523 @@ -use std::cell::RefCell; +//! C ABI facade for EasyTier. +//! +//! The exported API is intentionally kept in this file so C users and JNI +//! bindings can see the full callable surface without reading the internal +//! implementation modules. +//! +//! Network management APIs: +//! - `parse_config`: validate a TOML network config string. +//! - `run_network_instance`: start one local network instance from TOML. +//! - `retain_network_instance`: keep named instances and stop all others. +//! - `collect_network_infos`: collect running instance info as key/value pairs. +//! - `set_tun_fd`: attach a TUN file descriptor to a named instance. +//! +//! Config server client APIs: +//! - `start_config_server_client`: start the managed remote config client. +//! - `stop_config_server_client`: stop the remote config client and its managed instances. +//! - `is_config_server_client_connected`: report whether the client is connected. +//! +//! Data plane APIs, enabled by the `ffi-dataplane` feature: +//! - `data_plane_tcp_connect`: open an outbound TCP data-plane stream. +//! - `data_plane_tcp_bind`: bind a TCP data-plane listener. +//! - `data_plane_tcp_accept`: accept a TCP data-plane connection. +//! - `data_plane_tcp_read`: read from a TCP data-plane stream. +//! - `data_plane_tcp_write`: write to a TCP data-plane stream. +//! - `data_plane_tcp_close`: close a TCP data-plane stream. +//! - `data_plane_tcp_listener_close`: close a TCP data-plane listener. +//! - `data_plane_udp_bind`: bind a UDP data-plane socket. +//! - `data_plane_udp_send_to`: send one UDP data-plane datagram. +//! - `data_plane_udp_recv_from`: receive one UDP data-plane datagram. +//! - `data_plane_udp_close`: close a UDP data-plane socket. +//! +//! Shared FFI helper APIs: +//! - `get_error_msg`: copy the last FFI or config-server callback error message. +//! - `free_string`: release strings allocated by this library. +mod config_server; +mod data_plane; +mod error; +mod instance_api; +mod state; +mod strings; +mod types; + +#[cfg(test)] +mod tests; + +pub use config_server::{in_config_server_callback, validate_config_server_client_options}; +pub use types::{ConfigServerEventCallback, KeyValuePair}; + +use std::ffi::{c_char, c_int, c_void}; #[cfg(feature = "ffi-dataplane")] -use std::{ - future::Future, - net::{IpAddr, SocketAddr}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, - time::Duration, -}; +use std::ffi::{c_uchar, c_ushort}; -use dashmap::DashMap; -#[cfg(feature = "ffi-dataplane")] -use easytier::launcher::{DataPlaneTcpListener, DataPlaneTcpStream, DataPlaneUdpSocket}; -use easytier::{ - common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader}, - instance_manager::NetworkInstanceManager, -}; -#[cfg(feature = "ffi-dataplane")] -use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadHalf, WriteHalf}; -#[cfg(feature = "ffi-dataplane")] -use tokio_util::sync::CancellationToken; - -static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy> = - once_cell::sync::Lazy::new(DashMap::new); -static INSTANCE_MANAGER: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(NetworkInstanceManager::new); - -thread_local! { - // # Thread Safety - // set_error_msg and get_error_msg must be called on the same thread to - // get correct error. And since `Handle::block_on` polls the top-level - // future on the calling thread, set_error_msg always runs on the same - // thread as the corresponding get_error_msg. - static ERROR_MSG: RefCell> = const { RefCell::new(Vec::new()) }; -} -#[cfg(feature = "ffi-dataplane")] -static NEXT_DATA_PLANE_HANDLE: AtomicU64 = AtomicU64::new(1); -#[cfg(feature = "ffi-dataplane")] -static DATA_PLANE_HANDLES: once_cell::sync::Lazy> = - once_cell::sync::Lazy::new(DashMap::new); - -#[cfg(feature = "ffi-dataplane")] -struct DataPlaneHandle { - instance_id: uuid::Uuid, - runtime: tokio::runtime::Handle, - // Cancelled by close() to wake any in-flight op on this handle. - close_token: CancellationToken, - resource: DataPlaneResource, -} - -#[cfg(feature = "ffi-dataplane")] -struct TcpHalves { - read: tokio::sync::Mutex>, - write: tokio::sync::Mutex>, -} - -#[cfg(feature = "ffi-dataplane")] -enum DataPlaneResource { - Tcp(Arc), - TcpListener(Arc>), - Udp(Arc), -} - -#[repr(C)] -pub struct KeyValuePair { - pub key: *const std::ffi::c_char, - pub value: *const std::ffi::c_char, -} - -fn set_error_msg(msg: &str) { - ERROR_MSG.with(|cell| { - let mut buf = cell.borrow_mut(); - buf.clear(); - buf.extend_from_slice(msg.as_bytes()); - }); -} - -// Several helper functions for FFI data plane operations to facilitate logic reuse. - -#[cfg(feature = "ffi-dataplane")] -fn next_handle() -> u64 { - NEXT_DATA_PLANE_HANDLE.fetch_add(1, Ordering::Relaxed) -} - -#[cfg(feature = "ffi-dataplane")] -fn timeout_duration(timeout_ms: u64) -> Duration { - Duration::from_millis(timeout_ms) -} - -#[cfg(feature = "ffi-dataplane")] -unsafe fn cstr_to_string(ptr: *const std::ffi::c_char, name: &str) -> Option { - if ptr.is_null() { - set_error_msg(&format!("{} is null", name)); - return None; - } - Some( - unsafe { std::ffi::CStr::from_ptr(ptr) } - .to_string_lossy() - .into_owned(), - ) -} - -#[cfg(feature = "ffi-dataplane")] -fn get_instance_id(inst_name: &str) -> Option { - INSTANCE_NAME_ID_MAP.get(inst_name).map(|id| *id.value()) -} - -#[cfg(feature = "ffi-dataplane")] -fn parse_socket_addr(host: &str, port: u16) -> Option { - let ip = match host.parse::() { - Ok(ip) => ip, - Err(e) => { - set_error_msg(&format!("failed to parse ip address: {}", e)); - return None; - } - }; - Some(SocketAddr::new(ip, port)) -} - -/// Encode an IP address for FFI return. Returns `*mut c_char` to match -/// `CString::into_raw`; caller releases it via `free_string`. -#[cfg(feature = "ffi-dataplane")] -fn into_ffi_ip_cstring(ip: IpAddr) -> Option<*mut std::ffi::c_char> { - match std::ffi::CString::new(ip.to_string()) { - Ok(s) => Some(s.into_raw()), - Err(e) => { - set_error_msg(&format!("failed to encode ip: {}", e)); - None - } - } -} - -#[cfg(feature = "ffi-dataplane")] -fn get_runtime_handle( - inst_id: &uuid::Uuid, - deadline: std::time::Instant, -) -> Option { - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let Some(rt) = INSTANCE_MANAGER.data_plane_wait_runtime_handle(inst_id, remaining) else { - set_error_msg("instance runtime is not ready"); - return None; - }; - Some(rt) -} - -#[cfg(feature = "ffi-dataplane")] -fn insert_tcp_stream_handle( - instance_id: uuid::Uuid, - runtime: tokio::runtime::Handle, - stream: DataPlaneTcpStream, -) -> u64 { - let (rd, wr) = tokio::io::split(stream); - let handle = next_handle(); - DATA_PLANE_HANDLES.insert( - handle, - DataPlaneHandle { - instance_id, - runtime, - close_token: CancellationToken::new(), - resource: DataPlaneResource::Tcp(Arc::new(TcpHalves { - read: tokio::sync::Mutex::new(rd), - write: tokio::sync::Mutex::new(wr), - })), - }, - ); - handle -} - -#[cfg(feature = "ffi-dataplane")] -fn get_tcp_stream( - handle: u64, -) -> Option<(Arc, tokio::runtime::Handle, CancellationToken)> { - let Some(h) = DATA_PLANE_HANDLES.get(&handle) else { - set_error_msg("tcp stream handle not found"); - return None; - }; - match &h.resource { - DataPlaneResource::Tcp(halves) => { - Some((halves.clone(), h.runtime.clone(), h.close_token.clone())) - } - DataPlaneResource::TcpListener(_) | DataPlaneResource::Udp(_) => { - set_error_msg("handle is not a tcp stream"); - None - } - } -} - -#[cfg(feature = "ffi-dataplane")] -fn get_tcp_listener( - handle: u64, -) -> Option<( - Arc>, - tokio::runtime::Handle, - CancellationToken, - uuid::Uuid, -)> { - let Some(h) = DATA_PLANE_HANDLES.get(&handle) else { - set_error_msg("tcp listener handle not found"); - return None; - }; - match &h.resource { - DataPlaneResource::TcpListener(listener) => Some(( - listener.clone(), - h.runtime.clone(), - h.close_token.clone(), - h.instance_id, - )), - DataPlaneResource::Tcp(_) | DataPlaneResource::Udp(_) => { - set_error_msg("handle is not a tcp listener"); - None - } - } -} - -#[cfg(feature = "ffi-dataplane")] -fn get_udp_socket( - handle: u64, -) -> Option<( - Arc, - tokio::runtime::Handle, - CancellationToken, -)> { - let Some(h) = DATA_PLANE_HANDLES.get(&handle) else { - set_error_msg("udp socket handle not found"); - return None; - }; - match &h.resource { - DataPlaneResource::Udp(socket) => { - Some((socket.clone(), h.runtime.clone(), h.close_token.clone())) - } - DataPlaneResource::Tcp(_) | DataPlaneResource::TcpListener(_) => { - set_error_msg("handle is not a udp socket"); - None - } - } -} - -/// Run an IO op on the resource's owning runtime, supporting -/// timeout and cancellation. -#[cfg(feature = "ffi-dataplane")] -async fn run_with_cancel( - close_token: &CancellationToken, - timeout_ms: u64, - error_prefix: &str, - op: F, -) -> Option> -where - F: Future>, -{ - tokio::select! { - biased; - _ = close_token.cancelled() => { - set_error_msg(&format!("{}: handle closed", error_prefix)); - None - } - res = tokio::time::timeout(timeout_duration(timeout_ms), op) => match res { - Ok(r) => Some(r), - Err(_) => { - set_error_msg(&format!("{} timed out", error_prefix)); - None - } - } - } -} +// ===== Network Management API ===== +/// Validate a TOML network config string. +/// +/// This only parses and validates the config. It does not start an instance and +/// does not change global FFI state. +/// /// # Safety -/// Set the tun fd -#[unsafe(no_mangle)] -pub unsafe extern "C" fn set_tun_fd( - inst_name: *const std::ffi::c_char, - fd: std::ffi::c_int, -) -> std::ffi::c_int { - let inst_name = unsafe { - assert!(!inst_name.is_null()); - std::ffi::CStr::from_ptr(inst_name) - .to_string_lossy() - .into_owned() - }; - if !INSTANCE_NAME_ID_MAP.contains_key(&inst_name) { - return -1; - } - - let inst_id = *INSTANCE_NAME_ID_MAP - .get(&inst_name) - .as_ref() - .unwrap() - .value(); - - match INSTANCE_MANAGER.set_tun_fd(&inst_id, fd) { - Ok(_) => 0, - Err(_) => -1, - } +/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string. +/// +/// # Return +/// Returns `0` if the config parses successfully, or `-1` on failure. On +/// failure, call `get_error_msg` on the same thread to retrieve details. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub unsafe extern "C" fn parse_config(cfg_str: *const c_char) -> c_int { + unsafe { instance_api::parse_config(cfg_str) } } +/// Start one local EasyTier network instance from a TOML config string. +/// +/// The config's `inst_name` must be unique among instances started through this +/// FFI layer. This API is mutually exclusive with config-server callback +/// execution and will fail if called from a config-server event callback. +/// /// # Safety -/// Get the last error message produced on the calling thread. The returned -/// pointer (if non-null) must be released via `free_string`. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn get_error_msg(out: *mut *const std::ffi::c_char) { - let cstr = ERROR_MSG.with(|cell| { - let buf = cell.borrow(); - if buf.is_empty() { - None - } else { - std::ffi::CString::new(&buf[..]).ok() - } - }); - unsafe { - *out = match cstr { - Some(s) => s.into_raw() as *const std::ffi::c_char, - None => std::ptr::null(), - }; - } -} - -#[unsafe(no_mangle)] -pub extern "C" fn free_string(s: *const std::ffi::c_char) { - if s.is_null() { - return; - } - unsafe { - let _ = std::ffi::CString::from_raw(s as *mut std::ffi::c_char); - } +/// `cfg_str` must be a non-null pointer to a null-terminated UTF-8 string. +/// +/// # Return +/// Returns `0` after the instance is started and registered in the FFI name +/// cache, or `-1` on failure. On failure, call `get_error_msg` on the same +/// thread to retrieve details. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub unsafe extern "C" fn run_network_instance(cfg_str: *const c_char) -> c_int { + unsafe { instance_api::run_network_instance(cfg_str) } } +/// Keep the named network instances and stop all other instances. +/// +/// Passing `length == 0` stops all instances. When `length > 0`, `inst_names` +/// must point to an array of `length` non-null C strings. Instances that are not +/// retained are removed from the FFI name cache and any related data-plane +/// handles are closed. +/// +/// This API fails if called from a config-server event callback. +/// /// # Safety -/// Parse the config -#[unsafe(no_mangle)] -pub unsafe extern "C" fn parse_config(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { - let cfg_str = unsafe { - assert!(!cfg_str.is_null()); - std::ffi::CStr::from_ptr(cfg_str) - .to_string_lossy() - .into_owned() - }; - - if let Err(e) = TomlConfigLoader::new_from_str(&cfg_str) { - set_error_msg(&format!("failed to parse config: {:?}", e)); - return -1; - } - - 0 -} - -/// # Safety -/// Run the network instance -#[unsafe(no_mangle)] -pub unsafe extern "C" fn run_network_instance(cfg_str: *const std::ffi::c_char) -> std::ffi::c_int { - let cfg_str = unsafe { - assert!(!cfg_str.is_null()); - std::ffi::CStr::from_ptr(cfg_str) - .to_string_lossy() - .into_owned() - }; - let cfg = match TomlConfigLoader::new_from_str(&cfg_str) { - Ok(cfg) => cfg, - Err(e) => { - set_error_msg(&format!("failed to parse config: {}", e)); - return -1; - } - }; - - let inst_name = cfg.get_inst_name(); - - if INSTANCE_NAME_ID_MAP.contains_key(&inst_name) { - set_error_msg("instance already exists"); - return -1; - } - - let instance_id = - match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) { - Ok(id) => id, - Err(e) => { - set_error_msg(&format!("failed to start instance: {}", e)); - return -1; - } - }; - - INSTANCE_NAME_ID_MAP.insert(inst_name, instance_id); - - 0 -} - -/// # Safety -/// Retain the network instance -#[unsafe(no_mangle)] +/// If `length > 0`, `inst_names` must be a non-null pointer to an array of +/// `length` non-null pointers to null-terminated UTF-8 strings. +/// +/// # Return +/// Returns `0` on success, or `-1` on failure. On failure, call +/// `get_error_msg` on the same thread to retrieve details. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn retain_network_instance( - inst_names: *const *const std::ffi::c_char, + inst_names: *const *const c_char, length: usize, -) -> std::ffi::c_int { - if length == 0 { - if let Err(e) = INSTANCE_MANAGER.retain_network_instance(Vec::new()) { - set_error_msg(&format!("failed to retain instances: {}", e)); - return -1; - } - INSTANCE_NAME_ID_MAP.clear(); - #[cfg(feature = "ffi-dataplane")] - DATA_PLANE_HANDLES.clear(); - return 0; - } - - let inst_names = unsafe { - assert!(!inst_names.is_null()); - std::slice::from_raw_parts(inst_names, length) - .iter() - .map(|&name| { - assert!(!name.is_null()); - std::ffi::CStr::from_ptr(name) - .to_string_lossy() - .into_owned() - }) - .collect::>() - }; - - let inst_ids: Vec = inst_names - .iter() - .filter_map(|name| INSTANCE_NAME_ID_MAP.get(name).map(|id| *id)) - .collect(); - - if let Err(e) = INSTANCE_MANAGER.retain_network_instance(inst_ids.clone()) { - set_error_msg(&format!("failed to retain instances: {}", e)); - return -1; - } - - INSTANCE_NAME_ID_MAP.retain(|k, _| inst_names.contains(k)); - - // FIXME: `DATA_PLANE_HANDLES.retain()` could trigger drop and cleanup of TCP halves, - // but `retain_network_instance()` has shutdown the server. - // Maybe move this line before `retain_network_instance` to allow graceful close of TCP (TCP FIN)? - #[cfg(feature = "ffi-dataplane")] - DATA_PLANE_HANDLES.retain(|_, handle| inst_ids.contains(&handle.instance_id)); - - 0 +) -> c_int { + unsafe { instance_api::retain_network_instance(inst_names, length) } } +/// Collect running network instance information. +/// +/// Writes up to `max_length` entries into `infos`. Each returned key is the +/// instance name and each returned value is a JSON string containing that +/// instance's running information. Returned key/value strings are allocated by +/// this library and must be released with `free_string`. +/// +/// This API fails if called from a config-server event callback. +/// /// # Safety -/// Collect the network infos -#[unsafe(no_mangle)] +/// If `max_length > 0`, `infos` must be a non-null pointer to writable storage +/// for at least `max_length` `KeyValuePair` values. +/// +/// # Return +/// Returns the number of entries written, or `-1` on failure. On failure, call +/// `get_error_msg` on the same thread to retrieve details. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn collect_network_infos( infos: *mut KeyValuePair, max_length: usize, -) -> std::ffi::c_int { - if max_length == 0 { - return 0; - } - - let infos = unsafe { - assert!(!infos.is_null()); - std::slice::from_raw_parts_mut(infos, max_length) - }; - - let collected_infos = match INSTANCE_MANAGER.collect_network_infos_sync() { - Ok(infos) => infos, - Err(e) => { - set_error_msg(&format!("failed to collect network infos: {}", e)); - return -1; - } - }; - - let mut index = 0; - for (instance_id, value) in collected_infos.iter() { - if index >= max_length { - break; - } - let Some(key) = INSTANCE_MANAGER.get_instance_name(instance_id) else { - continue; - }; - // convert value to json string - let value = match serde_json::to_string(&value) { - Ok(value) => value, - Err(e) => { - set_error_msg(&format!("failed to serialize instance info: {}", e)); - return -1; - } - }; - - infos[index] = KeyValuePair { - key: std::ffi::CString::new(key).unwrap().into_raw(), - value: std::ffi::CString::new(value).unwrap().into_raw(), - }; - index += 1; - } - - index as std::ffi::c_int +) -> c_int { + unsafe { instance_api::collect_network_infos(infos, max_length) } } +/// Attach a TUN file descriptor to a named network instance. +/// +/// The instance must already have been registered in the FFI name cache by +/// `run_network_instance` or by a managed config-server remote start event. +/// /// # Safety -/// Open a TCP stream through an EasyTier instance data plane. Returns 0 on -/// failure. On success, writes the local socket address chosen for this -/// connection into `out_local_ip` (a heap-allocated C string the caller must -/// release via `free_string`) and `out_local_port`. Both out pointers must be -/// non-null. +/// `inst_name` must be a non-null pointer to a null-terminated UTF-8 string. +/// `fd` must be a valid TUN file descriptor owned by the caller. +/// +/// # Return +/// Returns `0` if the descriptor is accepted by the instance, or `-1` on +/// failure. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub unsafe extern "C" fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_int { + unsafe { instance_api::set_tun_fd(inst_name, fd) } +} + +// ===== Config Server Client API ===== + +/// Start the managed config-server client. +/// +/// The client reuses EasyTier's web-client path and applies remote config +/// changes through the shared `NetworkInstanceManager`. Successful remote run +/// and delete operations are delivered to `callback` as JSON event strings, one +/// callback per affected instance. The event string is valid only for the +/// duration of the callback; callers must copy it if they need to keep it. +/// +/// The config-server client is mutually exclusive with the FFI data plane. If a +/// data-plane handle exists or is being created, this function returns `-1`. +/// +/// # Safety +/// `config_server_url` and `machine_id` must be non-null pointers to +/// null-terminated UTF-8 strings. `hostname` may be null; when non-null it must +/// also point to a null-terminated UTF-8 string. `user_data` is passed back to +/// `callback` unchanged and must remain valid for the callback's expectations. +/// +/// # Return +/// Returns `0` after the client starts successfully, or `-1` on failure. On +/// failure, call `get_error_msg` on the same thread to retrieve details. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub unsafe extern "C" fn start_config_server_client( + config_server_url: *const c_char, + hostname: *const c_char, + machine_id: *const c_char, + secure_mode: bool, + callback: ConfigServerEventCallback, + user_data: *mut c_void, +) -> c_int { + unsafe { + config_server::start_config_server_client( + config_server_url, + hostname, + machine_id, + secure_mode, + callback, + user_data, + ) + } +} + +/// Stop the managed config-server client. +/// +/// This stops the client, removes instances tracked as remote config-server +/// instances, waits for in-flight callback delivery when safe to do so, and +/// releases the config-server/data-plane mutual exclusion state. +/// +/// # Return +/// Returns `0` if no client exists or if the active client is stopped +/// successfully. Returns `-1` on failure. On failure, call `get_error_msg` on +/// the same thread to retrieve details. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub extern "C" fn stop_config_server_client() -> c_int { + config_server::stop_config_server_client() +} + +/// Report whether the managed config-server client is currently connected. +/// +/// # Return +/// Returns `1` when a client exists and reports connected, otherwise returns +/// `0`. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub extern "C" fn is_config_server_client_connected() -> c_int { + config_server::is_config_server_client_connected() +} + +// ===== Data Plane API ===== + +/// Open an outbound TCP stream through an EasyTier instance data plane. +/// +/// On success, writes the local address selected for the connection into +/// `out_local_ip` and `out_local_port`. The returned IP string is allocated by +/// this library and must be released with `free_string`. +/// +/// The data plane is mutually exclusive with the config-server client. This +/// function returns `0` if the config-server client is active or stopping. +/// +/// # Safety +/// `inst_name`, `dst_ip`, `out_local_ip`, and `out_local_port` must be non-null. +/// String pointers must point to null-terminated UTF-8 strings. +/// +/// # Return +/// Returns a non-zero TCP stream handle on success, or `0` on failure. On +/// failure, call `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_tcp_connect( - inst_name: *const std::ffi::c_char, - dst_ip: *const std::ffi::c_char, - dst_port: std::ffi::c_ushort, + inst_name: *const c_char, + dst_ip: *const c_char, + dst_port: c_ushort, timeout_ms: u64, - out_local_ip: *mut *const std::ffi::c_char, - out_local_port: *mut std::ffi::c_ushort, + out_local_ip: *mut *const c_char, + out_local_port: *mut c_ushort, ) -> u64 { - if out_local_ip.is_null() || out_local_port.is_null() { - set_error_msg("output pointer is null"); - return 0; - } - let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else { - return 0; - }; - let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else { - return 0; - }; - let Some(inst_id) = get_instance_id(&inst_name) else { - set_error_msg("instance not found"); - return 0; - }; - let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else { - return 0; - }; - let deadline = std::time::Instant::now() + timeout_duration(timeout_ms); - let Some(runtime) = get_runtime_handle(&inst_id, deadline) else { - return 0; - }; - - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let result = - runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_connect(&inst_id, dst_addr, remaining)); - match result { - Ok(stream) => { - let local_addr = stream.local_addr(); - let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { - return 0; - }; - let handle = insert_tcp_stream_handle(inst_id, runtime, stream); - unsafe { - *out_local_ip = local_ip as *const std::ffi::c_char; - *out_local_port = local_addr.port(); - } - handle - } - Err(e) => { - set_error_msg(&format!("failed to connect tcp data plane: {}", e)); - 0 - } + unsafe { + data_plane::data_plane_tcp_connect( + inst_name, + dst_ip, + dst_port, + timeout_ms, + out_local_ip, + out_local_port, + ) } } +/// Bind a TCP listener through an EasyTier instance data plane. +/// +/// On success, writes the bound local address into `out_local_ip` and +/// `out_local_port`. The returned IP string is allocated by this library and +/// must be released with `free_string`. +/// /// # Safety -/// Bind a TCP listener through an EasyTier instance data plane. Returns 0 on -/// failure. The local address actually bound is written into `out_local_ip` / -/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`. +/// `inst_name`, `out_local_ip`, and `out_local_port` must be non-null. +/// `inst_name` must point to a null-terminated UTF-8 string. +/// +/// # Return +/// Returns a non-zero TCP listener handle on success, or `0` on failure. On +/// failure, call `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_tcp_bind( - inst_name: *const std::ffi::c_char, - local_port: std::ffi::c_ushort, + inst_name: *const c_char, + local_port: c_ushort, timeout_ms: u64, - out_local_ip: *mut *const std::ffi::c_char, - out_local_port: *mut std::ffi::c_ushort, + out_local_ip: *mut *const c_char, + out_local_port: *mut c_ushort, ) -> u64 { - if out_local_ip.is_null() || out_local_port.is_null() { - set_error_msg("output pointer is null"); - return 0; - } - let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else { - return 0; - }; - let Some(inst_id) = get_instance_id(&inst_name) else { - set_error_msg("instance not found"); - return 0; - }; - let deadline = std::time::Instant::now() + timeout_duration(timeout_ms); - let Some(runtime) = get_runtime_handle(&inst_id, deadline) else { - return 0; - }; - - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let result = - runtime.block_on(INSTANCE_MANAGER.data_plane_tcp_bind(&inst_id, local_port, remaining)); - match result { - Ok(listener) => { - let local_addr = listener.local_addr(); - let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { - return 0; - }; - let handle = next_handle(); - DATA_PLANE_HANDLES.insert( - handle, - DataPlaneHandle { - instance_id: inst_id, - runtime, - close_token: CancellationToken::new(), - resource: DataPlaneResource::TcpListener(Arc::new(tokio::sync::Mutex::new( - listener, - ))), - }, - ); - unsafe { - *out_local_ip = local_ip as *const std::ffi::c_char; - *out_local_port = local_addr.port(); - } - handle - } - Err(e) => { - set_error_msg(&format!("failed to bind tcp data plane: {}", e)); - 0 - } + unsafe { + data_plane::data_plane_tcp_bind( + inst_name, + local_port, + timeout_ms, + out_local_ip, + out_local_port, + ) } } +/// Accept one connection from a TCP data-plane listener. +/// +/// On success, writes both local and peer socket addresses to the output +/// pointers. Returned IP strings are allocated by this library and must be +/// released with `free_string`. +/// /// # Safety -/// Accept one connection from a TCP data-plane listener. Returns a TCP stream -/// handle, or 0 on failure. Local and peer addresses are written into out -/// parameters; returned IP strings must be released via `free_string`. +/// All output pointers must be non-null and writable. `handle` must be a valid +/// TCP listener handle returned by `data_plane_tcp_bind`. +/// +/// # Return +/// Returns a non-zero TCP stream handle on success, or `0` on failure. On +/// failure, call `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_tcp_accept( handle: u64, timeout_ms: u64, - out_local_ip: *mut *const std::ffi::c_char, - out_local_port: *mut std::ffi::c_ushort, - out_peer_ip: *mut *const std::ffi::c_char, - out_peer_port: *mut std::ffi::c_ushort, + out_local_ip: *mut *const c_char, + out_local_port: *mut c_ushort, + out_peer_ip: *mut *const c_char, + out_peer_port: *mut c_ushort, ) -> u64 { - if out_local_ip.is_null() - || out_local_port.is_null() - || out_peer_ip.is_null() - || out_peer_port.is_null() - { - set_error_msg("output pointer is null"); - return 0; - } - let Some((listener, runtime, close_token, instance_id)) = get_tcp_listener(handle) else { - return 0; - }; - - let ret = runtime.block_on(async move { - let mut listener = listener.lock().await; - run_with_cancel( - &close_token, + unsafe { + data_plane::data_plane_tcp_accept( + handle, timeout_ms, - "tcp data plane accept", - listener.accept(), + out_local_ip, + out_local_port, + out_peer_ip, + out_peer_port, ) - .await - }); - - match ret { - Some(Ok((stream, peer_addr))) => { - let local_addr = stream.local_addr(); - let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { - return 0; - }; - let Some(peer_ip) = into_ffi_ip_cstring(peer_addr.ip()) else { - free_string(local_ip); - return 0; - }; - let stream_handle = insert_tcp_stream_handle(instance_id, runtime, stream); - unsafe { - *out_local_ip = local_ip as *const std::ffi::c_char; - *out_local_port = local_addr.port(); - *out_peer_ip = peer_ip as *const std::ffi::c_char; - *out_peer_port = peer_addr.port(); - } - stream_handle - } - Some(Err(e)) => { - set_error_msg(&format!("failed to accept tcp data plane: {}", e)); - 0 - } - None => 0, } } +/// Read bytes from a TCP data-plane stream. +/// /// # Safety -/// Read from a TCP data-plane stream. +/// `handle` must be a valid TCP stream handle returned by +/// `data_plane_tcp_connect` or `data_plane_tcp_accept`. `buf` must be non-null +/// and writable for `len` bytes. +/// +/// # Return +/// Returns the number of bytes read, or `-1` on failure. On failure, call +/// `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_tcp_read( handle: u64, - buf: *mut std::ffi::c_uchar, + buf: *mut c_uchar, len: u32, timeout_ms: u64, -) -> std::ffi::c_int { - if buf.is_null() { - set_error_msg("buf is null"); - return -1; - } - let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else { - return -1; - }; - // Safety: caller-owned buffer outlives this blocking call. - let buf = unsafe { std::slice::from_raw_parts_mut(buf, len as usize) }; - runtime.block_on(async move { - let mut rd = halves.read.lock().await; - match run_with_cancel( - &close_token, - timeout_ms, - "failed to read tcp data plane", - rd.read(buf), - ) - .await - { - Some(Ok(n)) => n as std::ffi::c_int, - Some(Err(e)) => { - set_error_msg(&format!("failed to read tcp data plane: {}", e)); - -1 - } - None => -1, - } - }) +) -> c_int { + unsafe { data_plane::data_plane_tcp_read(handle, buf, len, timeout_ms) } } +/// Write bytes to a TCP data-plane stream. +/// +/// This function attempts to write exactly `len` bytes before returning +/// success. +/// /// # Safety -/// Write to a TCP data-plane stream. +/// `handle` must be a valid TCP stream handle returned by +/// `data_plane_tcp_connect` or `data_plane_tcp_accept`. `buf` must be non-null +/// and readable for `len` bytes. +/// +/// # Return +/// Returns `len` on success, or `-1` on failure. On failure, call +/// `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_tcp_write( handle: u64, - buf: *const std::ffi::c_uchar, + buf: *const c_uchar, len: u32, timeout_ms: u64, -) -> std::ffi::c_int { - if buf.is_null() { - set_error_msg("buf is null"); - return -1; - } - let Some((halves, runtime, close_token)) = get_tcp_stream(handle) else { - return -1; - }; - let total = len as usize; - // Safety: caller-owned buffer outlives this blocking call. - let buf = unsafe { std::slice::from_raw_parts(buf, total) }; - runtime.block_on(async move { - let mut wr = halves.write.lock().await; - // Use `write_all` to honor `net.Conn::Write` semantics on the Go side - // (must write everything or return an error); single `write()` can - // silently short-write and corrupt streams that the caller assumes are - // fully written. - match run_with_cancel( - &close_token, - timeout_ms, - "failed to write tcp data plane", - wr.write_all(buf), - ) - .await - { - Some(Ok(())) => total as std::ffi::c_int, - Some(Err(e)) => { - set_error_msg(&format!("failed to write tcp data plane: {}", e)); - -1 - } - None => -1, - } - }) +) -> c_int { + unsafe { data_plane::data_plane_tcp_write(handle, buf, len, timeout_ms) } } +/// Close a TCP data-plane stream handle. +/// +/// # Return +/// Returns `0` on success, or `-1` if the handle is missing, is not a TCP stream +/// handle, or data-plane calls are currently rejected. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] -pub extern "C" fn data_plane_tcp_close(handle: u64) -> std::ffi::c_int { - let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| { - matches!(e.resource, DataPlaneResource::Tcp(_)) - }) else { - set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) { - "handle is not a tcp stream" - } else { - "tcp stream handle not found" - }); - return -1; - }; - h.close_token.cancel(); - if let DataPlaneResource::Tcp(halves) = h.resource { - // Best-effort half-close; if write half is in use, the in-flight call - // observes the cancel token and releases the lock shortly after. - h.runtime.spawn(async move { - if let Ok(mut wr) = halves.write.try_lock() { - let _ = wr.shutdown().await; - } - }); - } - 0 +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub extern "C" fn data_plane_tcp_close(handle: u64) -> c_int { + data_plane::data_plane_tcp_close(handle) } +/// Close a TCP data-plane listener handle. +/// +/// # Return +/// Returns `0` on success, or `-1` if the handle is missing, is not a TCP +/// listener handle, or data-plane calls are currently rejected. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] -pub extern "C" fn data_plane_tcp_listener_close(handle: u64) -> std::ffi::c_int { - let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| { - matches!(e.resource, DataPlaneResource::TcpListener(_)) - }) else { - set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) { - "handle is not a tcp listener" - } else { - "tcp listener handle not found" - }); - return -1; - }; - h.close_token.cancel(); - 0 +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub extern "C" fn data_plane_tcp_listener_close(handle: u64) -> c_int { + data_plane::data_plane_tcp_listener_close(handle) } +/// Bind a UDP socket through an EasyTier instance data plane. +/// +/// On success, writes the bound local address into `out_local_ip` and +/// `out_local_port`. The returned IP string is allocated by this library and +/// must be released with `free_string`. +/// /// # Safety -/// Bind a UDP socket through an EasyTier instance data plane. Returns 0 on -/// failure. The local address actually bound (which may differ from the -/// requested port when `local_port == 0`) is written into `out_local_ip` / -/// `out_local_port`; the caller must release `*out_local_ip` via `free_string`. +/// `inst_name`, `out_local_ip`, and `out_local_port` must be non-null. +/// `inst_name` must point to a null-terminated UTF-8 string. +/// +/// # Return +/// Returns a non-zero UDP socket handle on success, or `0` on failure. On +/// failure, call `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_udp_bind( - inst_name: *const std::ffi::c_char, - local_port: std::ffi::c_ushort, + inst_name: *const c_char, + local_port: c_ushort, timeout_ms: u64, - out_local_ip: *mut *const std::ffi::c_char, - out_local_port: *mut std::ffi::c_ushort, + out_local_ip: *mut *const c_char, + out_local_port: *mut c_ushort, ) -> u64 { - if out_local_ip.is_null() || out_local_port.is_null() { - set_error_msg("output pointer is null"); - return 0; - } - let Some(inst_name) = (unsafe { cstr_to_string(inst_name, "inst_name") }) else { - return 0; - }; - let Some(inst_id) = get_instance_id(&inst_name) else { - set_error_msg("instance not found"); - return 0; - }; - let deadline = std::time::Instant::now() + timeout_duration(timeout_ms); - let Some(runtime) = get_runtime_handle(&inst_id, deadline) else { - return 0; - }; - - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - let result = - runtime.block_on(INSTANCE_MANAGER.data_plane_udp_bind(&inst_id, local_port, remaining)); - match result { - Ok(socket) => { - let local_addr = socket.local_addr(); - let Some(local_ip) = into_ffi_ip_cstring(local_addr.ip()) else { - return 0; - }; - let handle = next_handle(); - DATA_PLANE_HANDLES.insert( - handle, - DataPlaneHandle { - instance_id: inst_id, - runtime, - close_token: CancellationToken::new(), - resource: DataPlaneResource::Udp(Arc::new(socket)), - }, - ); - unsafe { - *out_local_ip = local_ip as *const std::ffi::c_char; - *out_local_port = local_addr.port(); - } - handle - } - Err(e) => { - set_error_msg(&format!("failed to bind udp data plane: {}", e)); - 0 - } + unsafe { + data_plane::data_plane_udp_bind( + inst_name, + local_port, + timeout_ms, + out_local_ip, + out_local_port, + ) } } +/// Send one UDP datagram through a data-plane socket. +/// /// # Safety -/// Send a datagram through a UDP data-plane socket. +/// `handle` must be a valid UDP socket handle returned by +/// `data_plane_udp_bind`. `dst_ip` must be non-null and point to a +/// null-terminated UTF-8 string. `buf` must be non-null and readable for `len` +/// bytes. +/// +/// # Return +/// Returns the number of bytes sent, or `-1` on failure. On failure, call +/// `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_udp_send_to( handle: u64, - dst_ip: *const std::ffi::c_char, - dst_port: std::ffi::c_ushort, - buf: *const std::ffi::c_uchar, + dst_ip: *const c_char, + dst_port: c_ushort, + buf: *const c_uchar, len: u32, timeout_ms: u64, -) -> std::ffi::c_int { - if buf.is_null() { - set_error_msg("buf is null"); - return -1; - } - let Some(dst_ip) = (unsafe { cstr_to_string(dst_ip, "dst_ip") }) else { - return -1; - }; - let Some(dst_addr) = parse_socket_addr(&dst_ip, dst_port) else { - return -1; - }; - let Some((socket, runtime, close_token)) = get_udp_socket(handle) else { - return -1; - }; - let total = len as usize; - // Safety: caller-owned buffer outlives this blocking call. - let buf = unsafe { std::slice::from_raw_parts(buf, total) }; - runtime.block_on(async move { - match run_with_cancel( - &close_token, - timeout_ms, - "failed to send udp data plane", - socket.send_to(buf, dst_addr), - ) - .await - { - Some(Ok(n)) => n as std::ffi::c_int, - Some(Err(e)) => { - set_error_msg(&format!("failed to send udp data plane: {}", e)); - -1 - } - None => -1, - } - }) +) -> c_int { + unsafe { data_plane::data_plane_udp_send_to(handle, dst_ip, dst_port, buf, len, timeout_ms) } } +/// Receive one UDP datagram from a data-plane socket. +/// +/// On success, writes the peer address into `out_ip` and `out_port`. The +/// returned IP string is allocated by this library and must be released with +/// `free_string`. +/// /// # Safety -/// Receive a datagram from a UDP data-plane socket. +/// `handle` must be a valid UDP socket handle returned by +/// `data_plane_udp_bind`. `buf`, `out_ip`, and `out_port` must be non-null. +/// `buf` must be writable for `len` bytes. +/// +/// # Return +/// Returns the number of bytes received, or `-1` on failure. On failure, call +/// `get_error_msg` on the same thread to retrieve details. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] pub unsafe extern "C" fn data_plane_udp_recv_from( handle: u64, - buf: *mut std::ffi::c_uchar, + buf: *mut c_uchar, len: u32, - out_ip: *mut *const std::ffi::c_char, - out_port: *mut std::ffi::c_ushort, + out_ip: *mut *const c_char, + out_port: *mut c_ushort, timeout_ms: u64, -) -> std::ffi::c_int { - if buf.is_null() || out_ip.is_null() || out_port.is_null() { - set_error_msg("output pointer is null"); - return -1; - } - let Some((socket, runtime, close_token)) = get_udp_socket(handle) else { - return -1; - }; - let total = len as usize; - // Safety: caller-owned buffer outlives this blocking call. - let buf = unsafe { std::slice::from_raw_parts_mut(buf, total) }; - let ret = runtime.block_on(run_with_cancel( - &close_token, - timeout_ms, - "udp data plane receive", - socket.recv_from(buf), - )); - - match ret { - Some(Ok((n, addr))) => { - // The returned ip pointer must be released by the caller via - // `free_string` (which calls `CString::from_raw`, matching - // `CString::into_raw` here). - let Some(ip_cstr) = into_ffi_ip_cstring(addr.ip()) else { - return -1; - }; - unsafe { - *out_ip = ip_cstr as *const std::ffi::c_char; - *out_port = addr.port() as std::ffi::c_ushort; - } - n as std::ffi::c_int - } - Some(Err(e)) => { - set_error_msg(&format!("failed to receive udp data plane: {}", e)); - -1 - } - None => -1, - } +) -> c_int { + unsafe { data_plane::data_plane_udp_recv_from(handle, buf, len, out_ip, out_port, timeout_ms) } } +/// Close a UDP data-plane socket handle. +/// +/// # Return +/// Returns `0` on success, or `-1` if the handle is missing, is not a UDP +/// socket handle, or data-plane calls are currently rejected. #[cfg(feature = "ffi-dataplane")] -#[unsafe(no_mangle)] -pub extern "C" fn data_plane_udp_close(handle: u64) -> std::ffi::c_int { - let Some((_, h)) = DATA_PLANE_HANDLES.remove_if(&handle, |_, e| { - matches!(e.resource, DataPlaneResource::Udp(_)) - }) else { - set_error_msg(if DATA_PLANE_HANDLES.contains_key(&handle) { - "handle is not a udp socket" - } else { - "udp socket handle not found" - }); - return -1; - }; - h.close_token.cancel(); - 0 +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub extern "C" fn data_plane_udp_close(handle: u64) -> c_int { + data_plane::data_plane_udp_close(handle) } -#[cfg(test)] -mod tests { - use super::*; +// ===== Shared FFI Helper API ===== - #[test] - fn test_parse_config() { - let cfg_str = r#" - inst_name = "test" - network = "test_network" - "#; - let cstr = std::ffi::CString::new(cfg_str).unwrap(); - unsafe { - assert_eq!(parse_config(cstr.as_ptr()), 0); - } - } - - #[test] - fn test_run_network_instance() { - let cfg_str = r#" - inst_name = "test" - network = "test_network" - "#; - let cstr = std::ffi::CString::new(cfg_str).unwrap(); - unsafe { - assert_eq!(run_network_instance(cstr.as_ptr()), 0); - } - } +/// Return the last FFI error message. +/// +/// Synchronous API failures are stored in a thread-local buffer, so call this +/// on the same thread that received `-1` or `0` from another API. Config-server +/// callback delivery failures may happen on a runtime thread; those are stored +/// globally and are included here so direct FFI callers can still retrieve the +/// last callback error. If there is no error message, this writes a null pointer +/// to `out`. +/// +/// The returned string is allocated by this library and must be released with +/// `free_string`. +/// +/// # Safety +/// `out` must be a non-null pointer to writable storage for one C string +/// pointer. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub unsafe extern "C" fn get_error_msg(out: *mut *const c_char) { + unsafe { error::get_error_msg(out) } +} + +/// Release a C string allocated by this library. +/// +/// Use this for strings returned through `get_error_msg`, +/// `collect_network_infos`, and data-plane address output parameters. Passing a +/// null pointer is allowed and has no effect. +#[cfg_attr(feature = "c-abi", unsafe(no_mangle))] +pub extern "C" fn free_string(s: *const c_char) { + error::free_string(s) } diff --git a/easytier-contrib/easytier-ffi/src/state.rs b/easytier-contrib/easytier-ffi/src/state.rs new file mode 100644 index 00000000..706e8b45 --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/state.rs @@ -0,0 +1,54 @@ +use std::sync::{Arc, Mutex}; + +use dashmap::DashMap; +use easytier::instance_manager::NetworkInstanceManager; +use tokio::runtime::{Builder, Runtime}; +use uuid::Uuid; + +pub(crate) static INSTANCE_NAME_ID_MAP: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(DashMap::new); +pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new())); +pub(crate) static ASYNC_RUNTIME: once_cell::sync::Lazy = + once_cell::sync::Lazy::new(|| { + Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime for easytier-ffi") + }); +pub(crate) static INSTANCE_MUTATION_LOCK: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Mutex::new(())); + +pub(crate) fn remove_instance_name_ids(ids: &[Uuid]) { + if ids.is_empty() { + return; + } + + INSTANCE_NAME_ID_MAP.retain(|_, instance_id| !ids.contains(instance_id)); +} + +pub(crate) fn lock_remote_instance_mutation() -> tokio::sync::OwnedMutexGuard<()> { + INSTANCE_MANAGER + .remote_mutation_lock() + .blocking_lock_owned() +} + +pub(crate) fn instance_name_exists(inst_name: &str) -> bool { + find_instance_id_by_name(inst_name).is_some() +} + +pub(crate) fn find_instance_id_by_name(inst_name: &str) -> Option { + INSTANCE_NAME_ID_MAP + .get(inst_name) + .map(|id| *id) + .or_else(|| { + INSTANCE_MANAGER + .list_network_instance_ids() + .into_iter() + .find(|id| { + INSTANCE_MANAGER + .get_instance_name(id) + .is_some_and(|name| name == inst_name) + }) + }) +} diff --git a/easytier-contrib/easytier-ffi/src/strings.rs b/easytier-contrib/easytier-ffi/src/strings.rs new file mode 100644 index 00000000..cb61d0f3 --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/strings.rs @@ -0,0 +1,23 @@ +use std::ffi::{CStr, c_char}; + +pub(crate) unsafe fn c_str_to_string(ptr: *const c_char, name: &str) -> Result { + if ptr.is_null() { + return Err(format!("{} is null", name)); + } + + unsafe { CStr::from_ptr(ptr) } + .to_str() + .map(|value| value.to_string()) + .map_err(|err| format!("{} is not valid UTF-8: {}", name, err)) +} + +pub(crate) unsafe fn optional_c_str_to_string( + ptr: *const c_char, + name: &str, +) -> Result, String> { + if ptr.is_null() { + return Ok(None); + } + + unsafe { c_str_to_string(ptr, name) }.map(Some) +} diff --git a/easytier-contrib/easytier-ffi/src/tests.rs b/easytier-contrib/easytier-ffi/src/tests.rs new file mode 100644 index 00000000..e327ab6d --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/tests.rs @@ -0,0 +1,477 @@ +use crate::{ + config_server::{ + ConfigServerCallbackScope, ManagedConfigServerClientHooks, set_active_for_test, + }, + state::{ + INSTANCE_MANAGER, INSTANCE_NAME_ID_MAP, find_instance_id_by_name, + lock_remote_instance_mutation, remove_instance_name_ids, + }, + *, +}; +use easytier::{ + common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader}, + web_client::WebClientHooks, +}; +use serde_json::Value; +use std::{ + collections::HashSet, + ffi::{CStr, CString, c_char, c_void}, + sync::{Mutex, mpsc}, + time::Duration, +}; +use uuid::Uuid; + +#[test] +fn test_parse_config() { + let cfg_str = r#" + inst_name = "test" + network = "test_network" + "#; + let cstr = std::ffi::CString::new(cfg_str).unwrap(); + unsafe { + assert_eq!(parse_config(cstr.as_ptr()), 0); + } +} + +#[test] +fn test_run_network_instance() { + let cfg_str = r#" + inst_name = "test" + network = "test_network" + "#; + let cstr = std::ffi::CString::new(cfg_str).unwrap(); + unsafe { + assert_eq!(run_network_instance(cstr.as_ptr()), 0); + } +} + +#[test] +fn get_error_msg_returns_config_server_callback_error() { + let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut()); + let callback_error = format!("callback delivery failed {}", Uuid::new_v4()); + crate::config_server::clear_last_callback_error(); + hooks.note_callback_error(callback_error.clone()); + + unsafe { + let mut error_ptr: *const c_char = std::ptr::null(); + get_error_msg(&mut error_ptr); + assert!(!error_ptr.is_null()); + let error_msg = CStr::from_ptr(error_ptr).to_string_lossy().into_owned(); + free_string(error_ptr); + assert!(error_msg.contains(&callback_error)); + } + + crate::config_server::clear_last_callback_error(); +} + +unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_data: *mut c_void) { + let events = unsafe { &*(user_data as *const Mutex>) }; + events.lock().unwrap().push( + unsafe { CStr::from_ptr(event_json) } + .to_string_lossy() + .into_owned(), + ); +} + +#[tokio::test] +async fn config_server_hooks_emit_run_event() { + let events: Mutex> = Mutex::new(Vec::new()); + let hooks = ManagedConfigServerClientHooks::new( + Some(record_config_server_event), + &events as *const _ as *mut c_void, + ); + let instance_id = Uuid::new_v4(); + let cfg = TomlConfigLoader::default(); + cfg.set_id(instance_id); + let inst_name = format!("test-{}", instance_id); + cfg.set_inst_name(inst_name.clone()); + hooks.pre_run_network_instance(&cfg).await.unwrap(); + INSTANCE_MANAGER + .run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) + .unwrap(); + + hooks.post_run_network_instance(&instance_id).await.unwrap(); + + let duplicate_cfg = TomlConfigLoader::default(); + duplicate_cfg.set_inst_name(inst_name); + duplicate_cfg.set_id(Uuid::new_v4()); + assert!( + hooks + .pre_run_network_instance(&duplicate_cfg) + .await + .is_err() + ); + + assert_eq!(hooks.tracked_instance_ids(), vec![instance_id]); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 1); + let event: Value = serde_json::from_str(&events[0]).unwrap(); + assert_eq!(event["event"], "run_network_instance"); + assert_eq!(event["success"], true); + assert_eq!(event["instance_id"], instance_id.to_string()); + assert!(event["error"].is_null()); + INSTANCE_MANAGER + .delete_network_instance(vec![instance_id]) + .unwrap(); + remove_instance_name_ids(&[instance_id]); +} + +#[tokio::test] +async fn config_server_hooks_emit_delete_events_for_tracked_instances() { + let events: Mutex> = Mutex::new(Vec::new()); + let hooks = ManagedConfigServerClientHooks::new( + Some(record_config_server_event), + &events as *const _ as *mut c_void, + ); + let instance_id_1 = Uuid::new_v4(); + let instance_id_2 = Uuid::new_v4(); + let unknown_instance_id = Uuid::new_v4(); + for id in [instance_id_1, instance_id_2] { + let cfg = TomlConfigLoader::default(); + cfg.set_id(id); + cfg.set_inst_name(format!("test-{}", id)); + hooks.pre_run_network_instance(&cfg).await.unwrap(); + INSTANCE_MANAGER + .run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) + .unwrap(); + } + + hooks + .post_run_network_instance(&instance_id_1) + .await + .unwrap(); + hooks + .post_run_network_instance(&instance_id_2) + .await + .unwrap(); + events.lock().unwrap().clear(); + + hooks + .post_remove_network_instances(&[instance_id_1, unknown_instance_id, instance_id_2]) + .await + .unwrap(); + + assert!(hooks.tracked_instance_ids().is_empty()); + let events = events.lock().unwrap(); + assert_eq!(events.len(), 2); + let event_ids = events + .iter() + .map(|event| { + let event: Value = serde_json::from_str(event).unwrap(); + assert_eq!(event["event"], "delete_network_instance"); + assert_eq!(event["success"], true); + assert!(event["error"].is_null()); + event["instance_id"].as_str().unwrap().to_string() + }) + .collect::>(); + assert_eq!( + event_ids, + HashSet::from([instance_id_1.to_string(), instance_id_2.to_string()]) + ); + INSTANCE_MANAGER + .delete_network_instance(vec![instance_id_1, instance_id_2]) + .unwrap(); + remove_instance_name_ids(&[instance_id_1, instance_id_2]); +} + +#[tokio::test] +async fn config_server_hooks_remove_untracked_name_mapping_without_event() { + let events: Mutex> = Mutex::new(Vec::new()); + let hooks = ManagedConfigServerClientHooks::new( + Some(record_config_server_event), + &events as *const _ as *mut c_void, + ); + let local_id = Uuid::new_v4(); + let inst_name = format!("local-{}", local_id); + INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), local_id); + + hooks + .post_remove_network_instances(&[local_id]) + .await + .unwrap(); + + assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none()); + assert!(events.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn config_server_hooks_reject_duplicate_instance_name() { + let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut()); + let inst_name = format!("test-{}", Uuid::new_v4()); + let existing_id = Uuid::new_v4(); + let new_id = Uuid::new_v4(); + INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), existing_id); + + let cfg = TomlConfigLoader::default(); + cfg.set_inst_name(inst_name.clone()); + cfg.set_id(new_id); + + assert!(hooks.pre_run_network_instance(&cfg).await.is_err()); + assert_eq!(*INSTANCE_NAME_ID_MAP.get(&inst_name).unwrap(), existing_id); + INSTANCE_NAME_ID_MAP.remove(&inst_name); +} + +#[tokio::test] +async fn config_server_hooks_remove_overwritten_id_before_duplicate_name_error() { + let events: Mutex> = Mutex::new(Vec::new()); + let hooks = ManagedConfigServerClientHooks::new( + Some(record_config_server_event), + &events as *const _ as *mut c_void, + ); + let old_name = format!("old-{}", Uuid::new_v4()); + let duplicate_name = format!("duplicate-{}", Uuid::new_v4()); + let overwritten_id = Uuid::new_v4(); + let duplicate_id = Uuid::new_v4(); + hooks.instance_ids.lock().unwrap().insert(overwritten_id); + INSTANCE_NAME_ID_MAP.insert(old_name.clone(), overwritten_id); + INSTANCE_NAME_ID_MAP.insert(duplicate_name.clone(), duplicate_id); + + hooks + .post_remove_network_instances(&[overwritten_id]) + .await + .unwrap(); + + let cfg = TomlConfigLoader::default(); + cfg.set_inst_name(duplicate_name.clone()); + cfg.set_id(overwritten_id); + + assert!(hooks.pre_run_network_instance(&cfg).await.is_err()); + assert!(hooks.tracked_instance_ids().is_empty()); + assert!(INSTANCE_NAME_ID_MAP.get(&old_name).is_none()); + assert_eq!( + *INSTANCE_NAME_ID_MAP.get(&duplicate_name).unwrap(), + duplicate_id + ); + assert_eq!(events.lock().unwrap().len(), 1); + INSTANCE_NAME_ID_MAP.remove(&duplicate_name); +} + +#[tokio::test] +async fn config_server_hooks_remove_tracked_state_before_overwrite_retry() { + let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut()); + let inst_name = format!("test-{}", Uuid::new_v4()); + let instance_id = Uuid::new_v4(); + hooks.instance_ids.lock().unwrap().insert(instance_id); + INSTANCE_NAME_ID_MAP.insert(inst_name.clone(), instance_id); + + let cfg = TomlConfigLoader::default(); + cfg.set_inst_name(inst_name.clone()); + cfg.set_id(instance_id); + + hooks + .post_remove_network_instances(&[instance_id]) + .await + .unwrap(); + hooks.pre_run_network_instance(&cfg).await.unwrap(); + + assert!(hooks.tracked_instance_ids().is_empty()); + assert!(INSTANCE_NAME_ID_MAP.get(&inst_name).is_none()); +} + +#[tokio::test] +async fn config_server_hooks_reject_post_run_after_external_delete() { + let hooks = ManagedConfigServerClientHooks::new(None, std::ptr::null_mut()); + let instance_id = Uuid::new_v4(); + let cfg = TomlConfigLoader::default(); + cfg.set_id(instance_id); + cfg.set_inst_name(format!("test-{}", instance_id)); + hooks.pre_run_network_instance(&cfg).await.unwrap(); + INSTANCE_MANAGER + .run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) + .unwrap(); + INSTANCE_MANAGER + .delete_network_instance(vec![instance_id]) + .unwrap(); + + assert!(hooks.post_run_network_instance(&instance_id).await.is_err()); +} + +#[test] +fn find_instance_id_by_name_resolves_uncommitted_manager_instance_name() { + let instance_id = Uuid::new_v4(); + let inst_name = format!("test-{}", instance_id); + let cfg = TomlConfigLoader::default(); + cfg.set_id(instance_id); + cfg.set_inst_name(inst_name.clone()); + INSTANCE_MANAGER + .run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) + .unwrap(); + + assert_eq!(find_instance_id_by_name(&inst_name), Some(instance_id)); + INSTANCE_MANAGER + .delete_network_instance(vec![instance_id]) + .unwrap(); + remove_instance_name_ids(&[instance_id]); +} + +#[test] +fn ffi_remote_mutation_lock_uses_manager_lock() { + let manager_guard = INSTANCE_MANAGER + .remote_mutation_lock() + .blocking_lock_owned(); + let (done_tx, done_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _ffi_guard = lock_remote_instance_mutation(); + done_tx.send(()).unwrap(); + }); + + assert!(done_rx.recv_timeout(Duration::from_millis(100)).is_err()); + drop(manager_guard); + done_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + waiter.join().unwrap(); +} + +#[tokio::test] +async fn config_server_hooks_suppress_late_run_events_while_stopping() { + let events: Mutex> = Mutex::new(Vec::new()); + let hooks = ManagedConfigServerClientHooks::new( + Some(record_config_server_event), + &events as *const _ as *mut c_void, + ); + hooks.start_stopping(); + + hooks + .post_run_network_instance(&Uuid::new_v4()) + .await + .unwrap(); + + assert!(hooks.tracked_instance_ids().is_empty()); + assert!(events.lock().unwrap().is_empty()); +} + +#[test] +fn config_server_callback_context_rejects_nested_blocking_ffi_calls() { + let _callback_scope = ConfigServerCallbackScope::enter(); + assert_eq!(is_config_server_client_connected(), 0); + assert_eq!( + unsafe { collect_network_infos(std::ptr::null_mut(), 0) }, + -1 + ); + let cfg = CString::new("inst_name = \"callback-test\"\nlisteners = []").unwrap(); + assert_eq!(unsafe { run_network_instance(cfg.as_ptr()) }, -1); + assert_eq!(unsafe { retain_network_instance(std::ptr::null(), 0) }, -1); + let url = CString::new("ring://test/token").unwrap(); + let machine_id = CString::new("test-machine").unwrap(); + assert_eq!( + unsafe { + start_config_server_client( + url.as_ptr(), + std::ptr::null(), + machine_id.as_ptr(), + false, + None, + std::ptr::null_mut(), + ) + }, + -1 + ); + assert_eq!(stop_config_server_client(), -1); + + #[cfg(feature = "ffi-dataplane")] + { + assert_eq!( + unsafe { + data_plane_tcp_connect( + std::ptr::null(), + std::ptr::null(), + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + unsafe { + data_plane_tcp_bind( + std::ptr::null(), + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + unsafe { + data_plane_tcp_accept( + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) }, + -1 + ); + assert_eq!( + unsafe { data_plane_tcp_write(0, std::ptr::null(), 0, 0) }, + -1 + ); + assert_eq!(data_plane_tcp_close(0), -1); + assert_eq!(data_plane_tcp_listener_close(0), -1); + assert_eq!( + unsafe { + data_plane_udp_bind( + std::ptr::null(), + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + unsafe { data_plane_udp_send_to(0, std::ptr::null(), 0, std::ptr::null(), 0, 0) }, + -1 + ); + assert_eq!( + unsafe { + data_plane_udp_recv_from( + 0, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + 0, + ) + }, + -1 + ); + assert_eq!(data_plane_udp_close(0), -1); + } +} + +#[cfg(feature = "ffi-dataplane")] +#[test] +fn active_config_server_rejects_data_plane() { + set_active_for_test(true); + + assert_eq!( + unsafe { + data_plane_tcp_connect( + std::ptr::null(), + std::ptr::null(), + 0, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }, + 0 + ); + assert_eq!( + unsafe { data_plane_tcp_read(0, std::ptr::null_mut(), 0, 0) }, + -1 + ); + + set_active_for_test(false); +} diff --git a/easytier-contrib/easytier-ffi/src/types.rs b/easytier-contrib/easytier-ffi/src/types.rs new file mode 100644 index 00000000..7701bc97 --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/types.rs @@ -0,0 +1,10 @@ +use std::ffi::{c_char, c_void}; + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct KeyValuePair { + pub key: *const c_char, + pub value: *const c_char, +} + +pub type ConfigServerEventCallback = Option; diff --git a/easytier/src/instance_manager.rs b/easytier/src/instance_manager.rs index e708c89f..a28f310a 100644 --- a/easytier/src/instance_manager.rs +++ b/easytier/src/instance_manager.rs @@ -34,6 +34,7 @@ pub struct NetworkInstanceManager { instance_error_messages: Arc>, config_dir: Option, guard_counter: Arc<()>, + remote_mutation_lock: Arc>, } impl Default for NetworkInstanceManager { @@ -51,6 +52,7 @@ impl NetworkInstanceManager { instance_error_messages: Arc::new(DashMap::new()), config_dir: None, guard_counter: Arc::new(()), + remote_mutation_lock: Arc::new(tokio::sync::Mutex::new(())), } } @@ -59,6 +61,10 @@ impl NetworkInstanceManager { self } + pub fn remote_mutation_lock(&self) -> Arc> { + self.remote_mutation_lock.clone() + } + fn start_instance_task(&self, instance_id: uuid::Uuid) -> Result<(), anyhow::Error> { if tokio::runtime::Handle::try_current().is_err() { return Err(anyhow::anyhow!( @@ -272,6 +278,12 @@ impl NetworkInstanceManager { .map(|instance| instance.value().get_config_file_control().clone()) } + pub fn get_instance_config(&self, instance_id: &uuid::Uuid) -> Option { + self.instance_map + .get(instance_id) + .map(|instance| instance.value().get_config()) + } + pub fn get_instance_network_config_source( &self, instance_id: &uuid::Uuid, diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index a5662475..9b7d4f4a 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -500,6 +500,10 @@ impl NetworkInstance { &self.config_file_control } + pub fn get_config(&self) -> TomlConfigLoader { + self.config.clone() + } + pub fn get_network_config_source(&self) -> ConfigSource { self.config.get_network_config_source() } diff --git a/easytier/src/rpc_service/instance_manage.rs b/easytier/src/rpc_service/instance_manage.rs index af134cc9..3dd038d4 100644 --- a/easytier/src/rpc_service/instance_manage.rs +++ b/easytier/src/rpc_service/instance_manage.rs @@ -1,7 +1,9 @@ use std::{collections::HashSet, sync::Arc}; use crate::{ - common::config::{ConfigFileControl, ConfigFilePermission, ConfigLoader, ConfigSource}, + common::config::{ + ConfigFileControl, ConfigFilePermission, ConfigLoader, ConfigSource, TomlConfigLoader, + }, instance_manager::NetworkInstanceManager, proto::{ api::{ @@ -27,11 +29,157 @@ use crate::{ pub struct InstanceManageRpcService { manager: Arc, hooks: Arc, + remote_mutation_lock: Arc>, } impl InstanceManageRpcService { pub fn new(manager: Arc, hooks: Arc) -> Self { - Self { manager, hooks } + let remote_mutation_lock = manager.remote_mutation_lock(); + Self { + manager, + hooks, + remote_mutation_lock, + } + } +} + +async fn is_remote_removable(control: &ConfigFileControl) -> bool { + if control.is_read_only() || !control.is_deletable() { + return false; + } + let Some(path) = control.path.as_ref() else { + return true; + }; + + !ConfigFileControl::from_path(path.clone()) + .await + .is_read_only() +} + +async fn ensure_remote_overwritable( + inst_id: uuid::Uuid, + control: &ConfigFileControl, +) -> anyhow::Result<()> { + if control.is_read_only() { + return Err(anyhow::anyhow!( + "instance {} is read-only, cannot be overwritten", + inst_id + )); + } + if !control.is_deletable() { + return Err(anyhow::anyhow!( + "instance {} is no-delete, cannot be overwritten", + inst_id + )); + } + + if let Some(path) = control.path.as_ref() { + let real_control = ConfigFileControl::from_path(path.clone()).await; + if real_control.is_read_only() { + return Err(anyhow::anyhow!( + "config file {} is read-only, cannot be overwritten", + path.display() + )); + } + } + + Ok(()) +} + +async fn ensure_overwritable( + inst_id: uuid::Uuid, + control: &ConfigFileControl, +) -> anyhow::Result<()> { + if control.is_read_only() { + return Err(anyhow::anyhow!( + "instance {} is read-only, cannot be overwritten", + inst_id + )); + } + + if let Some(path) = control.path.as_ref() { + let real_control = ConfigFileControl::from_path(path.clone()).await; + if real_control.is_read_only() { + return Err(anyhow::anyhow!( + "config file {} is read-only, cannot be overwritten", + path.display() + )); + } + } + + Ok(()) +} + +struct StartedInstanceCleanup { + manager: Arc, + started_inst_id: Option, + config_file_cleanup: Option, + restore_instance: Option<(TomlConfigLoader, ConfigFileControl)>, + armed: bool, +} + +enum ConfigFileCleanup { + Remove(std::path::PathBuf), + Restore { + path: std::path::PathBuf, + contents: Vec, + }, +} + +impl ConfigFileCleanup { + fn apply(self) { + match self { + Self::Remove(config_file) => { + let _ = std::fs::remove_file(config_file); + } + Self::Restore { path, contents } => { + if let Err(e) = std::fs::write(&path, contents) { + tracing::warn!("failed to restore config file {}: {}", path.display(), e); + } + } + } + } +} + +impl StartedInstanceCleanup { + fn new( + manager: Arc, + config_file_cleanup: Option, + restore_instance: Option<(TomlConfigLoader, ConfigFileControl)>, + ) -> Self { + Self { + manager, + started_inst_id: None, + config_file_cleanup, + restore_instance, + armed: true, + } + } + + fn mark_started(&mut self, inst_id: uuid::Uuid) { + self.started_inst_id = Some(inst_id); + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for StartedInstanceCleanup { + fn drop(&mut self) { + if self.armed { + if let Some(inst_id) = self.started_inst_id { + let _ = self.manager.delete_network_instance(vec![inst_id]); + } + if let Some(config_file_cleanup) = self.config_file_cleanup.take() { + config_file_cleanup.apply(); + } + if let Some((cfg, control)) = self.restore_instance.take() + && let Err(e) = self.manager.run_network_instance(cfg, true, control) + { + tracing::warn!("failed to restore overwritten instance: {}", e); + } + } } } @@ -66,7 +214,11 @@ impl WebClientService for InstanceManageRpcService { let resp = RunNetworkInstanceResponse { inst_id: Some(effective_id.into()), }; + let _mutation_guard = self.remote_mutation_lock.lock().await; + let managed_remote = self.hooks.manages_remote_config_instances(); + let mut overwrite_existing = false; + let mut restore_instance = None; let mut control = if let Some(control) = self.manager.get_instance_config_control(&effective_id) { let existing_source = self @@ -82,28 +234,18 @@ impl WebClientService for InstanceManageRpcService { if !req.overwrite && error_msg.is_empty() { return Ok(resp); } - if control.is_read_only() { - return Err(anyhow::anyhow!( - "instance {} is read-only, cannot be overwritten", - effective_id - ) - .into()); + if managed_remote { + ensure_remote_overwritable(effective_id, &control).await?; + } else { + ensure_overwritable(effective_id, &control).await?; } - if let Some(path) = control.path.as_ref() { - let real_control = ConfigFileControl::from_path(path.clone()).await; - if real_control.is_read_only() { - return Err(anyhow::anyhow!( - "config file {} is read-only, cannot be overwritten", - path.display() - ) - .into()); - } - } - - self.manager.delete_network_instance(vec![effective_id])?; - cfg.set_network_config_source(requested_source.or(existing_source)); + overwrite_existing = true; + restore_instance = self + .manager + .get_instance_config(&effective_id) + .map(|cfg| (cfg, control.clone())); control.clone() } else if let Some(config_dir) = self.manager.get_config_dir() { cfg.set_network_config_source(requested_source); @@ -116,28 +258,78 @@ impl WebClientService for InstanceManageRpcService { ConfigFileControl::new(None, ConfigFilePermission::default()) }; - if !control.is_read_only() - && let Some(config_file) = control.path.as_ref() - && let Err(e) = std::fs::write(config_file, cfg.dump()) - { - tracing::warn!( - "failed to write config file {}: {}", - config_file.display(), - e - ); - control.set_read_only(true); - } - if let Err(e) = self.hooks.pre_run_network_instance(&cfg).await { return Err(anyhow::anyhow!("pre-run hook failed: {}", e).into()); } - self.manager.run_network_instance(cfg, true, control)?; + if overwrite_existing { + if managed_remote { + ensure_remote_overwritable(effective_id, &control).await?; + } else { + ensure_overwritable(effective_id, &control).await?; + } + } + + let mut config_file_cleanup = None; + if !control.is_read_only() + && let Some(config_file) = control.path.as_ref() + { + let cleanup = if config_file.exists() { + match std::fs::read(config_file) { + Ok(contents) => Some(ConfigFileCleanup::Restore { + path: config_file.clone(), + contents, + }), + Err(e) => { + return Err(anyhow::anyhow!( + "failed to backup config file {} before overwrite: {}", + config_file.display(), + e + ) + .into()); + } + } + } else { + Some(ConfigFileCleanup::Remove(config_file.clone())) + }; + match std::fs::write(config_file, cfg.dump()) { + Ok(()) => { + config_file_cleanup = cleanup; + } + Err(e) => { + tracing::warn!( + "failed to write config file {}: {}", + config_file.display(), + e + ); + control.set_read_only(true); + } + } + } + + let mut started_instance = StartedInstanceCleanup::new( + self.manager.clone(), + config_file_cleanup, + restore_instance, + ); + + if overwrite_existing { + self.manager.delete_network_instance(vec![effective_id])?; + } + + if let Err(e) = self.manager.run_network_instance(cfg, true, control) { + return Err(e.into()); + } + started_instance.mark_started(effective_id); println!("instance {} started", effective_id); if let Err(e) = self.hooks.post_run_network_instance(&effective_id).await { + if managed_remote { + return Err(anyhow::anyhow!("post-run hook failed: {}", e).into()); + } tracing::warn!("post-run hook failed: {}", e); } + started_instance.disarm(); Ok(resp) } @@ -147,10 +339,45 @@ impl WebClientService for InstanceManageRpcService { _: BaseController, req: RetainNetworkInstanceRequest, ) -> Result { + let _mutation_guard = self.remote_mutation_lock.lock().await; + if !self.hooks.manages_remote_config_instances() { + let remain = self + .manager + .retain_network_instance(req.inst_ids.into_iter().map(Into::into).collect())?; + println!("instance {:?} retained", remain); + return Ok(RetainNetworkInstanceResponse { + remain_inst_ids: remain.iter().map(|item| (*item).into()).collect(), + }); + } + + let mut retain_id_set = req + .inst_ids + .into_iter() + .map(Into::into) + .collect::>(); + let mut removed_ids = Vec::new(); + for (instance_id, control) in self + .manager + .iter() + .map(|instance| (*instance.key(), instance.get_config_file_control().clone())) + .collect::>() + { + if retain_id_set.contains(&instance_id) { + continue; + } + if is_remote_removable(&control).await { + removed_ids.push(instance_id); + } else { + retain_id_set.insert(instance_id); + } + } let remain = self .manager - .retain_network_instance(req.inst_ids.into_iter().map(Into::into).collect())?; + .retain_network_instance(retain_id_set.into_iter().collect())?; println!("instance {:?} retained", remain); + if let Err(e) = self.hooks.post_remove_network_instances(&removed_ids).await { + return Err(anyhow::anyhow!("post-remove hook failed: {}", e).into()); + } Ok(RetainNetworkInstanceResponse { remain_inst_ids: remain.iter().map(|item| (*item).into()).collect(), }) @@ -213,17 +440,60 @@ impl WebClientService for InstanceManageRpcService { _: BaseController, req: DeleteNetworkInstanceRequest, ) -> Result { + let _mutation_guard = self.remote_mutation_lock.lock().await; let inst_ids: HashSet = req.inst_ids.into_iter().map(Into::into).collect(); - let hook_ids: Vec = inst_ids.iter().cloned().collect(); + if !self.hooks.manages_remote_config_instances() { + let hook_ids: Vec = inst_ids.iter().cloned().collect(); + let inst_ids = self + .manager + .iter() + .filter(|v| inst_ids.contains(v.key())) + .filter(|v| v.get_config_file_control().is_deletable()) + .map(|v| *v.key()) + .collect::>(); + let config_files = inst_ids + .iter() + .filter_map(|id| { + self.manager + .get_instance_config_control(id) + .and_then(|control| control.path) + }) + .collect::>(); + let remain_inst_ids = self.manager.delete_network_instance(inst_ids)?; + println!("instance {:?} retained", remain_inst_ids); - let inst_ids = self + if let Err(e) = self.hooks.post_remove_network_instances(&hook_ids).await { + tracing::warn!("post-remove hook failed: {}", e); + } + + for config_file in config_files { + if let Err(e) = std::fs::remove_file(&config_file) { + tracing::warn!( + "failed to remove config file {}: {}", + config_file.display(), + e + ); + } + } + return Ok(DeleteNetworkInstanceResponse { + remain_inst_ids: remain_inst_ids.into_iter().map(Into::into).collect(), + }); + } + + let mut deletable_inst_ids = Vec::new(); + for (instance_id, control) in self .manager .iter() .filter(|v| inst_ids.contains(v.key())) - .filter(|v| v.get_config_file_control().is_deletable()) - .map(|v| *v.key()) - .collect::>(); + .map(|instance| (*instance.key(), instance.get_config_file_control().clone())) + .collect::>() + { + if is_remote_removable(&control).await { + deletable_inst_ids.push(instance_id); + } + } + let inst_ids = deletable_inst_ids; let config_files = inst_ids .iter() .filter_map(|id| { @@ -232,14 +502,21 @@ impl WebClientService for InstanceManageRpcService { .and_then(|control| control.path) }) .collect::>(); + let hook_ids = inst_ids.clone(); let remain_inst_ids = self.manager.delete_network_instance(inst_ids)?; println!("instance {:?} retained", remain_inst_ids); if let Err(e) = self.hooks.post_remove_network_instances(&hook_ids).await { - tracing::warn!("post-remove hook failed: {}", e); + return Err(anyhow::anyhow!("post-remove hook failed: {}", e).into()); } for config_file in config_files { + if ConfigFileControl::from_path(config_file.clone()) + .await + .is_read_only() + { + continue; + } if let Err(e) = std::fs::remove_file(&config_file) { tracing::warn!( "failed to remove config file {}: {}", @@ -328,3 +605,697 @@ impl WebClientService for InstanceManageRpcService { Ok(ListNetworkInstanceMetaResponse { metas }) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::config::TomlConfigLoader; + use crate::proto::api::manage::{NetworkConfig, NetworkingMethod}; + use crate::web_client::DefaultHooks; + use std::{path::PathBuf, sync::Mutex}; + use uuid::Uuid; + + #[derive(Default)] + struct RecordingHooks { + run_ids: Mutex>, + removed_ids: Mutex>>, + reject_pre_run: bool, + reject_post_run: bool, + reject_post_remove: bool, + readonly_on_pre_run: Mutex>, + readonly_on_post_remove: Mutex>, + } + + #[async_trait::async_trait] + impl WebClientHooks for RecordingHooks { + fn manages_remote_config_instances(&self) -> bool { + true + } + + async fn pre_run_network_instance(&self, _cfg: &TomlConfigLoader) -> Result<(), String> { + for path in self.readonly_on_pre_run.lock().unwrap().drain(..) { + set_file_readonly(&path, true); + } + if self.reject_pre_run { + Err("pre-run rejected".to_string()) + } else { + Ok(()) + } + } + + async fn post_run_network_instance(&self, _id: &Uuid) -> Result<(), String> { + if self.reject_post_run { + Err("post-run rejected".to_string()) + } else { + self.run_ids.lock().unwrap().push(*_id); + Ok(()) + } + } + + async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> { + if self.reject_post_remove { + return Err("post-remove rejected".to_string()); + } + self.removed_ids.lock().unwrap().push(ids.to_vec()); + for path in self.readonly_on_post_remove.lock().unwrap().drain(..) { + set_file_readonly(&path, true); + } + Ok(()) + } + } + + fn temp_config_path(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "easytier-instance-manage-{}-{}.toml", + test_name, + Uuid::new_v4() + )) + } + + fn set_file_readonly(path: &PathBuf, readonly: bool) { + let mut permissions = std::fs::metadata(path).unwrap().permissions(); + permissions.set_readonly(readonly); + std::fs::set_permissions(path, permissions).unwrap(); + } + + fn cleanup_temp_config(path: &PathBuf) { + if path.exists() { + set_file_readonly(path, false); + let _ = std::fs::remove_file(path); + } + } + + #[tokio::test] + async fn retain_network_instance_preserves_protected_and_reports_actual_removals() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks::default()); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + + let no_delete_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::STATIC_CONFIG, + ) + .unwrap(); + let read_only_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + None, + ConfigFilePermission::default().with_flag(ConfigFilePermission::READ_ONLY), + ), + ) + .unwrap(); + let stale_readonly_path = temp_config_path("retain"); + std::fs::write(&stale_readonly_path, "listeners = []").unwrap(); + let stale_readonly_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + Some(stale_readonly_path.clone()), + ConfigFilePermission::default(), + ), + ) + .unwrap(); + set_file_readonly(&stale_readonly_path, true); + let deletable_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + let removed_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + let response = service + .retain_network_instance( + BaseController::default(), + RetainNetworkInstanceRequest { + inst_ids: vec![deletable_id.into()], + }, + ) + .await + .unwrap(); + let remain_ids = response + .remain_inst_ids + .into_iter() + .map(Into::into) + .collect::>(); + + assert_eq!( + remain_ids, + HashSet::from([no_delete_id, read_only_id, stale_readonly_id, deletable_id]) + ); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([no_delete_id, read_only_id, stale_readonly_id, deletable_id]) + ); + assert_eq!( + hooks.removed_ids.lock().unwrap().as_slice(), + &[vec![removed_id]] + ); + cleanup_temp_config(&stale_readonly_path); + } + + #[tokio::test] + async fn retain_network_instance_with_default_hooks_keeps_existing_api_behavior() { + let manager = Arc::new(NetworkInstanceManager::new()); + let service = InstanceManageRpcService::new(manager.clone(), Arc::new(DefaultHooks)); + + let protected_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::STATIC_CONFIG, + ) + .unwrap(); + let retained_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + let response = service + .retain_network_instance( + BaseController::default(), + RetainNetworkInstanceRequest { + inst_ids: vec![retained_id.into()], + }, + ) + .await + .unwrap(); + let remain_ids = response + .remain_inst_ids + .into_iter() + .map(Into::into) + .collect::>(); + + assert_eq!(remain_ids, HashSet::from([retained_id])); + assert!(!manager.list_network_instance_ids().contains(&protected_id)); + } + + #[tokio::test] + async fn retain_network_instance_reports_post_remove_state_failures() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks { + reject_post_remove: true, + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + let _removed_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + let result = service + .retain_network_instance( + BaseController::default(), + RetainNetworkInstanceRequest { inst_ids: vec![] }, + ) + .await; + + assert!(result.is_err()); + assert!(manager.list_network_instance_ids().is_empty()); + assert!(hooks.removed_ids.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn delete_network_instance_preserves_protected_and_reports_actual_removals() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks::default()); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + + let no_delete_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::STATIC_CONFIG, + ) + .unwrap(); + let read_only_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + None, + ConfigFilePermission::default().with_flag(ConfigFilePermission::READ_ONLY), + ), + ) + .unwrap(); + let stale_readonly_path = temp_config_path("delete"); + std::fs::write(&stale_readonly_path, "listeners = []").unwrap(); + let stale_readonly_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + Some(stale_readonly_path.clone()), + ConfigFilePermission::default(), + ), + ) + .unwrap(); + set_file_readonly(&stale_readonly_path, true); + let removed_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + let response = service + .delete_network_instance( + BaseController::default(), + DeleteNetworkInstanceRequest { + inst_ids: vec![ + no_delete_id.into(), + read_only_id.into(), + stale_readonly_id.into(), + removed_id.into(), + ], + }, + ) + .await + .unwrap(); + let remain_ids = response + .remain_inst_ids + .into_iter() + .map(Into::into) + .collect::>(); + + assert_eq!( + remain_ids, + HashSet::from([no_delete_id, read_only_id, stale_readonly_id]) + ); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([no_delete_id, read_only_id, stale_readonly_id]) + ); + assert_eq!( + hooks.removed_ids.lock().unwrap().as_slice(), + &[vec![removed_id]] + ); + cleanup_temp_config(&stale_readonly_path); + } + + #[tokio::test] + async fn delete_network_instance_with_default_hooks_keeps_existing_api_behavior() { + let manager = Arc::new(NetworkInstanceManager::new()); + let service = InstanceManageRpcService::new(manager.clone(), Arc::new(DefaultHooks)); + + let no_delete_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + None, + ConfigFilePermission::default().with_flag(ConfigFilePermission::NO_DELETE), + ), + ) + .unwrap(); + let read_only_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + None, + ConfigFilePermission::default().with_flag(ConfigFilePermission::READ_ONLY), + ), + ) + .unwrap(); + + let response = service + .delete_network_instance( + BaseController::default(), + DeleteNetworkInstanceRequest { + inst_ids: vec![no_delete_id.into(), read_only_id.into()], + }, + ) + .await + .unwrap(); + let remain_ids = response + .remain_inst_ids + .into_iter() + .map(Into::into) + .collect::>(); + + assert_eq!(remain_ids, HashSet::from([no_delete_id])); + assert!(!manager.list_network_instance_ids().contains(&read_only_id)); + } + + #[tokio::test] + async fn delete_network_instance_preserves_config_file_that_becomes_readonly_during_hook() { + let manager = Arc::new(NetworkInstanceManager::new()); + let config_path = temp_config_path("delete-hook"); + std::fs::write(&config_path, "listeners = []").unwrap(); + let hooks = Arc::new(RecordingHooks { + readonly_on_post_remove: Mutex::new(vec![config_path.clone()]), + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + let removed_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new(Some(config_path.clone()), ConfigFilePermission::default()), + ) + .unwrap(); + + let response = service + .delete_network_instance( + BaseController::default(), + DeleteNetworkInstanceRequest { + inst_ids: vec![removed_id.into()], + }, + ) + .await + .unwrap(); + + assert!(response.remain_inst_ids.is_empty()); + assert!(manager.list_network_instance_ids().is_empty()); + assert_eq!( + hooks.removed_ids.lock().unwrap().as_slice(), + &[vec![removed_id]] + ); + assert!(config_path.exists()); + cleanup_temp_config(&config_path); + } + + #[tokio::test] + async fn run_network_instance_rejects_overwrite_of_no_delete_instance() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks::default()); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + + let protected_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("listeners = []").unwrap(), + false, + ConfigFileControl::new( + None, + ConfigFilePermission::default().with_flag(ConfigFilePermission::NO_DELETE), + ), + ) + .unwrap(); + + let result = service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: Some(protected_id.into()), + config: Some(NetworkConfig { + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([protected_id]) + ); + assert!(hooks.removed_ids.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn run_network_instance_preserves_existing_when_path_becomes_readonly_during_pre_run() { + let manager = Arc::new(NetworkInstanceManager::new()); + let config_path = temp_config_path("overwrite-pre-run"); + std::fs::write(&config_path, "listeners = []").unwrap(); + let hooks = Arc::new(RecordingHooks { + readonly_on_pre_run: Mutex::new(vec![config_path.clone()]), + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + + let existing_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("inst_name = \"existing\"\nlisteners = []").unwrap(), + false, + ConfigFileControl::new(Some(config_path.clone()), ConfigFilePermission::default()), + ) + .unwrap(); + + let result = service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: Some(existing_id.into()), + config: Some(NetworkConfig { + network_name: Some("replacement".to_string()), + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([existing_id]) + ); + assert!(hooks.removed_ids.lock().unwrap().is_empty()); + cleanup_temp_config(&config_path); + } + + #[tokio::test] + async fn run_network_instance_overwrite_reports_run_without_remove() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks::default()); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + let existing_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("inst_name = \"existing\"\nlisteners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: Some(existing_id.into()), + config: Some(NetworkConfig { + network_name: Some("replacement".to_string()), + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + }, + ) + .await + .unwrap(); + + assert_eq!(hooks.run_ids.lock().unwrap().as_slice(), &[existing_id]); + assert!(hooks.removed_ids.lock().unwrap().is_empty()); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([existing_id]) + ); + } + + #[tokio::test] + async fn run_network_instance_overwrite_post_run_failure_does_not_report_remove() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks { + reject_post_run: true, + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + let existing_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("inst_name = \"existing\"\nlisteners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + let result = service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: Some(existing_id.into()), + config: Some(NetworkConfig { + network_name: Some("replacement".to_string()), + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + }, + ) + .await; + + assert!(result.is_err()); + assert!(hooks.run_ids.lock().unwrap().is_empty()); + assert!(hooks.removed_ids.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn run_network_instance_overwrite_post_run_failure_keeps_existing_config_file() { + let manager = Arc::new(NetworkInstanceManager::new()); + let config_path = temp_config_path("overwrite-post-run-failure"); + let original_config = "inst_name = \"existing\"\nlisteners = []"; + std::fs::write(&config_path, original_config).unwrap(); + let hooks = Arc::new(RecordingHooks { + reject_post_run: true, + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + let existing_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("inst_name = \"existing\"\nlisteners = []").unwrap(), + false, + ConfigFileControl::new(Some(config_path.clone()), ConfigFilePermission::default()), + ) + .unwrap(); + + let result = service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: Some(existing_id.into()), + config: Some(NetworkConfig { + network_name: Some("replacement".to_string()), + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + }, + ) + .await; + + assert!(result.is_err()); + assert!(config_path.exists()); + assert_eq!( + std::fs::read_to_string(&config_path).unwrap(), + original_config + ); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([existing_id]) + ); + cleanup_temp_config(&config_path); + } + + #[tokio::test] + async fn run_network_instance_reports_post_run_state_failures() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks { + reject_post_run: true, + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + + let result = service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + config: Some(NetworkConfig { + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + ..Default::default() + }, + ) + .await; + + assert!(result.is_err()); + assert!(manager.list_network_instance_ids().is_empty()); + } + + #[tokio::test] + async fn run_network_instance_preserves_existing_instance_when_pre_run_rejects_overwrite() { + let manager = Arc::new(NetworkInstanceManager::new()); + let hooks = Arc::new(RecordingHooks { + reject_pre_run: true, + ..Default::default() + }); + let service = InstanceManageRpcService::new(manager.clone(), hooks.clone()); + + let existing_id = manager + .run_network_instance( + TomlConfigLoader::new_from_str("inst_name = \"existing\"\nlisteners = []").unwrap(), + false, + ConfigFileControl::new(None, ConfigFilePermission::default()), + ) + .unwrap(); + + let result = service + .run_network_instance( + BaseController::default(), + RunNetworkInstanceRequest { + inst_id: Some(existing_id.into()), + config: Some(NetworkConfig { + network_name: Some("replacement".to_string()), + networking_method: Some(NetworkingMethod::Standalone as i32), + listener_urls: Vec::new(), + ..Default::default() + }), + overwrite: true, + source: Default::default(), + }, + ) + .await; + + assert!(result.is_err()); + assert_eq!( + manager + .list_network_instance_ids() + .into_iter() + .collect::>(), + HashSet::from([existing_id]) + ); + assert!(hooks.removed_ids.lock().unwrap().is_empty()); + } +} diff --git a/easytier/src/web_client/mod.rs b/easytier/src/web_client/mod.rs index 226acf99..926150af 100644 --- a/easytier/src/web_client/mod.rs +++ b/easytier/src/web_client/mod.rs @@ -23,6 +23,10 @@ use uuid::Uuid; #[async_trait] pub trait WebClientHooks: Send + Sync { + fn manages_remote_config_instances(&self) -> bool { + false + } + async fn pre_run_network_instance(&self, _cfg: &TomlConfigLoader) -> Result<(), String> { Ok(()) }