diff --git a/easytier-contrib/easytier-android-jni/README.md b/easytier-contrib/easytier-android-jni/README.md index 1062f400..be362de6 100644 --- a/easytier-contrib/easytier-android-jni/README.md +++ b/easytier-contrib/easytier-android-jni/README.md @@ -8,6 +8,7 @@ - 📱 原生 Android JNI 支持 - 🔧 支持多种 Android 架构 (arm64-v8a, armeabi-v7a, x86, x86_64) - 🛡️ 类型安全的 Java 接口 +- 🔌 支持通过 JSON 调用已暴露的 EasyTier RPC 查询/管理接口 - 📝 详细的错误处理和日志记录 ## 支持的架构 @@ -176,6 +177,20 @@ public class EasyTierManager { } ``` +### 通用 JSON RPC + +`EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson)` 可以调用已暴露的 +EasyTier RPC 服务,payload 和返回值均为 protobuf JSON。该接口不支持 +`api.manage.WebClientService`;实例启动、保留、删除、信息收集仍使用专用 JNI API。 + +```java +String response = EasyTierJNI.callJsonRpc( + "api.logger.LoggerRpcService", + "get_logger_config", + "{}" +); +``` + ### VPN 服务集成 如果您要在 Android VPN 服务中使用: @@ -264,4 +279,4 @@ public class EasyTierVpnService extends VpnService { - [EasyTier 主项目](https://github.com/EasyTier/EasyTier) - [Android NDK 文档](https://developer.android.com/ndk) -- [Rust JNI 文档](https://docs.rs/jni/) \ No newline at end of file +- [Rust JNI 文档](https://docs.rs/jni/) 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 60b28966..db07d54f 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 @@ -81,6 +81,43 @@ object EasyTierJNI { */ @JvmStatic external fun collectNetworkInfos(maxLength: Int): String? + /** + * 列出当前运行的实例名称和实例 ID。 + * @param maxLength 最大返回条目数 + * @return JSON 对象,key 为 instance name,value 为 instance id + * @throws RuntimeException 当操作失败时抛出异常 + */ + @JvmStatic external fun listInstances(maxLength: Int): String? + + /** + * 调用暴露的 EasyTier RPC 方法,输入和输出均为 protobuf JSON 字符串。 + * + * 不支持 api.manage.WebClientService;实例启动、保留、删除、信息收集请继续使用专用 JNI API。 + * payloadJson 需要包含目标 RPC 所需的 instance selector。 + * + * @param serviceName RPC 服务名,例如 api.instance.PeerManageRpcService + * @param methodName RPC 方法名,支持 snake_case 或 proto 方法名 + * @param domainName 仅 TcpProxyRpcService 使用;传 null 或空字符串默认 tcp + * @param payloadJson protobuf JSON 请求体 + * @return protobuf JSON 响应体 + * @throws RuntimeException 当 RPC 调用失败时抛出异常 + */ + @JvmStatic + external fun callJsonRpc( + serviceName: String, + methodName: String, + domainName: String?, + payloadJson: String + ): String? + + /** + * 调用不需要 domainName 的 EasyTier RPC 方法。 + */ + @JvmStatic + fun callJsonRpc(serviceName: String, methodName: String, payloadJson: String): String? { + return callJsonRpc(serviceName, methodName, null, payloadJson) + } + /** * 获取最后的错误消息 * @return 错误消息字符串,如果没有错误则返回 null diff --git a/easytier-contrib/easytier-android-jni/src/json_rpc_api.rs b/easytier-contrib/easytier-android-jni/src/json_rpc_api.rs new file mode 100644 index 00000000..7f3e7100 --- /dev/null +++ b/easytier-contrib/easytier-android-jni/src/json_rpc_api.rs @@ -0,0 +1,91 @@ +use std::{ + ffi::{CStr, c_char}, + ptr, +}; + +use easytier_ffi::{call_json_rpc, free_string}; +use jni::JNIEnv; +use jni::objects::{JClass, JString}; +use jni::sys::jstring; + +use crate::{ + error::{get_last_error, throw_exception}, + strings::{jstring_to_cstring, optional_jstring_to_cstring}, +}; + +pub(crate) fn call_json_rpc_jni( + mut env: JNIEnv, + _class: JClass, + service_name: JString, + method_name: JString, + domain_name: JString, + payload_json: JString, +) -> jstring { + let service_name_cstr = match jstring_to_cstring(&mut env, &service_name) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid service name: {}", e)); + return ptr::null_mut(); + } + }; + let method_name_cstr = match jstring_to_cstring(&mut env, &method_name) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid method name: {}", e)); + return ptr::null_mut(); + } + }; + let domain_name_cstr = match optional_jstring_to_cstring(&mut env, &domain_name) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid domain name: {}", e)); + return ptr::null_mut(); + } + }; + let payload_json_cstr = match jstring_to_cstring(&mut env, &payload_json) { + Ok(cstr) => cstr, + Err(e) => { + throw_exception(&mut env, &format!("Invalid payload JSON: {}", e)); + return ptr::null_mut(); + } + }; + + let domain_name_ptr = domain_name_cstr + .as_ref() + .map_or(ptr::null(), |cstr| cstr.as_ptr()); + let mut response_ptr: *const c_char = ptr::null(); + let result = unsafe { + call_json_rpc( + service_name_cstr.as_ptr(), + method_name_cstr.as_ptr(), + domain_name_ptr, + payload_json_cstr.as_ptr(), + &mut response_ptr, + ) + }; + + if result != 0 { + if let Some(error) = get_last_error() { + throw_exception(&mut env, &error); + } + return ptr::null_mut(); + } + + if response_ptr.is_null() { + throw_exception(&mut env, "JSON RPC returned a null response"); + return ptr::null_mut(); + } + + let response = unsafe { CStr::from_ptr(response_ptr) } + .to_string_lossy() + .into_owned(); + free_string(response_ptr); + + match env.new_string(&response) { + Ok(jstr) => jstr.into_raw(), + Err(_) => { + throw_exception(&mut env, "Failed to create JSON RPC response string"); + ptr::null_mut() + } + } +} diff --git a/easytier-contrib/easytier-android-jni/src/lib.rs b/easytier-contrib/easytier-android-jni/src/lib.rs index 309f4963..f673023e 100644 --- a/easytier-contrib/easytier-android-jni/src/lib.rs +++ b/easytier-contrib/easytier-android-jni/src/lib.rs @@ -9,7 +9,9 @@ //! - `parseConfig(config)`: validate TOML config text. //! - `runNetworkInstance(config)`: start a local network instance. //! - `retainNetworkInstance(instanceNames)`: retain named instances and stop the rest. +//! - `listInstances()`: return running instance names and IDs as JSON. //! - `collectNetworkInfos()`: return running instance info as a JSON string. +//! - `callJsonRpc(...)`: call an exposed EasyTier RPC service with JSON payload. //! //! Config server client APIs: //! - `startConfigServerClient(url, hostname, machineId, secureMode, callback)`: @@ -28,6 +30,7 @@ mod callback; mod config_server_api; mod data_plane_api; mod error; +mod json_rpc_api; mod logger; mod network_api; mod strings; @@ -126,6 +129,53 @@ pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_collectNetworkInfos( network_api::collect_network_infos_jni(env, class, max_length) } +/// List running network instance names and IDs. +/// +/// Java signature: +/// `EasyTierJNI.listInstances(maxLength: Int): String?` +/// +/// Returns a JSON object whose keys are instance names and whose values are +/// instance ID strings. On failure this returns null and throws +/// `RuntimeException`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_listInstances( + env: JNIEnv, + class: JClass, + max_length: jint, +) -> jstring { + logger::init(); + network_api::list_instances_jni(env, class, max_length) +} + +/// Call an exposed EasyTier RPC method using protobuf JSON. +/// +/// Java signature: +/// `EasyTierJNI.callJsonRpc(serviceName, methodName, domainName, payloadJson): String?` +/// +/// Instance lifecycle management RPCs are intentionally not exposed here. Use +/// the dedicated EasyTierJNI instance APIs for start/retain/delete/collect. +/// `payloadJson` must include any `instance` selector required by the target +/// RPC. On failure this returns null and throws `RuntimeException`. +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_easytier_jni_EasyTierJNI_callJsonRpc( + env: JNIEnv, + class: JClass, + service_name: JString, + method_name: JString, + domain_name: JString, + payload_json: JString, +) -> jstring { + logger::init(); + json_rpc_api::call_json_rpc_jni( + env, + class, + service_name, + method_name, + domain_name, + payload_json, + ) +} + /// Return the latest FFI/JNI error string for the calling thread. /// /// Java signature: diff --git a/easytier-contrib/easytier-android-jni/src/network_api.rs b/easytier-contrib/easytier-android-jni/src/network_api.rs index 4d3a8998..1ae76102 100644 --- a/easytier-contrib/easytier-android-jni/src/network_api.rs +++ b/easytier-contrib/easytier-android-jni/src/network_api.rs @@ -2,8 +2,8 @@ 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, + KeyValuePair, collect_network_infos, free_string, list_instance, parse_config, + retain_network_instance, run_network_instance, set_tun_fd, }; use jni::JNIEnv; use jni::objects::{JClass, JObjectArray, JString}; @@ -190,16 +190,18 @@ pub(crate) fn collect_network_infos_jni( 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()) { + let key = CStr::from_ptr(key_ptr).to_string_lossy().into_owned(); + let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned(); + free_string(key_ptr); + free_string(val_ptr); + let value = match serde_json::from_str::(&val) { Ok(v) => v, Err(_) => { throw_exception(&mut env, "Failed to parse JSON"); continue; } }; - ret.map.insert(key.to_string(), value); + ret.map.insert(key, value); } let json_str = serde_json::to_string(&ret).unwrap_or_else(|_| "{}".to_string()); @@ -212,3 +214,48 @@ pub(crate) fn collect_network_infos_jni( } } } + +pub(crate) fn list_instances_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 = list_instance(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 = serde_json::Map::new(); + 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().into_owned(); + let val = CStr::from_ptr(val_ptr).to_string_lossy().into_owned(); + free_string(key_ptr); + free_string(val_ptr); + ret.insert(key, serde_json::Value::String(val)); + } + + let json_str = serde_json::Value::Object(ret).to_string(); + match env.new_string(&json_str) { + Ok(jstr) => jstr.into_raw(), + Err(_) => { + throw_exception(&mut env, "Failed to create instance list JSON string"); + ptr::null_mut() + } + } + } +} diff --git a/easytier-contrib/easytier-ffi/examples/go/README.md b/easytier-contrib/easytier-ffi/examples/go/README.md index 9db93dbe..bd17f59e 100644 --- a/easytier-contrib/easytier-ffi/examples/go/README.md +++ b/easytier-contrib/easytier-ffi/examples/go/README.md @@ -88,6 +88,13 @@ are self-contained: they start two local EasyTier instances in the same test process with `no_tun = true` and `bind_device = false`, then run TCP and UDP ping/pong over the async data-plane API. +The synchronous wrapper also exposes `CallJSONRPC(service, method, domain, +payload)` for non-lifecycle EasyTier RPCs. For example, +`CallJSONRPC("api.logger.LoggerRpcService", "get_logger_config", "", "{}")` +returns the logger config as protobuf JSON. Instance lifecycle management RPCs +are intentionally filtered; use the dedicated FFI APIs for starting and +stopping instances. + To run only the async tests: ```sh diff --git a/easytier-contrib/easytier-ffi/examples/go/easytier.go b/easytier-contrib/easytier-ffi/examples/go/easytier.go index 15859ad1..6ac74b97 100644 --- a/easytier-contrib/easytier-ffi/examples/go/easytier.go +++ b/easytier-contrib/easytier-ffi/examples/go/easytier.go @@ -24,6 +24,7 @@ type Native struct { lib unsafe.Pointer runNetworkInstance symCall + callJSONRPC symCall getErrorMsg symCall freeString symCall tcpConnect symCall @@ -99,6 +100,48 @@ func (n *Native) RunNetworkInstance(config string) error { return nil } +func (n *Native) CallJSONRPC(serviceName, methodName, domainName, payloadJSON string) (string, error) { + defer pinErrorThread()() + service := cString(serviceName) + method := cString(methodName) + payload := cString(payloadJSON) + servicePtr := unsafe.Pointer(&service[0]) + methodPtr := unsafe.Pointer(&method[0]) + payloadPtr := unsafe.Pointer(&payload[0]) + var domain []byte + var domainPtr unsafe.Pointer + if domainName != "" { + domain = cString(domainName) + domainPtr = unsafe.Pointer(&domain[0]) + } + var response unsafe.Pointer + responseArg := unsafe.Pointer(&response) + var ret int32 + err := n.callJSONRPC.call( + unsafe.Pointer(&ret), + unsafe.Pointer(&servicePtr), + unsafe.Pointer(&methodPtr), + unsafe.Pointer(&domainPtr), + unsafe.Pointer(&payloadPtr), + unsafe.Pointer(&responseArg), + ) + runtime.KeepAlive(service) + runtime.KeepAlive(method) + runtime.KeepAlive(domain) + runtime.KeepAlive(payload) + if err != nil { + return "", err + } + if ret != 0 { + return "", n.lastError() + } + if response == nil { + return "", errors.New("easytier ffi JSON RPC returned nil response") + } + defer func() { _ = n.freeCString(response) }() + return readCString(response), nil +} + func (n *Native) DialContext(ctx context.Context, instance, network, address string) (net.Conn, error) { if network != "tcp" && network != "tcp4" && network != "tcp6" { return nil, net.UnknownNetworkError(network) @@ -219,6 +262,7 @@ func (l *Listener) Addr() net.Addr { return l.addr } func (n *Native) bind() error { return errors.Join( n.bindSym(&n.runNetworkInstance, "run_network_instance", types.SInt32TypeDescriptor, types.PointerTypeDescriptor), + n.bindSym(&n.callJSONRPC, "call_json_rpc", types.SInt32TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor), n.bindSym(&n.getErrorMsg, "get_error_msg", types.VoidTypeDescriptor, types.PointerTypeDescriptor), n.bindSym(&n.freeString, "free_string", types.VoidTypeDescriptor, types.PointerTypeDescriptor), n.bindSym(&n.tcpConnect, "data_plane_tcp_connect", types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor, types.UInt16TypeDescriptor, types.UInt64TypeDescriptor, types.PointerTypeDescriptor, types.PointerTypeDescriptor), diff --git a/easytier-contrib/easytier-ffi/src/config_server.rs b/easytier-contrib/easytier-ffi/src/config_server.rs index 511e6939..102a120f 100644 --- a/easytier-contrib/easytier-ffi/src/config_server.rs +++ b/easytier-contrib/easytier-ffi/src/config_server.rs @@ -199,11 +199,18 @@ impl ManagedConfigServerClientHooks { let Some(callback) = self.callback else { return Ok(()); }; - + let instance_name = INSTANCE_MANAGER + .get_instance_name(&instance_id) + .unwrap_or_default(); + let network_name = INSTANCE_MANAGER + .get_network_name(&instance_id) + .unwrap_or_default(); let event_json = serde_json::json!({ "event": event, "success": true, "instance_id": instance_id.to_string(), + "instance_name": instance_name, + "network_name": network_name, "error": null, }) .to_string(); diff --git a/easytier-contrib/easytier-ffi/src/instance_api.rs b/easytier-contrib/easytier-ffi/src/instance_api.rs index f39a78b0..1ce290d6 100644 --- a/easytier-contrib/easytier-ffi/src/instance_api.rs +++ b/easytier-contrib/easytier-ffi/src/instance_api.rs @@ -1,4 +1,4 @@ -use std::ffi::{c_char, c_int}; +use std::ffi::{CString, c_char, c_int}; use easytier::common::config::{ConfigFileControl, ConfigLoader as _, TomlConfigLoader}; @@ -301,3 +301,66 @@ pub(crate) unsafe fn collect_network_infos( index as std::ffi::c_int } + +/// # Safety +/// List the instance names and IDs known by the FFI instance manager. +pub(crate) unsafe fn list_instance(infos: *mut KeyValuePair, max_length: usize) -> std::ffi::c_int { + if in_config_server_callback() { + set_error_msg("cannot list instances from config server callback"); + return -1; + } + + if max_length == 0 { + return 0; + } + + if infos.is_null() { + set_error_msg("infos is null"); + return -1; + } + + let infos = unsafe { std::slice::from_raw_parts_mut(infos, max_length) }; + let mut instances = INSTANCE_MANAGER + .list_network_instance_ids() + .into_iter() + .filter_map(|id| { + INSTANCE_MANAGER + .get_instance_name(&id) + .map(|name| (name, id)) + }) + .collect::>(); + instances.sort_by(|(left_name, left_id), (right_name, right_id)| { + left_name + .cmp(right_name) + .then_with(|| left_id.to_string().cmp(&right_id.to_string())) + }); + + let encoded_instances = match instances + .into_iter() + .take(max_length) + .map(|(name, id)| { + let key = CString::new(name) + .map_err(|err| format!("failed to encode instance name: {}", err))?; + let value = CString::new(id.to_string()) + .map_err(|err| format!("failed to encode instance id: {}", err))?; + Ok((key, value)) + }) + .collect::, String>>() + { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + + let count = encoded_instances.len(); + for (index, (key, value)) in encoded_instances.into_iter().enumerate() { + infos[index] = KeyValuePair { + key: key.into_raw(), + value: value.into_raw(), + }; + } + + count as std::ffi::c_int +} diff --git a/easytier-contrib/easytier-ffi/src/json_rpc.rs b/easytier-contrib/easytier-ffi/src/json_rpc.rs new file mode 100644 index 00000000..52c29043 --- /dev/null +++ b/easytier-contrib/easytier-ffi/src/json_rpc.rs @@ -0,0 +1,100 @@ +use std::ffi::{CString, c_char, c_int}; + +use crate::{ + config_server::in_config_server_callback, + error::set_error_msg, + state::{ASYNC_RUNTIME, INSTANCE_MANAGER}, + strings::{c_str_to_string, optional_c_str_to_string}, +}; + +/// # Safety +/// See `crate::call_json_rpc`. +pub(crate) unsafe fn call_json_rpc( + service_name: *const c_char, + method_name: *const c_char, + domain_name: *const c_char, + payload_json: *const c_char, + out_response_json: *mut *const c_char, +) -> c_int { + if out_response_json.is_null() { + set_error_msg("out_response_json is null"); + return -1; + } + unsafe { + *out_response_json = std::ptr::null(); + } + + if in_config_server_callback() { + set_error_msg("cannot call JSON RPC from config server callback"); + return -1; + } + + let service_name = match unsafe { c_str_to_string(service_name, "service_name") } { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + let method_name = match unsafe { c_str_to_string(method_name, "method_name") } { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + let domain_name = match unsafe { optional_c_str_to_string(domain_name, "domain_name") } { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + let payload_json = match unsafe { c_str_to_string(payload_json, "payload_json") } { + Ok(value) => value, + Err(err) => { + set_error_msg(&err); + return -1; + } + }; + let payload = match serde_json::from_str::(&payload_json) { + Ok(value) => value, + Err(err) => { + set_error_msg(&format!("failed to parse payload_json: {}", err)); + return -1; + } + }; + + let response = match ASYNC_RUNTIME.block_on(easytier::rpc_service::call_json_rpc( + &INSTANCE_MANAGER, + &service_name, + &method_name, + domain_name.as_deref(), + payload, + )) { + Ok(value) => value, + Err(err) => { + set_error_msg(&format!("RPC Error: {}", err)); + return -1; + } + }; + let response_json = match serde_json::to_string(&response) { + Ok(value) => value, + Err(err) => { + set_error_msg(&format!("failed to serialize RPC response: {}", err)); + return -1; + } + }; + let response_json = match CString::new(response_json) { + Ok(value) => value, + Err(err) => { + set_error_msg(&format!("failed to allocate RPC response: {}", err)); + return -1; + } + }; + + unsafe { + *out_response_json = response_json.into_raw(); + } + 0 +} diff --git a/easytier-contrib/easytier-ffi/src/lib.rs b/easytier-contrib/easytier-ffi/src/lib.rs index 58fb49d6..b963793b 100644 --- a/easytier-contrib/easytier-ffi/src/lib.rs +++ b/easytier-contrib/easytier-ffi/src/lib.rs @@ -9,8 +9,10 @@ //! - `run_network_instance`: start one local network instance from TOML. //! - `retain_network_instance`: keep named instances and stop all others. //! - `delete_network_instance`: stop named local network instances. +//! - `list_instance`: list running instance names and IDs. //! - `collect_network_infos`: collect running instance info as key/value pairs. //! - `set_tun_fd`: attach a TUN file descriptor to a named instance. +//! - `call_json_rpc`: call an exposed EasyTier RPC service with JSON payload. //! //! Config server client APIs: //! - `start_config_server_client`: start the managed remote config client. @@ -42,6 +44,7 @@ mod data_plane; mod data_plane_async; mod error; mod instance_api; +mod json_rpc; mod state; mod strings; mod types; @@ -140,6 +143,27 @@ pub unsafe extern "C" fn delete_network_instance( unsafe { instance_api::delete_network_instance(inst_names, length) } } +/// List running network instance names and IDs. +/// +/// Writes up to `max_length` entries into `infos`. Each returned key is the +/// instance name and each returned value is the instance ID string. 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 +/// 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 list_instance(infos: *mut KeyValuePair, max_length: usize) -> c_int { + unsafe { instance_api::list_instance(infos, max_length) } +} + /// Collect running network instance information. /// /// Writes up to `max_length` entries into `infos`. Each returned key is the @@ -181,6 +205,50 @@ pub unsafe extern "C" fn set_tun_fd(inst_name: *const c_char, fd: c_int) -> c_in unsafe { instance_api::set_tun_fd(inst_name, fd) } } +/// Call an exposed EasyTier RPC method using protobuf JSON. +/// +/// This generic bridge intentionally excludes instance lifecycle management +/// RPCs. Use the dedicated FFI APIs for starting, retaining, deleting, and +/// collecting instances. `payload_json` must contain the protobuf JSON request, +/// including any `instance` selector required by the target RPC. +/// +/// `domain_name` may be null or empty. It is only used by +/// `api.instance.TcpProxyRpcService`; null or empty defaults to `tcp`, and the +/// only accepted explicit values are `tcp`, `kcp_src`, `kcp_dst`, `quic_src`, +/// and `quic_dst`. +/// +/// On success, writes a newly allocated JSON response string to +/// `out_response_json`. The caller must release it with `free_string`. +/// +/// This API fails if called from a config-server event callback. +/// +/// # Safety +/// `service_name`, `method_name`, `payload_json`, and `out_response_json` must +/// be non-null. String pointers must point to null-terminated UTF-8 strings. +/// `domain_name` may be null. +/// +/// # 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 call_json_rpc( + service_name: *const c_char, + method_name: *const c_char, + domain_name: *const c_char, + payload_json: *const c_char, + out_response_json: *mut *const c_char, +) -> c_int { + unsafe { + json_rpc::call_json_rpc( + service_name, + method_name, + domain_name, + payload_json, + out_response_json, + ) + } +} + // ===== Config Server Client API ===== /// Start the managed config-server client. diff --git a/easytier-contrib/easytier-ffi/src/tests.rs b/easytier-contrib/easytier-ffi/src/tests.rs index e658ca6f..8bbf01a7 100644 --- a/easytier-contrib/easytier-ffi/src/tests.rs +++ b/easytier-contrib/easytier-ffi/src/tests.rs @@ -73,6 +73,181 @@ unsafe extern "C" fn record_config_server_event(event_json: *const c_char, user_ ); } +fn take_last_error() -> Option { + unsafe { + let mut error_ptr: *const c_char = std::ptr::null(); + get_error_msg(&mut error_ptr); + if error_ptr.is_null() { + None + } else { + let error = CStr::from_ptr(error_ptr).to_string_lossy().into_owned(); + free_string(error_ptr); + Some(error) + } + } +} + +fn free_key_value_pairs(infos: &[KeyValuePair]) { + for info in infos { + free_string(info.key); + free_string(info.value); + } +} + +#[test] +fn list_instance_returns_instance_names_and_ids() { + let instance_id = Uuid::new_v4(); + let instance_name = format!("list-instance-{}", instance_id); + let cfg = TomlConfigLoader::default(); + cfg.set_id(instance_id); + cfg.set_inst_name(instance_name.clone()); + INSTANCE_MANAGER + .run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) + .unwrap(); + INSTANCE_NAME_ID_MAP.insert(instance_name.clone(), instance_id); + + let mut infos = vec![ + KeyValuePair { + key: std::ptr::null(), + value: std::ptr::null(), + }; + 16 + ]; + let count = unsafe { list_instance(infos.as_mut_ptr(), infos.len()) }; + assert!(count > 0); + + let mut found = false; + for info in infos.iter().take(count as usize) { + let key = unsafe { CStr::from_ptr(info.key) }.to_string_lossy(); + let value = unsafe { CStr::from_ptr(info.value) }.to_string_lossy(); + if key == instance_name { + assert_eq!(value, instance_id.to_string()); + found = true; + } + } + + free_key_value_pairs(&infos[..count as usize]); + INSTANCE_MANAGER + .delete_network_instance(vec![instance_id]) + .unwrap(); + remove_instance_name_ids(&[instance_id]); + assert!(found); +} + +#[test] +fn list_instance_allows_zero_length() { + assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 0) }, 0); +} + +#[test] +fn list_instance_rejects_null_output_pointer() { + assert_eq!(unsafe { list_instance(std::ptr::null_mut(), 1) }, -1); + assert!(take_last_error().unwrap().contains("infos is null")); +} + +#[test] +fn call_json_rpc_returns_logger_response() { + let service = CString::new("api.logger.LoggerRpcService").unwrap(); + let method = CString::new("get_logger_config").unwrap(); + let payload = CString::new("{}").unwrap(); + let mut response_ptr: *const c_char = std::ptr::null(); + + assert_eq!( + unsafe { + call_json_rpc( + service.as_ptr(), + method.as_ptr(), + std::ptr::null(), + payload.as_ptr(), + &mut response_ptr, + ) + }, + 0 + ); + assert!(!response_ptr.is_null()); + let response = unsafe { CStr::from_ptr(response_ptr) } + .to_string_lossy() + .into_owned(); + free_string(response_ptr); + let response: Value = serde_json::from_str(&response).unwrap(); + assert!(response.get("level").is_some()); +} + +#[test] +fn call_json_rpc_rejects_instance_management_service() { + let service = CString::new("api.manage.WebClientService").unwrap(); + let method = CString::new("list_network_instance").unwrap(); + let payload = CString::new("{}").unwrap(); + let mut response_ptr: *const c_char = std::ptr::null(); + + assert_eq!( + unsafe { + call_json_rpc( + service.as_ptr(), + method.as_ptr(), + std::ptr::null(), + payload.as_ptr(), + &mut response_ptr, + ) + }, + -1 + ); + assert!(response_ptr.is_null()); + assert!(take_last_error().unwrap().contains("not exposed")); +} + +#[test] +fn call_json_rpc_rejects_malformed_payload_json() { + let service = CString::new("api.logger.LoggerRpcService").unwrap(); + let method = CString::new("get_logger_config").unwrap(); + let payload = CString::new("{").unwrap(); + let mut response_ptr: *const c_char = std::ptr::null(); + + assert_eq!( + unsafe { + call_json_rpc( + service.as_ptr(), + method.as_ptr(), + std::ptr::null(), + payload.as_ptr(), + &mut response_ptr, + ) + }, + -1 + ); + assert!(response_ptr.is_null()); + assert!( + take_last_error() + .unwrap() + .contains("failed to parse payload_json") + ); +} + +#[test] +fn call_json_rpc_rejects_null_output_pointer() { + let service = CString::new("api.logger.LoggerRpcService").unwrap(); + let method = CString::new("get_logger_config").unwrap(); + let payload = CString::new("{}").unwrap(); + + assert_eq!( + unsafe { + call_json_rpc( + service.as_ptr(), + method.as_ptr(), + std::ptr::null(), + payload.as_ptr(), + std::ptr::null_mut(), + ) + }, + -1 + ); + assert!( + take_last_error() + .unwrap() + .contains("out_response_json is null") + ); +} + #[tokio::test] async fn config_server_hooks_emit_run_event() { let events: Mutex> = Mutex::new(Vec::new()); @@ -395,10 +570,28 @@ async fn config_server_hooks_suppress_late_run_events_while_stopping() { fn config_server_callback_context_rejects_nested_blocking_ffi_calls() { let _callback_scope = ConfigServerCallbackScope::enter(); assert_eq!(is_config_server_client_connected(), 0); + let service = CString::new("api.logger.LoggerRpcService").unwrap(); + let method = CString::new("get_logger_config").unwrap(); + let payload = CString::new("{}").unwrap(); + let mut response_ptr: *const c_char = std::ptr::null(); + assert_eq!( + unsafe { + call_json_rpc( + service.as_ptr(), + method.as_ptr(), + std::ptr::null(), + payload.as_ptr(), + &mut response_ptr, + ) + }, + -1 + ); + assert!(response_ptr.is_null()); assert_eq!( unsafe { collect_network_infos(std::ptr::null_mut(), 0) }, -1 ); + assert_eq!(unsafe { list_instance(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); diff --git a/easytier/src/rpc_service/json_rpc.rs b/easytier/src/rpc_service/json_rpc.rs new file mode 100644 index 00000000..7f63726d --- /dev/null +++ b/easytier/src/rpc_service/json_rpc.rs @@ -0,0 +1,232 @@ +use std::sync::Arc; + +use crate::{ + instance_manager::NetworkInstanceManager, + proto::{ + api::{ + config::ConfigRpc, + instance::{ + AclManageRpc, ConnectorManageRpc, CredentialManageRpc, MappedListenerManageRpc, + PeerManageRpc, PortForwardManageRpc, StatsRpc, TcpProxyRpc, VpnPortalRpc, + }, + logger::LoggerRpc, + }, + peer_rpc::PeerCenterRpc, + rpc_types::{ + controller::BaseController, + error::{Error, Result}, + }, + }, + rpc_service::{ + acl_manage::AclManageRpcService, config::ConfigRpcService, + connector_manage::ConnectorManageRpcService, credential_manage::CredentialManageRpcService, + logger::LoggerRpcService, mapped_listener_manage::MappedListenerManageRpcService, + peer_center::PeerCenterManageRpcService, peer_manage::PeerManageRpcService, + port_forward_manage::PortForwardManageRpcService, proxy::TcpProxyRpcService, + stats::StatsRpcService, vpn_portal::VpnPortalRpcService, + }, +}; + +const INSTANCE_MANAGEMENT_SERVICE: &str = "api.manage.WebClientService"; + +fn service_not_exposed(service_name: &str) -> Error { + anyhow::anyhow!( + "service {} is not exposed through FFI/JNI generic RPC", + service_name + ) + .into() +} + +fn tcp_proxy_domain(domain_name: Option<&str>) -> Result<&'static str> { + match domain_name { + None | Some("") => Ok("tcp"), + Some("tcp") => Ok("tcp"), + Some("kcp_src") => Ok("kcp_src"), + Some("kcp_dst") => Ok("kcp_dst"), + Some("quic_src") => Ok("quic_src"), + Some("quic_dst") => Ok("quic_dst"), + Some(domain) => { + Err(anyhow::anyhow!("invalid TcpProxyRpcService domain_name: {}", domain).into()) + } + } +} + +pub async fn call_json_rpc( + instance_manager: &Arc, + service_name: &str, + method_name: &str, + domain_name: Option<&str>, + payload: serde_json::Value, +) -> Result { + let ctrl = BaseController::default(); + + match service_name { + INSTANCE_MANAGEMENT_SERVICE => Err(service_not_exposed(service_name)), + "api.instance.PeerManageRpcService" => { + PeerManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.PeerCenterManageRpcService" => { + PeerCenterManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.ConnectorManageRpcService" => { + ConnectorManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.MappedListenerManageRpcService" => { + MappedListenerManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.VpnPortalRpcService" => { + VpnPortalRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.TcpProxyRpcService" => { + TcpProxyRpcService::new(instance_manager.clone(), tcp_proxy_domain(domain_name)?) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.AclManageRpcService" => { + AclManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.PortForwardManageRpcService" => { + PortForwardManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.StatsRpcService" => { + StatsRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.instance.CredentialManageRpcService" => { + CredentialManageRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + "api.logger.LoggerRpcService" => { + LoggerRpcService + .json_call_method(ctrl, method_name, payload) + .await + } + "api.config.ConfigRpcService" => { + ConfigRpcService::new(instance_manager.clone()) + .json_call_method(ctrl, method_name, payload) + .await + } + _ => Err(Error::InvalidServiceKey( + service_name.to_string(), + service_name.to_string(), + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manager() -> Arc { + Arc::new(NetworkInstanceManager::new()) + } + + #[tokio::test] + async fn logger_json_rpc_succeeds() { + let response = call_json_rpc( + &manager(), + "api.logger.LoggerRpcService", + "get_logger_config", + None, + serde_json::json!({}), + ) + .await + .unwrap(); + + assert!(response.get("level").is_some()); + } + + #[tokio::test] + async fn json_rpc_rejects_unknown_service() { + let err = call_json_rpc( + &manager(), + "api.unknown.Service", + "get_logger_config", + None, + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(matches!(err, Error::InvalidServiceKey(_, _))); + } + + #[tokio::test] + async fn json_rpc_rejects_instance_management_service() { + let err = call_json_rpc( + &manager(), + INSTANCE_MANAGEMENT_SERVICE, + "list_network_instance", + None, + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(err.to_string().contains("not exposed")); + } + + #[tokio::test] + async fn json_rpc_rejects_unknown_method() { + let err = call_json_rpc( + &manager(), + "api.logger.LoggerRpcService", + "missing_method", + None, + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!(matches!(err, Error::InvalidMethodIndex(0, _))); + } + + #[tokio::test] + async fn json_rpc_rejects_invalid_payload() { + let err = call_json_rpc( + &manager(), + "api.logger.LoggerRpcService", + "get_logger_config", + None, + serde_json::json!([]), + ) + .await + .unwrap_err(); + + assert!(matches!(err, Error::MalformatRpcPacket(_))); + } + + #[tokio::test] + async fn json_rpc_rejects_invalid_tcp_proxy_domain() { + let err = call_json_rpc( + &manager(), + "api.instance.TcpProxyRpcService", + "list_tcp_proxy_entry", + Some("bad"), + serde_json::json!({}), + ) + .await + .unwrap_err(); + + assert!( + err.to_string() + .contains("invalid TcpProxyRpcService domain_name") + ); + } +} diff --git a/easytier/src/rpc_service/mod.rs b/easytier/src/rpc_service/mod.rs index b9af8285..7ac80ad3 100644 --- a/easytier/src/rpc_service/mod.rs +++ b/easytier/src/rpc_service/mod.rs @@ -2,6 +2,7 @@ mod acl_manage; mod config; mod connector_manage; mod credential_manage; +mod json_rpc; mod mapped_listener_manage; mod peer_center; mod peer_manage; @@ -17,6 +18,7 @@ pub mod logger; pub mod remote_client; pub type ApiRpcServer = self::api::ApiRpcServer; +pub use json_rpc::call_json_rpc; pub trait InstanceRpcService: Sync + Send { fn get_peer_manage_service(