From 1178b312fa93b6418cdb7e8d92a8fa25983108e6 Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Tue, 5 May 2026 11:01:44 +0800 Subject: [PATCH 01/10] fix foreign network entry leak (#2211) --- easytier/src/peers/foreign_network_manager.rs | 140 ++++++++++++++++-- 1 file changed, 125 insertions(+), 15 deletions(-) diff --git a/easytier/src/peers/foreign_network_manager.rs b/easytier/src/peers/foreign_network_manager.rs index 31aef0d6..517137a3 100644 --- a/easytier/src/peers/foreign_network_manager.rs +++ b/easytier/src/peers/foreign_network_manager.rs @@ -6,11 +6,15 @@ in the future, with the help wo peer center we can forward packets of peers that connected to any node in the local network. */ use std::{ - sync::{Arc, Weak}, + sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering}, + }, time::SystemTime, }; use dashmap::{DashMap, DashSet}; +use guarden::defer; use tokio::{ sync::{ Mutex, @@ -93,6 +97,7 @@ struct ForeignNetworkEntry { stats_mgr: Arc, traffic_metrics: Arc, + event_handler_started: AtomicBool, tasks: Mutex>, @@ -160,10 +165,11 @@ impl ForeignNetworkEntry { InstanceLabelKind::From, )), { - let peer_map = peer_map.clone(); + let peer_map = Arc::downgrade(&peer_map); move |peer_id| { let peer_map = peer_map.clone(); async move { + let peer_map = peer_map.upgrade()?; peer_map .get_route_peer_info(peer_id) .await @@ -230,6 +236,7 @@ impl ForeignNetworkEntry { stats_mgr, traffic_metrics, + event_handler_started: AtomicBool::new(false), tasks: Mutex::new(JoinSet::new()), @@ -674,6 +681,8 @@ struct ForeignNetworkManagerData { network_peer_last_update: DashMap, accessor: Arc>, lock: std::sync::Mutex<()>, + #[cfg(test)] + fail_next_add_peer_conn_after_entry_insert: AtomicBool, } impl ForeignNetworkManagerData { @@ -732,6 +741,36 @@ impl ForeignNetworkManagerData { shrink_dashmap(&self.network_peer_last_update, None); } + fn remove_network_if_current( + &self, + network_name: &String, + expected_entry: &Weak, + ) { + let _l = self.lock.lock().unwrap(); + let Some(expected_entry) = expected_entry.upgrade() else { + return; + }; + let old = self + .network_peer_maps + .remove_if(network_name, |_, entry| Arc::ptr_eq(entry, &expected_entry)); + let Some((_, old)) = old else { + return; + }; + + old.traffic_metrics.clear_peer_cache(); + let to_remove_peers = old.peer_map.list_peers(); + for p in to_remove_peers { + self.peer_network_map.remove_if(&p, |_, v| { + v.remove(network_name); + v.is_empty() + }); + } + self.network_peer_last_update.remove(network_name); + shrink_dashmap(&self.peer_network_map, None); + shrink_dashmap(&self.network_peer_maps, None); + shrink_dashmap(&self.network_peer_last_update, None); + } + #[allow(clippy::too_many_arguments)] async fn get_or_insert_entry( &self, @@ -874,6 +913,8 @@ impl ForeignNetworkManager { network_peer_last_update: DashMap::new(), accessor: Arc::new(accessor), lock: std::sync::Mutex::new(()), + #[cfg(test)] + fail_next_add_peer_conn_after_entry_insert: AtomicBool::new(false), }); let tasks = Arc::new(std::sync::Mutex::new(JoinSet::new())); @@ -891,6 +932,13 @@ impl ForeignNetworkManager { } } + #[cfg(test)] + fn fail_next_add_peer_conn_after_entry_insert(&self) { + self.data + .fail_next_add_peer_conn_after_entry_insert + .store(true, Ordering::Release); + } + pub fn get_network_peer_id(&self, network_name: &str) -> Option { self.data .network_peer_maps @@ -939,6 +987,35 @@ impl ForeignNetworkManager { ) .await; + defer!(rollback_new_entry => sync [ + data = self.data.clone(), + network_name = entry.network.network_name.clone(), + peer_id = peer_conn.get_peer_id(), + should_rollback = new_added + ] { + if should_rollback { + tracing::warn!( + %network_name, + "rollback newly added foreign network entry after add_peer_conn returned error" + ); + data.remove_peer(peer_id, &network_name); + } + }); + + #[cfg(test)] + if self + .data + .fail_next_add_peer_conn_after_entry_insert + .swap(false, Ordering::AcqRel) + { + return Err(anyhow::anyhow!( + "injected add_peer_conn failure after foreign network entry insert" + ) + .into()); + } + + self.ensure_event_handler_started(&entry); + let same_identity = entry.network == peer_network; let peer_identity_type = peer_conn.get_peer_identity_type(); let credential_peer_trusted = peer_digest_empty @@ -952,10 +1029,6 @@ impl ForeignNetworkManager { || credential_identity_mismatch || entry.my_peer_id != peer_conn.get_my_peer_id() { - if new_added { - self.data - .remove_peer(peer_conn.get_peer_id(), &entry.network.network_name.clone()); - } let err = if entry.my_peer_id != peer_conn.get_my_peer_id() { anyhow::anyhow!( "my peer id not match. exp: {:?} real: {:?}, need retry connect", @@ -980,9 +1053,7 @@ impl ForeignNetworkManager { return Err(err.into()); } - if new_added { - self.start_event_handler(&entry).await; - } else if let Some(peer) = entry.peer_map.get_peer_by_id(peer_conn.get_peer_id()) { + if !new_added && let Some(peer) = entry.peer_map.get_peer_by_id(peer_conn.get_peer_id()) { let direct_conns_len = peer.get_directly_connections().len(); let max_count = use_global_var!(MAX_DIRECT_CONNS_PER_PEER_IN_FOREIGN_NETWORK); if direct_conns_len >= max_count as usize { @@ -996,23 +1067,31 @@ impl ForeignNetworkManager { } entry.peer_map.add_new_peer_conn(peer_conn).await?; + let _ = rollback_new_entry.defuse(); Ok(()) } - async fn start_event_handler(&self, entry: &ForeignNetworkEntry) { + fn ensure_event_handler_started(&self, entry: &Arc) { + if entry.event_handler_started.swap(true, Ordering::AcqRel) { + return; + } + let data = self.data.clone(); let network_name = entry.network.network_name.clone(); - let traffic_metrics = entry.traffic_metrics.clone(); + let entry_for_cleanup = Arc::downgrade(entry); + let traffic_metrics = Arc::downgrade(&entry.traffic_metrics); let mut s = entry.global_ctx.subscribe(); self.tasks.lock().unwrap().spawn(async move { while let Ok(e) = s.recv().await { match &e { GlobalCtxEvent::PeerRemoved(peer_id) => { tracing::info!(?e, "remove peer from foreign network manager"); - traffic_metrics.remove_peer(*peer_id); - data.remove_peer(*peer_id, &network_name); + if let Some(traffic_metrics) = traffic_metrics.upgrade() { + traffic_metrics.remove_peer(*peer_id); + } data.network_peer_last_update .insert(network_name.clone(), SystemTime::now()); + data.remove_peer(*peer_id, &network_name); } GlobalCtxEvent::PeerConnRemoved(..) => { tracing::info!(?e, "clear no conn peer from foreign network manager"); @@ -1028,8 +1107,10 @@ impl ForeignNetworkManager { } // if lagged or recv done just remove the network tracing::error!("global event handler at foreign network manager exit"); - traffic_metrics.clear_peer_cache(); - data.remove_network(&network_name); + if let Some(traffic_metrics) = traffic_metrics.upgrade() { + traffic_metrics.clear_peer_cache(); + } + data.remove_network_if_current(&network_name, &entry_for_cleanup); }); } @@ -1615,6 +1696,35 @@ pub mod tests { .await; } + #[tokio::test] + async fn failed_new_foreign_peer_conn_rolls_back_entry_maps() { + let pm_center = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; + let pma_net1 = create_mock_peer_manager_for_foreign_network("net1").await; + let foreign_mgr = pm_center.get_foreign_network_manager(); + + foreign_mgr.fail_next_add_peer_conn_after_entry_insert(); + + let (a_ring, b_ring) = crate::tunnel::ring::create_ring_tunnel_pair(); + let (client_ret, server_ret) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!( + pma_net1.add_client_tunnel(a_ring, false), + pm_center.add_tunnel_as_server(b_ring, true) + ) + }) + .await + .unwrap(); + + assert!(client_ret.is_ok()); + assert!(server_ret.is_err()); + assert!(foreign_mgr.data.get_network_entry("net1").is_none()); + assert!( + foreign_mgr + .data + .get_peer_network(pma_net1.my_peer_id()) + .is_none() + ); + } + #[tokio::test] async fn foreign_network_peer_removed_clears_traffic_metric_peer_cache() { let pm_center = create_mock_peer_manager_with_mock_stun(NatType::Unknown).await; From 4342c8d7a21dc42f777e8186d4d275172508f510 Mon Sep 17 00:00:00 2001 From: fanyang Date: Tue, 5 May 2026 17:05:34 +0800 Subject: [PATCH 02/10] fix: add missing CLI help text (#2213) --- easytier/locales/app.yml | 3 +++ easytier/src/easytier-cli.rs | 26 ++++++++++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index 818329b5..08388b1b 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -274,6 +274,9 @@ core_clap: check_config: en: Check config validity without starting the network zh-CN: 检查配置文件的有效性并退出 + daemon: + en: Run in daemon mode + zh-CN: 以守护进程模式运行 file_log_size_mb: en: "per file log size in MB, default is 100MB" zh-CN: "单个文件日志大小,单位 MB,默认值为 100MB" diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index 6d60d14b..462e8cdb 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -193,8 +193,11 @@ struct PeerArgs { #[derive(Subcommand, Debug)] enum PeerSubCommand { + /// List connected peers List, + /// Show public IPv6 address information Ipv6, + /// List foreign networks discovered by this instance ListForeign { #[arg( long, @@ -203,6 +206,7 @@ enum PeerSubCommand { )] trusted_keys: bool, }, + /// List global foreign networks from the peer center ListGlobalForeign, } @@ -214,16 +218,18 @@ struct RouteArgs { #[derive(Subcommand, Debug)] enum RouteSubCommand { + /// List routes propagated by peers List, + /// Dump routes in CIDR format Dump, } #[derive(Args, Debug)] struct ConnectorArgs { - #[arg(short, long)] + #[arg(short, long, help = "filter connectors by virtual IPv4 address")] ipv4: Option, - #[arg(short, long)] + #[arg(short, long, help = "filter connectors by peer URL")] peers: Vec, #[command(subcommand)] @@ -242,6 +248,7 @@ enum ConnectorSubCommand { #[arg(help = "connector url, e.g., tcp://1.2.3.4:11010")] url: String, }, + /// List connectors List, } @@ -283,6 +290,7 @@ struct AclArgs { #[derive(Subcommand, Debug)] enum AclSubCommand { + /// Show ACL rule hit statistics Stats, } @@ -450,19 +458,25 @@ struct InstallArgs { #[arg(long, default_value = env!("CARGO_PKG_DESCRIPTION"), help = "service description")] description: String, - #[arg(long)] + #[arg(long, help = "display name shown by the service manager")] display_name: Option, - #[arg(long)] + #[arg( + long, + help = "whether to disable starting the service automatically on boot (true/false)" + )] disable_autostart: Option, - #[arg(long)] + #[arg( + long, + help = "whether to disable automatic restart when the service fails (true/false)" + )] disable_restart_on_failure: Option, #[arg(long, help = "path to easytier-core binary")] core_path: Option, - #[arg(long)] + #[arg(long, help = "working directory for the easytier-core service")] service_work_dir: Option, #[arg( From baeee40b79507d1bab5eaacbcc83ef033e5fc17b Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Thu, 7 May 2026 00:57:42 +0800 Subject: [PATCH 03/10] fix machine uid and easytier-web panic (#2215) 1. fix(web-client): persist and migrate machine id 2. fix panic when easytier-web session receive malformat packet --- .github/workflows/core.yml | 3 + easytier-gui/src-tauri/src/lib.rs | 10 +- easytier-web/src/client_manager/mod.rs | 1 + easytier/locales/app.yml | 4 +- easytier/src/common/constants.rs | 2 - easytier/src/common/machine_id.rs | 596 +++++++++++++++++++++++++ easytier/src/common/mod.rs | 79 +--- easytier/src/core.rs | 5 +- easytier/src/tunnel/common.rs | 26 ++ easytier/src/web_client/controller.rs | 7 + easytier/src/web_client/mod.rs | 25 +- easytier/src/web_client/security.rs | 55 ++- easytier/src/web_client/session.rs | 12 +- 13 files changed, 725 insertions(+), 100 deletions(-) create mode 100644 easytier/src/common/machine_id.rs diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index a817bff1..c19c1ea7 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -157,6 +157,9 @@ jobs: - uses: mlugg/setup-zig@v2 if: ${{ contains(matrix.OS, 'ubuntu') }} + with: + version: 0.16.0 + use-cache: true - uses: taiki-e/install-action@v2 if: ${{ contains(matrix.OS, 'ubuntu') }} diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index 73a74c04..066b0c1e 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -490,10 +490,18 @@ async fn init_web_client(app: AppHandle, url: Option) -> Result<(), Stri .ok_or_else(|| "Instance manager is not available".to_string())?; let hooks = Arc::new(manager::GuiHooks { app: app.clone() }); + let machine_id_state_dir = app + .path() + .app_data_dir() + .with_context(|| "Failed to resolve machine id state directory") + .map_err(|e| format!("{:#}", e))?; let web_client = web_client::run_web_client( url.as_str(), - None, + easytier::common::MachineIdOptions { + explicit_machine_id: None, + state_dir: Some(machine_id_state_dir), + }, None, false, instance_manager, diff --git a/easytier-web/src/client_manager/mod.rs b/easytier-web/src/client_manager/mod.rs index abc53ed4..7b0dc4dc 100644 --- a/easytier-web/src/client_manager/mod.rs +++ b/easytier-web/src/client_manager/mod.rs @@ -365,6 +365,7 @@ mod tests { let _c = WebClient::new( connector, "test", + uuid::Uuid::new_v4(), "test", false, Arc::new(NetworkInstanceManager::new()), diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index 08388b1b..a6b98297 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -12,9 +12,9 @@ core_clap: 仅用户名:--config-server admin,将使用官方的服务器 machine_id: en: |+ - the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. default is from system. + the machine id to identify this machine, used for config recovery after disconnection, must be unique and fixed. by default it is loaded from persisted local state; on first start it may be migrated from system information or generated, then remains fixed. zh-CN: |+ - Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从系统获得。 + Web 配置服务器通过 machine id 来识别机器,用于断线重连后的配置恢复,需要保证唯一且固定不变。默认从本地持久化状态读取;首次启动时可能基于系统信息迁移或生成,之后保持固定不变。 config_file: en: "path to the config file, NOTE: the options set by cmdline args will override options in config file" zh-CN: "配置文件路径,注意:命令行中的配置的选项会覆盖配置文件中的选项" diff --git a/easytier/src/common/constants.rs b/easytier/src/common/constants.rs index 9bd62c09..c4b875fa 100644 --- a/easytier/src/common/constants.rs +++ b/easytier/src/common/constants.rs @@ -23,8 +23,6 @@ define_global_var!(MANUAL_CONNECTOR_RECONNECT_INTERVAL_MS, u64, 1000); define_global_var!(OSPF_UPDATE_MY_GLOBAL_FOREIGN_NETWORK_INTERVAL_SEC, u64, 10); -define_global_var!(MACHINE_UID, Option, None); - define_global_var!(MAX_DIRECT_CONNS_PER_PEER_IN_FOREIGN_NETWORK, u32, 3); define_global_var!(DIRECT_CONNECT_TO_PUBLIC_SERVER, bool, true); diff --git a/easytier/src/common/machine_id.rs b/easytier/src/common/machine_id.rs new file mode 100644 index 00000000..ce904244 --- /dev/null +++ b/easytier/src/common/machine_id.rs @@ -0,0 +1,596 @@ +use std::{ + env, + ffi::OsString, + io::Write as _, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +use anyhow::Context as _; +#[cfg(unix)] +use nix::{ + errno::Errno, + fcntl::{Flock, FlockArg}, +}; + +#[derive(Debug, Clone, Default)] +pub struct MachineIdOptions { + pub explicit_machine_id: Option, + pub state_dir: Option, +} + +pub fn resolve_machine_id(opts: &MachineIdOptions) -> anyhow::Result { + if let Some(explicit_machine_id) = opts.explicit_machine_id.as_deref() { + return Ok(parse_or_hash_machine_id(explicit_machine_id)); + } + + let state_file = resolve_machine_id_state_file(opts.state_dir.as_deref())?; + let allow_legacy_machine_uid_migration = + should_attempt_legacy_machine_uid_migration(&state_file); + if let Some(machine_id) = read_state_machine_id(&state_file)? { + return Ok(machine_id); + } + + if let Some(machine_id) = read_legacy_machine_id_file() { + return persist_machine_id(&state_file, machine_id); + } + + if allow_legacy_machine_uid_migration + && let Some(machine_id) = resolve_legacy_machine_uid_hash() + { + return persist_machine_id(&state_file, machine_id); + } + + let machine_id = resolve_new_machine_id().unwrap_or_else(uuid::Uuid::new_v4); + persist_machine_id(&state_file, machine_id) +} + +fn parse_or_hash_machine_id(raw: &str) -> uuid::Uuid { + if let Ok(mid) = uuid::Uuid::parse_str(raw.trim()) { + return mid; + } + digest_uuid_from_str(raw) +} + +fn digest_uuid_from_str(raw: &str) -> uuid::Uuid { + let mut b = [0u8; 16]; + crate::tunnel::generate_digest_from_str("", raw, &mut b); + uuid::Uuid::from_bytes(b) +} + +fn resolve_machine_id_state_file(state_dir: Option<&Path>) -> anyhow::Result { + let state_dir = match state_dir { + Some(dir) => dir.to_path_buf(), + None => default_machine_id_state_dir()?, + }; + Ok(state_dir.join("machine_id")) +} + +fn non_empty_os_string(value: Option) -> Option { + value.filter(|value| !value.is_empty()) +} + +#[cfg(target_os = "linux")] +fn default_linux_machine_id_state_dir( + xdg_data_home: Option, + home: Option, +) -> PathBuf { + if let Some(path) = non_empty_os_string(xdg_data_home) { + return PathBuf::from(path).join("easytier"); + } + + if let Some(home) = non_empty_os_string(home) { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("easytier"); + } + + PathBuf::from("/var/lib/easytier") +} + +fn default_machine_id_state_dir() -> anyhow::Result { + cfg_select! { + target_os = "linux" => Ok(default_linux_machine_id_state_dir( + env::var_os("XDG_DATA_HOME"), + env::var_os("HOME"), + )), + all(target_os = "macos", not(feature = "macos-ne")) => { + let home = non_empty_os_string(env::var_os("HOME")) + .ok_or_else(|| anyhow::anyhow!("HOME is not set, cannot resolve machine id state directory"))?; + Ok(PathBuf::from(home) + .join("Library") + .join("Application Support") + .join("com.easytier")) + }, + target_os = "windows" => { + let local_app_data = non_empty_os_string(env::var_os("LOCALAPPDATA")).ok_or_else(|| { + anyhow::anyhow!("LOCALAPPDATA is not set, cannot resolve machine id state directory") + })?; + Ok(PathBuf::from(local_app_data).join("easytier")) + }, + target_os = "freebsd" => { + let home = non_empty_os_string(env::var_os("HOME")) + .ok_or_else(|| anyhow::anyhow!("HOME is not set, cannot resolve machine id state directory"))?; + Ok(PathBuf::from(home).join(".local").join("share").join("easytier")) + }, + target_os = "android" => { + anyhow::bail!("machine id state directory must be provided explicitly on Android"); + }, + _ => anyhow::bail!("machine id state directory is unsupported on this platform"), + } +} + +fn read_state_machine_id(path: &Path) -> anyhow::Result> { + let Some(contents) = read_optional_file(path)? else { + return Ok(None); + }; + + let machine_id = uuid::Uuid::parse_str(contents.trim()) + .with_context(|| format!("invalid machine id in state file {}", path.display()))?; + Ok(Some(machine_id)) +} + +fn read_legacy_machine_id_file() -> Option { + let path = legacy_machine_id_file_path()?; + read_legacy_machine_id_file_at(&path) +} + +fn read_legacy_machine_id_file_at(path: &Path) -> Option { + let contents = match std::fs::read_to_string(path) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None, + Err(err) => { + tracing::warn!( + path = %path.display(), + %err, + "ignoring unreadable legacy machine id file" + ); + return None; + } + }; + + match uuid::Uuid::parse_str(contents.trim()) { + Ok(machine_id) => Some(machine_id), + Err(err) => { + tracing::warn!( + path = %path.display(), + %err, + "ignoring invalid legacy machine id file" + ); + None + } + } +} + +fn legacy_machine_id_file_path() -> Option { + std::env::current_exe() + .ok() + .map(|path| path.with_file_name("et_machine_id")) +} + +fn read_optional_file(path: &Path) -> anyhow::Result> { + match std::fs::read_to_string(path) { + Ok(contents) => Ok(Some(contents)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err).with_context(|| format!("failed to read {}", path.display())), + } +} + +fn should_attempt_legacy_machine_uid_migration(state_file: &Path) -> bool { + let Some(state_dir) = state_file.parent() else { + return false; + }; + + let Ok(mut entries) = std::fs::read_dir(state_dir) else { + return false; + }; + entries.any(|entry| entry.is_ok()) +} + +fn resolve_legacy_machine_uid_hash() -> Option { + machine_uid_seed().map(|seed| digest_uuid_from_str(seed.as_str())) +} + +fn resolve_new_machine_id() -> Option { + let seed = machine_uid_seed()?; + + #[cfg(target_os = "linux")] + { + let seed = linux_machine_id_seed(&seed); + Some(digest_uuid_from_str(&seed)) + } + + #[cfg(not(target_os = "linux"))] + { + Some(digest_uuid_from_str(&seed)) + } +} + +#[cfg(any( + target_os = "linux", + all(target_os = "macos", not(feature = "macos-ne")), + target_os = "windows", + target_os = "freebsd" +))] +fn machine_uid_seed() -> Option { + machine_uid::get() + .ok() + .filter(|value| !value.trim().is_empty()) +} + +#[cfg(not(any( + target_os = "linux", + all(target_os = "macos", not(feature = "macos-ne")), + target_os = "windows", + target_os = "freebsd" +)))] +fn machine_uid_seed() -> Option { + None +} + +#[cfg(target_os = "linux")] +fn linux_machine_id_seed(machine_uid: &str) -> String { + let mut seed = format!("machine_uid={machine_uid}"); + + let hostname = gethostname::gethostname() + .to_string_lossy() + .trim() + .to_string(); + if !hostname.is_empty() { + seed.push_str("\nhostname="); + seed.push_str(&hostname); + } + + let mac_addresses = collect_linux_mac_addresses(); + if !mac_addresses.is_empty() { + seed.push_str("\nmacs="); + seed.push_str(&mac_addresses.join(",")); + } + + seed +} + +#[cfg(target_os = "linux")] +fn collect_linux_mac_addresses() -> Vec { + let mut macs = Vec::new(); + let Ok(entries) = std::fs::read_dir("/sys/class/net") else { + return macs; + }; + + for entry in entries.flatten() { + let Ok(name) = entry.file_name().into_string() else { + continue; + }; + if name == "lo" { + continue; + } + + let address_path = entry.path().join("address"); + let Ok(address) = std::fs::read_to_string(address_path) else { + continue; + }; + let address = address.trim().to_ascii_lowercase(); + if address.is_empty() || address == "00:00:00:00:00:00" { + continue; + } + macs.push(address); + } + + macs.sort(); + macs.dedup(); + macs.truncate(3); + macs +} + +fn persist_machine_id(path: &Path, machine_id: uuid::Uuid) -> anyhow::Result { + if let Some(existing) = read_state_machine_id(path)? { + return Ok(existing); + } + + let _lock = MachineIdWriteLock::acquire(path)?; + + if let Some(existing) = read_state_machine_id(path)? { + return Ok(existing); + } + + write_uuid_file_atomically(path, machine_id)?; + Ok(machine_id) +} + +fn write_uuid_file_atomically(path: &Path, machine_id: uuid::Uuid) -> anyhow::Result<()> { + let parent = path.parent().ok_or_else(|| { + anyhow::anyhow!( + "machine id state file {} has no parent directory", + path.display() + ) + })?; + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create machine id state directory {}", + parent.display() + ) + })?; + + let tmp_path = parent.join(format!( + ".machine_id.tmp-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + { + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&tmp_path) + .with_context(|| format!("failed to create {}", tmp_path.display()))?; + file.write_all(machine_id.to_string().as_bytes()) + .with_context(|| format!("failed to write {}", tmp_path.display()))?; + file.sync_all() + .with_context(|| format!("failed to flush {}", tmp_path.display()))?; + } + + if let Err(err) = std::fs::rename(&tmp_path, path) { + let _ = std::fs::remove_file(&tmp_path); + return Err(err).with_context(|| { + format!( + "failed to move machine id state file into place at {}", + path.display() + ) + }); + } + + Ok(()) +} + +struct MachineIdWriteLock { + #[cfg(unix)] + _lock: Flock, + #[cfg(not(unix))] + path: PathBuf, +} + +impl MachineIdWriteLock { + fn acquire(path: &Path) -> anyhow::Result { + let parent = path.parent().ok_or_else(|| { + anyhow::anyhow!( + "machine id state file {} has no parent directory", + path.display() + ) + })?; + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create machine id state directory {}", + parent.display() + ) + })?; + + #[cfg(unix)] + { + Self::acquire_unix(path) + } + + #[cfg(not(unix))] + { + Self::acquire_fallback(path) + } + } + + #[cfg(unix)] + fn acquire_unix(path: &Path) -> anyhow::Result { + let lock_path = path.with_extension("lock"); + let deadline = Instant::now() + Duration::from_secs(5); + let mut lock_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .with_context(|| format!("failed to open machine id lock {}", lock_path.display()))?; + + loop { + match Flock::lock(lock_file, FlockArg::LockExclusiveNonblock) { + Ok(lock) => return Ok(Self { _lock: lock }), + Err((file, Errno::EAGAIN)) => { + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for machine id lock {}", + lock_path.display() + ); + } + lock_file = file; + std::thread::sleep(Duration::from_millis(50)); + } + Err((_file, err)) => { + anyhow::bail!( + "failed to acquire machine id lock {}: {}", + lock_path.display(), + err + ); + } + } + } + } + + #[cfg(not(unix))] + fn acquire_fallback(path: &Path) -> anyhow::Result { + let lock_path = path.with_extension("lock"); + let deadline = Instant::now() + Duration::from_secs(5); + + loop { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&lock_path) + { + Ok(mut file) => { + writeln!(file, "pid={}", std::process::id()).ok(); + return Ok(Self { path: lock_path }); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + if should_reap_stale_lock_file(&lock_path) { + let _ = std::fs::remove_file(&lock_path); + continue; + } + if Instant::now() >= deadline { + anyhow::bail!( + "timed out waiting for machine id lock {}", + lock_path.display() + ); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(err) => { + return Err(err).with_context(|| { + format!("failed to acquire machine id lock {}", lock_path.display()) + }); + } + } + } + } +} + +#[cfg(not(unix))] +fn should_reap_stale_lock_file(lock_path: &Path) -> bool { + const STALE_LOCK_AGE: Duration = Duration::from_secs(30); + + let Ok(metadata) = std::fs::metadata(lock_path) else { + return false; + }; + let Ok(modified) = metadata.modified() else { + return false; + }; + modified + .elapsed() + .is_ok_and(|elapsed| elapsed >= STALE_LOCK_AGE) +} + +impl Drop for MachineIdWriteLock { + fn drop(&mut self) { + #[cfg(not(unix))] + let _ = std::fs::remove_file(&self.path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_machine_id_uses_uuid_seed_verbatim() { + let raw = "33333333-3333-3333-3333-333333333333".to_string(); + let opts = MachineIdOptions { + explicit_machine_id: Some(raw.clone()), + state_dir: None, + }; + assert_eq!( + resolve_machine_id(&opts).unwrap(), + uuid::Uuid::parse_str(&raw).unwrap() + ); + } + + #[test] + fn test_resolve_machine_id_reads_state_file() { + let temp_dir = tempfile::tempdir().unwrap(); + let expected = uuid::Uuid::new_v4(); + std::fs::write(temp_dir.path().join("machine_id"), expected.to_string()).unwrap(); + + let opts = MachineIdOptions { + explicit_machine_id: None, + state_dir: Some(temp_dir.path().to_path_buf()), + }; + + assert_eq!(resolve_machine_id(&opts).unwrap(), expected); + } + + #[test] + fn test_read_legacy_machine_id_file_ignores_read_errors() { + let temp_dir = tempfile::tempdir().unwrap(); + + assert_eq!(read_legacy_machine_id_file_at(temp_dir.path()), None); + } + + #[test] + fn test_write_uuid_file_atomically_writes_expected_contents() { + let temp_dir = tempfile::tempdir().unwrap(); + let machine_id = uuid::Uuid::new_v4(); + let state_file = temp_dir.path().join("machine_id"); + + write_uuid_file_atomically(&state_file, machine_id).unwrap(); + + assert_eq!( + std::fs::read_to_string(state_file).unwrap(), + machine_id.to_string() + ); + } + + #[test] + fn test_non_empty_os_string_filters_empty_values() { + assert_eq!(non_empty_os_string(Some(OsString::new())), None); + assert_eq!( + non_empty_os_string(Some(OsString::from("foo"))), + Some(OsString::from("foo")) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_default_linux_machine_id_state_dir_falls_back_in_order() { + assert_eq!( + default_linux_machine_id_state_dir( + Some(OsString::from("/tmp/xdg")), + Some(OsString::from("/tmp/home")) + ), + PathBuf::from("/tmp/xdg").join("easytier") + ); + assert_eq!( + default_linux_machine_id_state_dir( + Some(OsString::new()), + Some(OsString::from("/tmp/home")) + ), + PathBuf::from("/tmp/home") + .join(".local") + .join("share") + .join("easytier") + ); + assert_eq!( + default_linux_machine_id_state_dir(Some(OsString::new()), Some(OsString::new())), + PathBuf::from("/var/lib/easytier") + ); + } + + #[test] + fn test_persist_machine_id_creates_missing_state_dir() { + let temp_dir = tempfile::tempdir().unwrap(); + let state_file = temp_dir.path().join("nested").join("machine_id"); + let machine_id = uuid::Uuid::new_v4(); + + assert_eq!( + persist_machine_id(&state_file, machine_id).unwrap(), + machine_id + ); + assert_eq!( + std::fs::read_to_string(state_file).unwrap(), + machine_id.to_string() + ); + } + + #[test] + fn test_legacy_machine_uid_migration_requires_existing_state_dir_content() { + let temp_dir = tempfile::tempdir().unwrap(); + let missing_state_file = temp_dir.path().join("missing").join("machine_id"); + assert!(!should_attempt_legacy_machine_uid_migration( + &missing_state_file + )); + + let empty_dir = temp_dir.path().join("empty"); + std::fs::create_dir_all(&empty_dir).unwrap(); + assert!(!should_attempt_legacy_machine_uid_migration( + &empty_dir.join("machine_id") + )); + + std::fs::write(empty_dir.join("config.toml"), "x=1").unwrap(); + assert!(should_attempt_legacy_machine_uid_migration( + &empty_dir.join("machine_id") + )); + } +} diff --git a/easytier/src/common/mod.rs b/easytier/src/common/mod.rs index f1c03b11..e323465e 100644 --- a/easytier/src/common/mod.rs +++ b/easytier/src/common/mod.rs @@ -1,15 +1,12 @@ use std::{ fmt::Debug, future, - io::Write as _, sync::{Arc, Mutex}, }; use time::util::refresh_tz; use tokio::{task::JoinSet, time::timeout}; use tracing::Instrument; -use crate::{set_global_var, use_global_var}; - pub mod acl_processor; pub mod compressor; pub mod config; @@ -21,6 +18,7 @@ pub mod global_ctx; pub mod idn; pub mod ifcfg; pub mod log; +pub mod machine_id; pub mod netns; pub mod network; pub mod os_info; @@ -31,6 +29,8 @@ pub mod token_bucket; pub mod tracing_rolling_appender; pub mod upnp; +pub use machine_id::{MachineIdOptions, resolve_machine_id}; + pub fn get_logger_timer( format: F, ) -> tracing_subscriber::fmt::time::OffsetTime { @@ -96,71 +96,6 @@ pub fn join_joinset_background( ); } -pub fn set_default_machine_id(mid: Option) { - set_global_var!(MACHINE_UID, mid); -} - -pub fn get_machine_id() -> uuid::Uuid { - if let Some(default_mid) = use_global_var!(MACHINE_UID) { - if let Ok(mid) = uuid::Uuid::parse_str(default_mid.trim()) { - return mid; - } - let mut b = [0u8; 16]; - crate::tunnel::generate_digest_from_str("", &default_mid, &mut b); - return uuid::Uuid::from_bytes(b); - } - - // a path same as the binary - let machine_id_file = std::env::current_exe() - .map(|x| x.with_file_name("et_machine_id")) - .unwrap_or_else(|_| std::path::PathBuf::from("et_machine_id")); - - // try load from local file - if let Ok(mid) = std::fs::read_to_string(&machine_id_file) - && let Ok(mid) = uuid::Uuid::parse_str(mid.trim()) - { - return mid; - } - - #[cfg(any( - target_os = "linux", - all(target_os = "macos", not(feature = "macos-ne")), - target_os = "windows", - target_os = "freebsd" - ))] - let gen_mid = machine_uid::get() - .map(|x| { - if x.is_empty() { - return uuid::Uuid::new_v4(); - } - let mut b = [0u8; 16]; - crate::tunnel::generate_digest_from_str("", x.as_str(), &mut b); - uuid::Uuid::from_bytes(b) - }) - .ok(); - - #[cfg(not(any( - target_os = "linux", - all(target_os = "macos", not(feature = "macos-ne")), - target_os = "windows", - target_os = "freebsd" - )))] - let gen_mid = None; - - if let Some(mid) = gen_mid { - return mid; - } - - let gen_mid = uuid::Uuid::new_v4(); - - // try save to local file - if let Ok(mut file) = std::fs::File::create(machine_id_file) { - let _ = file.write_all(gen_mid.to_string().as_bytes()); - } - - gen_mid -} - pub fn shrink_dashmap( map: &dashmap::DashMap, threshold: Option, @@ -210,12 +145,4 @@ mod tests { assert_eq!(weak_js.weak_count(), 0); assert_eq!(weak_js.strong_count(), 0); } - - #[test] - fn test_get_machine_id_uses_uuid_seed_verbatim() { - let raw = "33333333-3333-3333-3333-333333333333".to_string(); - set_default_machine_id(Some(raw.clone())); - assert_eq!(get_machine_id(), uuid::Uuid::parse_str(&raw).unwrap()); - set_default_machine_id(None); - } } diff --git a/easytier/src/core.rs b/easytier/src/core.rs index d29d7708..862f4937 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -1336,7 +1336,10 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> { let _web_client = if let Some(config_server_url_s) = cli.config_server.as_ref() { let wc = web_client::run_web_client( config_server_url_s, - cli.machine_id.clone(), + crate::common::MachineIdOptions { + explicit_machine_id: cli.machine_id.clone(), + state_dir: None, + }, cli.network_options.hostname.clone(), cli.network_options.secure_mode.unwrap_or(false), manager.clone(), diff --git a/easytier/src/tunnel/common.rs b/easytier/src/tunnel/common.rs index f24e5d27..874de258 100644 --- a/easytier/src/tunnel/common.rs +++ b/easytier/src/tunnel/common.rs @@ -115,6 +115,12 @@ impl FramedReader { return Some(Err(TunnelError::InvalidPacket("body too long".to_string()))); } + if body_len < PEER_MANAGER_HEADER_SIZE { + return Some(Err(TunnelError::InvalidPacket( + "body too short".to_string(), + ))); + } + if buf.len() < TCP_TUNNEL_HEADER_SIZE + body_len { // body is not complete return None; @@ -555,6 +561,26 @@ pub mod tests { tunnel::{TunnelConnector, TunnelListener, packet_def::ZCPacket}, }; + #[cfg(test)] + use crate::tunnel::{ + TunnelError, + packet_def::{PEER_MANAGER_HEADER_SIZE, TCP_TUNNEL_HEADER_SIZE}, + }; + + #[test] + fn framed_reader_rejects_short_peer_manager_body() { + let mut buf = BytesMut::new(); + buf.put_u32_le((PEER_MANAGER_HEADER_SIZE - 1) as u32); + buf.resize(TCP_TUNNEL_HEADER_SIZE + PEER_MANAGER_HEADER_SIZE - 1, 0); + + let ret = super::FramedReader::::extract_one_packet(&mut buf, 2000); + + assert!(matches!( + ret, + Some(Err(TunnelError::InvalidPacket(msg))) if msg == "body too short" + )); + } + pub async fn _tunnel_echo_server(tunnel: Box, once: bool) { let (mut recv, mut send) = tunnel.split(); diff --git a/easytier/src/web_client/controller.rs b/easytier/src/web_client/controller.rs index 177d4576..7e7e4cfa 100644 --- a/easytier/src/web_client/controller.rs +++ b/easytier/src/web_client/controller.rs @@ -9,6 +9,7 @@ use crate::{ pub struct Controller { token: String, + machine_id: uuid::Uuid, hostname: String, device_os: DeviceOsInfo, manager: Arc, @@ -18,6 +19,7 @@ pub struct Controller { impl Controller { pub fn new( token: String, + machine_id: uuid::Uuid, hostname: String, device_os: DeviceOsInfo, manager: Arc, @@ -25,6 +27,7 @@ impl Controller { ) -> Self { Controller { token, + machine_id, hostname, device_os, manager, @@ -44,6 +47,10 @@ impl Controller { self.hostname.clone() } + pub fn machine_id(&self) -> uuid::Uuid { + self.machine_id + } + pub fn device_os(&self) -> DeviceOsInfo { self.device_os.clone() } diff --git a/easytier/src/web_client/mod.rs b/easytier/src/web_client/mod.rs index 44e74a48..226acf99 100644 --- a/easytier/src/web_client/mod.rs +++ b/easytier/src/web_client/mod.rs @@ -2,11 +2,12 @@ use std::sync::Arc; use crate::{ common::{ + MachineIdOptions, config::TomlConfigLoader, global_ctx::{ArcGlobalCtx, GlobalCtx}, log, os_info::collect_device_os_info, - set_default_machine_id, + resolve_machine_id, stun::MockStunInfoCollector, }, connector::create_connector_by_url, @@ -81,6 +82,7 @@ impl WebClient { pub fn new( connector: T, token: S, + machine_id: Uuid, hostname: H, secure_mode: bool, manager: Arc, @@ -90,6 +92,7 @@ impl WebClient { let hooks = hooks.unwrap_or_else(|| Arc::new(DefaultHooks)); let controller = Arc::new(controller::Controller::new( token.to_string(), + machine_id, hostname.to_string(), collect_device_os_info(), manager, @@ -229,13 +232,14 @@ impl WebClient { pub async fn run_web_client( config_server_url_s: &str, - machine_id: Option, + machine_id_opts: MachineIdOptions, hostname: Option, secure_mode: bool, manager: Arc, hooks: Option>, ) -> Result { - set_default_machine_id(machine_id); + let machine_id = resolve_machine_id(&machine_id_opts) + .with_context(|| "failed to resolve machine id for web client")?; let config_server_url = match Url::parse(config_server_url_s) { Ok(u) => u, Err(_) => format!( @@ -289,6 +293,7 @@ pub async fn run_web_client( global_ctx, }, token.to_string(), + machine_id, hostname, secure_mode, manager, @@ -300,14 +305,18 @@ pub async fn run_web_client( mod tests { use std::sync::{Arc, atomic::AtomicBool}; - use crate::instance_manager::NetworkInstanceManager; + use crate::{common::MachineIdOptions, instance_manager::NetworkInstanceManager}; #[tokio::test] async fn test_manager_wait() { let manager = Arc::new(NetworkInstanceManager::new()); + let temp_dir = tempfile::tempdir().unwrap(); let client = super::run_web_client( format!("ring://{}/test", uuid::Uuid::new_v4()).as_str(), - None, + MachineIdOptions { + explicit_machine_id: None, + state_dir: Some(temp_dir.path().to_path_buf()), + }, None, false, manager.clone(), @@ -335,9 +344,13 @@ mod tests { #[tokio::test] async fn test_run_web_client_with_unreachable_config_server() { let manager = Arc::new(NetworkInstanceManager::new()); + let temp_dir = tempfile::tempdir().unwrap(); let client = super::run_web_client( "udp://config-server.invalid:22020/test", - None, + MachineIdOptions { + explicit_machine_id: None, + state_dir: Some(temp_dir.path().to_path_buf()), + }, None, false, manager, diff --git a/easytier/src/web_client/security.rs b/easytier/src/web_client/security.rs index 2916624a..2240a9ab 100644 --- a/easytier/src/web_client/security.rs +++ b/easytier/src/web_client/security.rs @@ -101,7 +101,11 @@ impl TunnelFilter for SecureDatagramTunnelFilter { Err(e) => return Some(Err(e)), }; - let mut cipher = ZCPacket::new_with_payload(packet.payload()); + let payload = match checked_payload(&packet, "secure packet") { + Ok(v) => v, + Err(e) => return Some(Err(e)), + }; + let mut cipher = ZCPacket::new_with_payload(payload); cipher.fill_peer_manager_hdr(0, 0, PacketType::Data as u8); cipher .mut_peer_manager_header() @@ -116,15 +120,27 @@ impl TunnelFilter for SecureDatagramTunnelFilter { )))); } - Some(Ok(ZCPacket::new_from_buf( - cipher.payload_bytes(), - ZCPacketType::DummyTunnel, - ))) + let packet = ZCPacket::new_from_buf(cipher.payload_bytes(), ZCPacketType::DummyTunnel); + if packet.peer_manager_header().is_none() { + return Some(Err(TunnelError::InvalidPacket( + "decrypted secure packet too short".to_string(), + ))); + } + + Some(Ok(packet)) } fn filter_output(&self) {} } +fn checked_payload<'a>(packet: &'a ZCPacket, context: &str) -> Result<&'a [u8], TunnelError> { + if packet.peer_manager_header().is_none() { + return Err(TunnelError::InvalidPacket(format!("{context} too short"))); + } + + Ok(packet.payload()) +} + fn pack_control_packet(payload: &[u8]) -> ZCPacket { let mut packet = ZCPacket::new_with_payload(payload); packet.fill_peer_manager_hdr(0, 0, PacketType::Data as u8); @@ -214,7 +230,8 @@ pub async fn upgrade_client_tunnel( Ok(None) => return Err(TunnelError::Shutdown), Err(error) => return Err(error.into()), }; - let msg2_cipher = decode_noise_payload(msg2_packet.payload()) + let msg2_payload = checked_payload(&msg2_packet, "noise msg2 packet")?; + let msg2_cipher = decode_noise_payload(msg2_payload) .ok_or_else(|| TunnelError::InvalidPacket("invalid noise msg2 magic".to_string()))?; let mut root_key_buf = [0u8; 32]; let root_key_len = state @@ -254,7 +271,8 @@ pub async fn accept_or_upgrade_server_tunnel( )); } }; - let Some(msg1_cipher) = decode_noise_payload(first_packet.payload()) else { + let first_payload = checked_payload(&first_packet, "first packet")?; + let Some(msg1_cipher) = decode_noise_payload(first_payload) else { let stream = Box::pin(futures::stream::once(async move { Ok(first_packet) }).chain(stream)); return Ok(( Box::new(RawSplitTunnel::new(info, stream, sink)) as Box, @@ -303,6 +321,7 @@ pub async fn accept_or_upgrade_server_tunnel( mod tests { use super::*; use crate::tunnel::ring::create_ring_tunnel_pair; + use bytes::BytesMut; #[test] fn web_secure_cipher_algorithm_matches_support_flag() { @@ -338,6 +357,28 @@ mod tests { assert!(matches!(err, TunnelError::Timeout(_))); } + #[tokio::test] + async fn accept_secure_tunnel_rejects_short_first_packet() { + let (server_tunnel, client_tunnel) = create_ring_tunnel_pair(); + + let server_task = + tokio::spawn(async move { accept_or_upgrade_server_tunnel(server_tunnel).await }); + + let (_stream, mut sink) = client_tunnel.split(); + sink.send(ZCPacket::new_from_buf( + BytesMut::from(&b"\x01"[..]), + ZCPacketType::TCP, + )) + .await + .unwrap(); + + let err = server_task.await.unwrap().unwrap_err(); + assert!(matches!( + err, + TunnelError::InvalidPacket(msg) if msg == "first packet too short" + )); + } + #[tokio::test] async fn accept_secure_tunnel_after_short_client_delay() { let (server_tunnel, client_tunnel) = create_ring_tunnel_pair(); diff --git a/easytier/src/web_client/session.rs b/easytier/src/web_client/session.rs index a81b3f80..dbc6505f 100644 --- a/easytier/src/web_client/session.rs +++ b/easytier/src/web_client/session.rs @@ -7,7 +7,7 @@ use tokio::{ }; use crate::{ - common::{constants::EASYTIER_VERSION, get_machine_id}, + common::constants::EASYTIER_VERSION, proto::{ rpc_impl::bidirect::BidirectRpcManager, rpc_types::controller::BaseController, @@ -65,11 +65,13 @@ impl Session { tasks: &mut JoinSet<()>, ctx: HeartbeatCtx, ) { - let mid = get_machine_id(); + let controller = controller.upgrade().unwrap(); + let mid = controller.machine_id(); let inst_id = uuid::Uuid::new_v4(); - let token = controller.upgrade().unwrap().token(); - let hostname = controller.upgrade().unwrap().hostname(); - let device_os = controller.upgrade().unwrap().device_os(); + let token = controller.token(); + let hostname = controller.hostname(); + let device_os = controller.device_os(); + let controller = Arc::downgrade(&controller); let ctx_clone = ctx.clone(); let mut tick = interval(std::time::Duration::from_secs(1)); From 74fc8b300dc7755a68dd8f929e95e8bdb59bf83c Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Thu, 7 May 2026 13:48:51 +0800 Subject: [PATCH 04/10] chore: bump version to 2.6.4 (#2219) --- .github/workflows/docker.yml | 2 +- .github/workflows/release.yml | 2 +- Cargo.lock | 6 +++--- easytier-contrib/easytier-magisk/module.prop | 2 +- easytier-gui/package.json | 2 +- easytier-gui/src-tauri/Cargo.toml | 2 +- easytier-gui/src-tauri/tauri.conf.json | 2 +- easytier-web/Cargo.toml | 2 +- easytier/Cargo.toml | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b39dc05e..19979b4d 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,7 +11,7 @@ on: image_tag: description: 'Tag for this image build' type: string - default: 'v2.6.3' + default: 'v2.6.4' required: true mark_latest: description: 'Mark this image as latest' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 405d7dea..83365bf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ on: version: description: 'Version for this release' type: string - default: 'v2.6.3' + default: 'v2.6.4' required: true make_latest: description: 'Mark this release as latest' diff --git a/Cargo.lock b/Cargo.lock index 7049216b..36c870d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2229,7 +2229,7 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "easytier" -version = "2.6.3" +version = "2.6.4" dependencies = [ "aes-gcm", "anyhow", @@ -2405,7 +2405,7 @@ dependencies = [ [[package]] name = "easytier-gui" -version = "2.6.3" +version = "2.6.4" dependencies = [ "anyhow", "async-trait", @@ -2486,7 +2486,7 @@ dependencies = [ [[package]] name = "easytier-web" -version = "2.6.3" +version = "2.6.4" dependencies = [ "anyhow", "async-trait", diff --git a/easytier-contrib/easytier-magisk/module.prop b/easytier-contrib/easytier-magisk/module.prop index 1b489081..0c15738b 100644 --- a/easytier-contrib/easytier-magisk/module.prop +++ b/easytier-contrib/easytier-magisk/module.prop @@ -1,6 +1,6 @@ id=easytier_magisk name=EasyTier_Magisk -version=v2.6.3 +version=v2.6.4 versionCode=1 author=EasyTier description=easytier magisk module @EasyTier(https://github.com/EasyTier/EasyTier) diff --git a/easytier-gui/package.json b/easytier-gui/package.json index 79d5f3d8..722e123a 100644 --- a/easytier-gui/package.json +++ b/easytier-gui/package.json @@ -1,7 +1,7 @@ { "name": "easytier-gui", "type": "module", - "version": "2.6.3", + "version": "2.6.4", "private": true, "packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4", "scripts": { diff --git a/easytier-gui/src-tauri/Cargo.toml b/easytier-gui/src-tauri/Cargo.toml index e1b3d71d..0a79975e 100644 --- a/easytier-gui/src-tauri/Cargo.toml +++ b/easytier-gui/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "easytier-gui" -version = "2.6.3" +version = "2.6.4" description = "EasyTier GUI" authors = ["you"] edition.workspace = true diff --git a/easytier-gui/src-tauri/tauri.conf.json b/easytier-gui/src-tauri/tauri.conf.json index e9ebd403..a2bc00d7 100644 --- a/easytier-gui/src-tauri/tauri.conf.json +++ b/easytier-gui/src-tauri/tauri.conf.json @@ -17,7 +17,7 @@ "createUpdaterArtifacts": false }, "productName": "easytier-gui", - "version": "2.6.3", + "version": "2.6.4", "identifier": "com.kkrainbow.easytier", "plugins": { "shell": { diff --git a/easytier-web/Cargo.toml b/easytier-web/Cargo.toml index 66ca7c1b..31cee392 100644 --- a/easytier-web/Cargo.toml +++ b/easytier-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "easytier-web" -version = "2.6.3" +version = "2.6.4" edition.workspace = true description = "Config server for easytier. easytier-core gets config from this and web frontend use it as restful api server." diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 618b6613..1bbb2b8a 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -3,7 +3,7 @@ name = "easytier" description = "A full meshed p2p VPN, connecting all your devices in one network with one command." homepage = "https://github.com/EasyTier/EasyTier" repository = "https://github.com/EasyTier/EasyTier" -version = "2.6.3" +version = "2.6.4" edition.workspace = true rust-version.workspace = true authors = ["kkrainbow"] From 96fd39649ae7729dc694d457cad0fe0de49a6a57 Mon Sep 17 00:00:00 2001 From: Luna Yao <40349250+ZnqbuZ@users.noreply.github.com> Date: Thu, 7 May 2026 12:49:40 +0200 Subject: [PATCH 05/10] revert UPX version to 4.2.4 in core.yml (#2221) --- .github/workflows/core.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index c19c1ea7..033952c9 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -230,7 +230,7 @@ jobs: *) UPX_ARCH="amd64" ;; esac - UPX_VERSION=5.1.1 + UPX_VERSION=4.2.4 UPX_PKG="upx-${UPX_VERSION}-${UPX_ARCH}_linux" curl -L "https://github.com/upx/upx/releases/download/v${UPX_VERSION}/${UPX_PKG}.tar.xz" -s | tar xJvf - cp "${UPX_PKG}/upx" . From 55f15bb6f035128b3df271320a79066ba250b7a3 Mon Sep 17 00:00:00 2001 From: fanyang Date: Fri, 8 May 2026 22:08:51 +0800 Subject: [PATCH 06/10] fix(connector): classify manual reconnect timeouts by stage (#2062) --- easytier/src/connector/manual.rs | 214 ++++++++++++++++++++++------- easytier/src/peers/peer_manager.rs | 17 ++- 2 files changed, 174 insertions(+), 57 deletions(-) diff --git a/easytier/src/connector/manual.rs b/easytier/src/connector/manual.rs index 8e9fd902..c797c50c 100644 --- a/easytier/src/connector/manual.rs +++ b/easytier/src/connector/manual.rs @@ -1,6 +1,8 @@ use std::{ collections::BTreeSet, + future::Future, sync::{Arc, Weak}, + time::{Duration, Instant}, }; use dashmap::DashSet; @@ -16,7 +18,7 @@ use crate::{ }, rpc_types::{self, controller::BaseController}, }, - tunnel::{IpVersion, TunnelConnector}, + tunnel::{IpVersion, TunnelConnector, TunnelScheme, matches_scheme}, utils::weak_upgrade, }; @@ -83,6 +85,55 @@ impl ManualConnectorManager { ret } + fn reconnect_timeout(dead_url: &url::Url) -> Duration { + let use_long_timeout = matches_scheme!( + dead_url, + TunnelScheme::Http | TunnelScheme::Https | TunnelScheme::Txt | TunnelScheme::Srv + ) || matches!(dead_url.scheme(), "ws" | "wss"); + + Duration::from_secs(if use_long_timeout { 20 } else { 2 }) + } + + fn remaining_budget(started_at: Instant, total_timeout: Duration) -> Option { + let remaining = total_timeout.checked_sub(started_at.elapsed())?; + (!remaining.is_zero()).then_some(remaining) + } + + fn emit_connect_error( + data: &ConnectorManagerData, + dead_url: &url::Url, + ip_version: IpVersion, + error: &Error, + ) { + data.global_ctx.issue_event(GlobalCtxEvent::ConnectError( + dead_url.to_string(), + format!("{:?}", ip_version), + format!("{:#?}", error), + )); + } + + fn reconnect_timeout_error(stage: &str, duration: Duration) -> Error { + Error::AnyhowError(anyhow::anyhow!("{} timeout after {:?}", stage, duration)) + } + + async fn with_reconnect_timeout( + stage: &'static str, + started_at: Instant, + total_timeout: Duration, + fut: F, + ) -> Result + where + F: Future>, + { + let remaining = Self::remaining_budget(started_at, total_timeout) + .ok_or_else(|| Self::reconnect_timeout_error(stage, started_at.elapsed()))?; + timeout(remaining, fut) + .await + .map_err(|_| Self::reconnect_timeout_error(stage, remaining))? + } +} + +impl ManualConnectorManager { pub fn add_connector(&self, connector: T) where T: TunnelConnector + 'static, @@ -242,11 +293,18 @@ impl ManualConnectorManager { async fn conn_reconnect_with_ip_version( data: Arc, - dead_url: String, + dead_url: url::Url, ip_version: IpVersion, + started_at: Instant, + total_timeout: Duration, ) -> Result { - let connector = - create_connector_by_url(&dead_url, &data.global_ctx.clone(), ip_version).await?; + let connector = Self::with_reconnect_timeout( + "resolve", + started_at, + total_timeout, + create_connector_by_url(dead_url.as_str(), &data.global_ctx, ip_version), + ) + .await?; data.global_ctx .issue_event(GlobalCtxEvent::Connecting(connector.remote_url())); @@ -257,10 +315,25 @@ impl ManualConnectorManager { ))); }; - let (peer_id, conn_id) = pm.try_direct_connect(connector).await?; + let tunnel = Self::with_reconnect_timeout( + "connect", + started_at, + total_timeout, + pm.connect_tunnel(connector), + ) + .await?; + + let (peer_id, conn_id) = Self::with_reconnect_timeout( + "handshake", + started_at, + total_timeout, + pm.add_client_tunnel_with_peer_id_hint(tunnel, true, None), + ) + .await?; + tracing::info!("reconnect succ: {} {} {}", peer_id, conn_id, dead_url); Ok(ReconnResult { - dead_url, + dead_url: dead_url.to_string(), peer_id, conn_id, }) @@ -273,22 +346,33 @@ impl ManualConnectorManager { tracing::info!("reconnect: {}", dead_url); let mut ip_versions = vec![]; - if dead_url.scheme() == "ring" || dead_url.scheme() == "txt" || dead_url.scheme() == "srv" { + if matches_scheme!( + dead_url, + TunnelScheme::Ring | TunnelScheme::Txt | TunnelScheme::Srv + ) { ip_versions.push(IpVersion::Both); } else { - let converted_dead_url = crate::common::idn::convert_idn_to_ascii(dead_url.clone())?; - let addrs = match socket_addrs(&converted_dead_url, || Some(1000)).await { + let converted_dead_url = + match crate::common::idn::convert_idn_to_ascii(dead_url.clone()) { + Ok(url) => url, + Err(error) => { + let error: Error = error.into(); + Self::emit_connect_error(&data, &dead_url, IpVersion::Both, &error); + return Err(error); + } + }; + let addrs = match Self::with_reconnect_timeout( + "resolve", + Instant::now(), + Self::reconnect_timeout(&dead_url), + socket_addrs(&converted_dead_url, || Some(1000)), + ) + .await + { Ok(addrs) => addrs, - Err(e) => { - data.global_ctx.issue_event(GlobalCtxEvent::ConnectError( - dead_url.to_string(), - format!("{:?}", IpVersion::Both), - format!("{:?}", e), - )); - return Err(Error::AnyhowError(anyhow::anyhow!( - "get ip from url failed: {:?}", - e - ))); + Err(error) => { + Self::emit_connect_error(&data, &dead_url, IpVersion::Both, &error); + return Err(error); } }; tracing::info!(?addrs, ?dead_url, "get ip from url done"); @@ -313,46 +397,24 @@ impl ManualConnectorManager { "cannot get ip from url" ))); for ip_version in ip_versions { - let use_long_timeout = dead_url.scheme() == "http" - || dead_url.scheme() == "https" - || dead_url.scheme() == "ws" - || dead_url.scheme() == "wss" - || dead_url.scheme() == "txt" - || dead_url.scheme() == "srv"; - let ret = timeout( - // allow http/websocket connector to wait longer - std::time::Duration::from_secs(if use_long_timeout { 20 } else { 2 }), - Self::conn_reconnect_with_ip_version( - data.clone(), - dead_url.to_string(), - ip_version, - ), + let started_at = Instant::now(); + let ret = Self::conn_reconnect_with_ip_version( + data.clone(), + dead_url.clone(), + ip_version, + started_at, + Self::reconnect_timeout(&dead_url), ) .await; tracing::info!("reconnect: {} done, ret: {:?}", dead_url, ret); match ret { - Ok(Ok(_)) => { - // 外层和内层都成功:解包并跳出 - reconn_ret = ret.unwrap(); - break; - } - Ok(Err(e)) => { - // 外层成功,内层失败 - reconn_ret = Err(e); - } - Err(e) => { - // 外层失败 - reconn_ret = Err(e.into()); + Ok(result) => return Ok(result), + Err(error) => { + Self::emit_connect_error(&data, &dead_url, ip_version, &error); + reconn_ret = Err(error); } } - - // 发送事件(只有在未 break 时才执行) - data.global_ctx.issue_event(GlobalCtxEvent::ConnectError( - dead_url.to_string(), - format!("{:?}", ip_version), - format!("{:?}", reconn_ret), - )); } reconn_ret @@ -388,6 +450,54 @@ mod tests { use super::*; + #[tokio::test] + async fn reconnect_timeout_reports_exhausted_budget_for_stage() { + let started_at = Instant::now() - Duration::from_millis(50); + let err = ManualConnectorManager::with_reconnect_timeout( + "resolve", + started_at, + Duration::from_millis(1), + async { Ok::<(), Error>(()) }, + ) + .await + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("resolve timeout after")); + } + + #[tokio::test] + async fn reconnect_timeout_reports_stage_timeout_with_remaining_budget() { + let err = ManualConnectorManager::with_reconnect_timeout( + "handshake", + Instant::now(), + Duration::from_millis(10), + async { + tokio::time::sleep(Duration::from_millis(50)).await; + Ok::<(), Error>(()) + }, + ) + .await + .unwrap_err(); + + let message = err.to_string(); + assert!(message.contains("handshake timeout after")); + } + + #[tokio::test] + async fn reconnect_timeout_preserves_success_within_budget() { + let result = ManualConnectorManager::with_reconnect_timeout( + "connect", + Instant::now(), + Duration::from_millis(50), + async { Ok::<_, Error>(123_u32) }, + ) + .await + .unwrap(); + + assert_eq!(result, 123); + } + #[tokio::test] async fn test_reconnect_with_connecting_addr() { set_global_var!(MANUAL_CONNECTOR_RECONNECT_INTERVAL_MS, 1); diff --git a/easytier/src/peers/peer_manager.rs b/easytier/src/peers/peer_manager.rs index c5d03fdd..a2748d26 100644 --- a/easytier/src/peers/peer_manager.rs +++ b/easytier/src/peers/peer_manager.rs @@ -636,20 +636,27 @@ impl PeerManager { #[tracing::instrument] pub async fn try_direct_connect_with_peer_id_hint( &self, - mut connector: C, + connector: C, peer_id_hint: Option, ) -> Result<(PeerId, PeerConnId), Error> where C: TunnelConnector + Debug, { - let ns = self.global_ctx.net_ns.clone(); - let t = ns - .run_async(|| async move { connector.connect().await }) - .await?; + let t = self.connect_tunnel(connector).await?; self.add_client_tunnel_with_peer_id_hint(t, true, peer_id_hint) .await } + pub(crate) async fn connect_tunnel(&self, mut connector: C) -> Result, Error> + where + C: TunnelConnector + Debug, + { + let ns = self.global_ctx.net_ns.clone(); + Ok(ns + .run_async(|| async move { connector.connect().await }) + .await?) + } + // avoid loop back to virtual network fn check_remote_addr_not_from_virtual_network( &self, From 8e1d0791421e95dd73533db07463b3afa08495ae Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Sat, 9 May 2026 09:56:31 +0800 Subject: [PATCH 07/10] feat: add Windows UDP broadcast relay (#2222) This may helps games to find rooms in virtual network. - add opt-in Windows UDP broadcast relay config flag and CLI/env plumbing - capture local UDP broadcasts with Windows raw sockets, normalize packets, and inject them via PeerManager --- .../frontend-lib/src/components/Config.vue | 1 + easytier-web/frontend-lib/src/locales/cn.yaml | 4 + easytier-web/frontend-lib/src/locales/en.yaml | 4 + .../frontend-lib/src/types/network.ts | 4 + easytier/locales/app.yml | 3 + easytier/src/arch/windows.rs | 5 +- easytier/src/common/config.rs | 1 + easytier/src/common/global_ctx.rs | 5 + easytier/src/common/stats_manager.rs | 22 + easytier/src/core.rs | 12 + easytier/src/instance/mod.rs | 3 + easytier/src/instance/public_ipv6_provider.rs | 9 +- easytier/src/instance/virtual_nic.rs | 35 + .../src/instance/windows_udp_broadcast.rs | 1097 +++++++++++++++++ easytier/src/instance_manager.rs | 22 + easytier/src/launcher.rs | 6 + easytier/src/peers/peer_manager.rs | 49 +- easytier/src/proto/api_manage.proto | 1 + easytier/src/proto/common.proto | 1 + 19 files changed, 1271 insertions(+), 13 deletions(-) create mode 100644 easytier/src/instance/windows_udp_broadcast.rs diff --git a/easytier-web/frontend-lib/src/components/Config.vue b/easytier-web/frontend-lib/src/components/Config.vue index a51417e0..2921da15 100644 --- a/easytier-web/frontend-lib/src/components/Config.vue +++ b/easytier-web/frontend-lib/src/components/Config.vue @@ -99,6 +99,7 @@ const bool_flags: BoolFlag[] = [ { field: 'disable_encryption', help: 'disable_encryption_help' }, { field: 'disable_tcp_hole_punching', help: 'disable_tcp_hole_punching_help' }, { field: 'disable_udp_hole_punching', help: 'disable_udp_hole_punching_help' }, + { field: 'enable_udp_broadcast_relay', help: 'enable_udp_broadcast_relay_help' }, { field: 'disable_upnp', help: 'disable_upnp_help' }, { field: 'disable_sym_hole_punching', help: 'disable_sym_hole_punching_help' }, { field: 'enable_magic_dns', help: 'enable_magic_dns_help' }, diff --git a/easytier-web/frontend-lib/src/locales/cn.yaml b/easytier-web/frontend-lib/src/locales/cn.yaml index db2524c6..305ccb76 100644 --- a/easytier-web/frontend-lib/src/locales/cn.yaml +++ b/easytier-web/frontend-lib/src/locales/cn.yaml @@ -160,6 +160,9 @@ disable_tcp_hole_punching_help: 禁用TCP打洞功能 disable_udp_hole_punching: 禁用UDP打洞 disable_udp_hole_punching_help: 禁用UDP打洞功能 +enable_udp_broadcast_relay: UDP 广播中继 +enable_udp_broadcast_relay_help: "仅 Windows:捕获物理网卡上的本机 UDP 广播包并转发给 EasyTier 对等节点,帮助局域网游戏发现房间。需要管理员权限。" + disable_upnp: 禁用 UPnP disable_upnp_help: 禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启。 @@ -260,6 +263,7 @@ event: DhcpIpv4Conflicted: DHCP IPv4地址冲突 PortForwardAdded: 端口转发添加 ProxyCidrsUpdated: 子网代理CIDR更新 + UdpBroadcastRelayStartResult: UDP广播中继启动结果 web: login: diff --git a/easytier-web/frontend-lib/src/locales/en.yaml b/easytier-web/frontend-lib/src/locales/en.yaml index 82a5e6d4..5c5d4337 100644 --- a/easytier-web/frontend-lib/src/locales/en.yaml +++ b/easytier-web/frontend-lib/src/locales/en.yaml @@ -159,6 +159,9 @@ disable_tcp_hole_punching_help: Disable tcp hole punching disable_udp_hole_punching: Disable UDP Hole Punching disable_udp_hole_punching_help: Disable udp hole punching +enable_udp_broadcast_relay: UDP Broadcast Relay +enable_udp_broadcast_relay_help: "Windows only: capture local UDP broadcast packets from physical interfaces and forward them to EasyTier peers. Helps games to find rooms in local network. Requires administrator privileges." + disable_upnp: Disable UPnP disable_upnp_help: Disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default. @@ -260,6 +263,7 @@ event: DhcpIpv4Conflicted: DhcpIpv4Conflicted PortForwardAdded: PortForwardAdded ProxyCidrsUpdated: ProxyCidrsUpdated + UdpBroadcastRelayStartResult: UDP Broadcast Relay Start Result web: login: diff --git a/easytier-web/frontend-lib/src/types/network.ts b/easytier-web/frontend-lib/src/types/network.ts index 2971dd86..a1ae6506 100644 --- a/easytier-web/frontend-lib/src/types/network.ts +++ b/easytier-web/frontend-lib/src/types/network.ts @@ -134,6 +134,7 @@ export interface NetworkConfig { disable_tcp_hole_punching?: boolean disable_udp_hole_punching?: boolean disable_upnp?: boolean + enable_udp_broadcast_relay?: boolean disable_sym_hole_punching?: boolean enable_relay_network_whitelist?: boolean @@ -211,6 +212,7 @@ export function DEFAULT_NETWORK_CONFIG(): NetworkConfig { disable_tcp_hole_punching: false, disable_udp_hole_punching: false, disable_upnp: false, + enable_udp_broadcast_relay: false, disable_sym_hole_punching: false, enable_relay_network_whitelist: false, relay_network_whitelist: [], @@ -447,4 +449,6 @@ export enum EventType { PortForwardAdded = 'PortForwardAdded', // PortForwardConfigPb ProxyCidrsUpdated = 'ProxyCidrsUpdated', // string[], string[] + + UdpBroadcastRelayStartResult = 'UdpBroadcastRelayStartResult', // { capture_backend?: string, error?: string } } diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index a6b98297..710bb4a7 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -184,6 +184,9 @@ core_clap: disable_upnp: en: "disable runtime UPnP/NAT-PMP port mapping for eligible listeners; automatic port mapping is enabled by default" zh-CN: "禁用符合条件监听器的运行时 UPnP/NAT-PMP 端口映射;自动端口映射默认开启" + enable_udp_broadcast_relay: + en: "Windows only: capture local UDP broadcast packets from physical interfaces and forward them to EasyTier peers. Helps games to find rooms in local network. Requires administrator privileges." + zh-CN: "仅 Windows:捕获物理网卡上的本机 UDP 广播包并转发给 EasyTier 对等节点,帮助局域网游戏发现房间。需要管理员权限。" relay_all_peer_rpc: en: "relay all peer rpc packets, even if the peer is not in the relay network whitelist. this can help peers not in relay network whitelist to establish p2p connection." zh-CN: "转发所有对等节点的RPC数据包,即使对等节点不在转发网络白名单中。这可以帮助白名单外网络中的对等节点建立P2P连接。" diff --git a/easytier/src/arch/windows.rs b/easytier/src/arch/windows.rs index 154c6b05..d8e113f0 100644 --- a/easytier/src/arch/windows.rs +++ b/easytier/src/arch/windows.rs @@ -11,9 +11,8 @@ use windows::{ NET_FW_RULE_DIR_OUT, }, Networking::WinSock::{ - IP_UNICAST_IF, IPPROTO_IP, IPPROTO_IPV6, IPV6_UNICAST_IF, SIO_UDP_CONNRESET, - SO_EXCLUSIVEADDRUSE, SOCKET, SOCKET_ERROR, SOL_SOCKET, WSAGetLastError, WSAIoctl, - htonl, setsockopt, + IP_UNICAST_IF, IPPROTO_IP, IPPROTO_IPV6, IPV6_UNICAST_IF, SIO_UDP_CONNRESET, SOCKET, + SOCKET_ERROR, WSAGetLastError, WSAIoctl, htonl, setsockopt, }, System::Com::{ CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index 2f02b8c8..f92a3100 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -72,6 +72,7 @@ pub fn gen_default_flags() -> Flags { instance_recv_bps_limit: u64::MAX, disable_upnp: false, disable_relay_data: false, + enable_udp_broadcast_relay: false, } } diff --git a/easytier/src/common/global_ctx.rs b/easytier/src/common/global_ctx.rs index 48645deb..91a4c457 100644 --- a/easytier/src/common/global_ctx.rs +++ b/easytier/src/common/global_ctx.rs @@ -77,6 +77,11 @@ pub enum GlobalCtxEvent { ProxyCidrsUpdated(Vec, Vec), // (added, removed) + UdpBroadcastRelayStartResult { + capture_backend: Option, + error: Option, + }, + CredentialChanged, } diff --git a/easytier/src/common/stats_manager.rs b/easytier/src/common/stats_manager.rs index b7314dd6..ecbde860 100644 --- a/easytier/src/common/stats_manager.rs +++ b/easytier/src/common/stats_manager.rs @@ -85,6 +85,15 @@ pub enum MetricName { /// Traffic packets forwarded for foreign network, forward TrafficPacketsForeignForwardForwarded, + /// UDP broadcast relay packets captured from the raw socket + UdpBroadcastRelayPacketsCaptured, + /// UDP broadcast relay packets ignored before forwarding + UdpBroadcastRelayPacketsIgnored, + /// UDP broadcast relay packets forwarded + UdpBroadcastRelayPacketsForwarded, + /// UDP broadcast relay packets that failed to forward + UdpBroadcastRelayPacketsForwardFailed, + /// Compression bytes before compression CompressionBytesRxBefore, /// Compression bytes after compression @@ -167,6 +176,19 @@ impl fmt::Display for MetricName { write!(f, "traffic_packets_foreign_forward_forwarded") } + MetricName::UdpBroadcastRelayPacketsCaptured => { + write!(f, "udp_broadcast_relay_packets_captured") + } + MetricName::UdpBroadcastRelayPacketsIgnored => { + write!(f, "udp_broadcast_relay_packets_ignored") + } + MetricName::UdpBroadcastRelayPacketsForwarded => { + write!(f, "udp_broadcast_relay_packets_forwarded") + } + MetricName::UdpBroadcastRelayPacketsForwardFailed => { + write!(f, "udp_broadcast_relay_packets_forward_failed") + } + MetricName::CompressionBytesRxBefore => write!(f, "compression_bytes_rx_before"), MetricName::CompressionBytesRxAfter => write!(f, "compression_bytes_rx_after"), MetricName::CompressionBytesTxBefore => write!(f, "compression_bytes_tx_before"), diff --git a/easytier/src/core.rs b/easytier/src/core.rs index 862f4937..85875135 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -484,6 +484,15 @@ struct NetworkOptions { )] disable_upnp: Option, + #[arg( + long, + env = "ET_ENABLE_UDP_BROADCAST_RELAY", + help = t!("core_clap.enable_udp_broadcast_relay").to_string(), + num_args = 0..=1, + default_missing_value = "true" + )] + enable_udp_broadcast_relay: Option, + #[arg( long, env = "ET_RELAY_ALL_PEER_RPC", @@ -1142,6 +1151,9 @@ impl NetworkOptions { .disable_sym_hole_punching .unwrap_or(f.disable_sym_hole_punching); f.disable_upnp = self.disable_upnp.unwrap_or(f.disable_upnp); + f.enable_udp_broadcast_relay = self + .enable_udp_broadcast_relay + .unwrap_or(f.enable_udp_broadcast_relay); // Configure tld_dns_zone: use provided value if set if let Some(tld_dns_zone) = &self.tld_dns_zone { f.tld_dns_zone = tld_dns_zone.clone(); diff --git a/easytier/src/instance/mod.rs b/easytier/src/instance/mod.rs index 756341c4..2535fd1b 100644 --- a/easytier/src/instance/mod.rs +++ b/easytier/src/instance/mod.rs @@ -10,3 +10,6 @@ pub mod proxy_cidrs_monitor; #[cfg(feature = "tun")] pub mod virtual_nic; + +#[cfg(any(windows, test))] +pub(crate) mod windows_udp_broadcast; diff --git a/easytier/src/instance/public_ipv6_provider.rs b/easytier/src/instance/public_ipv6_provider.rs index 999a9bb3..e27f75c6 100644 --- a/easytier/src/instance/public_ipv6_provider.rs +++ b/easytier/src/instance/public_ipv6_provider.rs @@ -1,5 +1,8 @@ -use std::{path::Path, sync::Arc}; +#[cfg(target_os = "linux")] +use std::path::Path; +use std::sync::Arc; +#[cfg(target_os = "linux")] use anyhow::Context; use cidr::{Ipv6Cidr, Ipv6Inet}; #[cfg(target_os = "linux")] @@ -321,7 +324,7 @@ async fn resolve_public_ipv6_provider_runtime_state_linux( } async fn resolve_public_ipv6_provider_runtime_state( - global_ctx: &ArcGlobalCtx, + _global_ctx: &ArcGlobalCtx, config: PublicIpv6ProviderConfigSnapshot, ) -> PublicIpv6ProviderRuntimeState { if !config.provider_enabled { @@ -331,7 +334,7 @@ async fn resolve_public_ipv6_provider_runtime_state( #[cfg(target_os = "linux")] { return resolve_public_ipv6_provider_runtime_state_linux( - global_ctx, + _global_ctx, config.configured_prefix, ) .await; diff --git a/easytier/src/instance/virtual_nic.rs b/easytier/src/instance/virtual_nic.rs index 1b422f6a..1faa1fd5 100644 --- a/easytier/src/instance/virtual_nic.rs +++ b/easytier/src/instance/virtual_nic.rs @@ -35,6 +35,8 @@ use tokio::{ task::JoinSet, }; use tokio_util::bytes::Bytes; +#[cfg(target_os = "windows")] +use tokio_util::task::AbortOnDropHandle; use tun::{AbstractDevice, AsyncDevice, Configuration, Layer}; use zerocopy::{NativeEndian, NetworkEndian}; @@ -801,6 +803,9 @@ pub struct NicCtx { nic: Arc>, tasks: JoinSet<()>, + + #[cfg(target_os = "windows")] + windows_udp_broadcast_relay: Option>, } impl NicCtx { @@ -819,6 +824,9 @@ impl NicCtx { nic: Arc::new(Mutex::new(VirtualNic::new(global_ctx))), tasks: JoinSet::new(), + + #[cfg(target_os = "windows")] + windows_udp_broadcast_relay: None, } } @@ -1005,6 +1013,31 @@ impl NicCtx { }); } + #[cfg(target_os = "windows")] + fn start_windows_udp_broadcast_relay(&mut self, virtual_ipv4: Ipv4Inet) { + if !self.global_ctx.get_flags().enable_udp_broadcast_relay { + return; + } + + let Some(peer_manager) = self.peer_mgr.upgrade() else { + tracing::warn!("peer manager is dropped, skip Windows UDP broadcast relay"); + return; + }; + + match super::windows_udp_broadcast::start(peer_manager, virtual_ipv4) { + Ok(handle) => { + self.windows_udp_broadcast_relay = Some(handle); + tracing::info!("Windows UDP broadcast relay started"); + } + Err(err) => { + tracing::warn!( + ?err, + "failed to start Windows UDP broadcast relay; administrator privileges are required" + ); + } + } + } + async fn apply_route_changes( ifcfg: &impl IfConfiguerTrait, ifname: &str, @@ -1347,6 +1380,8 @@ impl NicCtx { // Assign IPv4 address if provided if let Some(ipv4_addr) = ipv4_addr { self.assign_ipv4_to_tun_device(ipv4_addr).await?; + #[cfg(target_os = "windows")] + self.start_windows_udp_broadcast_relay(ipv4_addr); } // Assign IPv6 address if provided diff --git a/easytier/src/instance/windows_udp_broadcast.rs b/easytier/src/instance/windows_udp_broadcast.rs new file mode 100644 index 00000000..b49ee9e4 --- /dev/null +++ b/easytier/src/instance/windows_udp_broadcast.rs @@ -0,0 +1,1097 @@ +use std::net::Ipv4Addr; + +use cidr::Ipv4Inet; +use pnet::packet::{ + ip::IpNextHeaderProtocols, + ipv4::{self, Ipv4Flags, Ipv4Packet, MutableIpv4Packet}, + udp::{self, MutableUdpPacket, UdpPacket}, +}; + +#[cfg(any(windows, test))] +use { + crate::{ + common::global_ctx::GlobalCtxEvent, + common::stats_manager::{CounterHandle, LabelSet, LabelType, MetricName}, + peers::peer_manager::PeerManager, + tunnel::packet_def::ZCPacket, + }, + anyhow::Context, + network_interface::{Addr, NetworkInterface, NetworkInterfaceConfig}, + socket2::{Domain, Protocol, SockAddr, Socket, Type}, + std::{ + io, + net::{IpAddr, SocketAddrV4, UdpSocket as StdUdpSocket}, + sync::Arc, + }, + tokio_util::task::AbortOnDropHandle, +}; + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +use windivert::{ + WinDivert, + error::WinDivertError, + layer, + packet::WinDivertPacket, + prelude::{WinDivertFlags, WinDivertShutdownMode}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct PhysicalInterface { + addr: Ipv4Addr, + directed_broadcast: Ipv4Addr, +} + +impl PhysicalInterface { + fn from_ip_and_prefix(addr: Ipv4Addr, prefix: u8) -> Option { + if should_ignore_interface_addr(addr) || prefix > 30 { + return None; + } + + Some(Self { + addr, + directed_broadcast: directed_broadcast(addr, prefix)?, + }) + } +} + +#[derive(Debug, Clone)] +struct BroadcastRelayConfig { + virtual_ipv4: Ipv4Inet, + physical_interfaces: Vec, +} + +impl BroadcastRelayConfig { + fn new(virtual_ipv4: Ipv4Inet, physical_interfaces: Vec) -> Self { + Self { + virtual_ipv4, + physical_interfaces, + } + } + + fn is_physical_source(&self, addr: Ipv4Addr) -> bool { + self.physical_interfaces + .iter() + .any(|iface| iface.addr == addr) + } + + fn normalize_destination(&self, dst: Ipv4Addr) -> Option { + if dst.is_broadcast() || dst.is_multicast() { + return Some(dst); + } + + self.physical_interfaces + .iter() + .any(|iface| iface.directed_broadcast == dst) + .then_some(self.virtual_ipv4.last_address()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NormalizedPacket { + packet: Vec, + destination: Ipv4Addr, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct UdpPacketSummary { + src: Ipv4Addr, + dst: Ipv4Addr, + src_port: u16, + dst_port: u16, + ip_len: usize, + udp_len: usize, + payload_len: usize, +} + +impl UdpPacketSummary { + fn parse(packet: &[u8]) -> Option { + let ipv4_packet = Ipv4Packet::new(packet)?; + if ipv4_packet.get_version() != 4 + || ipv4_packet.get_next_level_protocol() != IpNextHeaderProtocols::Udp + { + return None; + } + + let header_len = usize::from(ipv4_packet.get_header_length()) * 4; + let total_len = usize::from(ipv4_packet.get_total_length()); + if header_len < Ipv4Packet::minimum_packet_size() + || total_len < header_len + UdpPacket::minimum_packet_size() + || total_len > packet.len() + { + return None; + } + + let udp_packet = UdpPacket::new(&packet[header_len..total_len])?; + let udp_len = usize::from(udp_packet.get_length()); + if udp_len < UdpPacket::minimum_packet_size() || header_len + udp_len != total_len { + return None; + } + + Some(Self { + src: ipv4_packet.get_source(), + dst: ipv4_packet.get_destination(), + src_port: udp_packet.get_source(), + dst_port: udp_packet.get_destination(), + ip_len: total_len, + udp_len, + payload_len: udp_len - UdpPacket::minimum_packet_size(), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ParsedUdpBroadcastPacket { + header_len: usize, + udp_len: usize, + normalized_destination: Ipv4Addr, + summary: UdpPacketSummary, +} + +#[cfg(any(windows, test))] +#[derive(Clone)] +struct BroadcastRelayStats { + packets_captured: CounterHandle, + packets_ignored: CounterHandle, + packets_forwarded: CounterHandle, + packets_forward_failed: CounterHandle, +} + +#[cfg(any(windows, test))] +impl BroadcastRelayStats { + fn new(peer_manager: &PeerManager) -> Self { + let global_ctx = peer_manager.get_global_ctx(); + let label_set = + LabelSet::new().with_label_type(LabelType::NetworkName(global_ctx.get_network_name())); + let stats_manager = global_ctx.stats_manager(); + + Self { + packets_captured: stats_manager.get_counter( + MetricName::UdpBroadcastRelayPacketsCaptured, + label_set.clone(), + ), + packets_ignored: stats_manager.get_counter( + MetricName::UdpBroadcastRelayPacketsIgnored, + label_set.clone(), + ), + packets_forwarded: stats_manager.get_counter( + MetricName::UdpBroadcastRelayPacketsForwarded, + label_set.clone(), + ), + packets_forward_failed: stats_manager + .get_counter(MetricName::UdpBroadcastRelayPacketsForwardFailed, label_set), + } + } + + fn record_captured(&self) { + self.packets_captured.inc(); + } + + fn record_ignored(&self) { + self.packets_ignored.inc(); + } + + fn record_forwarded(&self) { + self.packets_forwarded.inc(); + } + + fn record_forward_failed(&self) { + self.packets_forward_failed.inc(); + } +} + +fn should_ignore_interface_addr(addr: Ipv4Addr) -> bool { + addr.is_unspecified() || addr.is_loopback() || addr.is_multicast() || addr.is_broadcast() +} + +fn prefix_len_from_netmask(mask: Ipv4Addr) -> Option { + let raw = u32::from(mask); + let prefix = raw.count_ones() as u8; + let expected = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + (raw == expected).then_some(prefix) +} + +fn directed_broadcast(addr: Ipv4Addr, prefix: u8) -> Option { + if prefix > 32 { + return None; + } + + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Some(Ipv4Addr::from(u32::from(addr) | !mask)) +} + +fn parse_udp_broadcast( + packet: &[u8], + config: &BroadcastRelayConfig, +) -> Result { + let ipv4_packet = Ipv4Packet::new(packet).ok_or("malformed_ipv4")?; + if ipv4_packet.get_version() != 4 + || ipv4_packet.get_next_level_protocol() != IpNextHeaderProtocols::Udp + { + return Err("not_udp_ipv4"); + } + + if ipv4_packet.get_fragment_offset() != 0 + || ipv4_packet.get_flags() & Ipv4Flags::MoreFragments != 0 + { + return Err("fragmented"); + } + + let header_len = usize::from(ipv4_packet.get_header_length()) * 4; + let total_len = usize::from(ipv4_packet.get_total_length()); + if header_len < Ipv4Packet::minimum_packet_size() + || total_len < header_len + UdpPacket::minimum_packet_size() + || total_len > packet.len() + { + return Err("bad_ipv4_length"); + } + + let src = ipv4_packet.get_source(); + let dst = ipv4_packet.get_destination(); + if should_ignore_interface_addr(src) { + return Err("ignored_source"); + } + if src == config.virtual_ipv4.address() { + return Err("virtual_source_duplicate"); + } + if !config.is_physical_source(src) { + return Err("non_physical_source"); + } + + let normalized_destination = config + .normalize_destination(dst) + .ok_or("unsupported_destination")?; + if normalized_destination.is_loopback() { + return Err("loopback_destination"); + } + + let udp_packet = UdpPacket::new(&packet[header_len..total_len]).ok_or("malformed_udp")?; + let udp_len = usize::from(udp_packet.get_length()); + if udp_len < UdpPacket::minimum_packet_size() || header_len + udp_len != total_len { + return Err("bad_udp_length"); + } + + Ok(ParsedUdpBroadcastPacket { + header_len, + udp_len, + normalized_destination, + summary: UdpPacketSummary { + src, + dst, + src_port: udp_packet.get_source(), + dst_port: udp_packet.get_destination(), + ip_len: total_len, + udp_len, + payload_len: udp_len - UdpPacket::minimum_packet_size(), + }, + }) +} + +fn log_ignored_udp_packet(packet: &[u8], reason: &'static str) { + if let Some(summary) = UdpPacketSummary::parse(packet) { + tracing::debug!( + src = %summary.src, + dst = %summary.dst, + src_port = summary.src_port, + dst_port = summary.dst_port, + ip_len = summary.ip_len, + udp_len = summary.udp_len, + payload_len = summary.payload_len, + reason, + "ignored Windows UDP broadcast packet" + ); + } else { + tracing::debug!( + packet_len = packet.len(), + reason, + "ignored malformed Windows UDP raw packet" + ); + } +} + +fn normalize_udp_broadcast_packet( + packet: &[u8], + config: &BroadcastRelayConfig, +) -> Option { + let parsed = match parse_udp_broadcast(packet, config) { + Ok(parsed) => parsed, + Err(reason) => { + if tracing::enabled!(tracing::Level::DEBUG) { + log_ignored_udp_packet(packet, reason); + } + return None; + } + }; + let header_len = parsed.header_len; + let udp_len = parsed.udp_len; + let destination = parsed.normalized_destination; + let summary = parsed.summary; + let packet_len = header_len + udp_len; + let virtual_ipv4 = config.virtual_ipv4.address(); + let mut normalized = packet[..packet_len].to_vec(); + + { + let mut ipv4_packet = MutableIpv4Packet::new(&mut normalized)?; + ipv4_packet.set_source(virtual_ipv4); + ipv4_packet.set_destination(destination); + ipv4_packet.set_total_length(packet_len as u16); + ipv4_packet.set_checksum(0); + } + + { + let mut udp_packet = MutableUdpPacket::new(&mut normalized[header_len..packet_len])?; + udp_packet.set_checksum(0); + let checksum = udp::ipv4_checksum(&udp_packet.to_immutable(), &virtual_ipv4, &destination); + udp_packet.set_checksum(checksum); + } + + { + let mut ipv4_packet = MutableIpv4Packet::new(&mut normalized)?; + let checksum = ipv4::checksum(&ipv4_packet.to_immutable()); + ipv4_packet.set_checksum(checksum); + } + + tracing::debug!( + src = %summary.src, + dst = %summary.dst, + src_port = summary.src_port, + dst_port = summary.dst_port, + ip_len = summary.ip_len, + udp_len = summary.udp_len, + payload_len = summary.payload_len, + normalized_src = %virtual_ipv4, + normalized_dst = %destination, + "normalized Windows UDP broadcast packet" + ); + + Some(NormalizedPacket { + packet: normalized, + destination, + }) +} + +#[cfg(any(windows, test))] +fn log_captured_udp_packet(packet: &[u8]) { + if let Some(summary) = UdpPacketSummary::parse(packet) { + tracing::debug!( + src = %summary.src, + dst = %summary.dst, + src_port = summary.src_port, + dst_port = summary.dst_port, + ip_len = summary.ip_len, + udp_len = summary.udp_len, + payload_len = summary.payload_len, + "captured Windows UDP broadcast candidate" + ); + } else { + tracing::debug!( + packet_len = packet.len(), + "captured malformed Windows UDP broadcast candidate" + ); + } +} + +#[cfg(any(windows, test))] +fn collect_physical_interfaces(virtual_ipv4: Ipv4Inet) -> anyhow::Result> { + let mut ret = Vec::new(); + for iface in NetworkInterface::show().context("failed to list Windows network interfaces")? { + if iface.internal { + continue; + } + + for addr in iface.addr { + let Addr::V4(v4) = addr else { + continue; + }; + if v4.ip == virtual_ipv4.address() { + continue; + } + + let Some(netmask) = v4.netmask else { + continue; + }; + let Some(prefix) = prefix_len_from_netmask(netmask) else { + tracing::debug!( + iface = %iface.name, + ip = %v4.ip, + mask = %netmask, + "ignoring interface with non-contiguous IPv4 netmask" + ); + continue; + }; + let Some(physical) = PhysicalInterface::from_ip_and_prefix(v4.ip, prefix) else { + continue; + }; + if !ret.contains(&physical) { + ret.push(physical); + } + } + } + Ok(ret) +} + +#[cfg(any(windows, test))] +fn join_addr_equals(field: &str, addrs: &[Ipv4Addr]) -> String { + addrs + .iter() + .map(|addr| format!("{field} == {addr}")) + .collect::>() + .join(" or ") +} + +#[cfg(any(windows, test))] +fn build_windivert_udp_filter(physical_interfaces: &[PhysicalInterface]) -> String { + let mut src_addrs = Vec::new(); + let mut directed_broadcasts = Vec::new(); + + for iface in physical_interfaces { + if !src_addrs.contains(&iface.addr) { + src_addrs.push(iface.addr); + } + if !directed_broadcasts.contains(&iface.directed_broadcast) { + directed_broadcasts.push(iface.directed_broadcast); + } + } + + if src_addrs.is_empty() { + return "false".to_owned(); + } + + let src_filter = join_addr_equals("ip.SrcAddr", &src_addrs); + let mut dst_filters = vec!["ip.DstAddr == 255.255.255.255".to_owned()]; + if !directed_broadcasts.is_empty() { + dst_filters.push(join_addr_equals("ip.DstAddr", &directed_broadcasts)); + } + dst_filters.push("(ip.DstAddr >= 224.0.0.0 and ip.DstAddr <= 239.255.255.255)".to_owned()); + + format!( + "outbound and ip and udp and ({}) and ({})", + src_filter, + dst_filters.join(" or ") + ) +} + +#[cfg(any(windows, test))] +fn open_raw_udp_socket() -> io::Result { + let socket = Socket::new(Domain::IPV4, Type::RAW, Some(Protocol::UDP))?; + // Match ubihazard/broadcast: use one raw UDP listener on loopback, then + // inspect the IPv4 header to identify the real physical source interface. + socket.bind(&SockAddr::from(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)))?; + socket.set_nonblocking(true)?; + Ok(socket) +} + +#[cfg(windows)] +fn socket2_into_udp_socket(socket: Socket) -> StdUdpSocket { + use std::os::windows::io::{FromRawSocket, IntoRawSocket}; + + // The raw socket handle came from socket2 and is transferred exactly once. + unsafe { StdUdpSocket::from_raw_socket(socket.into_raw_socket()) } +} + +#[cfg(all(not(windows), unix))] +fn socket2_into_udp_socket(socket: Socket) -> StdUdpSocket { + use std::os::fd::{FromRawFd, IntoRawFd}; + + // The raw socket fd came from socket2 and is transferred exactly once. + unsafe { StdUdpSocket::from_raw_fd(socket.into_raw_fd()) } +} + +#[cfg(any(windows, test))] +struct RawUdpCaptureSocket { + socket: tokio::net::UdpSocket, + buf: Vec, +} + +#[cfg(any(windows, test))] +impl RawUdpCaptureSocket { + const MAX_PACKET_LEN: usize = 65_535; + + fn open() -> anyhow::Result { + let socket = open_raw_udp_socket().with_context(|| { + "failed to open Windows raw UDP broadcast listener; administrator privileges are required" + })?; + let socket = socket2_into_udp_socket(socket); + let socket = tokio::net::UdpSocket::from_std(socket) + .context("failed to register Windows raw UDP broadcast listener with Tokio")?; + + Ok(Self { + socket, + buf: vec![0; Self::MAX_PACKET_LEN], + }) + } + + async fn recv(&mut self) -> io::Result<&[u8]> { + let len = self.socket.recv(&mut self.buf).await?; + Ok(&self.buf[..len]) + } +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +struct WinDivertCaptureReader { + inner: std::cell::UnsafeCell>, +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +unsafe impl Send for WinDivertCaptureReader {} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +unsafe impl Sync for WinDivertCaptureReader {} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +impl WinDivertCaptureReader { + fn new(inner: WinDivert) -> Self { + Self { + inner: std::cell::UnsafeCell::new(inner), + } + } + + fn recv<'a>( + &self, + buffer: Option<&'a mut [u8]>, + ) -> Result, WinDivertError> { + let inner = unsafe { &*self.inner.get() }; + inner.recv(buffer) + } + + fn shutdown(&self) -> anyhow::Result<()> { + let inner = unsafe { &mut *self.inner.get() }; + inner + .shutdown(WinDivertShutdownMode::Recv) + .with_context(|| "WinDivert UDP broadcast capture shutdown failed")?; + Ok(()) + } + + fn close(&self) -> anyhow::Result<()> { + let inner = unsafe { &mut *self.inner.get() }; + inner + .close(windivert::CloseAction::Nothing) + .with_context(|| "WinDivert UDP broadcast capture close failed")?; + Ok(()) + } +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +impl Drop for WinDivertCaptureReader { + fn drop(&mut self) { + if let Err(err) = self.close() { + tracing::error!(?err, "WinDivert UDP broadcast capture close failed"); + } + } +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +struct WinDivertCaptureSocket { + rx: tokio::sync::mpsc::Receiver>, + reader: Arc, + buf: Vec, +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +impl WinDivertCaptureSocket { + const CHANNEL_CAPACITY: usize = 1024; + const MAX_PACKET_LEN: usize = 65_535; + + fn open(config: &BroadcastRelayConfig) -> anyhow::Result { + let filter = build_windivert_udp_filter(&config.physical_interfaces); + tracing::debug!( + filter = %filter, + "opening WinDivert UDP broadcast capture backend" + ); + + let flags = WinDivertFlags::default().set_sniff(); + let reader = WinDivert::network(&filter, 0, flags) + .map_err(io::Error::other) + .with_context(|| "failed to open WinDivert UDP broadcast capture")?; + let reader = Arc::new(WinDivertCaptureReader::new(reader)); + let reader_clone = reader.clone(); + let (tx, rx) = tokio::sync::mpsc::channel(Self::CHANNEL_CAPACITY); + + std::thread::Builder::new() + .name("easytier-udp-broadcast-windivert".to_owned()) + .spawn(move || { + let mut buffer = vec![0; Self::MAX_PACKET_LEN]; + loop { + match reader_clone.recv(Some(&mut buffer)) { + Ok(packet) => { + if tx.blocking_send(packet.data.to_vec()).is_err() { + break; + } + } + Err(err) => { + tracing::warn!(?err, "WinDivert UDP broadcast capture receive failed"); + break; + } + } + } + }) + .with_context(|| "failed to spawn WinDivert UDP broadcast capture thread")?; + + Ok(Self { + rx, + reader, + buf: Vec::new(), + }) + } + + async fn recv(&mut self) -> io::Result<&[u8]> { + self.buf = self.rx.recv().await.ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "WinDivert UDP broadcast capture stopped", + ) + })?; + Ok(&self.buf) + } +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +impl Drop for WinDivertCaptureSocket { + fn drop(&mut self) { + if let Err(err) = self.reader.shutdown() { + tracing::debug!(?err, "WinDivert UDP broadcast capture shutdown failed"); + } + } +} + +#[cfg(any(windows, test))] +enum CaptureSocket { + Raw(RawUdpCaptureSocket), + #[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] + WinDivert(WinDivertCaptureSocket), +} + +#[cfg(any(windows, test))] +impl CaptureSocket { + async fn recv(&mut self) -> io::Result<&[u8]> { + match self { + Self::Raw(socket) => socket.recv().await, + #[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] + Self::WinDivert(socket) => socket.recv().await, + } + } + + fn backend_name(&self) -> &'static str { + match self { + Self::Raw(_) => "raw_socket", + #[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] + Self::WinDivert(_) => "windivert", + } + } + + fn fallback_to_raw(&mut self) -> anyhow::Result { + #[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] + { + if matches!(self, Self::WinDivert(_)) { + *self = Self::Raw(RawUdpCaptureSocket::open()?); + return Ok(true); + } + } + + Ok(false) + } +} + +#[cfg(all(windows, any(target_arch = "x86_64", target_arch = "x86")))] +fn open_capture_socket(config: &BroadcastRelayConfig) -> anyhow::Result { + match WinDivertCaptureSocket::open(config) { + Ok(socket) => Ok(CaptureSocket::WinDivert(socket)), + Err(err) => { + tracing::warn!( + ?err, + "WinDivert UDP broadcast capture unavailable; falling back to raw socket" + ); + RawUdpCaptureSocket::open().map(CaptureSocket::Raw) + } + } +} + +#[cfg(all( + any(windows, test), + not(all(windows, any(target_arch = "x86_64", target_arch = "x86"))) +))] +fn open_capture_socket(_config: &BroadcastRelayConfig) -> anyhow::Result { + RawUdpCaptureSocket::open().map(CaptureSocket::Raw) +} + +#[cfg(any(windows, test))] +fn issue_start_result_event( + peer_manager: &PeerManager, + capture_backend: Option<&str>, + error: Option, +) { + peer_manager + .get_global_ctx() + .issue_event(GlobalCtxEvent::UdpBroadcastRelayStartResult { + capture_backend: capture_backend.map(str::to_owned), + error, + }); +} + +#[cfg(any(windows, test))] +async fn forward_normalized_packet( + peer_manager: &PeerManager, + normalized: NormalizedPacket, + stats: &BroadcastRelayStats, +) { + let packet = ZCPacket::new_with_payload(&normalized.packet); + let ret = peer_manager + .send_msg_by_ip(packet, IpAddr::V4(normalized.destination), true) + .await; + + let summary = UdpPacketSummary::parse(&normalized.packet); + match ret { + Ok(_) => { + stats.record_forwarded(); + + if let Some(summary) = summary { + tracing::debug!( + src = %summary.src, + dst = %summary.dst, + src_port = summary.src_port, + dst_port = summary.dst_port, + ip_len = summary.ip_len, + udp_len = summary.udp_len, + payload_len = summary.payload_len, + peer_dst = %normalized.destination, + broadcast = true, + "forwarded Windows UDP broadcast packet" + ); + } else { + tracing::debug!( + packet_len = normalized.packet.len(), + peer_dst = %normalized.destination, + broadcast = true, + "forwarded Windows UDP broadcast packet" + ); + } + } + Err(err) => { + stats.record_forward_failed(); + + if let Some(summary) = summary { + tracing::debug!( + src = %summary.src, + dst = %summary.dst, + src_port = summary.src_port, + dst_port = summary.dst_port, + ip_len = summary.ip_len, + udp_len = summary.udp_len, + payload_len = summary.payload_len, + peer_dst = %normalized.destination, + broadcast = true, + ?err, + "failed to forward Windows UDP broadcast packet" + ); + } else { + tracing::debug!( + packet_len = normalized.packet.len(), + peer_dst = %normalized.destination, + broadcast = true, + ?err, + "failed to forward Windows UDP broadcast packet" + ); + } + } + } +} + +#[cfg(any(windows, test))] +async fn capture_loop( + peer_manager: Arc, + config: BroadcastRelayConfig, + mut socket: CaptureSocket, + stats: BroadcastRelayStats, +) { + let mut capture_backend = socket.backend_name(); + + loop { + let normalized = match socket.recv().await { + Ok(packet) => { + stats.record_captured(); + if tracing::enabled!(tracing::Level::DEBUG) { + log_captured_udp_packet(packet); + } + let normalized = normalize_udp_broadcast_packet(packet, &config); + if normalized.is_none() { + stats.record_ignored(); + } + normalized + } + Err(err) => { + tracing::warn!( + ?err, + capture_backend, + "Windows UDP broadcast capture receive failed" + ); + match socket.fallback_to_raw() { + Ok(true) => { + let old_backend = capture_backend; + capture_backend = socket.backend_name(); + tracing::warn!( + old_backend, + new_backend = capture_backend, + "Windows UDP broadcast capture backend fell back" + ); + } + Ok(false) => {} + Err(fallback_err) => { + tracing::error!( + ?fallback_err, + "Windows UDP broadcast raw socket fallback failed; stopping relay" + ); + break; + } + } + continue; + } + }; + + if let Some(normalized) = normalized { + forward_normalized_packet(&peer_manager, normalized, &stats).await; + } + } +} + +#[cfg(any(windows, test))] +pub(crate) fn start( + peer_manager: Arc, + virtual_ipv4: Ipv4Inet, +) -> anyhow::Result> { + let physical_interfaces = match collect_physical_interfaces(virtual_ipv4) { + Ok(interfaces) => interfaces, + Err(err) => { + issue_start_result_event(&peer_manager, None, Some(format!("{err:#}"))); + return Err(err); + } + }; + if physical_interfaces.is_empty() { + let msg = "no physical IPv4 interface is available for UDP broadcast relay"; + issue_start_result_event(&peer_manager, None, Some(msg.to_owned())); + anyhow::bail!(msg); + } + + let config = BroadcastRelayConfig::new(virtual_ipv4, physical_interfaces); + let socket = match open_capture_socket(&config) { + Ok(socket) => socket, + Err(err) => { + issue_start_result_event(&peer_manager, None, Some(format!("{err:#}"))); + return Err(err); + } + }; + let capture_backend = socket.backend_name(); + issue_start_result_event(&peer_manager, Some(capture_backend), None); + + tracing::debug!( + virtual_ipv4 = %config.virtual_ipv4, + physical_interfaces = ?config.physical_interfaces, + capture_backend, + "starting Windows UDP broadcast relay" + ); + + let stats = BroadcastRelayStats::new(&peer_manager); + let task = tokio::spawn(capture_loop(peer_manager, config, socket, stats)); + Ok(AbortOnDropHandle::new(task)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pnet::packet::{MutablePacket, Packet}; + + fn config() -> BroadcastRelayConfig { + BroadcastRelayConfig::new( + "10.144.144.1/24".parse().unwrap(), + vec![PhysicalInterface::from_ip_and_prefix(Ipv4Addr::new(192, 168, 1, 7), 24).unwrap()], + ) + } + + fn build_udp_packet(src: Ipv4Addr, dst: Ipv4Addr, payload: &[u8]) -> Vec { + let mut packet = vec![0; 20 + 8 + payload.len()]; + { + let mut ipv4_packet = MutableIpv4Packet::new(&mut packet).unwrap(); + ipv4_packet.set_version(4); + ipv4_packet.set_header_length(5); + ipv4_packet.set_total_length((20 + 8 + payload.len()) as u16); + ipv4_packet.set_ttl(64); + ipv4_packet.set_next_level_protocol(IpNextHeaderProtocols::Udp); + ipv4_packet.set_source(src); + ipv4_packet.set_destination(dst); + } + + { + let mut udp_packet = MutableUdpPacket::new(&mut packet[20..]).unwrap(); + udp_packet.set_source(12345); + udp_packet.set_destination(37020); + udp_packet.set_length((8 + payload.len()) as u16); + udp_packet.payload_mut().copy_from_slice(payload); + let checksum = udp::ipv4_checksum(&udp_packet.to_immutable(), &src, &dst); + udp_packet.set_checksum(checksum); + } + + { + let mut ipv4_packet = MutableIpv4Packet::new(&mut packet).unwrap(); + let checksum = ipv4::checksum(&ipv4_packet.to_immutable()); + ipv4_packet.set_checksum(checksum); + } + + packet + } + + fn assert_valid_checksums(packet: &[u8]) { + let ipv4_packet = Ipv4Packet::new(packet).unwrap(); + assert_eq!(ipv4::checksum(&ipv4_packet), ipv4_packet.get_checksum()); + let udp_packet = UdpPacket::new(ipv4_packet.payload()).unwrap(); + assert_eq!( + udp::ipv4_checksum( + &udp_packet, + &ipv4_packet.get_source(), + &ipv4_packet.get_destination() + ), + udp_packet.get_checksum() + ); + } + + #[test] + fn windows_udp_broadcast_rewrites_limited_broadcast() { + let packet = build_udp_packet(Ipv4Addr::new(192, 168, 1, 7), Ipv4Addr::BROADCAST, b"hello"); + + let normalized = normalize_udp_broadcast_packet(&packet, &config()).unwrap(); + let ipv4_packet = Ipv4Packet::new(&normalized.packet).unwrap(); + + assert_eq!(normalized.destination, Ipv4Addr::BROADCAST); + assert_eq!(ipv4_packet.get_source(), Ipv4Addr::new(10, 144, 144, 1)); + assert_eq!(ipv4_packet.get_destination(), Ipv4Addr::BROADCAST); + assert_eq!(&ipv4_packet.payload()[8..], b"hello"); + assert_valid_checksums(&normalized.packet); + } + + #[test] + fn windows_udp_broadcast_rewrites_directed_broadcast() { + let packet = build_udp_packet( + Ipv4Addr::new(192, 168, 1, 7), + Ipv4Addr::new(192, 168, 1, 255), + b"directed", + ); + + let normalized = normalize_udp_broadcast_packet(&packet, &config()).unwrap(); + let ipv4_packet = Ipv4Packet::new(&normalized.packet).unwrap(); + + assert_eq!(normalized.destination, Ipv4Addr::new(10, 144, 144, 255)); + assert_eq!(ipv4_packet.get_source(), Ipv4Addr::new(10, 144, 144, 1)); + assert_eq!( + ipv4_packet.get_destination(), + Ipv4Addr::new(10, 144, 144, 255) + ); + assert_eq!(&ipv4_packet.payload()[8..], b"directed"); + assert_valid_checksums(&normalized.packet); + } + + #[test] + fn windows_udp_broadcast_preserves_multicast_destination() { + let multicast = Ipv4Addr::new(239, 255, 255, 250); + let packet = build_udp_packet(Ipv4Addr::new(192, 168, 1, 7), multicast, b"multicast"); + + let normalized = normalize_udp_broadcast_packet(&packet, &config()).unwrap(); + let ipv4_packet = Ipv4Packet::new(&normalized.packet).unwrap(); + + assert_eq!(normalized.destination, multicast); + assert_eq!(ipv4_packet.get_source(), Ipv4Addr::new(10, 144, 144, 1)); + assert_eq!(ipv4_packet.get_destination(), multicast); + assert_eq!(&ipv4_packet.payload()[8..], b"multicast"); + assert_valid_checksums(&normalized.packet); + } + + #[test] + fn windows_udp_broadcast_rejects_malformed_packets() { + assert!(normalize_udp_broadcast_packet(&[], &config()).is_none()); + + let mut packet = + build_udp_packet(Ipv4Addr::new(192, 168, 1, 7), Ipv4Addr::BROADCAST, b"bad"); + packet[2..4].copy_from_slice(&10u16.to_be_bytes()); + assert!(normalize_udp_broadcast_packet(&packet, &config()).is_none()); + } + + #[test] + fn windows_udp_broadcast_rejects_fragments() { + let mut packet = build_udp_packet( + Ipv4Addr::new(192, 168, 1, 7), + Ipv4Addr::BROADCAST, + b"fragment", + ); + { + let mut ipv4_packet = MutableIpv4Packet::new(&mut packet).unwrap(); + ipv4_packet.set_flags(Ipv4Flags::MoreFragments); + } + + assert!(normalize_udp_broadcast_packet(&packet, &config()).is_none()); + } + + #[test] + fn windows_udp_broadcast_rejects_non_broadcast_destinations() { + let packet = build_udp_packet( + Ipv4Addr::new(192, 168, 1, 7), + Ipv4Addr::new(192, 168, 1, 10), + b"unicast", + ); + + assert!(normalize_udp_broadcast_packet(&packet, &config()).is_none()); + } + + #[test] + fn windows_udp_broadcast_rejects_virtual_source_duplicates() { + let packet = build_udp_packet(Ipv4Addr::new(10, 144, 144, 1), Ipv4Addr::BROADCAST, b"loop"); + + assert!(normalize_udp_broadcast_packet(&packet, &config()).is_none()); + } + + #[test] + fn windows_udp_broadcast_detects_directed_broadcast_from_prefix() { + let physical = + PhysicalInterface::from_ip_and_prefix(Ipv4Addr::new(172, 16, 5, 10), 20).unwrap(); + assert_eq!(physical.directed_broadcast, Ipv4Addr::new(172, 16, 15, 255)); + assert_eq!( + prefix_len_from_netmask(Ipv4Addr::new(255, 255, 240, 0)), + Some(20) + ); + assert_eq!(prefix_len_from_netmask(Ipv4Addr::new(255, 0, 255, 0)), None); + } + + #[test] + fn windows_udp_broadcast_keeps_link_local_interfaces() { + let physical = + PhysicalInterface::from_ip_and_prefix(Ipv4Addr::new(169, 254, 13, 10), 16).unwrap(); + assert_eq!( + physical.directed_broadcast, + Ipv4Addr::new(169, 254, 255, 255) + ); + } + + #[test] + fn windows_udp_broadcast_windivert_filter_is_constrained() { + let interfaces = vec![ + PhysicalInterface::from_ip_and_prefix(Ipv4Addr::new(192, 168, 1, 7), 24).unwrap(), + PhysicalInterface::from_ip_and_prefix(Ipv4Addr::new(169, 254, 13, 10), 16).unwrap(), + PhysicalInterface::from_ip_and_prefix(Ipv4Addr::new(169, 254, 156, 121), 16).unwrap(), + ]; + + let filter = build_windivert_udp_filter(&interfaces); + + assert!(filter.starts_with("outbound and ip and udp and ")); + assert!(filter.contains("ip.SrcAddr == 192.168.1.7")); + assert!(filter.contains("ip.SrcAddr == 169.254.13.10")); + assert!(filter.contains("ip.DstAddr == 255.255.255.255")); + assert!(filter.contains("ip.DstAddr == 192.168.1.255")); + assert!(filter.contains("ip.DstAddr == 169.254.255.255")); + assert!(filter.contains("ip.DstAddr >= 224.0.0.0")); + assert!(filter.contains("ip.DstAddr <= 239.255.255.255")); + assert_eq!(filter.matches("ip.DstAddr == 169.254.255.255").count(), 1); + } +} diff --git a/easytier/src/instance_manager.rs b/easytier/src/instance_manager.rs index 2ea40079..229cb86a 100644 --- a/easytier/src/instance_manager.rs +++ b/easytier/src/instance_manager.rs @@ -474,6 +474,28 @@ fn handle_event( ); } + GlobalCtxEvent::UdpBroadcastRelayStartResult { + capture_backend, + error, + } => { + if let Some(error) = error { + event!( + warn, + ?capture_backend, + %error, + "[{}] UDP broadcast relay start failed", + instance_id + ); + } else { + event!( + info, + ?capture_backend, + "[{}] UDP broadcast relay started", + instance_id + ); + } + } + GlobalCtxEvent::CredentialChanged => { event!(info, "[{}] credential changed", instance_id); } diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index eff846ee..12815d84 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -820,6 +820,10 @@ impl NetworkConfig { flags.disable_relay_data = disable_relay_data; } + if let Some(enable_udp_broadcast_relay) = self.enable_udp_broadcast_relay { + flags.enable_udp_broadcast_relay = enable_udp_broadcast_relay; + } + if let Some(disable_sym_hole_punching) = self.disable_sym_hole_punching { flags.disable_sym_hole_punching = disable_sym_hole_punching; } @@ -995,6 +999,7 @@ impl NetworkConfig { result.disable_udp_hole_punching = Some(flags.disable_udp_hole_punching); result.disable_upnp = Some(flags.disable_upnp); result.disable_relay_data = Some(flags.disable_relay_data); + result.enable_udp_broadcast_relay = Some(flags.enable_udp_broadcast_relay); result.disable_sym_hole_punching = Some(flags.disable_sym_hole_punching); result.enable_magic_dns = Some(flags.accept_dns); result.mtu = Some(flags.mtu as i32); @@ -1263,6 +1268,7 @@ mod tests { flags.disable_tcp_hole_punching = rng.gen_bool(0.2); flags.disable_udp_hole_punching = rng.gen_bool(0.2); flags.disable_upnp = rng.gen_bool(0.2); + flags.enable_udp_broadcast_relay = rng.gen_bool(0.2); flags.accept_dns = rng.gen_bool(0.6); flags.mtu = rng.gen_range(1200..1500); flags.private_mode = rng.gen_bool(0.3); diff --git a/easytier/src/peers/peer_manager.rs b/easytier/src/peers/peer_manager.rs index a2748d26..d5502ac4 100644 --- a/easytier/src/peers/peer_manager.rs +++ b/easytier/src/peers/peer_manager.rs @@ -1569,17 +1569,26 @@ impl PeerManager { ipv6_addr.is_multicast() || *ipv6_addr == ipv6_inet.last_address() } + fn select_ipv4_broadcast_peers<'a>( + routes: impl IntoIterator, + my_peer_id: PeerId, + ) -> Vec { + routes + .into_iter() + .filter_map(|route| { + (route.peer_id != my_peer_id && route.ipv4_addr.is_some()).then_some(route.peer_id) + }) + .collect() + } + pub async fn get_msg_dst_peer_ipv4(&self, ipv4_addr: &Ipv4Addr) -> (Vec, bool) { let mut is_exit_node = false; let mut dst_peers = vec![]; if self.is_all_peers_broadcast_ipv4(ipv4_addr) { - dst_peers.extend(self.peers.list_routes().await.iter().filter_map(|x| { - if *x.key() != self.my_peer_id { - Some(*x.key()) - } else { - None - } - })); + dst_peers.extend(Self::select_ipv4_broadcast_peers( + &self.peers.list_route_infos().await, + self.my_peer_id, + )); } else if let Some(peer_id) = self.peers.get_peer_id_by_ipv4(ipv4_addr).await { dst_peers.push(peer_id); } else if !self @@ -2199,6 +2208,32 @@ mod tests { assert!(!PeerManager::should_mark_recent_traffic_for_fanout(2)); } + fn route_with_ipv4( + peer_id: u32, + ipv4_addr: Option, + ) -> crate::proto::api::instance::Route { + crate::proto::api::instance::Route { + peer_id, + ipv4_addr: ipv4_addr.map(|addr| cidr::Ipv4Inet::new(addr, 24).unwrap().into()), + ..Default::default() + } + } + + #[test] + fn ipv4_broadcast_peer_selection_skips_peers_without_ipv4() { + let routes = vec![ + route_with_ipv4(1, Some(std::net::Ipv4Addr::new(10, 126, 126, 1))), + route_with_ipv4(2, None), + route_with_ipv4(3, Some(std::net::Ipv4Addr::new(10, 126, 126, 3))), + route_with_ipv4(4, None), + ]; + + assert_eq!( + PeerManager::select_ipv4_broadcast_peers(&routes, 3), + vec![1] + ); + } + #[test] fn gc_recent_traffic_removes_expired_and_connected_entries() { let stale_peer = 1; diff --git a/easytier/src/proto/api_manage.proto b/easytier/src/proto/api_manage.proto index 0b1a472a..418cd7aa 100644 --- a/easytier/src/proto/api_manage.proto +++ b/easytier/src/proto/api_manage.proto @@ -100,6 +100,7 @@ message NetworkConfig { optional bool ipv6_public_addr_auto = 63; optional string ipv6_public_addr_prefix = 64; optional bool disable_relay_data = 65; + optional bool enable_udp_broadcast_relay = 66; } message PortForwardConfig { diff --git a/easytier/src/proto/common.proto b/easytier/src/proto/common.proto index 15ed939a..308796e8 100644 --- a/easytier/src/proto/common.proto +++ b/easytier/src/proto/common.proto @@ -76,6 +76,7 @@ message FlagsInConfig { uint64 instance_recv_bps_limit = 39; bool disable_upnp = 40; bool disable_relay_data = 41; + bool enable_udp_broadcast_relay = 42; } message RpcDescriptor { From bfbfa2ef8d2c0708866ecce49f8d873ea3f7a1c5 Mon Sep 17 00:00:00 2001 From: 21paradox Date: Sat, 9 May 2026 22:33:44 +0800 Subject: [PATCH 08/10] fix: reuse conn by dst_peer_id, every peer use only 1 quic conn, to fix nat lost problem (#2216) --- Cargo.lock | 4 + easytier/Cargo.toml | 1 + easytier/src/gateway/quic_proxy.rs | 126 +++++++++++++++++++---------- 3 files changed, 87 insertions(+), 44 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 36c870d7..6db0df14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2291,6 +2291,7 @@ dependencies = [ "machine-uid", "maplit", "mimalloc", + "moka", "multimap", "natpmp", "netlink-packet-core", @@ -5103,9 +5104,12 @@ version = "0.12.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9321642ca94a4282428e6ea4af8cc2ca4eac48ac7a6a4ea8f33f76d0ce70926" dependencies = [ + "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", + "event-listener", + "futures-util", "loom", "parking_lot", "portable-atomic", diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 1bbb2b8a..4b645546 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -70,6 +70,7 @@ async-stream = "0.3.5" async-trait = "0.1.74" dashmap = "6.0" +moka = { version = "0.12", features = ["future"] } timedmap = "=1.0.1" # for full-path zero-copy diff --git a/easytier/src/gateway/quic_proxy.rs b/easytier/src/gateway/quic_proxy.rs index 5d9d90ab..0019aaf6 100644 --- a/easytier/src/gateway/quic_proxy.rs +++ b/easytier/src/gateway/quic_proxy.rs @@ -25,6 +25,7 @@ use dashmap::DashMap; use derivative::Derivative; use derive_more::{Constructor, Deref, DerefMut, From, Into}; use guarden::defer; +use moka::future::Cache; use prost::Message; use quinn::udp::{EcnCodepoint, RecvMeta, Transmit}; use quinn::{ @@ -43,8 +44,8 @@ use tokio::io::{AsyncReadExt, Join, join}; use tokio::sync::mpsc::error::TrySendError; use tokio::sync::mpsc::{Receiver, Sender, channel}; use tokio::task::JoinSet; -use tokio::time::{Instant, timeout}; -use tokio::{join, pin, select}; +use tokio::time::timeout; +use tokio::{join, select}; use tokio_util::sync::PollSender; use tracing::{debug, error, info, instrument, trace, warn}; @@ -279,6 +280,7 @@ impl From<(SendStream, RecvStream)> for QuicStream { pub struct NatDstQuicConnector { pub(crate) endpoint: Endpoint, pub(crate) peer_mgr: Weak, + pub(crate) conn_map: Cache, } #[async_trait::async_trait] @@ -302,7 +304,6 @@ impl NatDstConnector for NatDstQuicConnector { }; trace!("quic nat dst: {:?}, dst peers: {:?}", nat_dst, dst_peer_id); - let addr = QuicAddr::new(dst_peer_id, PacketType::QuicSrc).into(); let header = { let conn_data = QuicConnData { @@ -323,50 +324,65 @@ impl NatDstConnector for NatDstQuicConnector { buf.freeze() }; - let mut connect_tasks = JoinSet::>::new(); - let connect = |tasks: &mut JoinSet<_>| { + for attempt in 0..2 { let endpoint = self.endpoint.clone(); - let header = header.clone(); - tasks.spawn(async move { - let connection = endpoint.connect(addr, "")?.await?; - let mut stream: QuicStream = connection.open_bi().await?.into(); - stream.writer_mut().write_chunk(header).await?; - Ok(stream) - }); - }; - - connect(&mut connect_tasks); - - let timer = tokio::time::sleep(Duration::from_millis(200)); - pin!(timer); - - let mut retry_remain = 5; - loop { - select! { - Some(result) = connect_tasks.join_next() => { - match result { - Ok(Ok(stream)) => return Ok(stream.into()), - _ => { - if connect_tasks.is_empty() { - if retry_remain == 0 { - return Err(anyhow!("failed to connect to nat dst: {:?}", nat_dst).into()) - } - - retry_remain -= 1; - connect(&mut connect_tasks); - timer.as_mut().reset(Instant::now() + Duration::from_millis(200)) - } - } + let connection = match self + .conn_map + .try_get_with(dst_peer_id, async move { + endpoint + .connect(addr, "") + .map_err(|e| anyhow!("quic connect: {:#}", e))? + .await + .map_err(|e| anyhow!("quic connection: {:#}", e)) + }) + .await + { + Ok(conn) => conn, + Err(e) => { + if attempt == 0 { + debug!("quic connect failed, retrying: {:#}", e); + tokio::time::sleep(Duration::from_millis(300)).await; + continue; } + return Err(anyhow!("{:#}", e).into()); } - _ = &mut timer, if retry_remain > 0 => { - retry_remain -= 1; - connect(&mut connect_tasks); - timer.as_mut().reset(Instant::now() + Duration::from_millis(200)); + }; + + let stream: Result = async { + let mut stream: QuicStream = connection + .open_bi() + .await + .map_err(|e| anyhow!("open bi: {:#}", e))? + .into(); + stream.writer_mut().write_chunk(header.clone()).await?; + Ok(stream.into()) + } + .await; + + match stream { + Ok(stream) => return Ok(stream), + Err(error) => { + debug!( + ?dst_peer_id, + attempt, + ?error, + "quic connect: stream setup failed" + ); } } + + // Evict stale connection; + self.conn_map.invalidate(&dst_peer_id).await; } + + Err(anyhow!( + "quic connect: failed after {} attempts, dst_peer_id={}, nat_dst={}", + 2, + dst_peer_id, + nat_dst + ) + .into()) } #[inline] @@ -595,10 +611,17 @@ impl QuicStreamReceiver { } }; - match Self::establish_stream(stream, ctx.clone()).await { - Ok(stream) => drop(tasks.spawn(stream)), - Err(e) => warn!("failed to establish quic stream from {:?}: {:?}", connection.remote_address(), e), - } + let ctx = ctx.clone(); + tasks.spawn(async move { + match Self::establish_stream(stream, ctx).await { + Ok(transfer_fut) => { + if let Err(e) = transfer_fut.await { + warn!("quic stream transfer error: {:?}", e); + } + } + Err(e) => warn!("failed to establish quic stream: {:?}", e), + } + }); } res = tasks.join_next(), if !tasks.is_empty() => { @@ -840,11 +863,26 @@ impl QuicProxy { return; } + let conn_map = Cache::builder() + .max_capacity(u8::MAX.into()) // same with max_concurrent_bidi_streams, can be increased + .time_to_idle(Duration::from_secs(600)) + .build(); + + let conn_map_bg = conn_map.clone(); + self.tasks.spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + loop { + interval.tick().await; + conn_map_bg.run_pending_tasks().await; + } + }); + let tcp_proxy = TcpProxyForQuicSrc(TcpProxy::new( peer_mgr.clone(), NatDstQuicConnector { endpoint: endpoint.clone(), peer_mgr: Arc::downgrade(&peer_mgr), + conn_map, }, )); From 513695297ce91bbeba1bb4b2811a6f05c47e3088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9F=A9=E5=98=89=E4=B9=90?= Date: Sun, 10 May 2026 14:15:31 +0800 Subject: [PATCH 09/10] [OHOS] feat: Enhance Rust kernel with config management and routing improvements (#2227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [OHOS.with ai] 将配置管理/配置分享/路由聚合/实例状态解析下沉至 Rust 内核,收敛职责并提升性能 (#2209) * feat: add ohrs config store and startup error logging * feat: full ability core for ohos * feat: full ability core for ohos * feat: clean code --------- Co-authored-by: FrankHan * fix: 添加缺失文件 * fix: 修复更新路由启动两次TUN问题,并调整日志 * fix: rustfmt * fix: 适配Cidr忽略/32格式路由 * fix: 修复Option适配错误 * fix: rustfmt * fix: rustfmt --------- Co-authored-by: FrankHan --- easytier-contrib/easytier-ohrs/Cargo.lock | 676 ++++++++++++++---- easytier-contrib/easytier-ohrs/Cargo.toml | 10 + easytier-contrib/easytier-ohrs/src/config.rs | 4 + .../src/config/repository/mod.rs | 13 + .../easytier-ohrs/src/config/services/mod.rs | 2 + .../src/config/services/schema_service.rs | 414 +++++++++++ .../src/config/services/share_link_service.rs | 197 +++++ .../src/config/storage/config_meta.rs | 333 +++++++++ .../easytier-ohrs/src/config/storage/mod.rs | 1 + .../easytier-ohrs/src/config/types/mod.rs | 1 + .../src/config/types/stored_config.rs | 68 ++ .../easytier-ohrs/src/config_repo.rs | 349 +++++++++ .../src/config_repo/field_store.rs | 67 ++ .../src/config_repo/import_export.rs | 48 ++ .../src/config_repo/legacy_migration.rs | 45 ++ .../src/config_repo/validation.rs | 30 + easytier-contrib/easytier-ohrs/src/exports.rs | 2 + .../easytier-ohrs/src/exports/config_api.rs | 46 ++ .../easytier-ohrs/src/exports/runtime_api.rs | 184 +++++ .../easytier-ohrs/src/kernel_bridge.rs | 6 + .../src/kernel_bridge/protocol.rs | 50 ++ .../src/kernel_bridge/routing.rs | 105 +++ .../src/kernel_bridge/socket_server.rs | 196 +++++ easytier-contrib/easytier-ohrs/src/lib.rs | 578 +++++++++++---- .../easytier-ohrs/src/platform.rs | 1 + .../easytier-ohrs/src/platform/logging/mod.rs | 1 + .../src/{ => platform/logging}/native_log.rs | 0 easytier-contrib/easytier-ohrs/src/runtime.rs | 1 + .../easytier-ohrs/src/runtime/state/mod.rs | 1 + .../src/runtime/state/runtime_state.rs | 293 ++++++++ easytier/src/proto/mod.rs | 5 +- 31 files changed, 3455 insertions(+), 272 deletions(-) create mode 100644 easytier-contrib/easytier-ohrs/src/config.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/repository/mod.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/services/mod.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/services/schema_service.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/services/share_link_service.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/storage/mod.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/types/mod.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config_repo.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config_repo/field_store.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config_repo/import_export.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs create mode 100644 easytier-contrib/easytier-ohrs/src/config_repo/validation.rs create mode 100644 easytier-contrib/easytier-ohrs/src/exports.rs create mode 100644 easytier-contrib/easytier-ohrs/src/exports/config_api.rs create mode 100644 easytier-contrib/easytier-ohrs/src/exports/runtime_api.rs create mode 100644 easytier-contrib/easytier-ohrs/src/kernel_bridge.rs create mode 100644 easytier-contrib/easytier-ohrs/src/kernel_bridge/protocol.rs create mode 100644 easytier-contrib/easytier-ohrs/src/kernel_bridge/routing.rs create mode 100644 easytier-contrib/easytier-ohrs/src/kernel_bridge/socket_server.rs create mode 100644 easytier-contrib/easytier-ohrs/src/platform.rs create mode 100644 easytier-contrib/easytier-ohrs/src/platform/logging/mod.rs rename easytier-contrib/easytier-ohrs/src/{ => platform/logging}/native_log.rs (100%) create mode 100644 easytier-contrib/easytier-ohrs/src/runtime.rs create mode 100644 easytier-contrib/easytier-ohrs/src/runtime/state/mod.rs create mode 100644 easytier-contrib/easytier-ohrs/src/runtime/state/runtime_state.rs diff --git a/easytier-contrib/easytier-ohrs/Cargo.lock b/easytier-contrib/easytier-ohrs/Cargo.lock index cc4a5ee2..0c502ba2 100644 --- a/easytier-contrib/easytier-ohrs/Cargo.lock +++ b/easytier-contrib/easytier-ohrs/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -35,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -52,6 +43,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy 0.8.27", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -228,6 +231,18 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41e67cd8309bbd06cd603a9e693a784ac2e5d1e955f11286e355089fcab3047c" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "auto_impl" version = "1.3.0" @@ -245,21 +260,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base62" version = "2.2.3" @@ -326,6 +326,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.106", +] + [[package]] name = "boringtun-easytier" version = "0.6.1" @@ -471,7 +496,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -481,7 +517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -498,7 +534,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -678,6 +714,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.3.0" @@ -807,7 +852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -939,6 +984,17 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "delegate" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "deranged" version = "0.5.3" @@ -1066,6 +1122,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dtor" version = "0.0.6" @@ -1083,7 +1150,7 @@ checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" [[package]] name = "easytier" -version = "2.6.0" +version = "2.6.4" dependencies = [ "anyhow", "arc-swap", @@ -1096,11 +1163,11 @@ dependencies = [ "auto_impl", "base64 0.22.1", "bitflags 2.9.4", + "bon", "boringtun-easytier", "bytecodec", "byteorder", "bytes", - "cfg-if", "cfg_aliases", "chrono", "cidr", @@ -1110,6 +1177,7 @@ dependencies = [ "crossbeam", "dashmap", "dbus", + "delegate", "derivative", "derive_builder", "derive_more", @@ -1118,10 +1186,10 @@ dependencies = [ "flume", "forwarded-header-value", "futures", - "gethostname", + "gethostname 0.5.0", "git-version", "globwalk", - "hashbrown 0.15.5", + "guarden", "hickory-client", "hickory-proto", "hickory-resolver", @@ -1132,13 +1200,15 @@ dependencies = [ "humansize", "humantime-serde", "idna", + "igd-next", "indoc", "itertools 0.14.0", "kcp-sys", "machine-uid", "multimap", + "natpmp", "netlink-packet-core", - "netlink-packet-route", + "netlink-packet-route 0.21.0", "netlink-packet-utils", "netlink-sys", "network-interface", @@ -1205,9 +1275,8 @@ dependencies = [ "wildmatch", "winapi", "windivert", - "windows 0.52.0", + "windows 0.62.2", "windows-service", - "windows-sys 0.52.0", "winreg 0.52.0", "x25519-dalek", "zerocopy 0.7.35", @@ -1219,16 +1288,26 @@ dependencies = [ name = "easytier-ohrs" version = "0.1.0" dependencies = [ + "async-trait", + "base64 0.22.1", "easytier", + "flate2", + "gethostname 1.1.0", + "ipnet", "napi-build-ohos", "napi-derive-ohos", "napi-ohos", "ohos-hilog-binding", "once_cell", + "prost-reflect", + "rusqlite", + "serde", "serde_json", + "tokio", "tracing", "tracing-core", "tracing-subscriber", + "url", "uuid", ] @@ -1390,6 +1469,18 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastbloom" version = "0.14.1" @@ -1621,6 +1712,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.2", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -1643,11 +1744,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "ghash" version = "0.5.1" @@ -1658,12 +1773,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "git-version" version = "0.3.9" @@ -1714,6 +1823,28 @@ dependencies = [ "walkdir", ] +[[package]] +name = "guarden" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31c7272e004bec8ea7fe50b2ec5451858695bb2743e897c353753fcb3415f4ef" +dependencies = [ + "futures", + "guarden-macros", + "tokio", +] + +[[package]] +name = "guarden-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d291d94f41471fe84384a426b3e2c9d22f960a351a5bf26aaa7cd75fbc02c88" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "h2" version = "0.4.12" @@ -1747,6 +1878,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" @@ -1765,6 +1899,15 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heapless" version = "0.9.2" @@ -2055,7 +2198,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -2075,7 +2218,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.0", + "windows-core 0.62.2", ] [[package]] @@ -2173,6 +2316,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -2200,6 +2349,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + [[package]] name = "ignore" version = "0.4.23" @@ -2224,6 +2393,8 @@ checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown 0.16.0", + "serde", + "serde_core", ] [[package]] @@ -2253,17 +2424,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ip_network" version = "0.4.1" @@ -2426,6 +2586,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -2434,9 +2600,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.175" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libdbus-sys" @@ -2455,7 +2621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -2494,6 +2660,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libz-rs-sys" version = "0.5.2" @@ -2625,13 +2802,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.4" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -2743,6 +2920,35 @@ dependencies = [ "tempfile", ] +[[package]] +name = "natpmp" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77366fa8ce34e2e1322dd97da65f11a62f451bd3daae8be6993c00800f61dd07" +dependencies = [ + "async-trait", + "cc", + "netdev", + "tokio", +] + +[[package]] +name = "netdev" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f901362e84cd407be6f8cd9d3a46bccf09136b095792785401ea7d283c79b91d" +dependencies = [ + "dlopen2", + "ipnet", + "libc", + "netlink-packet-core", + "netlink-packet-route 0.17.1", + "netlink-sys", + "once_cell", + "system-configuration", + "windows-sys 0.52.0", +] + [[package]] name = "netlink-packet-core" version = "0.7.0" @@ -2754,6 +2960,20 @@ dependencies = [ "netlink-packet-utils", ] +[[package]] +name = "netlink-packet-route" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053998cea5a306971f88580d0829e90f270f940befd7cf928da179d4187a5a66" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "byteorder", + "libc", + "netlink-packet-core", + "netlink-packet-utils", +] + [[package]] name = "netlink-packet-route" version = "0.21.0" @@ -2908,15 +3128,6 @@ dependencies = [ "libc", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "ohos-hilog-binding" version = "0.1.2" @@ -3232,7 +3443,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3244,7 +3455,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3523,7 +3734,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.0", + "socket2 0.6.3", "thiserror 2.0.16", "tokio", "tracing", @@ -3574,7 +3785,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.0", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -3594,6 +3805,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -3625,6 +3842,17 @@ dependencies = [ "rand_core 0.9.3", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3663,6 +3891,12 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rcgen" version = "0.12.1" @@ -3797,6 +4031,20 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.9.4", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-i18n" version = "3.1.5" @@ -3851,12 +4099,6 @@ dependencies = [ "triomphe", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "2.1.1" @@ -4170,7 +4412,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4181,7 +4423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -4290,12 +4532,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -4615,29 +4857,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.52.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.6.0", + "socket2 0.6.3", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -4684,6 +4923,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -5146,7 +5386,16 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", ] [[package]] @@ -5221,6 +5470,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.9.4", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" version = "0.3.80" @@ -5375,27 +5658,29 @@ dependencies = [ "windows-targets 0.48.5", ] -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5408,12 +5693,12 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.52.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-targets 0.52.6", + "windows-core 0.62.2", ] [[package]] @@ -5431,15 +5716,15 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.62.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.0", - "windows-result 0.4.0", - "windows-strings 0.5.0", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -5450,14 +5735,25 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -5466,9 +5762,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -5483,9 +5779,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" @@ -5497,6 +5793,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.5.3" @@ -5519,11 +5825,11 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5548,11 +5854,11 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5606,7 +5912,7 @@ version = "0.61.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -5681,6 +5987,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5915,6 +6230,94 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.106", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.9.4", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "writeable" version = "0.6.1" @@ -5939,6 +6342,15 @@ version = "0.8.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yasna" version = "0.5.2" diff --git a/easytier-contrib/easytier-ohrs/Cargo.toml b/easytier-contrib/easytier-ohrs/Cargo.toml index 20c0c169..b1d65d9e 100644 --- a/easytier-contrib/easytier-ohrs/Cargo.toml +++ b/easytier-contrib/easytier-ohrs/Cargo.toml @@ -7,6 +7,10 @@ edition = "2024" crate-type=["cdylib"] [dependencies] +async-trait = "0.1" +base64 = "0.22" +flate2 = "1.1" +gethostname = "1.1" ohos-hilog-binding = {version = "*", features = ["redirect"]} easytier = { path = "../../easytier" } napi-derive-ohos = "1.1" @@ -26,10 +30,16 @@ napi-ohos = { version = "1.1", default-features = false, features = [ "web_stream", ] } once_cell = "1.21.3" +ipnet = "2.10" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.125" +prost-reflect = { version = "0.14.5", default-features = false, features = ["derive"] } +rusqlite = { version = "0.32", features = ["bundled"] } tracing-subscriber = "0.3.19" tracing-core = "0.1.33" tracing = "0.1.41" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } +url = "2.5" uuid = { version = "1.5.0", features = [ "v4", "fast-rng", diff --git a/easytier-contrib/easytier-ohrs/src/config.rs b/easytier-contrib/easytier-ohrs/src/config.rs new file mode 100644 index 00000000..af649e50 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config.rs @@ -0,0 +1,4 @@ +pub(crate) mod repository; +pub(crate) mod services; +pub(crate) mod storage; +pub(crate) mod types; diff --git a/easytier-contrib/easytier-ohrs/src/config/repository/mod.rs b/easytier-contrib/easytier-ohrs/src/config/repository/mod.rs new file mode 100644 index 00000000..1b66eb24 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/repository/mod.rs @@ -0,0 +1,13 @@ +#[path = "../../config_repo/field_store.rs"] +mod field_store; +#[path = "../../config_repo/import_export.rs"] +mod import_export; +#[path = "../../config_repo/legacy_migration.rs"] +mod legacy_migration; +#[path = "../../config_repo/validation.rs"] +mod validation; + +#[path = "../../config_repo.rs"] +mod repo; + +pub use repo::*; diff --git a/easytier-contrib/easytier-ohrs/src/config/services/mod.rs b/easytier-contrib/easytier-ohrs/src/config/services/mod.rs new file mode 100644 index 00000000..88b329de --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/services/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod schema_service; +pub(crate) mod share_link_service; diff --git a/easytier-contrib/easytier-ohrs/src/config/services/schema_service.rs b/easytier-contrib/easytier-ohrs/src/config/services/schema_service.rs new file mode 100644 index 00000000..d1425b56 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/services/schema_service.rs @@ -0,0 +1,414 @@ +use easytier::proto::ALL_DESCRIPTOR_BYTES; +use napi_derive_ohos::napi; +use once_cell::sync::Lazy; +use prost_reflect::{Cardinality, DescriptorPool, FieldDescriptor, Kind, MessageDescriptor}; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[napi(object)] +pub struct FieldOption { + pub label: String, + pub value: String, +} + +#[derive(Debug, Clone, Serialize)] +#[napi(object)] +pub struct ValidationRule { + pub rule_type: String, + pub arg: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize)] +#[napi(object)] +pub struct NetworkConfigSchema { + pub node_kind: String, + pub name: String, + pub field_number: i32, + pub type_name: Option, + pub semantic_type: Option, + pub value_kind: String, + pub is_list: bool, + pub required: bool, + pub default_value_text: Option, + pub enum_options: Vec, + pub validations: Vec, + pub children: Vec, + pub definitions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[napi(object)] +pub struct ConfigFieldMapping { + pub field_name: String, + pub field_number: i32, +} + +static DESCRIPTOR_POOL: Lazy = Lazy::new(|| { + DescriptorPool::decode(ALL_DESCRIPTOR_BYTES) + .expect("easytier descriptor pool should decode from embedded protobuf descriptors") +}); + +const NETWORK_CONFIG_MESSAGE_NAME: &str = "api.manage.NetworkConfig"; + +fn descriptor_pool() -> &'static DescriptorPool { + &DESCRIPTOR_POOL +} + +fn network_config_descriptor() -> MessageDescriptor { + descriptor_pool() + .get_message_by_name(NETWORK_CONFIG_MESSAGE_NAME) + .expect("api.manage.NetworkConfig descriptor should exist") +} + +fn field_default_value_text(field: &FieldDescriptor) -> Option { + if field.is_list() || field.is_map() { + return Some("[]".to_string()); + } + + match field.kind() { + Kind::Bool => Some("false".to_string()), + Kind::String => Some("\"\"".to_string()), + Kind::Bytes => Some("\"\"".to_string()), + Kind::Int32 + | Kind::Sint32 + | Kind::Sfixed32 + | Kind::Int64 + | Kind::Sint64 + | Kind::Sfixed64 + | Kind::Uint32 + | Kind::Fixed32 + | Kind::Uint64 + | Kind::Fixed64 + | Kind::Float + | Kind::Double => Some("0".to_string()), + Kind::Enum(enum_desc) => enum_desc + .get_value(0) + .map(|value| value.number().to_string()), + Kind::Message(_) => None, + } +} + +fn field_type_name(field: &FieldDescriptor) -> Option { + match field.kind() { + Kind::Enum(enum_desc) => Some(enum_desc.full_name().to_string()), + Kind::Message(message_desc) => Some(message_desc.full_name().to_string()), + _ => None, + } +} + +fn field_semantic_type(field: &FieldDescriptor) -> Option { + match field.name() { + "virtual_ipv4" => Some("cidr_ip".to_string()), + "network_length" => Some("cidr_mask".to_string()), + "peer_urls" => Some("peer[]".to_string()), + "proxy_cidrs" => Some("cidr[]".to_string()), + "listener_urls" => Some("listener[]".to_string()), + "routes" => Some("route[]".to_string()), + "exit_nodes" => Some("ip[]".to_string()), + "relay_network_whitelist" => Some("network_name[]".to_string()), + "mapped_listeners" => Some("mapped_listener[]".to_string()), + "port_forwards" => Some("port_forward[]".to_string()), + _ => None, + } +} + +fn enum_options(kind: Kind) -> Vec { + match kind { + Kind::Enum(enum_desc) => enum_desc + .values() + .map(|value| FieldOption { + label: value.name().to_string(), + value: value.number().to_string(), + }) + .collect(), + _ => Vec::new(), + } +} + +fn should_expose_field(field: &FieldDescriptor) -> bool { + match field.containing_oneof() { + Some(_) => field + .field_descriptor_proto() + .proto3_optional + .unwrap_or(false), + None => true, + } +} + +fn build_validations(field: &FieldDescriptor) -> Vec { + if field.cardinality() == Cardinality::Required { + return vec![ValidationRule { + rule_type: "required".to_string(), + arg: String::new(), + message: format!("{} is required", field.name()), + }]; + } + + Vec::new() +} + +fn kind_to_value_kind(field: &FieldDescriptor) -> String { + if field.is_map() { + return "object".to_string(); + } + + match field.kind() { + Kind::Bool => "boolean".to_string(), + Kind::String | Kind::Bytes => "string".to_string(), + Kind::Int32 + | Kind::Sint32 + | Kind::Sfixed32 + | Kind::Int64 + | Kind::Sint64 + | Kind::Sfixed64 + | Kind::Uint32 + | Kind::Fixed32 + | Kind::Uint64 + | Kind::Fixed64 + | Kind::Float + | Kind::Double => "number".to_string(), + Kind::Enum(_) => "enum".to_string(), + Kind::Message(_) => "object".to_string(), + } +} + +fn build_node( + node_kind: &str, + name: String, + field_number: i32, + type_name: Option, + semantic_type: Option, + value_kind: String, + is_list: bool, + required: bool, + default_value_text: Option, + enum_options: Vec, + validations: Vec, + children: Vec, + definitions: Vec, +) -> NetworkConfigSchema { + NetworkConfigSchema { + node_kind: node_kind.to_string(), + name, + field_number, + type_name, + semantic_type, + value_kind, + is_list, + required, + default_value_text, + enum_options, + validations, + children, + definitions, + } +} + +fn build_map_entry_node(message_desc: &MessageDescriptor) -> NetworkConfigSchema { + let key_field = message_desc.map_entry_key_field(); + let value_field = message_desc.map_entry_value_field(); + + build_node( + "object", + message_desc.name().to_string(), + 0, + Some(message_desc.full_name().to_string()), + None, + "object".to_string(), + false, + true, + None, + Vec::new(), + Vec::new(), + vec![ + build_schema_field_node(&key_field), + build_schema_field_node(&value_field), + ], + Vec::new(), + ) +} + +fn field_children(field: &FieldDescriptor) -> Vec { + if field.is_map() { + if let Kind::Message(message_desc) = field.kind() { + return vec![build_map_entry_node(&message_desc)]; + } + } + + match field.kind() { + Kind::Message(message_desc) => build_message_children(&message_desc), + _ => Vec::new(), + } +} + +fn build_message_children(message_desc: &MessageDescriptor) -> Vec { + message_desc + .fields() + .filter(should_expose_field) + .map(|field| build_schema_field_node(&field)) + .collect() +} + +fn build_schema_field_node(field: &FieldDescriptor) -> NetworkConfigSchema { + build_node( + "field", + field.name().to_string(), + field.number() as i32, + field_type_name(field), + field_semantic_type(field), + kind_to_value_kind(field), + field.is_list() || field.is_map(), + field.cardinality() == Cardinality::Required, + field_default_value_text(field), + enum_options(field.kind()), + build_validations(field), + field_children(field), + Vec::new(), + ) +} + +fn collect_definitions() -> Vec { + let mut definitions = Vec::new(); + + for message_desc in descriptor_pool().all_messages() { + let full_name = message_desc.full_name(); + if full_name == NETWORK_CONFIG_MESSAGE_NAME || message_desc.is_map_entry() { + continue; + } + + definitions.push(build_node( + "object", + full_name.to_string(), + 0, + Some(full_name.to_string()), + None, + "object".to_string(), + false, + true, + None, + Vec::new(), + Vec::new(), + build_message_children(&message_desc), + Vec::new(), + )); + } + + for enum_desc in descriptor_pool().all_enums() { + definitions.push(build_node( + "enum", + enum_desc.full_name().to_string(), + 0, + Some(enum_desc.full_name().to_string()), + None, + "enum".to_string(), + false, + false, + None, + enum_options(Kind::Enum(enum_desc.clone())), + Vec::new(), + Vec::new(), + Vec::new(), + )); + } + + definitions.sort_by(|a, b| a.name.cmp(&b.name)); + definitions +} + +fn build_network_config_schema() -> NetworkConfigSchema { + let network_config = network_config_descriptor(); + build_node( + "schema", + network_config.name().to_string(), + 0, + Some(network_config.full_name().to_string()), + None, + "object".to_string(), + false, + true, + None, + Vec::new(), + Vec::new(), + build_message_children(&network_config), + collect_definitions(), + ) +} + +fn build_network_config_field_mappings() -> Vec { + network_config_descriptor() + .fields() + .filter(should_expose_field) + .map(|field| ConfigFieldMapping { + field_name: field.name().to_string(), + field_number: field.number() as i32, + }) + .collect() +} + +pub fn get_network_config_schema() -> NetworkConfigSchema { + build_network_config_schema() +} + +pub fn get_network_config_field_mappings() -> Vec { + build_network_config_field_mappings() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_is_exposed_as_single_tree_type() { + let schema = get_network_config_schema(); + assert_eq!(schema.node_kind, "schema"); + assert_eq!(schema.name, "NetworkConfig"); + assert_eq!( + schema.type_name.as_deref(), + Some("api.manage.NetworkConfig") + ); + + let virtual_ipv4 = schema + .children + .iter() + .find(|field| field.name == "virtual_ipv4") + .expect("virtual_ipv4 field"); + assert_eq!(virtual_ipv4.semantic_type.as_deref(), Some("cidr_ip")); + + let secure_mode = schema + .children + .iter() + .find(|field| field.name == "secure_mode") + .expect("secure_mode field"); + assert!( + secure_mode + .children + .iter() + .any(|field| field.name == "enabled") + ); + + let secure_mode_definition = schema + .definitions + .iter() + .find(|definition| definition.name == "common.SecureModeConfig") + .expect("secure mode definition"); + assert!( + secure_mode_definition + .children + .iter() + .any(|field| field.name == "local_private_key") + ); + + let networking_method_definition = schema + .definitions + .iter() + .find(|definition| definition.name == "api.manage.NetworkingMethod") + .expect("networking method enum definition"); + assert!( + networking_method_definition + .enum_options + .iter() + .any(|option| option.label == "PublicServer") + ); + } +} diff --git a/easytier-contrib/easytier-ohrs/src/config/services/share_link_service.rs b/easytier-contrib/easytier-ohrs/src/config/services/share_link_service.rs new file mode 100644 index 00000000..33bc65bd --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/services/share_link_service.rs @@ -0,0 +1,197 @@ +use crate::config::repository::{get_config_record, save_config_record}; +use crate::config::services::schema_service::get_network_config_field_mappings; +use crate::config::types::stored_config::SharedConfigLinkPayload; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use easytier::proto::api::manage::NetworkConfig; +use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; +use gethostname::gethostname; +use std::collections::HashMap; +use std::io::{Read, Write}; +use url::Url; +use uuid::Uuid; + +const SHARE_LINK_HOST: &str = "easytier.cn"; +const SHARE_LINK_PATH: &str = "/comp_cfg"; + +fn field_name_to_id_map() -> HashMap { + get_network_config_field_mappings() + .into_iter() + .map(|mapping| (mapping.field_name, mapping.field_number.to_string())) + .collect() +} + +fn field_id_to_name_map() -> HashMap { + get_network_config_field_mappings() + .into_iter() + .map(|mapping| (mapping.field_number.to_string(), mapping.field_name)) + .collect() +} + +fn prune_empty(value: &serde_json::Value) -> Option { + match value { + serde_json::Value::Null => None, + serde_json::Value::Array(values) if values.is_empty() => None, + _ => Some(value.clone()), + } +} + +fn map_config_json(config: &NetworkConfig) -> Result { + let field_name_to_id = field_name_to_id_map(); + let raw = serde_json::to_value(config).map_err(|err| err.to_string())?; + let mut mapped = serde_json::Map::new(); + + for (key, value) in raw.as_object().cloned().unwrap_or_default() { + let Some(value) = prune_empty(&value) else { + continue; + }; + let mapped_key = field_name_to_id.get(&key).cloned().unwrap_or(key); + mapped.insert(mapped_key, value); + } + + serde_json::to_string(&mapped).map_err(|err| err.to_string()) +} + +fn unmap_config_json(raw: &str) -> Result { + let field_id_to_name = field_id_to_name_map(); + let value = serde_json::from_str::(raw).map_err(|err| err.to_string())?; + let mut mapped = serde_json::Map::new(); + for (key, value) in value.as_object().cloned().unwrap_or_default() { + let field_name = field_id_to_name.get(&key).cloned().unwrap_or(key); + mapped.insert(field_name, value); + } + serde_json::from_value(serde_json::Value::Object(mapped)).map_err(|err| err.to_string()) +} + +fn compress_to_base64url(raw: &str) -> Result { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::best()); + encoder + .write_all(raw.as_bytes()) + .map_err(|err| err.to_string())?; + let compressed = encoder.finish().map_err(|err| err.to_string())?; + Ok(URL_SAFE_NO_PAD.encode(compressed)) +} + +fn decompress_from_base64url(raw: &str) -> Result { + let compressed = URL_SAFE_NO_PAD.decode(raw).map_err(|err| err.to_string())?; + let mut decoder = ZlibDecoder::new(compressed.as_slice()); + let mut out = String::new(); + decoder + .read_to_string(&mut out) + .map_err(|err| err.to_string())?; + Ok(out) +} + +pub fn build_config_share_link( + config_id: &str, + display_name: Option, + only_start: bool, +) -> Option { + let record = get_config_record(config_id)?; + let config = serde_json::from_str::(&record.config_json).ok()?; + let mapped_json = map_config_json(&config).ok()?; + let compressed = compress_to_base64url(&mapped_json).ok()?; + let final_name = display_name + .or(Some(record.meta.display_name)) + .filter(|name| !name.is_empty()); + + let mut url = Url::parse(&format!("https://{SHARE_LINK_HOST}{SHARE_LINK_PATH}")).ok()?; + url.query_pairs_mut().append_pair("cfg", &compressed); + if let Some(name) = final_name { + url.query_pairs_mut().append_pair("name", &name); + } + if only_start { + url.query_pairs_mut().append_pair("only_start", "true"); + } + Some(url.to_string()) +} + +pub fn parse_config_share_link(share_link: &str) -> Option { + let url = Url::parse(share_link).ok()?; + if url.host_str()? != SHARE_LINK_HOST || url.path() != SHARE_LINK_PATH { + return None; + } + + let cfg = url + .query_pairs() + .find(|(key, _)| key == "cfg")? + .1 + .to_string(); + let mapped_json = decompress_from_base64url(&cfg).ok()?; + let mut config = unmap_config_json(&mapped_json).ok()?; + config.instance_id = Some(Uuid::new_v4().to_string()); + let hostname = gethostname().to_string_lossy().to_string(); + if !hostname.is_empty() { + config.hostname = Some(hostname); + } + + let config_json = serde_json::to_string(&config).ok()?; + let display_name = url + .query_pairs() + .find(|(key, _)| key == "name") + .map(|(_, value)| value.to_string()) + .filter(|name| !name.is_empty()); + let only_start = url + .query_pairs() + .find(|(key, _)| key == "only_start") + .map(|(_, value)| value == "true") + .unwrap_or(false); + + Some(SharedConfigLinkPayload { + config_json, + display_name, + only_start, + }) +} + +pub fn import_config_share_link( + share_link: &str, + display_name_override: Option, +) -> Option { + let payload = parse_config_share_link(share_link)?; + let config = serde_json::from_str::(&payload.config_json).ok()?; + let config_id = config.instance_id.clone()?; + let display_name = display_name_override + .filter(|name| !name.is_empty()) + .or(payload.display_name) + .unwrap_or_else(|| config_id.clone()); + + save_config_record(config_id.clone(), display_name, payload.config_json)?; + Some(config_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_repo::{create_config_record, init_config_store}; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_root() -> String { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir() + .join(format!("easytier_ohrs_share_test_{unique}")) + .to_string_lossy() + .into_owned() + } + + #[test] + fn share_link_roundtrip_works() { + assert!(init_config_store(test_root())); + create_config_record("cfg-share".to_string(), "share-demo".to_string()) + .expect("create config"); + + let link = build_config_share_link("cfg-share", None, true).expect("share link"); + let payload = parse_config_share_link(&link).expect("parse link"); + let config = + serde_json::from_str::(&payload.config_json).expect("config json"); + + assert!(payload.only_start); + assert_eq!(payload.display_name.as_deref(), Some("share-demo")); + assert_ne!(config.instance_id.as_deref(), Some("cfg-share")); + + let imported_id = import_config_share_link(&link, None).expect("import link"); + assert_ne!(imported_id, "cfg-share"); + } +} diff --git a/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs b/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs new file mode 100644 index 00000000..a9a920c1 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/storage/config_meta.rs @@ -0,0 +1,333 @@ +use crate::config::types::stored_config::{StoredConfigList, StoredConfigMeta}; +use ohos_hilog_binding::{hilog_debug, hilog_error}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{SystemTime, UNIX_EPOCH}; + +static CONFIG_DB_PATH: Mutex> = Mutex::new(None); +const CONFIG_DB_FILE_NAME: &str = "easytier-config-store.db"; + +#[derive(Debug, Clone)] +struct StoredConfigMetaRecord { + config_id: String, + display_name: String, + created_at: String, + updated_at: String, + favorite: bool, + temporary: bool, +} + +pub(crate) fn now_ts_string() -> String { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs().to_string()) + .unwrap_or_else(|_| "0".to_string()) +} + +fn db_file_path() -> Option { + CONFIG_DB_PATH + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()) +} + +fn init_schema(conn: &Connection) -> rusqlite::Result<()> { + conn.execute_batch( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS stored_configs ( + config_id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + favorite INTEGER NOT NULL DEFAULT 0, + temporary INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS stored_config_fields ( + config_id TEXT NOT NULL, + field_name TEXT NOT NULL, + field_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (config_id, field_name), + FOREIGN KEY (config_id) REFERENCES stored_configs(config_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_stored_config_fields_config_id + ON stored_config_fields(config_id);", + ) +} + +pub(crate) fn open_db() -> Option { + let path = db_file_path()?; + let conn = match Connection::open(&path) { + Ok(conn) => conn, + Err(e) => { + hilog_error!("[Rust] failed to open config db {}: {}", path.display(), e); + return None; + } + }; + + if let Err(e) = init_schema(&conn) { + hilog_error!( + "[Rust] failed to initialize config db {}: {}", + path.display(), + e + ); + return None; + } + + Some(conn) +} + +fn row_to_meta(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(StoredConfigMetaRecord { + config_id: row.get(0)?, + display_name: row.get(1)?, + created_at: row.get(2)?, + updated_at: row.get(3)?, + favorite: row.get::<_, i64>(4)? != 0, + temporary: row.get::<_, i64>(5)? != 0, + }) +} + +fn load_meta_record(conn: &Connection, config_id: &str) -> Option { + conn.query_row( + "SELECT config_id, display_name, created_at, updated_at, favorite, temporary + FROM stored_configs WHERE config_id = ?1", + params![config_id], + row_to_meta, + ) + .optional() + .ok() + .flatten() +} + +fn to_meta(record: StoredConfigMetaRecord) -> StoredConfigMeta { + StoredConfigMeta { + config_id: record.config_id, + display_name: record.display_name, + created_at: record.created_at, + updated_at: record.updated_at, + favorite: record.favorite, + temporary: record.temporary, + } +} + +pub fn init_config_meta_store(root_dir: String) -> bool { + let root = PathBuf::from(root_dir); + if let Err(e) = std::fs::create_dir_all(&root) { + hilog_error!( + "[Rust] failed to create config db dir {}: {}", + root.display(), + e + ); + return false; + } + + let db_path = root.join(CONFIG_DB_FILE_NAME); + match CONFIG_DB_PATH.lock() { + Ok(mut guard) => { + *guard = Some(db_path.clone()); + } + Err(e) => { + hilog_error!("[Rust] failed to lock config db path: {}", e); + return false; + } + } + + if open_db().is_none() { + return false; + } + + hilog_debug!("[Rust] initialized config db at {}", db_path.display()); + true +} + +pub fn list_config_meta_entries() -> StoredConfigList { + let Some(conn) = open_db() else { + return StoredConfigList { configs: vec![] }; + }; + + let mut stmt = match conn.prepare( + "SELECT config_id, display_name, created_at, updated_at, favorite, temporary + FROM stored_configs + ORDER BY updated_at DESC, display_name ASC", + ) { + Ok(stmt) => stmt, + Err(e) => { + hilog_error!("[Rust] failed to prepare list meta query: {}", e); + return StoredConfigList { configs: vec![] }; + } + }; + + let rows = match stmt.query_map([], row_to_meta) { + Ok(rows) => rows, + Err(e) => { + hilog_error!("[Rust] failed to list config meta rows: {}", e); + return StoredConfigList { configs: vec![] }; + } + }; + + let configs = rows.filter_map(Result::ok).map(to_meta).collect(); + StoredConfigList { configs } +} + +pub fn get_config_display_name(config_id: &str) -> Option { + let conn = open_db()?; + load_meta_record(&conn, config_id).map(|record| record.display_name) +} + +pub fn get_config_meta(config_id: &str) -> Option { + let conn = open_db()?; + load_meta_record(&conn, config_id).map(to_meta) +} + +pub fn upsert_config_meta( + config_id: String, + display_name: String, + favorite: bool, + temporary: bool, +) -> StoredConfigMeta { + let now = now_ts_string(); + let Some(conn) = open_db() else { + return StoredConfigMeta { + config_id, + display_name, + created_at: now.clone(), + updated_at: now, + favorite, + temporary, + }; + }; + + let created_at = load_meta_record(&conn, &config_id) + .map(|record| record.created_at) + .unwrap_or_else(|| now.clone()); + + if let Err(e) = conn.execute( + "INSERT INTO stored_configs ( + config_id, display_name, created_at, updated_at, favorite, temporary + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(config_id) DO UPDATE SET + display_name = excluded.display_name, + updated_at = excluded.updated_at, + favorite = excluded.favorite, + temporary = excluded.temporary", + params![ + config_id, + display_name, + created_at, + now, + if favorite { 1 } else { 0 }, + if temporary { 1 } else { 0 } + ], + ) { + hilog_error!("[Rust] failed to upsert config meta: {}", e); + } + + get_config_meta(&config_id).unwrap_or(StoredConfigMeta { + config_id, + display_name, + created_at, + updated_at: now, + favorite, + temporary, + }) +} + +pub(crate) fn upsert_config_meta_in_tx( + tx: &rusqlite::Transaction<'_>, + config_id: String, + display_name: String, + favorite: bool, + temporary: bool, +) -> Option { + let now = now_ts_string(); + let created_at = tx + .query_row( + "SELECT config_id, display_name, created_at, updated_at, favorite, temporary + FROM stored_configs WHERE config_id = ?1", + params![config_id], + row_to_meta, + ) + .optional() + .ok() + .flatten() + .map(|record| record.created_at) + .unwrap_or_else(|| now.clone()); + + tx.execute( + "INSERT INTO stored_configs ( + config_id, display_name, created_at, updated_at, favorite, temporary + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ON CONFLICT(config_id) DO UPDATE SET + display_name = excluded.display_name, + updated_at = excluded.updated_at, + favorite = excluded.favorite, + temporary = excluded.temporary", + params![ + config_id, + display_name, + created_at, + now, + if favorite { 1 } else { 0 }, + if temporary { 1 } else { 0 } + ], + ) + .ok()?; + + tx.query_row( + "SELECT config_id, display_name, created_at, updated_at, favorite, temporary + FROM stored_configs WHERE config_id = ?1", + params![config_id], + row_to_meta, + ) + .optional() + .ok() + .flatten() + .map(to_meta) + .or(Some(StoredConfigMeta { + config_id, + display_name, + created_at, + updated_at: now, + favorite, + temporary, + })) +} + +pub fn set_config_display_name( + config_id: String, + display_name: String, +) -> Option { + let conn = open_db()?; + let mut record = load_meta_record(&conn, &config_id)?; + record.display_name = display_name; + record.updated_at = now_ts_string(); + + conn.execute( + "UPDATE stored_configs + SET display_name = ?2, updated_at = ?3 + WHERE config_id = ?1", + params![config_id, record.display_name, record.updated_at], + ) + .ok()?; + + Some(to_meta(record)) +} + +pub fn delete_config_meta(config_id: &str) -> bool { + let Some(conn) = open_db() else { + return false; + }; + + match conn.execute( + "DELETE FROM stored_configs WHERE config_id = ?1", + params![config_id], + ) { + Ok(rows) => rows > 0, + Err(e) => { + hilog_error!("[Rust] failed to delete config meta {}: {}", config_id, e); + false + } + } +} diff --git a/easytier-contrib/easytier-ohrs/src/config/storage/mod.rs b/easytier-contrib/easytier-ohrs/src/config/storage/mod.rs new file mode 100644 index 00000000..765a7267 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/storage/mod.rs @@ -0,0 +1 @@ +pub(crate) mod config_meta; diff --git a/easytier-contrib/easytier-ohrs/src/config/types/mod.rs b/easytier-contrib/easytier-ohrs/src/config/types/mod.rs new file mode 100644 index 00000000..71a56173 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/types/mod.rs @@ -0,0 +1 @@ +pub(crate) mod stored_config; diff --git a/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs b/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs new file mode 100644 index 00000000..86375416 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config/types/stored_config.rs @@ -0,0 +1,68 @@ +use napi_derive_ohos::napi; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct StoredConfigMeta { + pub config_id: String, + pub display_name: String, + pub created_at: String, + pub updated_at: String, + pub favorite: bool, + pub temporary: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct StoredConfigRecord { + pub meta: StoredConfigMeta, + pub config_json: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct StoredConfigList { + pub configs: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct ExportTomlResult { + pub toml_text: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct StoredConfigSummary { + pub config_id: String, + pub display_name: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct SharedConfigLinkPayload { + pub config_json: String, + pub display_name: Option, + pub only_start: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct LocalSocketSyncMessage { + pub message_type: String, + pub payload_json: String, +} + +#[derive(Debug, Clone, Serialize)] +#[napi(object)] +pub struct KeyValuePair { + pub key: String, + pub value: String, +} diff --git a/easytier-contrib/easytier-ohrs/src/config_repo.rs b/easytier-contrib/easytier-ohrs/src/config_repo.rs new file mode 100644 index 00000000..cbd8bf5b --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config_repo.rs @@ -0,0 +1,349 @@ +use super::{field_store, import_export, legacy_migration, validation}; +use crate::config::storage::config_meta::{ + delete_config_meta, get_config_meta, init_config_meta_store, list_config_meta_entries, open_db, + upsert_config_meta_in_tx, +}; +use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord}; +use easytier::common::config::ConfigLoader; +use easytier::proto::api::manage::NetworkConfig; +use ohos_hilog_binding::{hilog_debug, hilog_error}; +use rusqlite::params; +use serde_json::Value; +use std::path::PathBuf; +use std::sync::Mutex; + +static CONFIG_ROOT_DIR: Mutex> = Mutex::new(None); +pub(crate) const CONFIG_DIR_NAME: &str = "easytier-configs"; +pub(crate) const KERNEL_SOCKET_FILE_NAME: &str = "easytier-kernel.sock"; + +pub(crate) fn config_root_dir() -> Option { + CONFIG_ROOT_DIR + .lock() + .ok() + .and_then(|guard| guard.as_ref().cloned()) +} + +pub(crate) fn kernel_socket_path() -> Option { + config_root_dir().map(|root| root.join(KERNEL_SOCKET_FILE_NAME)) +} + +pub(crate) fn legacy_config_file_path(config_id: &str) -> Option { + legacy_migration::legacy_config_file_path(&config_root_dir(), CONFIG_DIR_NAME, config_id) +} + +pub fn init_config_store(root_dir: String) -> bool { + let root = PathBuf::from(root_dir); + let configs_dir = root.join(CONFIG_DIR_NAME); + if let Err(e) = std::fs::create_dir_all(&configs_dir) { + hilog_error!( + "[Rust] failed to create config dir {}: {}", + configs_dir.display(), + e + ); + return false; + } + + match CONFIG_ROOT_DIR.lock() { + Ok(mut guard) => { + *guard = Some(root.clone()); + } + Err(e) => { + hilog_error!("[Rust] failed to lock config root dir: {}", e); + return false; + } + } + + if !init_config_meta_store(root.to_string_lossy().into_owned()) { + return false; + } + + hilog_debug!( + "[Rust] initialized config repo at {}", + configs_dir.display() + ); + true +} + +fn migrate_legacy_file_if_needed(config_id: &str) -> Option<()> { + legacy_migration::migrate_legacy_file_if_needed( + &config_root_dir(), + CONFIG_DIR_NAME, + config_id, + save_config_record, + ) +} + +pub fn save_config_record( + config_id: String, + display_name: String, + config_json: String, +) -> Option { + let config = match validation::validate_config_json(&config_json, config_id.clone()) { + Ok(config) => config, + Err(e) => { + hilog_error!("[Rust] save_config_record failed {}", e); + return None; + } + }; + + let normalized_json = match serde_json::to_string(&config) { + Ok(raw) => raw, + Err(e) => { + hilog_error!( + "[Rust] failed to serialize normalized config {}: {}", + config_id, + e + ); + return None; + } + }; + + let fields = match validation::config_to_top_level_map(&config) { + Some(fields) => fields, + None => return None, + }; + + let conn = open_db()?; + let tx = conn.unchecked_transaction().ok()?; + let existing_meta = get_config_meta(&config_id); + let favorite = existing_meta + .as_ref() + .map(|meta| meta.favorite) + .unwrap_or(false); + let temporary = existing_meta + .as_ref() + .map(|meta| meta.temporary) + .unwrap_or(false); + let meta = upsert_config_meta_in_tx(&tx, config_id.clone(), display_name, favorite, temporary)?; + + field_store::replace_config_fields(&tx, &config_id, fields)?; + + tx.commit().ok()?; + + if let Some(legacy_path) = legacy_config_file_path(&config_id) { + if legacy_path.exists() { + let _ = std::fs::remove_file(legacy_path); + } + } + + Some(StoredConfigRecord { + meta, + config_json: normalized_json, + }) +} + +pub fn load_config_json(config_id: &str) -> Option { + migrate_legacy_file_if_needed(config_id)?; + let object = field_store::load_config_map_from_db(config_id)?; + serde_json::to_string(&Value::Object(object)).ok() +} + +pub fn get_config_record(config_id: &str) -> Option { + let config_json = load_config_json(config_id)?; + let meta = get_config_meta(config_id)?; + Some(StoredConfigRecord { meta, config_json }) +} + +pub fn get_config_field_value(config_id: &str, field: &str) -> Option { + migrate_legacy_file_if_needed(config_id)?; + let conn = open_db()?; + conn.query_row( + "SELECT field_json FROM stored_config_fields + WHERE config_id = ?1 AND field_name = ?2", + params![config_id, field], + |row| row.get::<_, String>(0), + ) + .ok() +} + +pub fn set_config_field_value(config_id: &str, field: &str, json_value: &str) -> bool { + if field.contains('.') { + return false; + } + + let raw = match load_config_json(config_id) { + Some(raw) => raw, + None => return false, + }; + let mut value = match serde_json::from_str::(&raw) { + Ok(value) => value, + Err(_) => return false, + }; + let new_field_value = match serde_json::from_str::(json_value) { + Ok(value) => value, + Err(_) => return false, + }; + let object = match value.as_object_mut() { + Some(object) => object, + None => return false, + }; + object.insert(field.to_string(), new_field_value); + + let normalized = match serde_json::to_string(&value) { + Ok(raw) => raw, + Err(_) => return false, + }; + + let display_name = get_config_meta(config_id) + .map(|meta| meta.display_name) + .unwrap_or_else(|| config_id.to_string()); + + save_config_record(config_id.to_string(), display_name, normalized).is_some() +} + +pub fn get_display_name(config_id: &str) -> Option { + get_config_meta(config_id).map(|meta| meta.display_name) +} + +pub fn get_default_config_json() -> Option { + crate::build_default_network_config_json().ok() +} + +pub fn create_config_record(config_id: String, display_name: String) -> Option { + let raw = get_default_config_json()?; + let mut config = serde_json::from_str::(&raw).ok()?; + config.instance_id = Some(config_id.clone()); + let normalized_json = serde_json::to_string(&config).ok()?; + save_config_record(config_id, display_name, normalized_json) +} + +pub fn start_kernel_with_config_id(config_id: &str) -> bool { + let raw = match load_config_json(config_id) { + Some(raw) => raw, + None => return false, + }; + crate::run_network_instance_from_json(&raw) +} + +pub fn list_config_meta_json() -> String { + serde_json::to_string(&list_config_meta_entries().configs).unwrap_or_else(|_| "[]".to_string()) +} + +pub fn delete_config_record(config_id: &str) -> bool { + if let Some(path) = legacy_config_file_path(config_id) { + if path.exists() { + let _ = std::fs::remove_file(path); + } + } + + let conn = match open_db() { + Some(conn) => conn, + None => return false, + }; + if let Err(e) = conn.execute( + "DELETE FROM stored_config_fields WHERE config_id = ?1", + params![config_id], + ) { + hilog_error!("[Rust] failed to delete config fields {}: {}", config_id, e); + return false; + } + + delete_config_meta(config_id) +} + +pub fn export_config_toml(config_id: &str) -> Option { + let record = get_config_record(config_id)?; + import_export::export_config_toml_from_record(&record) +} + +pub fn import_toml_config( + toml_text: String, + display_name: Option, +) -> Option { + import_export::import_toml_to_record(toml_text, display_name, save_config_record) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::params; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn test_root() -> String { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("easytier_ohrs_test_{}", unique)); + dir.to_string_lossy().into_owned() + } + + #[test] + fn save_get_export_delete_roundtrip() { + let root = test_root(); + assert!(init_config_store(root.clone())); + + let config_json = crate::build_default_network_config_json().expect("default config"); + let saved = save_config_record("cfg-1".to_string(), "test-config".to_string(), config_json) + .expect("save config"); + + assert_eq!(saved.meta.config_id, "cfg-1"); + assert_eq!(saved.meta.display_name, "test-config"); + + let loaded = get_config_record("cfg-1").expect("load config"); + assert_eq!(loaded.meta.display_name, "test-config"); + assert!(loaded.config_json.contains("cfg-1")); + + let legacy_json_path = PathBuf::from(&root) + .join(CONFIG_DIR_NAME) + .join("cfg-1.json"); + assert!( + !legacy_json_path.exists(), + "config should no longer be persisted as a per-config json file" + ); + + let conn = open_db().expect("db should be open"); + let field_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM stored_config_fields WHERE config_id = ?1", + params!["cfg-1"], + |row| row.get(0), + ) + .expect("count config fields"); + assert!(field_count > 0, "config fields should be stored in sqlite"); + + let exported = export_config_toml("cfg-1").expect("export toml"); + assert!(exported.toml_text.contains("instance_id")); + + assert!(delete_config_record("cfg-1")); + assert!(get_config_record("cfg-1").is_none()); + } + + #[test] + fn set_config_field_updates_only_requested_top_level_field() { + let root = test_root(); + assert!(init_config_store(root)); + + let config_json = crate::build_default_network_config_json().expect("default config"); + save_config_record( + "cfg-field".to_string(), + "field-config".to_string(), + config_json, + ) + .expect("save config"); + + let before_network_name = get_config_field_value("cfg-field", "network_name"); + let before_instance_id = get_config_field_value("cfg-field", "instance_id") + .expect("instance id field should exist"); + + assert!(set_config_field_value( + "cfg-field", + "network_name", + "\"changed-network\"" + )); + + assert_eq!( + get_config_field_value("cfg-field", "network_name"), + Some("\"changed-network\"".to_string()) + ); + assert_eq!( + get_config_field_value("cfg-field", "instance_id"), + Some(before_instance_id) + ); + assert_ne!( + get_config_field_value("cfg-field", "network_name"), + before_network_name + ); + } +} diff --git a/easytier-contrib/easytier-ohrs/src/config_repo/field_store.rs b/easytier-contrib/easytier-ohrs/src/config_repo/field_store.rs new file mode 100644 index 00000000..c954ac02 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config_repo/field_store.rs @@ -0,0 +1,67 @@ +use crate::config::storage::config_meta::{now_ts_string, open_db}; +use ohos_hilog_binding::hilog_error; +use rusqlite::{Connection, params}; +use serde_json::{Map, Value}; + +pub(super) fn load_config_map_from_db(config_id: &str) -> Option> { + let conn = open_db()?; + let mut stmt = conn + .prepare( + "SELECT field_name, field_json + FROM stored_config_fields + WHERE config_id = ?1", + ) + .ok()?; + let rows = stmt + .query_map(params![config_id], |row| { + let field_name: String = row.get(0)?; + let field_json: String = row.get(1)?; + Ok((field_name, field_json)) + }) + .ok()?; + + let mut object = Map::new(); + for row in rows { + let (field_name, field_json) = row.ok()?; + let value = serde_json::from_str::(&field_json).ok()?; + object.insert(field_name, value); + } + + if object.is_empty() { + None + } else { + Some(object) + } +} + +pub(super) fn replace_config_fields( + tx: &Connection, + config_id: &str, + fields: Map, +) -> Option<()> { + if let Err(e) = tx.execute( + "DELETE FROM stored_config_fields WHERE config_id = ?1", + params![config_id], + ) { + hilog_error!( + "[Rust] failed to clear existing config fields {}: {}", + config_id, + e + ); + return None; + } + + for (field_name, value) in fields { + let field_json = serde_json::to_string(&value).ok()?; + if let Err(e) = tx.execute( + "INSERT INTO stored_config_fields (config_id, field_name, field_json, updated_at) + VALUES (?1, ?2, ?3, ?4)", + params![config_id, field_name, field_json, now_ts_string()], + ) { + hilog_error!("[Rust] failed to persist config field {}: {}", config_id, e); + return None; + } + } + + Some(()) +} diff --git a/easytier-contrib/easytier-ohrs/src/config_repo/import_export.rs b/easytier-contrib/easytier-ohrs/src/config_repo/import_export.rs new file mode 100644 index 00000000..7f698aa4 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config_repo/import_export.rs @@ -0,0 +1,48 @@ +use crate::config::types::stored_config::{ExportTomlResult, StoredConfigRecord}; +use easytier::common::config::{ConfigLoader, TomlConfigLoader}; +use easytier::proto::api::manage::NetworkConfig; + +pub(super) fn export_config_toml_from_record( + record: &StoredConfigRecord, +) -> Option { + let config = serde_json::from_str::(&record.config_json).ok()?; + let toml = config.gen_config().ok()?; + Some(ExportTomlResult { + toml_text: toml.dump(), + }) +} + +pub(super) fn import_toml_to_record( + toml_text: String, + display_name: Option, + save_config_record: impl Fn(String, String, String) -> Option, +) -> Option { + let config = + NetworkConfig::new_from_config(TomlConfigLoader::new_from_str(&toml_text).ok()?).ok()?; + + let config_id = config.instance_id.clone()?; + let name_from_toml = toml_text + .lines() + .find_map(|line| { + let trimmed = line.trim(); + if !trimmed.starts_with("instance_name") { + return None; + } + trimmed.split_once('=').map(|(_, value)| { + value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string() + }) + }) + .filter(|name| !name.is_empty()); + + let final_name = display_name + .filter(|name| !name.is_empty()) + .or(name_from_toml) + .unwrap_or_else(|| config_id.clone()); + + let config_json = serde_json::to_string(&config).ok()?; + save_config_record(config_id, final_name, config_json) +} diff --git a/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs b/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs new file mode 100644 index 00000000..6efa3b6c --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config_repo/legacy_migration.rs @@ -0,0 +1,45 @@ +use crate::config::storage::config_meta::get_config_meta; +use ohos_hilog_binding::hilog_error; +use std::path::PathBuf; + +pub(super) fn legacy_config_file_path( + root_dir: &Option, + config_dir_name: &str, + config_id: &str, +) -> Option { + root_dir.as_ref().map(|root| { + root.join(config_dir_name) + .join(format!("{}.json", config_id)) + }) +} + +pub(super) fn migrate_legacy_file_if_needed( + root_dir: &Option, + config_dir_name: &str, + config_id: &str, + save_config_record: impl Fn( + String, + String, + String, + ) -> Option, +) -> Option<()> { + let legacy_path = legacy_config_file_path(root_dir, config_dir_name, config_id)?; + if !legacy_path.exists() { + return Some(()); + } + + let raw = std::fs::read_to_string(&legacy_path).ok()?; + let display_name = get_config_meta(config_id) + .map(|meta| meta.display_name) + .unwrap_or_else(|| config_id.to_string()); + save_config_record(config_id.to_string(), display_name, raw)?; + + if let Err(e) = std::fs::remove_file(&legacy_path) { + hilog_error!( + "[Rust] failed to remove legacy config file {}: {}", + legacy_path.display(), + e + ); + } + Some(()) +} diff --git a/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs b/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs new file mode 100644 index 00000000..cc7551fb --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/config_repo/validation.rs @@ -0,0 +1,30 @@ +use easytier::proto::api::manage::NetworkConfig; +use serde_json::{Map, Value}; + +pub(super) fn normalize_config_id( + mut config: NetworkConfig, + requested_id: String, +) -> Result { + if requested_id.is_empty() { + return Err("config_id is required".to_string()); + } + config.instance_id = Some(requested_id); + Ok(config) +} + +pub(super) fn validate_config_json( + config_json: &str, + config_id: String, +) -> Result { + let config = serde_json::from_str::(config_json) + .map_err(|e| format!("parse config json failed: {}", e))?; + let config = normalize_config_id(config, config_id)?; + config + .gen_config() + .map_err(|e| format!("generate toml failed: {}", e))?; + Ok(config) +} + +pub(super) fn config_to_top_level_map(config: &NetworkConfig) -> Option> { + serde_json::to_value(config).ok()?.as_object().cloned() +} diff --git a/easytier-contrib/easytier-ohrs/src/exports.rs b/easytier-contrib/easytier-ohrs/src/exports.rs new file mode 100644 index 00000000..57588876 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/exports.rs @@ -0,0 +1,2 @@ +pub(crate) mod config_api; +pub(crate) mod runtime_api; diff --git a/easytier-contrib/easytier-ohrs/src/exports/config_api.rs b/easytier-contrib/easytier-ohrs/src/exports/config_api.rs new file mode 100644 index 00000000..7bcf413c --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/exports/config_api.rs @@ -0,0 +1,46 @@ +use crate::config; + +pub(crate) fn init_config_store(root_dir: String) -> bool { + config::repository::init_config_store(root_dir) +} + +pub(crate) fn list_configs() -> String { + config::repository::list_config_meta_json() +} + +pub(crate) fn save_config(config_id: String, display_name: String, config_json: String) -> bool { + config::repository::save_config_record(config_id, display_name, config_json).is_some() +} + +pub(crate) fn create_config(config_id: String, display_name: String) -> bool { + config::repository::create_config_record(config_id, display_name).is_some() +} + +pub(crate) fn delete_stored_config_meta(config_id: String) -> bool { + config::repository::delete_config_record(&config_id) +} + +pub(crate) fn get_config(config_id: String) -> Option { + config::repository::load_config_json(&config_id) +} + +pub(crate) fn get_default_config() -> Option { + config::repository::get_default_config_json() +} + +pub(crate) fn get_config_field(config_id: String, field: String) -> Option { + config::repository::get_config_field_value(&config_id, &field) +} + +pub(crate) fn set_config_field(config_id: String, field: String, json_value: String) -> bool { + config::repository::set_config_field_value(&config_id, &field, &json_value) +} + +pub(crate) fn import_toml(toml_text: String, display_name: Option) -> Option { + config::repository::import_toml_config(toml_text, display_name) + .map(|record| record.meta.config_id) +} + +pub(crate) fn export_toml(config_id: String) -> Option { + config::repository::export_config_toml(&config_id).map(|ret| ret.toml_text) +} diff --git a/easytier-contrib/easytier-ohrs/src/exports/runtime_api.rs b/easytier-contrib/easytier-ohrs/src/exports/runtime_api.rs new file mode 100644 index 00000000..6ca437c4 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/exports/runtime_api.rs @@ -0,0 +1,184 @@ +use crate::config::repository::load_config_json; +use crate::config::storage::config_meta::get_config_display_name; +use crate::config::types::stored_config::KeyValuePair; +use crate::kernel_bridge::{ + aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner, + stop_local_socket_server as stop_local_socket_server_inner, +}; +use crate::runtime::state::runtime_state::{ + RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached, + runtime_instance_from_running_info, +}; +use crate::{ASYNC_RUNTIME, EASYTIER_VERSION, INSTANCE_MANAGER, WEB_CLIENTS}; +use easytier::proto::api::manage::NetworkConfig; +use ohos_hilog_binding::{hilog_error, hilog_info}; +use std::sync::Arc; + +pub(crate) fn start_kernel( + config_id: String, + start_kernel_with_config_id: impl Fn(&str) -> bool, +) -> bool { + start_kernel_with_config_id(&config_id) +} + +pub(crate) fn stop_kernel( + config_id: String, + stop_web_client: impl Fn(&str) -> bool, + parse_instance_uuid: impl Fn(&str) -> Option, + maybe_stop_local_socket_server: impl Fn(), +) -> bool { + clear_tun_attached(&config_id); + if stop_web_client(&config_id) { + return true; + } + + let Some(instance_id) = parse_instance_uuid(&config_id) else { + return false; + }; + + let ret = INSTANCE_MANAGER + .delete_network_instance(vec![instance_id]) + .map(|_| true) + .unwrap_or_else(|err| { + hilog_error!("[Rust] stop_kernel failed {}: {}", config_id, err); + false + }); + maybe_stop_local_socket_server(); + ret +} + +pub(crate) fn stop_network_instance( + config_ids: Vec, + stop_kernel: impl Fn(String) -> bool, +) -> bool { + let mut ok = true; + for config_id in config_ids { + ok = stop_kernel(config_id) && ok; + } + ok +} + +pub(crate) fn collect_network_infos() -> Vec { + let infos = match INSTANCE_MANAGER.collect_network_infos_sync() { + Ok(infos) => infos, + Err(err) => { + hilog_error!("[Rust] collect network infos failed {}", err); + return vec![]; + } + }; + + infos + .into_iter() + .filter_map(|(key, value)| { + serde_json::to_string(&value) + .ok() + .map(|value_json| KeyValuePair { + key: key.to_string(), + value: value_json, + }) + }) + .collect() +} + +pub(crate) fn set_tun_fd( + config_id: String, + fd: i32, + parse_instance_uuid: impl Fn(&str) -> Option, +) -> bool { + let Some(instance_id) = parse_instance_uuid(&config_id) else { + hilog_error!("[Rust] set_tun_fd invalid instance id: {}", config_id); + return false; + }; + + INSTANCE_MANAGER + .set_tun_fd(&instance_id, fd) + .map(|_| { + mark_tun_attached(&config_id); + hilog_info!( + "[Rust] set_tun_fd success instance={} fd={} marked_attached=true", + config_id, + fd + ); + true + }) + .unwrap_or_else(|err| { + hilog_error!("[Rust] set_tun_fd failed {}: {}", config_id, err); + false + }) +} + +pub(crate) fn get_runtime_snapshot() -> RuntimeAggregateState { + get_runtime_snapshot_inner() +} + +pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState { + let infos = match INSTANCE_MANAGER.collect_network_infos_sync() { + Ok(infos) => infos, + Err(err) => { + hilog_error!("[Rust] collect network infos failed {}", err); + return RuntimeAggregateState { + instances: vec![], + tun: TunAggregateState { + active: false, + attached_instance_ids: vec![], + aggregated_routes: vec![], + dns_servers: vec![], + need_rebuild: false, + }, + running_instance_count: 0, + }; + } + }; + + let mut instances = Vec::with_capacity(infos.len()); + for (instance_uuid, info) in infos { + let config_id = instance_uuid.to_string(); + let display_name = get_config_display_name(&config_id).unwrap_or_else(|| config_id.clone()); + let config_json = load_config_json(&config_id); + let stored_config = config_json + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()); + let magic_dns_enabled = stored_config + .as_ref() + .and_then(|cfg| cfg.enable_magic_dns) + .unwrap_or(false); + let need_exit_node = stored_config + .as_ref() + .map(|cfg| !cfg.exit_nodes.is_empty()) + .unwrap_or(false); + instances.push(runtime_instance_from_running_info( + config_id, + display_name, + magic_dns_enabled, + need_exit_node, + info, + )); + } + + instances.sort_by(|a, b| { + a.display_name + .cmp(&b.display_name) + .then_with(|| a.instance_id.cmp(&b.instance_id)) + }); + let attached_instance_ids = instances + .iter() + .filter(|instance| instance.tun_required) + .map(|instance| instance.instance_id.clone()) + .collect::>(); + let aggregated_routes = aggregate_requested_tun_routes(&instances); + let running_instance_count = + instances.iter().filter(|instance| instance.running).count() as i32; + let tun_active = !attached_instance_ids.is_empty(); + + RuntimeAggregateState { + instances, + tun: TunAggregateState { + active: tun_active, + attached_instance_ids, + aggregated_routes, + dns_servers: vec![], + need_rebuild: false, + }, + running_instance_count, + } +} diff --git a/easytier-contrib/easytier-ohrs/src/kernel_bridge.rs b/easytier-contrib/easytier-ohrs/src/kernel_bridge.rs new file mode 100644 index 00000000..f01868b0 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/kernel_bridge.rs @@ -0,0 +1,6 @@ +mod protocol; +mod routing; +mod socket_server; + +pub(crate) use routing::aggregate_requested_tun_routes; +pub use socket_server::{start_local_socket_server, stop_local_socket_server}; diff --git a/easytier-contrib/easytier-ohrs/src/kernel_bridge/protocol.rs b/easytier-contrib/easytier-ohrs/src/kernel_bridge/protocol.rs new file mode 100644 index 00000000..13cc6aed --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/kernel_bridge/protocol.rs @@ -0,0 +1,50 @@ +use crate::config::types::stored_config::LocalSocketSyncMessage; +use serde::Serialize; +use std::io::{Error, ErrorKind, Write}; +use std::os::unix::net::UnixStream; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TunRequestPayload { + pub config_id: String, + pub instance_id: String, + pub display_name: String, + pub virtual_ipv4: Option, + pub virtual_ipv4_cidr: Option, + pub aggregated_routes: Vec, + pub magic_dns_enabled: bool, + pub need_exit_node: bool, +} + +pub(crate) fn send_local_socket_message( + stream: &mut UnixStream, + message_type: &str, + payload_json: String, +) -> std::io::Result<()> { + let message = LocalSocketSyncMessage { + message_type: message_type.to_string(), + payload_json, + }; + let mut raw = serde_json::to_vec(&message) + .map_err(|err| Error::new(ErrorKind::InvalidData, err.to_string()))?; + raw.push(b'\n'); + stream.write_all(&raw)?; + Ok(()) +} + +pub(crate) fn broadcast_local_socket_message( + clients: &mut Vec, + message_type: &str, + payload_json: &str, +) -> bool { + let mut active_clients = Vec::with_capacity(clients.len()); + let mut delivered = false; + for mut client in clients.drain(..) { + if send_local_socket_message(&mut client, message_type, payload_json.to_string()).is_ok() { + delivered = true; + active_clients.push(client); + } + } + *clients = active_clients; + delivered +} diff --git a/easytier-contrib/easytier-ohrs/src/kernel_bridge/routing.rs b/easytier-contrib/easytier-ohrs/src/kernel_bridge/routing.rs new file mode 100644 index 00000000..52a1649f --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/kernel_bridge/routing.rs @@ -0,0 +1,105 @@ +use crate::config::repository::load_config_json; +use crate::runtime::state::runtime_state::RuntimeInstanceState; +use easytier::proto::api::manage::NetworkConfig; +use ipnet::IpNet; +use ohos_hilog_binding::hilog_debug; +use std::collections::HashSet; +use std::net::IpAddr; + +pub(crate) fn load_manual_routes(config_id: &str) -> Vec { + load_config_json(config_id) + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .map(|config| config.routes) + .unwrap_or_default() +} + +fn normalize_route_cidr(route: &str) -> Option { + route + .parse::() + .ok() + .map(|network| match network { + IpNet::V4(net) => net.trunc().to_string(), + IpNet::V6(net) => net.trunc().to_string(), + }) + .or_else(|| { + route.parse::().ok().map(|addr| match addr { + IpAddr::V4(ip) => format!("{}/32", ip), + IpAddr::V6(ip) => format!("{}/128", ip), + }) + }) +} + +fn simplify_routes(routes: Vec) -> Vec { + let mut parsed = routes + .into_iter() + .filter_map(|route| normalize_route_cidr(&route)) + .filter_map(|route| route.parse::().ok()) + .collect::>(); + parsed.sort_by(|left, right| { + left.prefix_len() + .cmp(&right.prefix_len()) + .then_with(|| left.network().to_string().cmp(&right.network().to_string())) + }); + + let mut simplified = Vec::::new(); + 'outer: for route in parsed { + for existing in &simplified { + if existing.contains(&route.network()) && existing.prefix_len() <= route.prefix_len() { + continue 'outer; + } + } + simplified.retain(|existing| { + !(route.contains(&existing.network()) && route.prefix_len() <= existing.prefix_len()) + }); + simplified.push(route); + } + + let mut seen = HashSet::new(); + simplified + .into_iter() + .map(|route| route.to_string()) + .filter(|route| seen.insert(route.clone())) + .collect() +} + +pub(crate) fn aggregate_tun_routes(instance: &RuntimeInstanceState) -> Vec { + let virtual_ipv4_cidr = instance + .my_node_info + .as_ref() + .and_then(|info| info.virtual_ipv4_cidr.clone()); + let manual_routes = load_manual_routes(&instance.config_id); + let proxy_cidrs = instance + .routes + .iter() + .flat_map(|route| route.proxy_cidrs.iter().cloned()) + .collect::>(); + let mut raw_routes = Vec::new(); + + if let Some(cidr) = virtual_ipv4_cidr.clone() { + raw_routes.push(cidr); + } + + raw_routes.extend(manual_routes.iter().cloned()); + raw_routes.extend(proxy_cidrs.iter().cloned()); + let aggregated_routes = simplify_routes(raw_routes); + hilog_debug!( + "[Rust] aggregate_tun_routes instance={} proxy_cidrs={:?} aggregated_routes={:?}", + instance.instance_id, + proxy_cidrs, + aggregated_routes + ); + aggregated_routes +} + +pub(crate) fn aggregate_requested_tun_routes(instances: &[RuntimeInstanceState]) -> Vec { + let mut aggregated_routes = Vec::new(); + let mut seen_routes = HashSet::new(); + for instance in instances.iter().filter(|instance| instance.tun_required) { + for route in aggregate_tun_routes(instance) { + if seen_routes.insert(route.clone()) { + aggregated_routes.push(route); + } + } + } + aggregated_routes +} diff --git a/easytier-contrib/easytier-ohrs/src/kernel_bridge/socket_server.rs b/easytier-contrib/easytier-ohrs/src/kernel_bridge/socket_server.rs new file mode 100644 index 00000000..f91372cc --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/kernel_bridge/socket_server.rs @@ -0,0 +1,196 @@ +use super::protocol::{TunRequestPayload, broadcast_local_socket_message}; +use crate::config::repository::kernel_socket_path; +use crate::get_runtime_snapshot_inner; +use crate::kernel_bridge::routing::aggregate_tun_routes; +use ohos_hilog_binding::{hilog_error, hilog_info}; +use once_cell::sync::Lazy; +use std::collections::{HashMap, HashSet}; +use std::io::ErrorKind; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +struct LocalSocketState { + stop_flag: std::sync::Arc, + socket_path: PathBuf, + worker: JoinHandle<()>, +} + +static LOCAL_SOCKET_STATE: Lazy>> = Lazy::new(|| Mutex::new(None)); + +pub fn start_local_socket_server() -> bool { + let socket_path = match kernel_socket_path() { + Some(path) => path, + None => { + hilog_error!("[Rust] kernel socket path unavailable"); + return false; + } + }; + + match LOCAL_SOCKET_STATE.lock() { + Ok(guard) if guard.is_some() => return true, + Ok(_) => {} + Err(err) => { + hilog_error!("[Rust] lock localsocket state failed: {}", err); + return false; + } + } + + if socket_path.exists() { + let _ = std::fs::remove_file(&socket_path); + } + + let listener = match UnixListener::bind(&socket_path) { + Ok(listener) => listener, + Err(err) => { + hilog_error!( + "[Rust] bind localsocket failed {}: {}", + socket_path.display(), + err + ); + return false; + } + }; + if let Err(err) = listener.set_nonblocking(true) { + hilog_error!("[Rust] set localsocket nonblocking failed: {}", err); + let _ = std::fs::remove_file(&socket_path); + return false; + } + + let stop_flag = std::sync::Arc::new(AtomicBool::new(false)); + let worker_stop_flag = stop_flag.clone(); + let worker = thread::spawn(move || { + let mut last_snapshot_json = String::new(); + let mut delivered_tun_requests = HashSet::new(); + let mut last_tun_route_signatures = HashMap::::new(); + let mut clients = Vec::::new(); + + while !worker_stop_flag.load(Ordering::Relaxed) { + let mut accepted_client = false; + loop { + match listener.accept() { + Ok((stream, _addr)) => { + accepted_client = true; + clients.push(stream); + } + Err(err) if err.kind() == ErrorKind::WouldBlock => break, + Err(err) => { + hilog_error!("[Rust] accept localsocket failed: {}", err); + break; + } + } + } + + let snapshot = get_runtime_snapshot_inner(); + let snapshot_json = match serde_json::to_string(&snapshot) { + Ok(json) => json, + Err(err) => { + hilog_error!("[Rust] serialize runtime snapshot failed: {}", err); + thread::sleep(Duration::from_millis(250)); + continue; + } + }; + + if accepted_client || snapshot_json != last_snapshot_json { + let _ = broadcast_local_socket_message( + &mut clients, + "runtime_snapshot", + &snapshot_json, + ); + last_snapshot_json = snapshot_json; + } + + for instance in snapshot.instances.iter() { + if instance.running && instance.tun_required { + let virtual_ipv4 = instance + .my_node_info + .as_ref() + .and_then(|info| info.virtual_ipv4.clone()); + let virtual_ipv4_cidr = instance + .my_node_info + .as_ref() + .and_then(|info| info.virtual_ipv4_cidr.clone()); + if clients.is_empty() { + continue; + } + if virtual_ipv4.is_none() || virtual_ipv4_cidr.is_none() { + continue; + } + let aggregated_routes = aggregate_tun_routes(instance); + let route_signature = serde_json::to_string(&aggregated_routes) + .unwrap_or_else(|_| "[]".to_string()); + let should_send = !delivered_tun_requests.contains(&instance.instance_id) + || last_tun_route_signatures + .get(&instance.instance_id) + .map(|value| value != &route_signature) + .unwrap_or(true); + if !should_send { + continue; + } + let payload = TunRequestPayload { + config_id: instance.config_id.clone(), + instance_id: instance.instance_id.clone(), + display_name: instance.display_name.clone(), + virtual_ipv4, + virtual_ipv4_cidr, + aggregated_routes, + magic_dns_enabled: instance.magic_dns_enabled, + need_exit_node: instance.need_exit_node, + }; + let payload_json = match serde_json::to_string(&payload) { + Ok(json) => json, + Err(err) => { + hilog_error!("[Rust] serialize tun request failed: {}", err); + continue; + } + }; + if broadcast_local_socket_message(&mut clients, "tun_request", &payload_json) { + delivered_tun_requests.insert(instance.instance_id.clone()); + last_tun_route_signatures + .insert(instance.instance_id.clone(), route_signature); + } + } else { + delivered_tun_requests.remove(&instance.instance_id); + last_tun_route_signatures.remove(&instance.instance_id); + } + } + + thread::sleep(Duration::from_millis(250)); + } + }); + + match LOCAL_SOCKET_STATE.lock() { + Ok(mut guard) => { + *guard = Some(LocalSocketState { + stop_flag, + socket_path, + worker, + }); + true + } + Err(err) => { + hilog_error!("[Rust] lock localsocket state failed: {}", err); + false + } + } +} + +pub fn stop_local_socket_server() -> bool { + let state = match LOCAL_SOCKET_STATE.lock() { + Ok(mut guard) => guard.take(), + Err(err) => { + hilog_error!("[Rust] lock localsocket state failed: {}", err); + return false; + } + }; + + if let Some(state) = state { + state.stop_flag.store(true, Ordering::Relaxed); + let _ = state.worker.join(); + let _ = std::fs::remove_file(state.socket_path); + } + true +} diff --git a/easytier-contrib/easytier-ohrs/src/lib.rs b/easytier-contrib/easytier-ohrs/src/lib.rs index 352ce3c7..48a79968 100644 --- a/easytier-contrib/easytier-ohrs/src/lib.rs +++ b/easytier-contrib/easytier-ohrs/src/lib.rs @@ -1,185 +1,485 @@ -mod native_log; +mod config; +mod exports; +mod kernel_bridge; +mod platform; +mod runtime; -use easytier::common::config::{ConfigFileControl, ConfigLoader, TomlConfigLoader}; +use config::repository::{ + create_config_record, delete_config_record, export_config_toml, get_config_field_value, + get_default_config_json, import_toml_config, init_config_store as init_repo_store, + list_config_meta_json, save_config_record, set_config_field_value, start_kernel_with_config_id, +}; +use config::services::schema_service::{ + ConfigFieldMapping, NetworkConfigSchema, + get_network_config_field_mappings as build_network_config_field_mappings, + get_network_config_schema as build_network_config_schema, +}; +use config::services::share_link_service::{ + build_config_share_link as build_config_share_link_inner, + import_config_share_link as import_config_share_link_inner, + parse_config_share_link as parse_config_share_link_inner, +}; +use config::storage::config_meta::get_config_display_name; +use config::types::stored_config::{KeyValuePair, SharedConfigLinkPayload}; use easytier::common::constants::EASYTIER_VERSION; +use easytier::common::{ + MachineIdOptions, + config::{ConfigFileControl, ConfigLoader, TomlConfigLoader}, +}; use easytier::instance_manager::NetworkInstanceManager; use easytier::proto::api::manage::NetworkConfig; +use easytier::proto::api::manage::NetworkingMethod; +use easytier::web_client::{WebClient, WebClientHooks, run_web_client}; +use kernel_bridge::{ + aggregate_requested_tun_routes, start_local_socket_server as start_local_socket_server_inner, + stop_local_socket_server as stop_local_socket_server_inner, +}; use napi_derive_ohos::napi; -use ohos_hilog_binding::{hilog_debug, hilog_error}; +use ohos_hilog_binding::{hilog_error, hilog_info}; +use runtime::state::runtime_state::{ + RuntimeAggregateState, TunAggregateState, clear_tun_attached, mark_tun_attached, + runtime_instance_from_running_info, +}; +use std::collections::{HashMap, HashSet}; use std::format; +use std::sync::{Arc, Mutex}; +use tokio::runtime::{Builder, Runtime}; use uuid::Uuid; -static INSTANCE_MANAGER: once_cell::sync::Lazy = - once_cell::sync::Lazy::new(NetworkInstanceManager::new); +pub(crate) static INSTANCE_MANAGER: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Arc::new(NetworkInstanceManager::new())); +static ASYNC_RUNTIME: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { + Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime for easytier-ohrs") +}); +static WEB_CLIENTS: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(HashMap::new())); -#[napi(object)] -pub struct KeyValuePair { - pub key: String, - pub value: String, +#[derive(Default)] +struct TrackedWebClientHooks { + instance_ids: Mutex>, } -#[napi] -pub fn easytier_version() -> String { - EASYTIER_VERSION.to_string() +struct ManagedWebClient { + _client: WebClient, + hooks: Arc, } -#[napi] -pub fn set_tun_fd(inst_id: String, fd: i32) -> bool { - match Uuid::try_parse(&inst_id) { - Ok(uuid) => match INSTANCE_MANAGER.set_tun_fd(&uuid, fd) { - Ok(_) => { - hilog_debug!("[Rust] set tun fd {} to {}.", fd, inst_id); - true - } - Err(e) => { - hilog_error!("[Rust] cant set tun fd {} to {}. {}", fd, inst_id, e); - false - } - }, - Err(e) => { - hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e); +#[async_trait::async_trait] +impl WebClientHooks for TrackedWebClientHooks { + async fn post_run_network_instance(&self, id: &Uuid) -> Result<(), String> { + self.instance_ids + .lock() + .map_err(|err| err.to_string())? + .insert(*id); + Ok(()) + } + + async fn post_remove_network_instances(&self, ids: &[Uuid]) -> Result<(), String> { + let mut guard = self.instance_ids.lock().map_err(|err| err.to_string())?; + for id in ids { + guard.remove(id); + } + Ok(()) + } +} + +fn is_config_server_config(config: &NetworkConfig) -> bool { + matches!( + NetworkingMethod::try_from(config.networking_method.unwrap_or_default()) + .unwrap_or_default(), + NetworkingMethod::PublicServer + ) && config + .public_server_url + .as_ref() + .is_some_and(|url| !url.trim().is_empty()) +} + +fn stop_web_client(config_id: &str) -> bool { + let managed = match WEB_CLIENTS.lock() { + Ok(mut guard) => guard.remove(config_id), + Err(err) => { + hilog_error!("[Rust] stop_web_client lock failed {}", err); + return false; + } + }; + + let Some(managed) = managed else { + return false; + }; + + let tracked_ids = managed + .hooks + .instance_ids + .lock() + .map(|guard| guard.iter().copied().collect::>()) + .unwrap_or_default(); + drop(managed); + + if tracked_ids.is_empty() { + maybe_stop_local_socket_server(); + return true; + } + + let ret = INSTANCE_MANAGER + .delete_network_instance(tracked_ids) + .map(|_| true) + .unwrap_or_else(|err| { + hilog_error!( + "[Rust] stop config server instances failed {}: {}", + config_id, + err + ); + false + }); + maybe_stop_local_socket_server(); + ret +} + +fn ensure_local_socket_server_started() -> bool { + start_local_socket_server_inner() +} + +fn maybe_stop_local_socket_server() { + let no_local_instances = INSTANCE_MANAGER.list_network_instance_ids().is_empty(); + let no_web_clients = WEB_CLIENTS + .lock() + .map(|guard| guard.is_empty()) + .unwrap_or(false); + if no_local_instances && no_web_clients { + let _ = stop_local_socket_server_inner(); + } +} + +fn run_config_server_instance(config_id: &str, config: &NetworkConfig) -> bool { + if INSTANCE_MANAGER + .list_network_instance_ids() + .iter() + .next() + .is_some() + { + hilog_error!("[Rust] there is a running instance!"); + return false; + } + + let Some(config_server_url) = config.public_server_url.clone() else { + hilog_error!("[Rust] public_server_url missing for config server mode"); + return false; + }; + let hooks = Arc::new(TrackedWebClientHooks::default()); + let secure_mode = config + .secure_mode + .as_ref() + .map(|mode| mode.enabled) + .unwrap_or(false); + let hostname = config.hostname.clone(); + + if !ensure_local_socket_server_started() { + return false; + } + + let client = ASYNC_RUNTIME.block_on(run_web_client( + &config_server_url, + MachineIdOptions::default(), + hostname, + secure_mode, + INSTANCE_MANAGER.clone(), + Some(hooks.clone()), + )); + + let client = match client { + Ok(client) => client, + Err(err) => { + hilog_error!("[Rust] start config server failed {}", err); + return false; + } + }; + + match WEB_CLIENTS.lock() { + Ok(mut guard) => { + guard.insert( + config_id.to_string(), + ManagedWebClient { + _client: client, + hooks, + }, + ); + true + } + Err(err) => { + hilog_error!("[Rust] store config server client failed {}", err); false } } } -#[napi] -pub fn default_network_config() -> String { - match NetworkConfig::new_from_config(TomlConfigLoader::default()) { - Ok(result) => serde_json::to_string(&result).unwrap_or_else(|e| format!("ERROR {}", e)), - Err(e) => { - hilog_error!("[Rust] default_network_config failed {}", e); - format!("ERROR {}", e) - } - } +pub(crate) fn build_default_network_config_json() -> Result { + let config = NetworkConfig::new_from_config(TomlConfigLoader::default()) + .map_err(|e| format!("default_network_config failed {}", e))?; + serde_json::to_string(&config).map_err(|e| format!("default_network_config failed {}", e)) } -#[napi] -pub fn convert_toml_to_network_config(cfg_str: String) -> String { - match TomlConfigLoader::new_from_str(&cfg_str) { - Ok(cfg) => match NetworkConfig::new_from_config(cfg) { - Ok(result) => serde_json::to_string(&result).unwrap_or_else(|e| format!("ERROR {}", e)), - Err(e) => { - hilog_error!("[Rust] convert_toml_to_network_config failed {}", e); - format!("ERROR {}", e) - } - }, - Err(e) => { - hilog_error!("[Rust] convert_toml_to_network_config failed {}", e); - format!("ERROR {}", e) - } - } +fn convert_toml_to_network_config_inner(toml_text: &str) -> Result { + let config = NetworkConfig::new_from_config( + TomlConfigLoader::new_from_str(toml_text).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + serde_json::to_string(&config).map_err(|e| e.to_string()) } -#[napi] -pub fn parse_network_config(cfg_json: String) -> bool { - match serde_json::from_str::(&cfg_json) { - Ok(cfg) => match cfg.gen_config() { - Ok(toml) => { - hilog_debug!("[Rust] Convert to Toml {}", toml.dump()); - true - } - Err(e) => { - hilog_error!("[Rust] parse config failed {}", e); - false - } - }, - Err(e) => { - hilog_error!("[Rust] parse config failed {}", e); - false - } - } +fn parse_network_config_inner(cfg_json: &str) -> bool { + serde_json::from_str::(cfg_json) + .ok() + .and_then(|cfg| cfg.gen_config().ok()) + .is_some() } -#[napi] -pub fn run_network_instance(cfg_json: String) -> bool { - let cfg = match serde_json::from_str::(&cfg_json) { - Ok(cfg) => match cfg.gen_config() { - Ok(toml) => toml, - Err(e) => { - hilog_error!("[Rust] parse config failed {}", e); - return false; - } - }, +pub(crate) fn run_network_instance_from_json(cfg_json: &str) -> bool { + let config = match serde_json::from_str::(cfg_json) { + Ok(cfg) => cfg, Err(e) => { hilog_error!("[Rust] parse config failed {}", e); return false; } }; - if INSTANCE_MANAGER.list_network_instance_ids().len() > 0 { + if is_config_server_config(&config) { + let Some(config_id) = config.instance_id.as_deref() else { + hilog_error!("[Rust] config server config missing instance id"); + return false; + }; + return run_config_server_instance(config_id, &config); + } + + let cfg = match config.gen_config() { + Ok(toml) => toml, + Err(e) => { + hilog_error!("[Rust] parse config failed {}", e); + return false; + } + }; + + if !INSTANCE_MANAGER.list_network_instance_ids().is_empty() { hilog_error!("[Rust] there is a running instance!"); return false; } + if !ensure_local_socket_server_started() { + return false; + } + let inst_id = cfg.get_id(); if INSTANCE_MANAGER .list_network_instance_ids() .contains(&inst_id) { + hilog_error!("[Rust] instance {} already exists", inst_id); return false; } - INSTANCE_MANAGER - .run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) - .unwrap(); - true -} -#[napi] -pub fn stop_network_instance(inst_names: Vec) { - INSTANCE_MANAGER - .delete_network_instance( - inst_names - .into_iter() - .filter_map(|s| Uuid::parse_str(&s).ok()) - .collect(), - ) - .unwrap(); - hilog_debug!("[Rust] stop_network_instance"); -} - -#[napi] -pub fn collect_network_infos() -> Vec { - let mut result = Vec::new(); - match INSTANCE_MANAGER.collect_network_infos_sync() { - Ok(map) => { - for (uuid, info) in map.iter() { - // convert value to json string - let value = match serde_json::to_string(&info) { - Ok(value) => value, - Err(e) => { - hilog_error!("[Rust] failed to serialize instance {} info: {}", uuid, e); - continue; - } - }; - result.push(KeyValuePair { - key: uuid.clone().to_string(), - value: value.clone(), - }); - } - } - Err(_) => {} - } - result -} - -#[napi] -pub fn collect_running_network() -> Vec { - INSTANCE_MANAGER - .list_network_instance_ids() - .clone() - .into_iter() - .map(|id| id.to_string()) - .collect() -} - -#[napi] -pub fn is_running_network(inst_id: String) -> bool { - match Uuid::try_parse(&inst_id) { - Ok(uuid) => INSTANCE_MANAGER.list_network_instance_ids().contains(&uuid), - Err(e) => { - hilog_error!("[Rust] cant covert {} to uuid. {}", inst_id, e); + match INSTANCE_MANAGER.run_network_instance(cfg, false, ConfigFileControl::STATIC_CONFIG) { + Ok(_) => true, + Err(err) => { + hilog_error!("[Rust] start_kernel failed for {}: {}", inst_id, err); false } } } + +fn parse_instance_uuid(config_id: &str) -> Option { + match Uuid::parse_str(config_id) { + Ok(uuid) => Some(uuid), + Err(err) => { + hilog_error!("[Rust] invalid config_id {}: {}", config_id, err); + None + } + } +} + +#[napi] +pub fn init_config_store(root_dir: String) -> bool { + exports::config_api::init_config_store(root_dir) +} + +#[napi] +pub fn list_configs() -> String { + exports::config_api::list_configs() +} + +#[napi] +pub fn get_config_display_name_by_id(config_id: String) -> Option { + get_config_display_name(&config_id) +} + +#[napi] +pub fn save_config(config_id: String, display_name: String, config_json: String) -> bool { + exports::config_api::save_config(config_id, display_name, config_json) +} + +#[napi] +pub fn create_config(config_id: String, display_name: String) -> bool { + exports::config_api::create_config(config_id, display_name) +} + +#[napi] +pub fn rename_stored_config(config_id: String, display_name: String) -> bool { + config::storage::config_meta::set_config_display_name(config_id, display_name).is_some() +} + +#[napi] +pub fn delete_stored_config_meta(config_id: String) -> bool { + exports::config_api::delete_stored_config_meta(config_id) +} + +#[napi] +pub fn get_config(config_id: String) -> Option { + exports::config_api::get_config(config_id) +} + +#[napi] +pub fn get_default_config() -> Option { + exports::config_api::get_default_config() +} + +#[napi] +pub fn get_config_field(config_id: String, field: String) -> Option { + exports::config_api::get_config_field(config_id, field) +} + +#[napi] +pub fn set_config_field(config_id: String, field: String, json_value: String) -> bool { + exports::config_api::set_config_field(config_id, field, json_value) +} + +#[napi] +pub fn import_toml(toml_text: String, display_name: Option) -> Option { + exports::config_api::import_toml(toml_text, display_name) +} + +#[napi] +pub fn export_toml(config_id: String) -> Option { + exports::config_api::export_toml(config_id) +} + +#[napi] +pub fn start_kernel(config_id: String) -> bool { + exports::runtime_api::start_kernel(config_id, start_kernel_with_config_id) +} + +#[napi] +pub fn stop_kernel(config_id: String) -> bool { + exports::runtime_api::stop_kernel( + config_id, + stop_web_client, + parse_instance_uuid, + maybe_stop_local_socket_server, + ) +} + +#[napi] +pub fn stop_network_instance(config_ids: Vec) -> bool { + exports::runtime_api::stop_network_instance(config_ids, stop_kernel) +} + +#[napi] +pub fn easytier_version() -> String { + EASYTIER_VERSION.to_string() +} + +#[napi] +pub fn default_network_config() -> String { + get_default_config().unwrap_or_else(|| "{}".to_string()) +} + +#[napi] +pub fn convert_toml_to_network_config(toml_text: String) -> String { + convert_toml_to_network_config_inner(&toml_text).unwrap_or_else(|err| format!("ERROR: {err}")) +} + +#[napi] +pub fn parse_network_config(cfg_json: String) -> bool { + parse_network_config_inner(&cfg_json) +} + +#[napi] +pub fn run_network_instance(cfg_json: String) -> bool { + run_network_instance_from_json(&cfg_json) +} + +#[napi] +pub fn collect_network_infos() -> Vec { + exports::runtime_api::collect_network_infos() +} + +#[napi] +pub fn set_tun_fd(config_id: String, fd: i32) -> bool { + exports::runtime_api::set_tun_fd(config_id, fd, parse_instance_uuid) +} + +#[napi] +pub fn get_network_config_schema() -> NetworkConfigSchema { + build_network_config_schema() +} + +#[napi] +pub fn get_network_config_field_mappings() -> Vec { + build_network_config_field_mappings() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exported_plain_object_schema_contains_core_networkconfig_metadata() { + let schema = get_network_config_schema(); + assert_eq!(schema.name, "NetworkConfig"); + assert_eq!(schema.node_kind, "schema"); + assert!( + schema + .children + .iter() + .any(|field| field.name == "network_name") + ); + let secure_mode = schema + .children + .iter() + .find(|field| field.name == "secure_mode") + .expect("secure_mode field"); + assert!( + secure_mode + .children + .iter() + .any(|field| field.name == "enabled") + ); + } +} + +#[napi] +pub fn get_runtime_snapshot() -> RuntimeAggregateState { + exports::runtime_api::get_runtime_snapshot() +} + +pub(crate) fn get_runtime_snapshot_inner() -> RuntimeAggregateState { + exports::runtime_api::get_runtime_snapshot_inner() +} + +#[napi] +pub fn build_config_share_link(config_id: String, only_start: Option) -> Option { + build_config_share_link_inner(&config_id, None, only_start.unwrap_or(false)) +} + +#[napi] +pub fn parse_config_share_link(share_link: String) -> Option { + parse_config_share_link_inner(&share_link) +} + +#[napi] +pub fn import_config_share_link( + share_link: String, + display_name_override: Option, +) -> Option { + import_config_share_link_inner(&share_link, display_name_override) +} diff --git a/easytier-contrib/easytier-ohrs/src/platform.rs b/easytier-contrib/easytier-ohrs/src/platform.rs new file mode 100644 index 00000000..6a79dd07 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/platform.rs @@ -0,0 +1 @@ +pub(crate) mod logging; diff --git a/easytier-contrib/easytier-ohrs/src/platform/logging/mod.rs b/easytier-contrib/easytier-ohrs/src/platform/logging/mod.rs new file mode 100644 index 00000000..0b44f5b3 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/platform/logging/mod.rs @@ -0,0 +1 @@ +pub(crate) mod native_log; diff --git a/easytier-contrib/easytier-ohrs/src/native_log.rs b/easytier-contrib/easytier-ohrs/src/platform/logging/native_log.rs similarity index 100% rename from easytier-contrib/easytier-ohrs/src/native_log.rs rename to easytier-contrib/easytier-ohrs/src/platform/logging/native_log.rs diff --git a/easytier-contrib/easytier-ohrs/src/runtime.rs b/easytier-contrib/easytier-ohrs/src/runtime.rs new file mode 100644 index 00000000..33e14d22 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/runtime.rs @@ -0,0 +1 @@ +pub(crate) mod state; diff --git a/easytier-contrib/easytier-ohrs/src/runtime/state/mod.rs b/easytier-contrib/easytier-ohrs/src/runtime/state/mod.rs new file mode 100644 index 00000000..f84ecc4a --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/runtime/state/mod.rs @@ -0,0 +1 @@ +pub(crate) mod runtime_state; diff --git a/easytier-contrib/easytier-ohrs/src/runtime/state/runtime_state.rs b/easytier-contrib/easytier-ohrs/src/runtime/state/runtime_state.rs new file mode 100644 index 00000000..2a1cb059 --- /dev/null +++ b/easytier-contrib/easytier-ohrs/src/runtime/state/runtime_state.rs @@ -0,0 +1,293 @@ +use easytier::proto::{api, common}; +use napi_derive_ohos::napi; +use serde::Serialize; +use std::collections::HashSet; +use std::sync::Mutex; + +static ATTACHED_TUN_INSTANCE_IDS: once_cell::sync::Lazy>> = + once_cell::sync::Lazy::new(|| Mutex::new(HashSet::new())); + +pub fn mark_tun_attached(instance_id: &str) { + if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() { + guard.insert(instance_id.to_string()); + } +} + +pub fn clear_tun_attached(instance_id: &str) { + if let Ok(mut guard) = ATTACHED_TUN_INSTANCE_IDS.lock() { + guard.remove(instance_id); + } +} + +pub fn is_tun_attached(instance_id: &str) -> bool { + ATTACHED_TUN_INSTANCE_IDS + .lock() + .map(|guard| guard.contains(instance_id)) + .unwrap_or(false) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct PeerConnStats { + pub rx_bytes: i64, + pub tx_bytes: i64, + pub rx_packets: i64, + pub tx_packets: i64, + pub latency_us: i64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct PeerConnInfo { + pub conn_id: String, + pub my_peer_id: i64, + pub peer_id: i64, + pub features: Vec, + pub tunnel_type: Option, + pub local_addr: Option, + pub remote_addr: Option, + pub resolved_remote_addr: Option, + pub stats: Option, + pub loss_rate: Option, + pub is_client: bool, + pub network_name: Option, + pub is_closed: bool, + pub secure_auth_level: Option, + pub peer_identity_type: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct PeerInfo { + pub peer_id: i64, + pub default_conn_id: Option, + pub directly_connected_conns: Vec, + pub conns: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct RouteView { + pub peer_id: i64, + pub hostname: Option, + pub ipv4: Option, + pub ipv4_cidr: Option, + pub ipv6_cidr: Option, + pub proxy_cidrs: Vec, + pub next_hop_peer_id: Option, + pub cost: Option, + pub path_latency: Option, + pub udp_nat_type: Option, + pub tcp_nat_type: Option, + pub inst_id: Option, + pub version: Option, + pub is_public_server: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct MyNodeInfo { + pub virtual_ipv4: Option, + pub virtual_ipv4_cidr: Option, + pub hostname: Option, + pub version: Option, + pub peer_id: Option, + pub listeners: Vec, + pub vpn_portal_cfg: Option, + pub udp_nat_type: Option, + pub tcp_nat_type: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct RuntimeInstanceState { + pub config_id: String, + pub instance_id: String, + pub display_name: String, + pub running: bool, + pub tun_required: bool, + pub tun_attached: bool, + pub magic_dns_enabled: bool, + pub need_exit_node: bool, + pub error_message: Option, + pub my_node_info: Option, + pub events: Vec, + pub routes: Vec, + pub peers: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct TunAggregateState { + pub active: bool, + pub attached_instance_ids: Vec, + pub aggregated_routes: Vec, + pub dns_servers: Vec, + pub need_rebuild: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +#[napi(object)] +pub struct RuntimeAggregateState { + pub instances: Vec, + pub tun: TunAggregateState, + pub running_instance_count: i32, +} + +fn stringify_ipv4_inet(value: Option) -> Option { + value.map(|v| v.to_string()) +} + +fn stringify_ipv6_inet(value: Option) -> Option { + value.map(|v| v.to_string()) +} + +fn stringify_url(value: Option) -> Option { + value.map(|v| v.to_string()) +} + +fn stringify_uuid(value: Option) -> Option { + value.map(|v| v.to_string()) +} + +fn optional_u32_to_i64(value: Option) -> Option { + value.map(|v| v as i64) +} + +fn optional_i32_to_i64(value: Option) -> Option { + value.map(|v| v as i64) +} + +fn route_to_view(route: api::instance::Route) -> RouteView { + let stun = route.stun_info; + let feature_flag = route.feature_flag; + RouteView { + peer_id: route.peer_id as i64, + hostname: (!route.hostname.is_empty()).then_some(route.hostname), + ipv4: route + .ipv4_addr + .as_ref() + .and_then(|inet| inet.address.as_ref()) + .map(|addr| addr.to_string()), + ipv4_cidr: stringify_ipv4_inet(route.ipv4_addr), + ipv6_cidr: stringify_ipv6_inet(route.ipv6_addr), + proxy_cidrs: route.proxy_cidrs, + next_hop_peer_id: optional_u32_to_i64(route.next_hop_peer_id_latency_first) + .or_else(|| Some(route.next_hop_peer_id as i64)), + cost: Some(route.cost), + path_latency: optional_i32_to_i64(route.path_latency_latency_first) + .or_else(|| Some(route.path_latency as i64)), + udp_nat_type: stun.as_ref().map(|info| info.udp_nat_type), + tcp_nat_type: stun.as_ref().map(|info| info.tcp_nat_type), + inst_id: (!route.inst_id.is_empty()).then_some(route.inst_id), + version: (!route.version.is_empty()).then_some(route.version), + is_public_server: feature_flag.map(|flag| flag.is_public_server), + } +} + +fn peer_conn_to_view(conn: api::instance::PeerConnInfo) -> PeerConnInfo { + let stats = conn.stats.map(|stats| PeerConnStats { + rx_bytes: stats.rx_bytes as i64, + tx_bytes: stats.tx_bytes as i64, + rx_packets: stats.rx_packets as i64, + tx_packets: stats.tx_packets as i64, + latency_us: stats.latency_us as i64, + }); + + PeerConnInfo { + conn_id: conn.conn_id, + my_peer_id: conn.my_peer_id as i64, + peer_id: conn.peer_id as i64, + features: conn.features, + tunnel_type: conn.tunnel.as_ref().map(|t| t.tunnel_type.clone()), + local_addr: conn + .tunnel + .as_ref() + .and_then(|t| stringify_url(t.local_addr.clone())), + remote_addr: conn + .tunnel + .as_ref() + .and_then(|t| stringify_url(t.remote_addr.clone())), + resolved_remote_addr: conn + .tunnel + .as_ref() + .and_then(|t| stringify_url(t.resolved_remote_addr.clone())), + stats, + loss_rate: Some(conn.loss_rate as f64), + is_client: conn.is_client, + network_name: (!conn.network_name.is_empty()).then_some(conn.network_name), + is_closed: conn.is_closed, + secure_auth_level: Some(conn.secure_auth_level), + peer_identity_type: Some(conn.peer_identity_type), + } +} + +fn peer_to_view(peer: api::instance::PeerInfo) -> PeerInfo { + PeerInfo { + peer_id: peer.peer_id as i64, + default_conn_id: stringify_uuid(peer.default_conn_id), + directly_connected_conns: peer + .directly_connected_conns + .into_iter() + .map(|id| id.to_string()) + .collect(), + conns: peer.conns.into_iter().map(peer_conn_to_view).collect(), + } +} + +fn my_node_info_to_view(info: api::manage::MyNodeInfo) -> MyNodeInfo { + MyNodeInfo { + virtual_ipv4: info + .virtual_ipv4 + .as_ref() + .and_then(|inet| inet.address.as_ref()) + .map(|addr| addr.to_string()), + virtual_ipv4_cidr: stringify_ipv4_inet(info.virtual_ipv4), + hostname: (!info.hostname.is_empty()).then_some(info.hostname), + version: (!info.version.is_empty()).then_some(info.version), + peer_id: Some(info.peer_id as i64), + listeners: info + .listeners + .into_iter() + .map(|url| url.to_string()) + .collect(), + vpn_portal_cfg: info.vpn_portal_cfg, + udp_nat_type: info.stun_info.as_ref().map(|stun| stun.udp_nat_type), + tcp_nat_type: info.stun_info.as_ref().map(|stun| stun.tcp_nat_type), + } +} + +pub fn runtime_instance_from_running_info( + config_id: String, + display_name: String, + magic_dns_enabled: bool, + need_exit_node: bool, + info: api::manage::NetworkInstanceRunningInfo, +) -> RuntimeInstanceState { + let tun_attached = info.running && is_tun_attached(&config_id); + let tun_required = info.running && (info.dev_name != "no_tun" || tun_attached); + + RuntimeInstanceState { + config_id: config_id.clone(), + instance_id: config_id, + display_name, + running: info.running, + tun_required, + tun_attached, + magic_dns_enabled, + need_exit_node, + error_message: info.error_msg, + my_node_info: info.my_node_info.map(my_node_info_to_view), + events: info.events, + routes: info.routes.into_iter().map(route_to_view).collect(), + peers: info.peers.into_iter().map(peer_to_view).collect(), + } +} diff --git a/easytier/src/proto/mod.rs b/easytier/src/proto/mod.rs index cb455a89..3315a5da 100644 --- a/easytier/src/proto/mod.rs +++ b/easytier/src/proto/mod.rs @@ -14,5 +14,8 @@ pub mod web; pub mod tests; pub mod utils; -const DESCRIPTOR_POOL_BYTES: &[u8] = +pub const DESCRIPTOR_POOL_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/file_descriptor_set.bin")); + +pub const ALL_DESCRIPTOR_BYTES: &[u8] = + include_bytes!(concat!(env!("OUT_DIR"), "/descriptors.bin")); From 8428a89d2dabc94c97d370ec607c6ca142473626 Mon Sep 17 00:00:00 2001 From: Luna Yao <40349250+ZnqbuZ@users.noreply.github.com> Date: Tue, 12 May 2026 14:26:16 +0200 Subject: [PATCH 10/10] refactor: introduce HedgeExt for task hedging; rewrite NatDstQuicConnector (#2229) --- easytier/src/common/error.rs | 3 +- easytier/src/gateway/kcp_proxy.rs | 104 ++++++++---------- easytier/src/gateway/quic_proxy.rs | 168 +++++++++++++++-------------- easytier/src/gateway/socks5.rs | 2 +- easytier/src/gateway/tcp_proxy.rs | 17 ++- easytier/src/proto/utils.rs | 22 +--- easytier/src/utils/error.rs | 58 ++++++++++ easytier/src/utils/mod.rs | 1 + easytier/src/utils/task.rs | 62 +++++++++++ 9 files changed, 271 insertions(+), 166 deletions(-) create mode 100644 easytier/src/utils/error.rs diff --git a/easytier/src/common/error.rs b/easytier/src/common/error.rs index 87ef7ff4..906499c8 100644 --- a/easytier/src/common/error.rs +++ b/easytier/src/common/error.rs @@ -1,5 +1,4 @@ use std::{io, result}; - use thiserror::Error; use crate::tunnel; @@ -55,4 +54,6 @@ pub enum Error { pub type Result = result::Result; +pub type ErrorCollection = crate::utils::error::ErrorCollection; + // impl From for std:: diff --git a/easytier/src/gateway/kcp_proxy.rs b/easytier/src/gateway/kcp_proxy.rs index 6025d307..b1dceacc 100644 --- a/easytier/src/gateway/kcp_proxy.rs +++ b/easytier/src/gateway/kcp_proxy.rs @@ -4,7 +4,7 @@ use std::{ time::Duration, }; -use anyhow::Context; +use anyhow::{Context, anyhow, bail}; use bytes::Bytes; use dashmap::DashMap; use guarden::defer; @@ -15,12 +15,13 @@ use kcp_sys::{ stream::KcpStream, }; use prost::Message; -use tokio::{select, task::JoinSet}; +use tokio::task::JoinSet; use super::{ CidrSet, tcp_proxy::{NatDstConnector, NatDstTcpConnector, TcpProxy}, }; +use crate::utils::task::HedgeExt; use crate::{ common::{ acl_processor::PacketInfo, @@ -114,72 +115,57 @@ pub struct NatDstKcpConnector { impl NatDstConnector for NatDstKcpConnector { type DstStream = KcpStream; - async fn connect(&self, src: SocketAddr, nat_dst: SocketAddr) -> Result { + async fn connect( + &self, + src: SocketAddr, + nat_dst: SocketAddr, + ) -> anyhow::Result { + let peer_mgr = self + .peer_mgr + .upgrade() + .ok_or_else(|| anyhow!("peer manager is not available"))?; + + let dst_peer = { + let SocketAddr::V4(addr) = nat_dst else { + bail!("ipv6 is not supported"); + }; + peer_mgr + .get_peer_map() + .get_peer_id_by_ipv4(addr.ip()) + .await + .ok_or_else(|| anyhow!("no peer found for nat dst: {}", nat_dst))? + }; + + tracing::trace!(?nat_dst, ?dst_peer, "kcp nat"); + let conn_data = KcpConnData { src: Some(src.into()), dst: Some(nat_dst.into()), }; - let Some(peer_mgr) = self.peer_mgr.upgrade() else { - return Err(anyhow::anyhow!("peer manager is not available").into()); - }; + let stream = (0..5) + .map(|_| { + let kcp_endpoint = self.kcp_endpoint.clone(); + let my_peer_id = peer_mgr.my_peer_id(); - let dst_peer_id = match nat_dst { - SocketAddr::V4(addr) => peer_mgr.get_peer_map().get_peer_id_by_ipv4(addr.ip()).await, - SocketAddr::V6(_) => return Err(anyhow::anyhow!("ipv6 is not supported").into()), - }; + async move { + let conn_id = kcp_endpoint + .connect( + Duration::from_secs(10), + my_peer_id, + dst_peer, + Bytes::from(conn_data.encode_to_vec()), + ) + .await?; - let Some(dst_peer) = dst_peer_id else { - return Err(anyhow::anyhow!("no peer found for nat dst: {}", nat_dst).into()); - }; - - tracing::trace!("kcp nat dst: {:?}, dst peers: {:?}", nat_dst, dst_peer); - - let mut connect_tasks: JoinSet> = JoinSet::new(); - let mut retry_remain = 5; - loop { - select! { - Some(Ok(Ok(ret))) = connect_tasks.join_next() => { - // just wait for the previous connection to finish - let stream = KcpStream::new(&self.kcp_endpoint, ret) - .ok_or(anyhow::anyhow!("failed to create kcp stream"))?; - return Ok(stream); + KcpStream::new(&kcp_endpoint, conn_id).context("failed to create kcp stream") } - _ = tokio::time::sleep(Duration::from_millis(200)), if !connect_tasks.is_empty() && retry_remain > 0 => { - // no successful connection yet, trigger another connection attempt - } - else => { - // got error in connect_tasks, continue to retry - if retry_remain == 0 && connect_tasks.is_empty() { - break; - } - } - } + }) + .hedge(Duration::from_millis(200)) + .await + .context("failed to connect to peer")?; - // create a new connection task - if retry_remain == 0 { - continue; - } - retry_remain -= 1; - - let kcp_endpoint = self.kcp_endpoint.clone(); - let my_peer_id = peer_mgr.my_peer_id(); - let conn_data_clone = conn_data; - - connect_tasks.spawn(async move { - kcp_endpoint - .connect( - Duration::from_secs(10), - my_peer_id, - dst_peer, - Bytes::from(conn_data_clone.encode_to_vec()), - ) - .await - .with_context(|| format!("failed to connect to nat dst: {}", nat_dst)) - }); - } - - Err(anyhow::anyhow!("failed to connect to nat dst: {}", nat_dst).into()) + Ok(stream) } fn check_packet_from_peer_fast(&self, _cidr_set: &CidrSet, _global_ctx: &GlobalCtx) -> bool { diff --git a/easytier/src/gateway/quic_proxy.rs b/easytier/src/gateway/quic_proxy.rs index 0019aaf6..7e0767aa 100644 --- a/easytier/src/gateway/quic_proxy.rs +++ b/easytier/src/gateway/quic_proxy.rs @@ -18,7 +18,8 @@ use crate::tunnel::packet_def::{ PacketType, PeerManagerHeader, TAIL_RESERVED_SIZE, ZCPacket, ZCPacketType, }; use crate::tunnel::quic::{client_config, endpoint_config, server_config}; -use anyhow::{Context, Error, anyhow}; +use crate::utils::task::HedgeExt; +use anyhow::{Context, Error, anyhow, bail, ensure}; use atomic_refcell::AtomicRefCell; use bytes::{BufMut, Bytes, BytesMut}; use dashmap::DashMap; @@ -29,7 +30,8 @@ use moka::future::Cache; use prost::Message; use quinn::udp::{EcnCodepoint, RecvMeta, Transmit}; use quinn::{ - AsyncUdpSocket, Endpoint, RecvStream, SendStream, StreamId, UdpPoller, default_runtime, + AsyncUdpSocket, Connection, ConnectionError, Endpoint, RecvStream, SendStream, StreamId, + UdpPoller, WriteError, default_runtime, }; use std::cmp::min; use std::future::Future; @@ -280,7 +282,7 @@ impl From<(SendStream, RecvStream)> for QuicStream { pub struct NatDstQuicConnector { pub(crate) endpoint: Endpoint, pub(crate) peer_mgr: Weak, - pub(crate) conn_map: Cache, + pub(crate) conn_map: Cache, } #[async_trait::async_trait] @@ -291,20 +293,25 @@ impl NatDstConnector for NatDstQuicConnector { &self, src: SocketAddr, nat_dst: SocketAddr, - ) -> crate::common::error::Result { - let Some(peer_mgr) = self.peer_mgr.upgrade() else { - return Err(anyhow::anyhow!("peer manager is not available").into()); + ) -> anyhow::Result { + let peer_mgr = self + .peer_mgr + .upgrade() + .ok_or_else(|| anyhow!("peer manager is not available"))?; + + let dst_peer = { + let SocketAddr::V4(addr) = nat_dst else { + bail!("ipv6 is not supported"); + }; + peer_mgr + .get_peer_map() + .get_peer_id_by_ipv4(addr.ip()) + .await + .ok_or_else(|| anyhow!("no peer found for nat dst: {}", nat_dst))? }; - let Some(dst_peer_id) = (match nat_dst { - SocketAddr::V4(addr) => peer_mgr.get_peer_map().get_peer_id_by_ipv4(addr.ip()).await, - SocketAddr::V6(_) => return Err(anyhow::anyhow!("ipv6 is not supported").into()), - }) else { - return Err(anyhow::anyhow!("no peer found for nat dst: {}", nat_dst).into()); - }; + tracing::trace!(?nat_dst, ?dst_peer, "quic nat"); - trace!("quic nat dst: {:?}, dst peers: {:?}", nat_dst, dst_peer_id); - let addr = QuicAddr::new(dst_peer_id, PacketType::QuicSrc).into(); let header = { let conn_data = QuicConnData { src: Some(src.into()), @@ -312,77 +319,91 @@ impl NatDstConnector for NatDstQuicConnector { }; let len = conn_data.encoded_len(); - if len > (u16::MAX as usize) { - return Err(anyhow!("conn data too large: {:?}", len).into()); - } + ensure!(len <= u16::MAX as usize, "conn data too large: {len}"); let mut buf = BytesMut::with_capacity(2 + len); buf.put_u16(len as u16); - conn_data.encode(&mut buf).unwrap(); + conn_data.encode(&mut buf)?; buf.freeze() }; - for attempt in 0..2 { - let endpoint = self.endpoint.clone(); + let reconnect = || async move { + self.conn_map.invalidate(&dst_peer).await; - let connection = match self - .conn_map - .try_get_with(dst_peer_id, async move { - endpoint - .connect(addr, "") - .map_err(|e| anyhow!("quic connect: {:#}", e))? - .await - .map_err(|e| anyhow!("quic connection: {:#}", e)) - }) - .await - { - Ok(conn) => conn, - Err(e) => { - if attempt == 0 { - debug!("quic connect failed, retrying: {:#}", e); - tokio::time::sleep(Duration::from_millis(300)).await; - continue; + let connect = (0..5) + .map(|_| { + let endpoint = self.endpoint.clone(); + async move { + endpoint + .connect(QuicAddr::new(dst_peer, PacketType::QuicSrc).into(), "") + .context("failed to create connection")? + .await + .context("connection failed") } - return Err(anyhow!("{:#}", e).into()); - } - }; + }) + .hedge(Duration::from_millis(200)); - let stream: Result = async { + self.conn_map + .try_get_with(dst_peer, connect) + .await + .context("failed to connect to peer") + }; + + let mut reconnected = false; + + let mut connection = if let Some(connection) = self.conn_map.get(&dst_peer).await + && connection.close_reason().is_none() + { + connection + } else { + reconnected = true; + reconnect().await? + }; + + loop { + let is_retryable = |error: &ConnectionError| { + matches!( + error, + ConnectionError::ConnectionClosed(_) + | ConnectionError::ApplicationClosed(_) + | ConnectionError::Reset + | ConnectionError::TimedOut + ) + }; + let mut retry = !reconnected; + let header = header.clone(); + let result = async { let mut stream: QuicStream = connection .open_bi() .await - .map_err(|e| anyhow!("open bi: {:#}", e))? + .inspect_err(|error| retry &= is_retryable(error))? .into(); - stream.writer_mut().write_chunk(header.clone()).await?; + stream + .writer_mut() + .write_chunk(header) + .await + .inspect_err(|error| { + retry &= matches!(error, WriteError::ConnectionLost(error) if is_retryable(error)) + })?; Ok(stream.into()) } - .await; + .await; - match stream { - Ok(stream) => return Ok(stream), - Err(error) => { - debug!( - ?dst_peer_id, - attempt, - ?error, - "quic connect: stream setup failed" - ); + if let Err(error) = &result { + if retry { + debug!(?error, "failed to open quic stream, retrying..."); + reconnected = true; + connection = reconnect().await?; + continue; + } else { + self.conn_map.invalidate(&dst_peer).await; } } - // Evict stale connection; - self.conn_map.invalidate(&dst_peer_id).await; + break result; } - - Err(anyhow!( - "quic connect: failed after {} attempts, dst_peer_id={}, nat_dst={}", - 2, - dst_peer_id, - nat_dst - ) - .into()) } #[inline] @@ -839,7 +860,7 @@ impl QuicProxy { Arc::new(socket), default_runtime().unwrap(), ) - .unwrap(); + .unwrap(); // TODO: maybe a different transport config endpoint.set_default_client_config(client_config()); self.endpoint = Some(endpoint.clone()); @@ -863,26 +884,15 @@ impl QuicProxy { return; } - let conn_map = Cache::builder() - .max_capacity(u8::MAX.into()) // same with max_concurrent_bidi_streams, can be increased - .time_to_idle(Duration::from_secs(600)) - .build(); - - let conn_map_bg = conn_map.clone(); - self.tasks.spawn(async move { - let mut interval = tokio::time::interval(Duration::from_secs(60)); - loop { - interval.tick().await; - conn_map_bg.run_pending_tasks().await; - } - }); - let tcp_proxy = TcpProxyForQuicSrc(TcpProxy::new( peer_mgr.clone(), NatDstQuicConnector { endpoint: endpoint.clone(), peer_mgr: Arc::downgrade(&peer_mgr), - conn_map, + conn_map: Cache::builder() + .max_capacity(u8::MAX.into()) // cf. quinn transport config (max_concurrent_bidi_streams) + .time_to_idle(Duration::from_secs(600)) // cf. quinn transport config (max_idle_timeout) + .build(), }, )); diff --git a/easytier/src/gateway/socks5.rs b/easytier/src/gateway/socks5.rs index fcad40bf..0cd5d07d 100644 --- a/easytier/src/gateway/socks5.rs +++ b/easytier/src/gateway/socks5.rs @@ -240,7 +240,7 @@ impl AsyncTcpConnector for Socks5KcpConnector { let ret = c .connect(self.src_addr, addr) .await - .map_err(|e| super::fast_socks5::SocksError::Other(e.into()))?; + .map_err(super::fast_socks5::SocksError::Other)?; Ok(SocksTcpStream::Kcp(ret)) } } diff --git a/easytier/src/gateway/tcp_proxy.rs b/easytier/src/gateway/tcp_proxy.rs index 3b4075c6..6e252268 100644 --- a/easytier/src/gateway/tcp_proxy.rs +++ b/easytier/src/gateway/tcp_proxy.rs @@ -44,7 +44,7 @@ use super::tokio_smoltcp::{self, Net, NetConfig, channel_device}; pub(crate) trait NatDstConnector: Send + Sync + Clone + 'static { type DstStream: AsyncRead + AsyncWrite + Unpin + Send; - async fn connect(&self, src: SocketAddr, dst: SocketAddr) -> Result; + async fn connect(&self, src: SocketAddr, dst: SocketAddr) -> anyhow::Result; fn check_packet_from_peer_fast(&self, cidr_set: &CidrSet, global_ctx: &GlobalCtx) -> bool; fn check_packet_from_peer( &self, @@ -63,14 +63,13 @@ pub struct NatDstTcpConnector; #[async_trait::async_trait] impl NatDstConnector for NatDstTcpConnector { type DstStream = TcpStream; - async fn connect(&self, _src: SocketAddr, nat_dst: SocketAddr) -> Result { - let socket = match TcpSocket::new_v4() { - Ok(s) => s, - Err(error) => { - log::error!(?error, "create v4 socket failed"); - return Err(error.into()); - } - }; + async fn connect( + &self, + _src: SocketAddr, + nat_dst: SocketAddr, + ) -> anyhow::Result { + let socket = TcpSocket::new_v4() + .inspect_err(|error| log::error!(?error, "create v4 socket failed"))?; let stream = timeout(Duration::from_secs(10), socket.connect(nat_dst)) .await? diff --git a/easytier/src/proto/utils.rs b/easytier/src/proto/utils.rs index c9ab016e..951a9b2c 100644 --- a/easytier/src/proto/utils.rs +++ b/easytier/src/proto/utils.rs @@ -1,6 +1,6 @@ use delegate::delegate; use derivative::Derivative; -use derive_more::{Deref, DerefMut, From, IntoIterator}; +use derive_more::{AsMut, AsRef, Deref, DerefMut, From, IntoIterator}; use prost::Message; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -45,11 +45,15 @@ where From, Deref, DerefMut, + AsRef, + AsMut, Serialize, Deserialize, IntoIterator, )] #[derivative(Default(bound = ""))] +#[as_ref(forward)] +#[as_mut(forward)] #[serde(transparent)] #[into_iterator(owned, ref, ref_mut)] pub struct RepeatedMessageModel(Vec); @@ -74,22 +78,6 @@ impl Extend for RepeatedMessageModel { } } -impl AsRef<[Model]> for RepeatedMessageModel { - delegate! { - to self.0 { - fn as_ref(&self) -> &[Model]; - } - } -} - -impl AsMut<[Model]> for RepeatedMessageModel { - delegate! { - to self.0 { - fn as_mut(&mut self) -> &mut [Model]; - } - } -} - impl<'m, Message, Model> TryFrom<&'m [Message]> for RepeatedMessageModel where Message: prost::Message, diff --git a/easytier/src/utils/error.rs b/easytier/src/utils/error.rs new file mode 100644 index 00000000..75711d82 --- /dev/null +++ b/easytier/src/utils/error.rs @@ -0,0 +1,58 @@ +use delegate::delegate; +use derivative::Derivative; +use derive_more::{AsMut, AsRef, Deref, DerefMut, From, Into, IntoIterator}; +use std::fmt; +use std::fmt::Display; +use thiserror::Error; + +#[derive(Derivative, Debug, From, Into, Deref, DerefMut, AsRef, AsMut, IntoIterator, Error)] +#[derivative(Default(bound = ""))] +#[as_ref(forward)] +#[as_mut(forward)] +#[into_iterator(owned, ref, ref_mut)] +pub struct ErrorCollection { + pub errors: Vec, +} + +impl ErrorCollection { + delegate! { + to Vec { + #[into] + pub fn new() -> Self; + #[into] + pub fn with_capacity(capacity: usize) -> Self; + } + } +} + +impl> FromIterator for ErrorCollection { + fn from_iter>(iter: I) -> Self { + Self { + errors: iter.into_iter().map(Into::into).collect(), + } + } +} + +impl Extend for ErrorCollection { + delegate! { + to self.errors { + fn extend>(&mut self, iter: T); + } + } +} + +impl Display for ErrorCollection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.errors.is_empty() { + return write!(f, "No errors"); + } + + write!(f, "{} error(s) occurred:", self.errors.len())?; + for (i, err) in self.errors.iter().enumerate() { + writeln!(f)?; + write!(f, " {}. {}", i + 1, err)?; + } + + Ok(()) + } +} diff --git a/easytier/src/utils/mod.rs b/easytier/src/utils/mod.rs index 1339c10d..280cd307 100644 --- a/easytier/src/utils/mod.rs +++ b/easytier/src/utils/mod.rs @@ -1,3 +1,4 @@ +pub mod error; pub mod panic; pub mod string; pub mod task; diff --git a/easytier/src/utils/task.rs b/easytier/src/utils/task.rs index b0ac0b40..ce34df56 100644 --- a/easytier/src/utils/task.rs +++ b/easytier/src/utils/task.rs @@ -1,9 +1,13 @@ +use crate::utils::error::ErrorCollection; +use futures::StreamExt; +use futures::stream::FuturesUnordered; use std::future::Future; use std::io; use std::pin::Pin; use std::task::{Context, Poll}; use std::time::Duration; use tokio::task::JoinHandle; +use tokio::time::sleep; use tokio_util::sync::CancellationToken; use tokio_util::task::AbortOnDropHandle; @@ -78,3 +82,61 @@ impl Future for CancellableTask { } // endregion + +// region HedgeExt + +pub(crate) trait HedgeExt: Iterator + Sized { + async fn hedge(self, delay: Duration) -> Result> + where + Self::Item: Future>; +} + +impl HedgeExt for I +where + I: Iterator, +{ + async fn hedge(mut self, delay: Duration) -> Result> + where + Self::Item: Future>, + { + let mut tasks = FuturesUnordered::new(); + let mut errors = ErrorCollection::new(); + let mut exhausted = false; + + macro_rules! spawn { + () => { + if let Some(fut) = self.next() { + tasks.push(fut); + } else { + exhausted = true; + } + }; + } + + spawn!(); + + while !tasks.is_empty() { + tokio::select! { + res = tasks.next() => { + match res { + Some(Ok(v)) => return Ok(v), + Some(Err(e)) => errors.push(e), + None => unreachable!(), + } + + if !exhausted { + spawn!(); + } + } + + _ = sleep(delay), if !exhausted => { + spawn!(); + } + } + } + + Err(errors) + } +} + +// endregion