diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 3b6558d..d5b9b4d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -104,6 +104,7 @@ jobs: TAG=$GITHUB_SHA fi mv ./target/$TARGET/release/easytier-core"$SUFFIX" ./artifacts/objects/ + mv ./target/$TARGET/release/easytier-gui"$SUFFIX" ./artifacts/objects/ mv ./target/$TARGET/release/easytier-cli"$SUFFIX" ./artifacts/objects/ tar -cvf ./artifacts/$NAME-$TARGET-$TAG.tar -C ./artifacts/objects/ . rm -rf ./artifacts/objects/ @@ -124,7 +125,6 @@ jobs: remote-path: /easytier-releases/${{ github.sha }}/ no-delete-remote-files: true retry: 5 - increment: true test: runs-on: ubuntu-latest steps: diff --git a/Cargo.toml b/Cargo.toml index d0f3be3..06fa63f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["easytier"] +members = ["easytier", "easytier-gui"] [profile.dev] panic = "unwind" diff --git a/easytier-gui/Cargo.toml b/easytier-gui/Cargo.toml new file mode 100644 index 0000000..d60dd55 --- /dev/null +++ b/easytier-gui/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "easytier-gui" +description = "A full meshed p2p VPN, connecting all your devices in one network with one command." +homepage = "https://github.com/KKRainbow/EasyTier" +repository = "https://github.com/KKRainbow/EasyTier" +version = "0.1.0" +edition = "2021" +authors = ["kkrainbow"] +keywords = ["vpn", "p2p", "network", "easytier"] +categories = ["network-programming"] +rust-version = "1.75" +license-file = "LICENSE" +readme = "README.md" + +[dependencies] +easytier = { path = "../easytier" } +tokio = { version = "1", features = ["full"] } +anyhow = "1.0" +chrono = "0.4.37" + +once_cell = "1.18.0" +dashmap = "5.5.3" +egui = { version = "0.27.2" } +egui-modal = "0.3.6" + +humansize = "2.1.3" + +eframe = { version = "0.27.2", features = [ + "default", + "serde", + "persistence", + "wgpu" +] } +wgpu = { version = "0.19.3", features = [ "webgpu", "webgl"] } + +# For image support: +egui_extras = { version = "0.27.2", features = ["default", "image"] } + +env_logger = { version = "0.10", default-features = false, features = [ + "auto-color", + "humantime", +] } + +egui_tiles = "0.8.0" + +derivative = "2.2.0" +serde = { version = "1.0", features = ["derive"] } +elevated-command = "1.1.2" \ No newline at end of file diff --git a/easytier-gui/LICENSE b/easytier-gui/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/easytier-gui/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/easytier-gui/README.md b/easytier-gui/README.md new file mode 100644 index 0000000..e69de29 diff --git a/easytier-gui/assets/msyh.ttc b/easytier-gui/assets/msyh.ttc new file mode 100644 index 0000000..ddc87b9 Binary files /dev/null and b/easytier-gui/assets/msyh.ttc differ diff --git a/easytier-gui/src/launcher.rs b/easytier-gui/src/launcher.rs new file mode 100644 index 0000000..6a1c665 --- /dev/null +++ b/easytier-gui/src/launcher.rs @@ -0,0 +1,217 @@ +use std::{ + collections::VecDeque, + sync::{atomic::AtomicBool, Arc, RwLock}, +}; + +use chrono::{DateTime, Local}; +use easytier::{ + common::{ + config::{ConfigLoader, TomlConfigLoader}, + global_ctx::GlobalCtxEvent, + stun::StunInfoCollectorTrait, + }, + instance::instance::Instance, + peers::rpc_service::PeerManagerRpcService, + rpc::{ + cli::{PeerInfo, Route, StunInfo}, + peer::GetIpListResponse, + }, +}; + +#[derive(Default, Clone)] +pub struct MyNodeInfo { + pub virtual_ipv4: String, + pub ips: GetIpListResponse, + pub stun_info: StunInfo, + pub listeners: Vec, + pub vpn_portal_cfg: Option, +} + +#[derive(Default, Clone)] +struct EasyTierData { + events: Arc, GlobalCtxEvent)>>>, + node_info: Arc>, + routes: Arc>>, + peers: Arc>>, +} + +pub struct EasyTierLauncher { + instance_alive: Arc, + stop_flag: Arc, + thread_handle: Option>, + running_cfg: String, + + error_msg: Arc>>, + data: EasyTierData, +} + +impl EasyTierLauncher { + pub fn new() -> Self { + let instance_alive = Arc::new(AtomicBool::new(false)); + Self { + instance_alive, + thread_handle: None, + error_msg: Arc::new(RwLock::new(None)), + running_cfg: String::new(), + + stop_flag: Arc::new(AtomicBool::new(false)), + data: EasyTierData::default(), + } + } + + async fn handle_easytier_event(event: GlobalCtxEvent, data: EasyTierData) { + let mut events = data.events.write().unwrap(); + events.push_back((chrono::Local::now(), event)); + if events.len() > 100 { + events.pop_front(); + } + } + + async fn easytier_routine( + cfg: TomlConfigLoader, + stop_signal: Arc, + data: EasyTierData, + ) -> Result<(), anyhow::Error> { + let mut instance = Instance::new(cfg); + let peer_mgr = instance.get_peer_manager(); + + // Subscribe to global context events + let global_ctx = instance.get_global_ctx(); + let data_c = data.clone(); + tokio::spawn(async move { + let mut receiver = global_ctx.subscribe(); + while let Ok(event) = receiver.recv().await { + Self::handle_easytier_event(event, data_c.clone()).await; + } + }); + + // update my node info + let data_c = data.clone(); + let global_ctx_c = instance.get_global_ctx(); + let peer_mgr_c = peer_mgr.clone(); + let vpn_portal = instance.get_vpn_portal_inst(); + tokio::spawn(async move { + loop { + let node_info = MyNodeInfo { + virtual_ipv4: global_ctx_c + .get_ipv4() + .map(|x| x.to_string()) + .unwrap_or_default(), + ips: global_ctx_c.get_ip_collector().collect_ip_addrs().await, + stun_info: global_ctx_c.get_stun_info_collector().get_stun_info(), + listeners: global_ctx_c + .get_running_listeners() + .iter() + .map(|x| x.to_string()) + .collect(), + vpn_portal_cfg: Some( + vpn_portal + .lock() + .await + .dump_client_config(peer_mgr_c.clone()) + .await, + ), + }; + *data_c.node_info.write().unwrap() = node_info.clone(); + *data_c.routes.write().unwrap() = peer_mgr_c.list_routes().await; + *data_c.peers.write().unwrap() = PeerManagerRpcService::new(peer_mgr_c.clone()) + .list_peers() + .await; + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + }); + + instance.run().await?; + stop_signal.notified().await; + Ok(()) + } + + pub fn start(&mut self, cfg_generator: F) + where + F: FnOnce() -> Result + Send + Sync, + { + let error_msg = self.error_msg.clone(); + let cfg = cfg_generator(); + if let Err(e) = cfg { + error_msg.write().unwrap().replace(e.to_string()); + return; + } + + self.running_cfg = cfg.as_ref().unwrap().dump(); + + let stop_flag = self.stop_flag.clone(); + + let instance_alive = self.instance_alive.clone(); + instance_alive.store(true, std::sync::atomic::Ordering::Relaxed); + + let data = self.data.clone(); + + self.thread_handle = Some(std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .unwrap(); + let stop_notifier = Arc::new(tokio::sync::Notify::new()); + + let stop_notifier_clone = stop_notifier.clone(); + rt.spawn(async move { + while !stop_flag.load(std::sync::atomic::Ordering::Relaxed) { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + stop_notifier_clone.notify_one(); + }); + + let ret = rt.block_on(Self::easytier_routine( + cfg.unwrap(), + stop_notifier.clone(), + data, + )); + if let Err(e) = ret { + error_msg.write().unwrap().replace(e.to_string()); + } + instance_alive.store(false, std::sync::atomic::Ordering::Relaxed); + })); + } + + pub fn error_msg(&self) -> Option { + self.error_msg.read().unwrap().clone() + } + + pub fn running(&self) -> bool { + self.instance_alive + .load(std::sync::atomic::Ordering::Relaxed) + } + + pub fn get_events(&self) -> Vec<(DateTime, GlobalCtxEvent)> { + let events = self.data.events.read().unwrap(); + events.iter().cloned().collect() + } + + pub fn get_node_info(&self) -> MyNodeInfo { + self.data.node_info.read().unwrap().clone() + } + + pub fn get_routes(&self) -> Vec { + self.data.routes.read().unwrap().clone() + } + + pub fn get_peers(&self) -> Vec { + self.data.peers.read().unwrap().clone() + } + + pub fn running_cfg(&self) -> String { + self.running_cfg.clone() + } +} + +impl Drop for EasyTierLauncher { + fn drop(&mut self) { + self.stop_flag + .store(true, std::sync::atomic::Ordering::Relaxed); + if let Some(handle) = self.thread_handle.take() { + if let Err(e) = handle.join() { + println!("Error when joining thread: {:?}", e); + } + } + } +} diff --git a/easytier-gui/src/main.rs b/easytier-gui/src/main.rs new file mode 100644 index 0000000..2b6f670 --- /dev/null +++ b/easytier-gui/src/main.rs @@ -0,0 +1,1095 @@ +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] // hide console window on Windows in release + +use std::{ + env::current_exe, + sync::{atomic::AtomicU32, Arc, Mutex}, + time::Duration, +}; + +use anyhow::Context; +use dashmap::DashMap; +use easytier::{ + common::config::{ + ConfigLoader, NetworkIdentity, PeerConfig, TomlConfigLoader, VpnPortalConfig, + }, + utils::{cost_to_str, float_to_str, list_peer_route_pair}, +}; +use egui::{Align, Layout, Separator, Widget}; +use egui_extras::{Column, Size, StripBuilder, TableBuilder}; +use egui_modal::Modal; +use humansize::format_size; +use launcher::{EasyTierLauncher, MyNodeInfo}; +use serde::{Deserialize, Serialize}; +use text_list::TextListOption; + +use easytier::rpc::cli::NatType; + +pub mod launcher; +pub mod text_list; +pub mod toggle_switch; + +#[derive(Deserialize, Serialize)] +struct TextsForI18n { + // Main window + network_config_label: String, + + config_change_notify: String, + + unnamed_network_name: String, + new_network: String, + del_network: String, + + current_status_label: String, + running_text: String, + stopped_text: String, + + virtual_ipv4_label: String, + network_name_label: String, + network_secret_label: String, + + networking_method_label: String, + public_server_method: String, + manual_method: String, + standalone_method: String, + public_server_url_label: String, + peer_urls_label: String, + + proxy_cidr_label: String, + + optional_hint_text: String, + + enable_vpn_portal_label: String, + vpn_portal_listen_port_label: String, + vpn_portal_client_cidr_label: String, + + listerners_label: String, + rpc_port_label: String, + + copy_config_button: String, + + advanced_settings: String, + + node_info_label: String, + route_table_label: String, + other_info_label: String, + running_event_label: String, + vpn_portal_info_btn: String, + + event_time_table_col: String, + detail_table_col: String, +} + +impl TextsForI18n { + fn new_english() -> Self { + Self { + unnamed_network_name: "default".to_string(), + + new_network: "New Network".to_string(), + del_network: "Remove Current".to_string(), + + current_status_label: "Current Status".to_string(), + running_text: "Running. Press To Stop: ".to_string(), + stopped_text: "Stopped, Press To Run: ".to_string(), + config_change_notify: "*Config Changed. Need Rerun".to_string(), + + virtual_ipv4_label: "Virtual IPv4".to_string(), + network_name_label: "Network Name".to_string(), + network_secret_label: "Network Secret".to_string(), + + networking_method_label: "Networking Method".to_string(), + public_server_method: "Public Server".to_string(), + manual_method: "Manual".to_string(), + standalone_method: "Standalone".to_string(), + peer_urls_label: "Peer URLs".to_string(), + + optional_hint_text: "Optional".to_string(), + + enable_vpn_portal_label: "Enable VPN Portal".to_string(), + vpn_portal_listen_port_label: "VPN Listen Port".to_string(), + vpn_portal_client_cidr_label: "VPN Client CIDR".to_string(), + + listerners_label: "Listeners".to_string(), + rpc_port_label: "RPC Port".to_string(), + + copy_config_button: "Copy Config".to_string(), + + advanced_settings: "Advanced Settings".to_string(), + + node_info_label: "Node Info".to_string(), + route_table_label: "Route Table".to_string(), + other_info_label: "Other Info".to_string(), + running_event_label: "Running Event".to_string(), + + vpn_portal_info_btn: "VPN Portal Info".to_string(), + + network_config_label: "Network Config".to_string(), + public_server_url_label: "Public Server URL".to_string(), + proxy_cidr_label: "Proxy CIDR".to_string(), + event_time_table_col: "Event Time".to_string(), + detail_table_col: "Detail".to_string(), + } + } + + fn new_chinese() -> Self { + Self { + unnamed_network_name: "default".to_string(), + new_network: "新建网络".to_string(), + del_network: "删除当前".to_string(), + + current_status_label: "当前状态".to_string(), + running_text: "运行中。点击停止: ".to_string(), + stopped_text: "已停止,点击运行: ".to_string(), + config_change_notify: "*配置已更改,需要重新运行".to_string(), + + virtual_ipv4_label: "虚拟IPv4".to_string(), + network_name_label: "网络名称".to_string(), + network_secret_label: "网络密钥".to_string(), + + networking_method_label: "组网方式".to_string(), + public_server_method: "公共服务器".to_string(), + manual_method: "手动".to_string(), + standalone_method: "独立模式".to_string(), + peer_urls_label: "节点URL".to_string(), + + optional_hint_text: "可选".to_string(), + + enable_vpn_portal_label: "启用VPN门户".to_string(), + vpn_portal_listen_port_label: "VPN监听端口".to_string(), + vpn_portal_client_cidr_label: "VPN客户端CIDR".to_string(), + + listerners_label: "监听器".to_string(), + rpc_port_label: "RPC端口".to_string(), + + copy_config_button: "复制配置".to_string(), + + advanced_settings: "高级设置".to_string(), + + node_info_label: "节点信息".to_string(), + route_table_label: "路由表".to_string(), + other_info_label: "其他信息".to_string(), + running_event_label: "运行事件".to_string(), + + vpn_portal_info_btn: "VPN门户信息".to_string(), + + network_config_label: "网络配置".to_string(), + public_server_url_label: "公共服务器URL".to_string(), + proxy_cidr_label: "子网代理".to_string(), + event_time_table_col: "事件时间".to_string(), + detail_table_col: "详情".to_string(), + } + } +} + +static TEXTS_MAP: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(DashMap::new); + +// 0: English, 1: Chinese +static LANGUAGE: AtomicU32 = AtomicU32::new(0); + +static MESSAGE_BOX: once_cell::sync::Lazy>>> = + once_cell::sync::Lazy::new(Default::default); + +#[macro_export] +macro_rules! TEXT { + ($name:ident) => { + TEXTS_MAP + .get(&LANGUAGE.load(std::sync::atomic::Ordering::Relaxed)) + .unwrap() + .$name + .clone() + }; +} + +#[derive(derivative::Derivative, Deserialize, Serialize, PartialEq)] +enum NetworkingMethod { + PublicServer, + Manual, + Standalone, +} + +#[derive(derivative::Derivative, Deserialize, Serialize)] +struct NetworkInstancePane { + running: bool, + virtual_ipv4: String, + network_name: String, + network_secret: String, + networking_method: NetworkingMethod, + + public_server_url: String, + peer_urls: Vec, + + proxy_cidrs: Vec, + + enable_vpn_portal: bool, + vpn_portal_listne_port: String, + vpn_portal_client_cidr: String, + + advanced_settings: bool, + + listener_urls: Vec, + rpc_port: String, + + modal_title: String, + modal_content: String, + + #[serde(skip)] + launcher: Option, +} + +impl NetworkInstancePane { + fn default() -> Self { + Self { + running: false, + virtual_ipv4: "".to_string(), + network_name: TEXT!(unnamed_network_name), + network_secret: "".to_string(), + networking_method: NetworkingMethod::PublicServer, + + public_server_url: "tcp://easytier.public.kkrainbow.top:11010".to_string(), + peer_urls: vec![], + + proxy_cidrs: vec![], + + enable_vpn_portal: false, + vpn_portal_listne_port: "11222".to_string(), + vpn_portal_client_cidr: "10.14.14.0/24".to_string(), + + advanced_settings: false, + + listener_urls: vec![ + "tcp://0.0.0.0:11010".to_string(), + "udp://0.0.0.0:11010".to_string(), + "wg://0.0.0.0:11011".to_string(), + ], + + rpc_port: "15888".to_string(), + + modal_title: "".to_string(), + modal_content: "".to_string(), + + launcher: None, + } + } +} + +impl NetworkInstancePane { + fn gen_config(&self) -> Result { + let cfg = TomlConfigLoader::default(); + cfg.set_inst_name(self.network_name.clone()); + cfg.set_network_identity(NetworkIdentity { + network_name: self.network_name.clone(), + network_secret: self.network_secret.clone(), + }); + + if self.virtual_ipv4.len() > 0 { + cfg.set_ipv4( + self.virtual_ipv4.parse().with_context(|| { + format!("failed to parse ipv4 address: {}", self.virtual_ipv4) + })?, + ) + } + + match self.networking_method { + NetworkingMethod::PublicServer => { + cfg.set_peers(vec![PeerConfig { + uri: self.public_server_url.parse().with_context(|| { + format!( + "failed to parse public server uri: {}", + self.public_server_url + ) + })?, + }]); + } + NetworkingMethod::Manual => { + let mut peers = vec![]; + for peer_url in self.peer_urls.iter() { + if peer_url.is_empty() { + continue; + } + peers.push(PeerConfig { + uri: peer_url + .parse() + .with_context(|| format!("failed to parse peer uri: {}", peer_url))?, + }); + } + + cfg.set_peers(peers); + } + NetworkingMethod::Standalone => {} + } + + let mut listener_urls = vec![]; + for listener_url in self.listener_urls.iter() { + if listener_url.is_empty() { + continue; + } + listener_urls.push( + listener_url + .parse() + .with_context(|| format!("failed to parse listener uri: {}", listener_url))?, + ); + } + cfg.set_listeners(listener_urls); + + for n in self.proxy_cidrs.iter() { + cfg.add_proxy_cidr( + n.parse() + .with_context(|| format!("failed to parse proxy network: {}", n))?, + ); + } + + cfg.set_rpc_portal( + format!("127.0.0.1:{}", self.rpc_port) + .parse() + .with_context(|| format!("failed to parse rpc portal port: {}", self.rpc_port))?, + ); + + if self.enable_vpn_portal { + cfg.set_vpn_portal_config(VpnPortalConfig { + client_cidr: self.vpn_portal_client_cidr.parse().with_context(|| { + format!( + "failed to parse vpn portal client cidr: {}", + self.vpn_portal_client_cidr + ) + })?, + wireguard_listen: format!("0.0.0.0:{}", self.vpn_portal_listne_port) + .parse() + .with_context(|| { + format!( + "failed to parse vpn portal wireguard listen port. {}", + self.vpn_portal_listne_port + ) + })?, + }); + } + + Ok(cfg) + } + + fn is_easytier_running(&self) -> bool { + self.launcher.is_some() && self.launcher.as_ref().unwrap().running() + } + + fn need_restart(&self) -> bool { + let Ok(cfg) = self.gen_config() else { + return false; + }; + + if !self.is_easytier_running() { + return false; + } + + self.launcher.as_ref().unwrap().running_cfg() != cfg.dump() + } + + fn update_advanced_settings(&mut self, ui: &mut egui::Ui) { + ui.label(TEXT!(listerners_label)); + text_list::text_list_ui( + ui, + "listeners", + &mut self.listener_urls, + Some(TextListOption { + hint: "e.g: tcp://0.0.0.0:11010".to_string(), + }), + ); + ui.end_row(); + + ui.label(TEXT!(rpc_port_label)); + ui.text_edit_singleline(&mut self.rpc_port); + ui.end_row(); + } + + fn start_easytier(&mut self) { + let mut l = EasyTierLauncher::new(); + l.start(|| self.gen_config()); + self.launcher = Some(l); + } + + fn update_basic_settings(&mut self, ui: &mut egui::Ui) { + ui.label(TEXT!(current_status_label)); + ui.horizontal(|ui| { + if self.launcher.is_none() || !self.launcher.as_ref().unwrap().running() { + self.running = false; + ui.label(TEXT!(stopped_text)); + } else { + self.running = true; + ui.label(TEXT!(running_text)); + } + + if toggle_switch::toggle_ui(ui, &mut self.running).clicked() { + if self.running { + self.start_easytier(); + } else { + self.launcher = None; + } + } + + if let Some(inst) = &self.launcher { + ui.label(inst.error_msg().unwrap_or_default()); + } + }); + ui.end_row(); + + ui.label(TEXT!(virtual_ipv4_label)); + ui.horizontal(|ui| { + egui::TextEdit::singleline(&mut self.virtual_ipv4) + .hint_text("e.g: 10.144.144.3") + .ui(ui); + ui.label("/24"); + }); + ui.end_row(); + + ui.label(TEXT!(network_name_label)); + egui::TextEdit::singleline(&mut self.network_name) + .hint_text(TEXT!(optional_hint_text)) + .ui(ui); + ui.end_row(); + + ui.label(TEXT!(network_secret_label)); + egui::TextEdit::singleline(&mut self.network_secret) + .hint_text(TEXT!(optional_hint_text)) + .ui(ui); + ui.end_row(); + + ui.label(TEXT!(networking_method_label)); + ui.horizontal(|ui| { + ui.selectable_value( + &mut self.networking_method, + NetworkingMethod::PublicServer, + TEXT!(public_server_method), + ); + ui.selectable_value( + &mut self.networking_method, + NetworkingMethod::Manual, + TEXT!(manual_method), + ); + ui.selectable_value( + &mut self.networking_method, + NetworkingMethod::Standalone, + TEXT!(standalone_method), + ); + }); + ui.end_row(); + + match self.networking_method { + NetworkingMethod::PublicServer => { + ui.label(TEXT!(public_server_url_label)); + ui.text_edit_singleline(&mut self.public_server_url); + ui.end_row(); + } + NetworkingMethod::Standalone => {} + NetworkingMethod::Manual => { + ui.label(TEXT!(peer_urls_label)); + text_list::text_list_ui( + ui, + "peers", + &mut self.peer_urls, + Some(TextListOption { + hint: "e.g: tcp://192.168.99.12:11010".to_string(), + }), + ); + ui.end_row(); + } + } + + ui.label(TEXT!(proxy_cidr_label)); + text_list::text_list_ui( + ui, + "proxy_cidr", + &mut self.proxy_cidrs, + Some(TextListOption { + hint: "e.g: 10.147.223.0/24".to_string(), + }), + ); + ui.end_row(); + + ui.label(TEXT!(enable_vpn_portal_label)); + toggle_switch::toggle_ui(ui, &mut self.enable_vpn_portal); + ui.end_row(); + + if self.enable_vpn_portal { + ui.label(TEXT!(vpn_portal_listen_port_label)); + ui.text_edit_singleline(&mut self.vpn_portal_listne_port); + ui.end_row(); + + ui.label(TEXT!(vpn_portal_client_cidr_label)); + ui.text_edit_singleline(&mut self.vpn_portal_client_cidr); + ui.end_row(); + } + + ui.label(TEXT!(advanced_settings)); + toggle_switch::toggle_ui(ui, &mut self.advanced_settings); + ui.end_row(); + + if self.advanced_settings { + self.update_advanced_settings(ui); + } + } + + fn update_config_zone(&mut self, ui: &mut egui::Ui) { + StripBuilder::new(ui) + .size(Size::exact(25.0)) + .size(Size::remainder()) + .size(Size::exact(15.0)) + .size(Size::exact(100.0)) + .size(Size::exact(20.0)) + .vertical(|mut strip| { + strip.cell(|ui| { + ui.horizontal(|ui| { + ui.label(TEXT!(network_config_label)); + if self.need_restart() { + ui.label(TEXT!(config_change_notify)); + } + }); + }); + + strip.cell(|ui| { + ui.with_layout( + Layout::top_down(Align::LEFT).with_cross_justify(true), + |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("grid") + .spacing([10.0, 15.0]) + .show(ui, |ui| { + self.update_basic_settings(ui); + }); + }); + }, + ); + }); + + strip.cell(|ui| { + Separator::default().spacing(10.0).ui(ui); + }); + + if let Ok(cfg) = self.gen_config() { + // ui.separator(); + strip.cell(|ui| { + ui.with_layout( + Layout::top_down(Align::LEFT).with_cross_justify(true), + |ui| { + egui::ScrollArea::vertical().show(ui, |ui| { + ui.text_edit_multiline(&mut cfg.dump()); + }); + }, + ); + }); + strip.cell(|ui| { + ui.with_layout( + Layout::top_down(Align::Center).with_cross_justify(true), + |ui| { + if ui.button(TEXT!(copy_config_button)).clicked() { + ui.output_mut(|o| o.copied_text = cfg.dump()); + }; + }, + ); + }); + } else { + strip.cell(|_ui| {}); + strip.cell(|_ui| {}); + } + }); + // ui.vertical_centered_justified(|ui| { + // ui.group(|ui| {}); + // }); + } + + fn update_event_table(&mut self, ui: &mut egui::Ui) { + let table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto()) + .column(Column::remainder()) + .stick_to_bottom(true) + .min_scrolled_height(0.0); + + let table = table.header(20.0, |mut header| { + header.col(|ui| { + ui.strong(TEXT!(event_time_table_col)); + }); + header.col(|ui| { + ui.strong(TEXT!(detail_table_col)); + }); + }); + + let mut events = vec![]; + if let Some(l) = self.launcher.as_ref() { + if l.running() { + events.extend(l.get_events()); + } + }; + + table.body(|mut body| { + for (time, event) in events.iter() { + body.row(20.0, |mut row| { + row.col(|ui| { + ui.monospace(time.format("%Y-%m-%d %H:%M:%S").to_string()); + }); + row.col(|ui| { + ui.monospace(format!("{:?}", event)); + }); + }); + } + if events.len() < 10 { + for _ in 0..(10 - events.len()) { + body.row(20.0, |mut row| { + row.col(|ui| { + ui.monospace("".to_string()); + }); + row.col(|ui| { + ui.monospace("".to_string()); + }); + }); + } + } + }); + } + + fn update_node_info(&mut self, ui: &mut egui::Ui, node_info: MyNodeInfo) { + let add_card = |ui: &mut egui::Ui, content: String| { + if ui.button(&content).clicked() { + ui.output_mut(|o| o.copied_text = content); + }; + }; + + ui.horizontal_wrapped(|ui| { + add_card( + ui, + format!("{}: {}", "Virtual IPV4: ", node_info.virtual_ipv4), + ); + + add_card( + ui, + format!( + "{}: {:#?}", + "UDP NAT Type:", + NatType::try_from(node_info.stun_info.udp_nat_type).unwrap() + ), + ); + + for (idx, l) in node_info.listeners.iter().enumerate() { + add_card(ui, format!("Listener {}: {}", idx, l)); + } + + for (idx, ipv4) in node_info.ips.interface_ipv4s.iter().enumerate() { + add_card(ui, format!("Local IPV4 {}: {}", idx, ipv4)); + } + + if node_info.ips.public_ipv4.len() > 0 { + add_card(ui, format!("Public IPV4: {}", node_info.ips.public_ipv4)); + } + }); + } + + fn update_route_table(&mut self, ui: &mut egui::Ui) { + let table = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::auto()) + .column(Column::auto()) + .column(Column::auto()) + .column(Column::auto()) + .column(Column::auto()) + .column(Column::auto()) + .column(Column::remainder()) + .stick_to_bottom(true) + .min_scrolled_height(0.0); + + let table = table.header(20.0, |mut header| { + header.col(|ui| { + ui.strong("Virtual IP"); + }); + header.col(|ui| { + ui.strong("HostName"); + }); + header.col(|ui| { + ui.strong("Cost"); + }); + header.col(|ui| { + ui.strong("Latency"); + }); + header.col(|ui| { + ui.strong("TX"); + }); + header.col(|ui| { + ui.strong("RX"); + }); + header.col(|ui| { + ui.strong("LossRate"); + }); + }); + + let mut peers = vec![]; + let mut routes = vec![]; + if let Some(l) = self.launcher.as_ref() { + if l.running() { + routes.extend(l.get_routes()); + peers.extend(l.get_peers()); + } + }; + + let pairs = list_peer_route_pair(peers, routes); + + table.body(|mut body| { + for pair in pairs.iter() { + body.row(20.0, |mut row| { + row.col(|ui| { + ui.monospace(&pair.route.ipv4_addr); + }); + row.col(|ui| { + ui.monospace(pair.route.hostname.to_string()); + }); + row.col(|ui| { + ui.monospace(cost_to_str(pair.route.cost)); + }); + row.col(|ui| { + ui.monospace(float_to_str(pair.get_latency_ms().unwrap_or_default(), 2)); + }); + row.col(|ui| { + ui.monospace(format_size( + pair.get_tx_bytes().unwrap_or_default(), + humansize::DECIMAL, + )); + }); + row.col(|ui| { + ui.monospace(format_size( + pair.get_rx_bytes().unwrap_or_default(), + humansize::DECIMAL, + )); + }); + row.col(|ui| { + ui.monospace(float_to_str(pair.get_loss_rate().unwrap_or_default(), 2)); + }); + }); + } + }); + } + + fn update(&mut self, ui: &mut egui::Ui) -> egui_tiles::UiResponse { + // Give each pane a unique color: + // let color = egui::epaint::Hsva::new(0.103 as f32, 0.5, 0.5, 1.0); + // ui.painter().rect_filled(ui.max_rect(), 0.0, color); + ui.add(egui::Separator::default().spacing(5.0)); + + const CONFIG_PANE_WIDTH: f32 = 440.0; + + let mut modal_ref = MESSAGE_BOX.lock().unwrap(); + let modal = modal_ref.as_mut().unwrap(); + modal.show(|ui| { + // these helper functions help set the ui based on the modal's + // set style, but they are not required and you can put whatever + // ui you want inside [`.show()`] + modal.title(ui, self.modal_title.clone()); + modal.frame(ui, |ui| { + modal.body(ui, self.modal_content.clone()); + }); + modal.buttons(ui, |ui| { + // After clicking, the modal is automatically closed + if modal.button(ui, "Copy And Close").clicked() { + ui.output_mut(|o| o.copied_text = self.modal_content.clone()); + modal.close(); + }; + }); + }); + + let node_info = if let Some(l) = self.launcher.as_ref() { + l.get_node_info() + } else { + Default::default() + }; + + StripBuilder::new(ui) + .size(Size::exact(CONFIG_PANE_WIDTH)) + .size(Size::remainder()) + .horizontal(|mut strip| { + strip.cell(|ui| { + self.update_config_zone(ui); + }); + + strip.strip(|builder| { + builder + .size(Size::exact(100.0)) + .size(Size::relative(0.4)) + .size(Size::exact(20.0)) + .size(Size::remainder()) + .vertical(|mut strip| { + strip.cell(|ui| { + ui.label(TEXT!(node_info_label)); + ui.group(|ui| { + egui::ScrollArea::both().show(ui, |ui| { + self.update_node_info(ui, node_info.clone()); + }); + }); + }); + + strip.cell(|ui| { + ui.label(TEXT!(route_table_label)); + ui.with_layout( + Layout::top_down(Align::LEFT).with_cross_justify(true), + |ui| { + ui.group(|ui| { + egui::ScrollArea::both().show(ui, |ui| { + self.update_route_table(ui); + }); + }); + }, + ); + }); + + strip.cell(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(TEXT!(other_info_label)); + if ui.button(TEXT!(vpn_portal_info_btn)).clicked() { + self.modal_title = TEXT!(vpn_portal_info_btn); + self.modal_content = + node_info.vpn_portal_cfg.unwrap_or_default(); + modal.open(); + } + }); + }); + + strip.cell(|ui| { + ui.label(TEXT!(running_event_label)); + ui.with_layout( + Layout::top_down(Align::LEFT).with_cross_justify(true), + |ui| { + ui.group(|ui| { + egui::ScrollArea::both().show(ui, |ui| { + self.update_event_table(ui); + }); + }); + }, + ); + }); + }); + }); + }); + + egui_tiles::UiResponse::None + } +} + +struct MainWindowTabsBehavior { + simplification_options: egui_tiles::SimplificationOptions, + add_child_to: Option, + remove_child: Option, +} + +impl Default for MainWindowTabsBehavior { + fn default() -> Self { + let simplification_options = egui_tiles::SimplificationOptions { + all_panes_must_have_tabs: true, + ..Default::default() + }; + Self { + simplification_options, + add_child_to: None, + remove_child: None, + } + } +} + +// ref: https://github.com/rerun-io/egui_tiles/blob/main/examples/advanced.rs +impl egui_tiles::Behavior for MainWindowTabsBehavior { + fn tab_title_for_pane(&mut self, pane: &NetworkInstancePane) -> egui::WidgetText { + format!("{}", pane.network_name).into() + } + + fn pane_ui( + &mut self, + ui: &mut egui::Ui, + _tile_id: egui_tiles::TileId, + pane: &mut NetworkInstancePane, + ) -> egui_tiles::UiResponse { + pane.update(ui) + } + + fn top_bar_right_ui( + &mut self, + _tiles: &egui_tiles::Tiles, + ui: &mut egui::Ui, + tile_id: egui_tiles::TileId, + _tabs: &egui_tiles::Tabs, + _scroll_offset: &mut f32, + ) { + ui.add_space(7.0); + let cur_lang = LANGUAGE.load(std::sync::atomic::Ordering::Relaxed); + if ui + .button(format!( + "{}{}", + "🌐", + if cur_lang == 0 { "中" } else { "En" } + )) + .clicked() + { + LANGUAGE.store(1 - cur_lang, std::sync::atomic::Ordering::Relaxed); + } + + ui.separator(); + + if ui + .button(format!("{}{}", "➕", TEXT!(new_network))) + .clicked() + { + self.add_child_to = Some(tile_id); + } + + if _tabs.children.len() > 1 + && ui + .button(format!("{}{}", "➖", TEXT!(del_network))) + .clicked() + { + if let Some(tid) = _tabs.active { + self.remove_child = Some(tid); + } + } + } + + fn simplification_options(&self) -> egui_tiles::SimplificationOptions { + self.simplification_options + } + + /// The height of the bar holding tab titles. + fn tab_bar_height(&self, _style: &egui::Style) -> f32 { + 40.0 + } + + /// Width of the gap between tiles in a horizontal or vertical layout, + /// and between rows/columns in a grid layout. + fn gap_width(&self, _style: &egui::Style) -> f32 { + 1.0 + } + + /// No child should shrink below this width nor height. + fn min_size(&self) -> f32 { + 32.0 + } + + /// Show we preview panes that are being dragged, + /// i.e. show their ui in the region where they will end up? + fn preview_dragged_panes(&self) -> bool { + false + } +} + +#[derive(serde::Deserialize, serde::Serialize)] +struct MyApp { + tree: egui_tiles::Tree, + + #[serde(skip)] + behavior: MainWindowTabsBehavior, +} + +impl MyApp { + fn default() -> Self { + let mut tiles = egui_tiles::Tiles::default(); + let mut tabs = vec![]; + tabs.push(tiles.insert_pane(NetworkInstancePane::default())); + let root = tiles.insert_tab_tile(tabs); + let tree = egui_tiles::Tree::new("my_tree", root, tiles); + + Self { + tree, + behavior: Default::default(), + } + } +} + +impl eframe::App for MyApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + if let Some(tile_id) = self.behavior.add_child_to.take() { + let tiles = &mut self.tree.tiles; + let new_pane = NetworkInstancePane::default(); + let new_tab = tiles.insert_pane(new_pane); + if let Some(egui_tiles::Tile::Container(egui_tiles::Container::Tabs(tabs))) = + self.tree.tiles.get_mut(tile_id) + { + tabs.add_child(new_tab); + tabs.set_active(new_tab); + } + } + + if let Some(tile_id) = self.behavior.remove_child.take() { + let tiles = &mut self.tree.tiles; + tiles.remove(tile_id); + } + + ctx.request_repaint_after(Duration::from_secs(1)); // animation + egui::CentralPanel::default().show(ctx, |ui| { + self.tree.ui(&mut self.behavior, ui); + }); + } + + fn save(&mut self, _storage: &mut dyn eframe::Storage) { + eframe::set_value(_storage, eframe::APP_KEY, &self); + } +} + +fn init_text_map() { + TEXTS_MAP.insert(0, TextsForI18n::new_english()); + TEXTS_MAP.insert(1, TextsForI18n::new_chinese()); +} + +fn check_sudo() -> bool { + let is_elevated = elevated_command::Command::is_elevated(); + if !is_elevated { + let Ok(my_exe) = current_exe() else { + return true; + }; + let elevated_cmd = elevated_command::Command::new(std::process::Command::new(my_exe)); + let _ = elevated_cmd.output(); + } + is_elevated +} + +fn load_fonts(ctx: &egui::Context) { + let mut fonts = egui::FontDefinitions::default(); + fonts.font_data.insert( + "my_font".to_owned(), + egui::FontData::from_static(include_bytes!("../assets/msyh.ttc")), + ); + fonts + .families + .get_mut(&egui::FontFamily::Proportional) + .unwrap() + .insert(0, "my_font".to_owned()); + fonts + .families + .get_mut(&egui::FontFamily::Monospace) + .unwrap() + .push("my_font".to_owned()); + ctx.set_fonts(fonts); +} + +fn main() -> Result<(), eframe::Error> { + if !check_sudo() { + return Ok(()); + } + env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`). + init_text_map(); + + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 600.0]), + ..Default::default() + }; + + eframe::run_native( + "EasyTier", + options, + Box::new(|ctx| { + load_fonts(&ctx.egui_ctx); + let mut message_box = MESSAGE_BOX.lock().unwrap(); + *message_box = Some(Modal::new(&ctx.egui_ctx, "MessageBox")); + + let mut app = MyApp::default(); + if let Some(storage) = ctx.storage { + if let Some(state) = eframe::get_value(storage, eframe::APP_KEY) { + app = state; + } + } + Box::new(app) + }), + ) +} diff --git a/easytier-gui/src/text_list.rs b/easytier-gui/src/text_list.rs new file mode 100644 index 0000000..b743f62 --- /dev/null +++ b/easytier-gui/src/text_list.rs @@ -0,0 +1,57 @@ +#[derive(Default)] +pub struct TextListOption { + pub hint: String, +} + +pub fn text_list_ui( + ui: &mut egui::Ui, + id: &str, + texts: &mut Vec, + option: Option, +) { + let option = option.unwrap_or_default(); + // convert text vec to (index, text) vec + let mut add_new_item = false; + let mut remove_idxs = vec![]; + + egui::Grid::new(id).max_col_width(200.0).show(ui, |ui| { + for i in 0..texts.len() { + egui::TextEdit::singleline(&mut texts[i]) + .hint_text(&option.hint) + .show(ui); + + ui.horizontal(|ui| { + if ui.button("➖").clicked() { + remove_idxs.push(i); + } + + if i == texts.len() - 1 { + if ui.button("➕").clicked() { + add_new_item = true; + } + } + }); + + ui.end_row(); + } + + if texts.len() == 0 { + if ui.button("➕").clicked() { + add_new_item = true; + } + ui.end_row(); + } + }); + + let new_texts = texts + .iter() + .enumerate() + .filter(|(i, _)| !remove_idxs.contains(i)) + .map(|(_, t)| t.clone()) + .collect::>(); + *texts = new_texts; + + if add_new_item && texts.last().map(|t| !t.is_empty()).unwrap_or(true) { + texts.push("".to_string()); + } +} diff --git a/easytier-gui/src/toggle_switch.rs b/easytier-gui/src/toggle_switch.rs new file mode 100644 index 0000000..1e51060 --- /dev/null +++ b/easytier-gui/src/toggle_switch.rs @@ -0,0 +1,107 @@ +//! Source code example of how to create your own widget. +//! This is meant to be read as a tutorial, hence the plethora of comments. + +/// iOS-style toggle switch: +/// +/// ``` text +/// _____________ +/// / /.....\ +/// | |.......| +/// \_______\_____/ +/// ``` +/// +/// ## Example: +/// ``` ignore +/// toggle_ui(ui, &mut my_bool); +/// ``` +pub fn toggle_ui(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { + // Widget code can be broken up in four steps: + // 1. Decide a size for the widget + // 2. Allocate space for it + // 3. Handle interactions with the widget (if any) + // 4. Paint the widget + + // 1. Deciding widget size: + // You can query the `ui` how much space is available, + // but in this example we have a fixed size widget based on the height of a standard button: + let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0); + + // 2. Allocating space: + // This is where we get a region of the screen assigned. + // We also tell the Ui to sense clicks in the allocated region. + let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + + // 3. Interact: Time to check for clicks! + if response.clicked() { + *on = !*on; + response.mark_changed(); // report back that the value changed + } + + // Attach some meta-data to the response which can be used by screen readers: + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, "")); + + // 4. Paint! + // Make sure we need to paint: + if ui.is_rect_visible(rect) { + // Let's ask for a simple animation from egui. + // egui keeps track of changes in the boolean associated with the id and + // returns an animated value in the 0-1 range for how much "on" we are. + let how_on = ui.ctx().animate_bool(response.id, *on); + // We will follow the current style by asking + // "how should something that is being interacted with be painted?". + // This will, for instance, give us different colors when the widget is hovered or clicked. + let visuals = ui.style().interact_selectable(&response, *on); + // All coordinates are in absolute screen coordinates so we use `rect` to place the elements. + let rect = rect.expand(visuals.expansion); + let radius = 0.5 * rect.height(); + ui.painter() + .rect(rect, radius, visuals.bg_fill, visuals.bg_stroke); + // Paint the circle, animating it from left to right with `how_on`: + let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); + let center = egui::pos2(circle_x, rect.center().y); + ui.painter() + .circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke); + } + + // All done! Return the interaction response so the user can check what happened + // (hovered, clicked, ...) and maybe show a tooltip: + response +} + +/// Here is the same code again, but a bit more compact: +#[allow(dead_code)] +fn toggle_ui_compact(ui: &mut egui::Ui, on: &mut bool) -> egui::Response { + let desired_size = ui.spacing().interact_size.y * egui::vec2(2.0, 1.0); + let (rect, mut response) = ui.allocate_exact_size(desired_size, egui::Sense::click()); + if response.clicked() { + *on = !*on; + response.mark_changed(); + } + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, *on, "")); + + if ui.is_rect_visible(rect) { + let how_on = ui.ctx().animate_bool(response.id, *on); + let visuals = ui.style().interact_selectable(&response, *on); + let rect = rect.expand(visuals.expansion); + let radius = 0.5 * rect.height(); + ui.painter() + .rect(rect, radius, visuals.bg_fill, visuals.bg_stroke); + let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); + let center = egui::pos2(circle_x, rect.center().y); + ui.painter() + .circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke); + } + + response +} + +// A wrapper that allows the more idiomatic usage pattern: `ui.add(toggle(&mut my_bool))` +/// iOS-style toggle switch. +/// +/// ## Example: +/// ``` ignore +/// ui.add(toggle(&mut my_bool)); +/// ``` +pub fn toggle(on: &mut bool) -> impl egui::Widget + '_ { + move |ui: &mut egui::Ui| toggle_ui(ui, on) +} diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index d1123c0..ca975f0 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -23,6 +23,11 @@ name = "easytier-cli" path = "src/easytier-cli.rs" test = false +[lib] +name = "easytier" +path = "src/lib.rs" +test = false + [dependencies] tracing = { version = "0.1", features = ["log"] } tracing-subscriber = { version = "0.3", features = [ diff --git a/easytier/LICENSE b/easytier/LICENSE new file mode 120000 index 0000000..ea5b606 --- /dev/null +++ b/easytier/LICENSE @@ -0,0 +1 @@ +../LICENSE \ No newline at end of file diff --git a/easytier/src/easytier-cli.rs b/easytier/src/easytier-cli.rs index 2a984ec..e4366fc 100644 --- a/easytier/src/easytier-cli.rs +++ b/easytier/src/easytier-cli.rs @@ -4,11 +4,13 @@ use std::{net::SocketAddr, vec}; use clap::{command, Args, Parser, Subcommand}; use rpc::vpn_portal_rpc_client::VpnPortalRpcClient; +use utils::{list_peer_route_pair, PeerRoutePair}; mod arch; mod common; mod rpc; mod tunnels; +mod utils; use crate::{ common::stun::{StunInfoCollector, UdpNatTypeDetector}, @@ -17,6 +19,7 @@ use crate::{ peer_center_rpc_client::PeerCenterRpcClient, peer_manage_rpc_client::PeerManageRpcClient, *, }, + utils::{cost_to_str, float_to_str}, }; use humansize::format_size; use tabled::settings::Style; @@ -94,107 +97,6 @@ enum Error { TonicRpcError(#[from] tonic::Status), } -#[derive(Debug)] -struct PeerRoutePair { - route: Route, - peer: Option, -} - -impl PeerRoutePair { - fn get_latency_ms(&self) -> Option { - let mut ret = u64::MAX; - let p = self.peer.as_ref()?; - for conn in p.conns.iter() { - let Some(stats) = &conn.stats else { - continue; - }; - ret = ret.min(stats.latency_us); - } - - if ret == u64::MAX { - None - } else { - Some(f64::from(ret as u32) / 1000.0) - } - } - - fn get_rx_bytes(&self) -> Option { - let mut ret = 0; - let p = self.peer.as_ref()?; - for conn in p.conns.iter() { - let Some(stats) = &conn.stats else { - continue; - }; - ret += stats.rx_bytes; - } - - if ret == 0 { - None - } else { - Some(ret) - } - } - - fn get_tx_bytes(&self) -> Option { - let mut ret = 0; - let p = self.peer.as_ref()?; - for conn in p.conns.iter() { - let Some(stats) = &conn.stats else { - continue; - }; - ret += stats.tx_bytes; - } - - if ret == 0 { - None - } else { - Some(ret) - } - } - - fn get_loss_rate(&self) -> Option { - let mut ret = 0.0; - let p = self.peer.as_ref()?; - for conn in p.conns.iter() { - ret += conn.loss_rate; - } - - if ret == 0.0 { - None - } else { - Some(ret as f64) - } - } - - fn get_conn_protos(&self) -> Option> { - let mut ret = vec![]; - let p = self.peer.as_ref()?; - for conn in p.conns.iter() { - let Some(tunnel_info) = &conn.tunnel else { - continue; - }; - // insert if not exists - if !ret.contains(&tunnel_info.tunnel_type) { - ret.push(tunnel_info.tunnel_type.clone()); - } - } - - if ret.is_empty() { - None - } else { - Some(ret) - } - } - - fn get_udp_nat_type(self: &Self) -> String { - let mut ret = NatType::Unknown; - if let Some(r) = &self.route.stun_info { - ret = NatType::try_from(r.udp_nat_type).unwrap(); - } - format!("{:?}", ret) - } -} - struct CommandHandler { addr: String, } @@ -239,19 +141,9 @@ impl CommandHandler { } async fn list_peer_route_pair(&self) -> Result, Error> { - let mut peers = self.list_peers().await?.peer_infos; - let mut routes = self.list_routes().await?.routes; - let mut pairs: Vec = vec![]; - - for route in routes.iter_mut() { - let peer = peers.iter_mut().find(|peer| peer.peer_id == route.peer_id); - pairs.push(PeerRoutePair { - route: route.clone(), - peer: peer.cloned(), - }); - } - - Ok(pairs) + let peers = self.list_peers().await?.peer_infos; + let routes = self.list_routes().await?.routes; + Ok(list_peer_route_pair(peers, routes)) } #[allow(dead_code)] @@ -279,18 +171,6 @@ impl CommandHandler { id: String, } - fn cost_to_str(cost: i32) -> String { - if cost == 1 { - "p2p".to_string() - } else { - format!("relay({})", cost) - } - } - - fn float_to_str(f: f64, precision: usize) -> String { - format!("{:.1$}", f, precision) - } - impl From for PeerTableItem { fn from(p: PeerRoutePair) -> Self { PeerTableItem { diff --git a/easytier/src/instance/instance.rs b/easytier/src/instance/instance.rs index becb66d..317665a 100644 --- a/easytier/src/instance/instance.rs +++ b/easytier/src/instance/instance.rs @@ -35,6 +35,35 @@ use tokio_stream::wrappers::ReceiverStream; use super::listeners::ListenerManager; use super::virtual_nic; +#[derive(Clone)] +struct IpProxy { + tcp_proxy: Arc, + icmp_proxy: Arc, + udp_proxy: Arc, +} + +impl IpProxy { + fn new(global_ctx: ArcGlobalCtx, peer_manager: Arc) -> Result { + let tcp_proxy = TcpProxy::new(global_ctx.clone(), peer_manager.clone()); + let icmp_proxy = IcmpProxy::new(global_ctx.clone(), peer_manager.clone()) + .with_context(|| "create icmp proxy failed")?; + let udp_proxy = UdpProxy::new(global_ctx.clone(), peer_manager.clone()) + .with_context(|| "create udp proxy failed")?; + Ok(IpProxy { + tcp_proxy, + icmp_proxy, + udp_proxy, + }) + } + + async fn start(&self) -> Result<(), Error> { + self.tcp_proxy.start().await?; + self.icmp_proxy.start().await?; + self.udp_proxy.start().await?; + Ok(()) + } +} + pub struct Instance { inst_name: String, @@ -51,9 +80,7 @@ pub struct Instance { direct_conn_manager: Arc, udp_hole_puncher: Arc>, - tcp_proxy: Arc, - icmp_proxy: Arc, - udp_proxy: Arc, + ip_proxy: Option, peer_center: Arc, @@ -97,14 +124,6 @@ impl Instance { let udp_hole_puncher = UdpHolePunchConnector::new(global_ctx.clone(), peer_manager.clone()); - let arc_tcp_proxy = TcpProxy::new(global_ctx.clone(), peer_manager.clone()); - let arc_icmp_proxy = IcmpProxy::new(global_ctx.clone(), peer_manager.clone()) - .with_context(|| "create icmp proxy failed") - .unwrap(); - let arc_udp_proxy = UdpProxy::new(global_ctx.clone(), peer_manager.clone()) - .with_context(|| "create udp proxy failed") - .unwrap(); - let peer_center = Arc::new(PeerCenterInstance::new(peer_manager.clone())); let vpn_portal_inst = vpn_portal::wireguard::WireGuard::default(); @@ -123,9 +142,7 @@ impl Instance { direct_conn_manager: Arc::new(direct_conn_manager), udp_hole_puncher: Arc::new(Mutex::new(udp_hole_puncher)), - tcp_proxy: arc_tcp_proxy, - icmp_proxy: arc_icmp_proxy, - udp_proxy: arc_udp_proxy, + ip_proxy: None, peer_center, @@ -269,9 +286,12 @@ impl Instance { self.run_rpc_server().unwrap(); - self.tcp_proxy.start().await.unwrap(); - self.icmp_proxy.start().await.unwrap(); - self.udp_proxy.start().await.unwrap(); + self.ip_proxy = Some(IpProxy::new( + self.get_global_ctx(), + self.get_peer_manager(), + )?); + self.ip_proxy.as_ref().unwrap().start().await?; + self.run_proxy_cidrs_route_updater(); self.udp_hole_puncher.lock().await.run().await?; @@ -478,4 +498,8 @@ impl Instance { pub fn get_global_ctx(&self) -> ArcGlobalCtx { self.global_ctx.clone() } + + pub fn get_vpn_portal_inst(&self) -> Arc>> { + self.vpn_portal.clone() + } } diff --git a/easytier/src/lib.rs b/easytier/src/lib.rs new file mode 100644 index 0000000..2a38034 --- /dev/null +++ b/easytier/src/lib.rs @@ -0,0 +1,13 @@ +#![allow(dead_code)] + +pub mod arch; +pub mod common; +pub mod connector; +pub mod gateway; +pub mod instance; +pub mod peer_center; +pub mod peers; +pub mod rpc; +pub mod tunnels; +pub mod utils; +pub mod vpn_portal; diff --git a/easytier/src/rpc/peer.rs b/easytier/src/rpc/peer.rs index 394808d..6fbb0da 100644 --- a/easytier/src/rpc/peer.rs +++ b/easytier/src/rpc/peer.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize, Default)] pub struct GetIpListResponse { pub public_ipv4: String, pub interface_ipv4s: Vec, diff --git a/easytier/src/tunnels/common.rs b/easytier/src/tunnels/common.rs index ed38704..56f8908 100644 --- a/easytier/src/tunnels/common.rs +++ b/easytier/src/tunnels/common.rs @@ -6,7 +6,7 @@ use std::{ }; use async_stream::stream; -use futures::{Future, FutureExt, Sink, SinkExt, Stream, StreamExt}; +use futures::{stream::FuturesUnordered, Future, FutureExt, Sink, SinkExt, Stream, StreamExt}; use network_interface::NetworkInterfaceConfig; use tokio::{sync::Mutex, time::error::Elapsed}; @@ -319,6 +319,29 @@ pub(crate) fn setup_sokcet2_ext( Ok(()) } +pub(crate) async fn wait_for_connect_futures( + mut futures: FuturesUnordered, +) -> Result +where + Fut: Future> + Send + Sync, + E: std::error::Error + Into + Send + Sync + 'static, +{ + // return last error + let mut last_err = None; + + while let Some(ret) = futures.next().await { + if let Err(e) = ret { + last_err = Some(e.into()); + } else { + return ret.map_err(|e| e.into()); + } + } + + Err(last_err.unwrap_or(super::TunnelError::CommonError( + "no connect futures".to_string(), + ))) +} + pub(crate) fn setup_sokcet2( socket2_socket: &socket2::Socket, bind_addr: &SocketAddr, diff --git a/easytier/src/tunnels/mod.rs b/easytier/src/tunnels/mod.rs index 6ad25e1..aa2860c 100644 --- a/easytier/src/tunnels/mod.rs +++ b/easytier/src/tunnels/mod.rs @@ -22,7 +22,7 @@ pub enum TunnelError { CommonError(String), #[error("io error")] IOError(#[from] std::io::Error), - #[error("wait resp error")] + #[error("wait resp error {0}")] WaitRespError(String), #[error("Connect Error: {0}")] ConnectError(String), diff --git a/easytier/src/tunnels/tcp_tunnel.rs b/easytier/src/tunnels/tcp_tunnel.rs index a6931a3..88e82b9 100644 --- a/easytier/src/tunnels/tcp_tunnel.rs +++ b/easytier/src/tunnels/tcp_tunnel.rs @@ -1,14 +1,16 @@ use std::net::SocketAddr; use async_trait::async_trait; -use futures::{stream::FuturesUnordered, StreamExt}; +use futures::stream::FuturesUnordered; use tokio::net::{TcpListener, TcpSocket, TcpStream}; use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; use crate::tunnels::common::setup_sokcet2; use super::{ - check_scheme_and_get_socket_addr, common::FramedTunnel, Tunnel, TunnelInfo, TunnelListener, + check_scheme_and_get_socket_addr, + common::{wait_for_connect_futures, FramedTunnel}, + Tunnel, TunnelInfo, TunnelListener, }; #[derive(Debug)] @@ -115,7 +117,7 @@ impl TcpTunnelConnector { } async fn connect_with_custom_bind(&mut self) -> Result, super::TunnelError> { - let mut futures = FuturesUnordered::new(); + let futures = FuturesUnordered::new(); let dst_addr = check_scheme_and_get_socket_addr::(&self.addr, "tcp")?; for bind_addr in self.bind_addrs.iter() { @@ -132,12 +134,7 @@ impl TcpTunnelConnector { futures.push(socket.connect(dst_addr.clone())); } - let Some(ret) = futures.next().await else { - return Err(super::TunnelError::CommonError( - "join connect futures failed".to_owned(), - )); - }; - + let ret = wait_for_connect_futures(futures).await; return get_tunnel_with_tcp_stream(ret?, self.addr.clone().into()); } } @@ -162,7 +159,7 @@ impl super::TunnelConnector for TcpTunnelConnector { #[cfg(test)] mod tests { - use futures::SinkExt; + use futures::{SinkExt, StreamExt}; use crate::tunnels::{ common::tests::{_tunnel_bench, _tunnel_pingpong}, diff --git a/easytier/src/tunnels/udp_tunnel.rs b/easytier/src/tunnels/udp_tunnel.rs index 2656b85..48ff187 100644 --- a/easytier/src/tunnels/udp_tunnel.rs +++ b/easytier/src/tunnels/udp_tunnel.rs @@ -23,7 +23,10 @@ use crate::{ use super::{ codec::BytesCodec, - common::{setup_sokcet2, setup_sokcet2_ext, FramedTunnel, TunnelWithCustomInfo}, + common::{ + setup_sokcet2, setup_sokcet2_ext, wait_for_connect_futures, FramedTunnel, + TunnelWithCustomInfo, + }, ring_tunnel::create_ring_tunnel_pair, DatagramSink, DatagramStream, Tunnel, TunnelListener, TunnelUrl, }; @@ -555,7 +558,7 @@ impl UdpTunnelConnector { } async fn connect_with_custom_bind(&mut self) -> Result, super::TunnelError> { - let mut futures = FuturesUnordered::new(); + let futures = FuturesUnordered::new(); for bind_addr in self.bind_addrs.iter() { let socket2_socket = socket2::Socket::new( @@ -567,14 +570,7 @@ impl UdpTunnelConnector { let socket = UdpSocket::from_std(socket2_socket.into())?; futures.push(self.try_connect_with_socket(socket)); } - - let Some(ret) = futures.next().await else { - return Err(super::TunnelError::CommonError( - "join connect futures failed".to_owned(), - )); - }; - - return ret; + wait_for_connect_futures(futures).await } } diff --git a/easytier/src/tunnels/wireguard.rs b/easytier/src/tunnels/wireguard.rs index 11d750b..b09225c 100644 --- a/easytier/src/tunnels/wireguard.rs +++ b/easytier/src/tunnels/wireguard.rs @@ -27,7 +27,7 @@ use crate::{ use super::{ check_scheme_and_get_socket_addr, - common::{setup_sokcet2, setup_sokcet2_ext}, + common::{setup_sokcet2, setup_sokcet2_ext, wait_for_connect_futures}, ring_tunnel::create_ring_tunnel_pair, DatagramSink, DatagramStream, Tunnel, TunnelError, TunnelListener, TunnelUrl, }; @@ -689,7 +689,7 @@ impl super::TunnelConnector for WgTunnelConnector { } else { self.bind_addrs.clone() }; - let mut futures = FuturesUnordered::new(); + let futures = FuturesUnordered::new(); for bind_addr in bind_addrs.into_iter() { let socket2_socket = socket2::Socket::new( @@ -707,13 +707,7 @@ impl super::TunnelConnector for WgTunnelConnector { )); } - let Some(ret) = futures.next().await else { - return Err(super::TunnelError::CommonError( - "join connect futures failed".to_owned(), - )); - }; - - return ret; + wait_for_connect_futures(futures).await } fn remote_url(&self) -> url::Url { diff --git a/easytier/src/utils.rs b/easytier/src/utils.rs new file mode 100644 index 0000000..b5d4539 --- /dev/null +++ b/easytier/src/utils.rs @@ -0,0 +1,128 @@ +use crate::rpc::cli::{NatType, PeerInfo, Route}; + +#[derive(Debug)] +pub struct PeerRoutePair { + pub route: Route, + pub peer: Option, +} + +impl PeerRoutePair { + pub fn get_latency_ms(&self) -> Option { + let mut ret = u64::MAX; + let p = self.peer.as_ref()?; + for conn in p.conns.iter() { + let Some(stats) = &conn.stats else { + continue; + }; + ret = ret.min(stats.latency_us); + } + + if ret == u64::MAX { + None + } else { + Some(f64::from(ret as u32) / 1000.0) + } + } + + pub fn get_rx_bytes(&self) -> Option { + let mut ret = 0; + let p = self.peer.as_ref()?; + for conn in p.conns.iter() { + let Some(stats) = &conn.stats else { + continue; + }; + ret += stats.rx_bytes; + } + + if ret == 0 { + None + } else { + Some(ret) + } + } + + pub fn get_tx_bytes(&self) -> Option { + let mut ret = 0; + let p = self.peer.as_ref()?; + for conn in p.conns.iter() { + let Some(stats) = &conn.stats else { + continue; + }; + ret += stats.tx_bytes; + } + + if ret == 0 { + None + } else { + Some(ret) + } + } + + pub fn get_loss_rate(&self) -> Option { + let mut ret = 0.0; + let p = self.peer.as_ref()?; + for conn in p.conns.iter() { + ret += conn.loss_rate; + } + + if ret == 0.0 { + None + } else { + Some(ret as f64) + } + } + + pub fn get_conn_protos(&self) -> Option> { + let mut ret = vec![]; + let p = self.peer.as_ref()?; + for conn in p.conns.iter() { + let Some(tunnel_info) = &conn.tunnel else { + continue; + }; + // insert if not exists + if !ret.contains(&tunnel_info.tunnel_type) { + ret.push(tunnel_info.tunnel_type.clone()); + } + } + + if ret.is_empty() { + None + } else { + Some(ret) + } + } + + pub fn get_udp_nat_type(self: &Self) -> String { + let mut ret = NatType::Unknown; + if let Some(r) = &self.route.stun_info { + ret = NatType::try_from(r.udp_nat_type).unwrap(); + } + format!("{:?}", ret) + } +} + +pub fn list_peer_route_pair(peers: Vec, routes: Vec) -> Vec { + let mut pairs: Vec = vec![]; + + for route in routes.iter() { + let peer = peers.iter().find(|peer| peer.peer_id == route.peer_id); + pairs.push(PeerRoutePair { + route: route.clone(), + peer: peer.cloned(), + }); + } + + pairs +} + +pub fn cost_to_str(cost: i32) -> String { + if cost == 1 { + "p2p".to_string() + } else { + format!("relay({})", cost) + } +} + +pub fn float_to_str(f: f64, precision: usize) -> String { + format!("{:.1$}", f, precision) +}