use std::{ hash::Hasher, net::{IpAddr, SocketAddr}, path::PathBuf, sync::{Arc, Mutex}, }; use anyhow::Context; use base64::{Engine as _, prelude::BASE64_STANDARD}; use clap::ValueEnum; use clap::builder::PossibleValue; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString, VariantArray}; use tokio::io::AsyncReadExt as _; use crate::{ common::stun::StunInfoCollector, instance::dns_server::DEFAULT_ET_DNS_ZONE, proto::{ acl::Acl, api::manage::ConfigSource as RpcConfigSource, common::{CompressionAlgoPb, PortForwardConfigPb, SecureModeConfig, SocketType}, }, tunnel::{IpScheme, TunnelScheme, generate_digest_from_str}, }; use super::env_parser; pub type Flags = crate::proto::common::FlagsInConfig; pub fn gen_default_flags() -> Flags { #[allow(deprecated)] Flags { default_protocol: "tcp".to_string(), dev_name: "".to_string(), enable_encryption: true, enable_ipv6: true, mtu: 1380, latency_first: false, enable_exit_node: false, proxy_forward_by_system: false, no_tun: false, use_smoltcp: false, relay_network_whitelist: "*".to_string(), disable_p2p: false, p2p_only: false, lazy_p2p: false, relay_all_peer_rpc: false, disable_tcp_hole_punching: false, disable_udp_hole_punching: false, multi_thread: true, data_compress_algo: CompressionAlgoPb::None.into(), bind_device: true, enable_kcp_proxy: false, disable_kcp_input: false, disable_relay_kcp: false, enable_relay_foreign_network_kcp: false, accept_dns: false, private_mode: false, enable_quic_proxy: false, disable_quic_input: false, disable_relay_quic: false, enable_relay_foreign_network_quic: false, foreign_relay_bps_limit: u64::MAX, multi_thread_count: 2, encryption_algorithm: EncryptionAlgorithm::default().to_string(), disable_sym_hole_punching: false, tld_dns_zone: DEFAULT_ET_DNS_ZONE.to_string(), quic_listen_port: u32::MAX, need_p2p: false, instance_recv_bps_limit: u64::MAX, disable_upnp: false, disable_relay_data: false, } } fn mapped_listener_allows_implicit_port(url: &url::Url) -> bool { TunnelScheme::try_from(url) .ok() .and_then(|scheme| IpScheme::try_from(scheme).ok()) .is_some() } pub fn validate_mapped_listener_url(url: &url::Url) -> Result<(), anyhow::Error> { if url.port().is_none() && !mapped_listener_allows_implicit_port(url) { anyhow::bail!("mapped listener port is missing: {}", url); } Ok(()) } pub fn parse_mapped_listener_urls( mapped_listeners: &[String], ) -> Result, anyhow::Error> { mapped_listeners .iter() .map(|s| { let url: url::Url = s .parse() .with_context(|| format!("mapped listener is not a valid url: {}", s))?; validate_mapped_listener_url(&url)?; Ok(url) }) .collect() } #[derive(Debug, Clone, PartialEq, Eq, Display, EnumString, VariantArray)] #[strum(ascii_case_insensitive)] pub enum EncryptionAlgorithm { #[strum(serialize = "xor")] Xor, #[cfg(any(feature = "aes-gcm", feature = "wireguard", feature = "openssl-crypto"))] #[strum(serialize = "aes-gcm")] AesGcm, #[cfg(any(feature = "aes-gcm", feature = "wireguard", feature = "openssl-crypto"))] #[strum(serialize = "aes-256-gcm")] Aes256Gcm, #[cfg(any(feature = "wireguard", feature = "openssl-crypto"))] #[strum(serialize = "chacha20")] ChaCha20, } impl ValueEnum for EncryptionAlgorithm { fn value_variants<'a>() -> &'a [Self] { Self::VARIANTS } fn from_str(input: &str, _ignore_case: bool) -> Result { input .parse() .map_err(|_| format!("'{}' is not a valid encryption algorithm", input)) } fn to_possible_value(&self) -> Option { Some(PossibleValue::new(self.to_string())) } } #[allow(clippy::derivable_impls)] impl Default for EncryptionAlgorithm { fn default() -> Self { cfg_select! { any(feature = "aes-gcm", feature = "wireguard", feature = "openssl-crypto") => EncryptionAlgorithm::AesGcm, _ => { crate::common::log::warn!("no AEAD encryption algorithm is available, using INSECURE XOR"); EncryptionAlgorithm::Xor } } } } #[auto_impl::auto_impl(Box, &)] pub trait ConfigLoader: Send + Sync { fn get_id(&self) -> uuid::Uuid; fn set_id(&self, id: uuid::Uuid); fn get_hostname(&self) -> String; fn set_hostname(&self, name: Option); fn get_inst_name(&self) -> String; fn set_inst_name(&self, name: String); fn get_netns(&self) -> Option; fn set_netns(&self, ns: Option); fn get_ipv4(&self) -> Option; fn set_ipv4(&self, addr: Option); fn get_ipv6(&self) -> Option; fn set_ipv6(&self, addr: Option); fn get_ipv6_public_addr_provider(&self) -> bool; fn set_ipv6_public_addr_provider(&self, enabled: bool); fn get_ipv6_public_addr_auto(&self) -> bool; fn set_ipv6_public_addr_auto(&self, enabled: bool); fn get_ipv6_public_addr_prefix(&self) -> Option; fn set_ipv6_public_addr_prefix(&self, prefix: Option); fn get_dhcp(&self) -> bool; fn set_dhcp(&self, dhcp: bool); fn add_proxy_cidr( &self, cidr: cidr::Ipv4Cidr, mapped_cidr: Option, ) -> Result<(), anyhow::Error>; fn remove_proxy_cidr(&self, cidr: cidr::Ipv4Cidr); fn clear_proxy_cidrs(&self); fn get_proxy_cidrs(&self) -> Vec; fn get_network_identity(&self) -> NetworkIdentity; fn set_network_identity(&self, identity: NetworkIdentity); fn get_listener_uris(&self) -> Vec; fn get_peers(&self) -> Vec; fn set_peers(&self, peers: Vec); fn get_listeners(&self) -> Option>; fn set_listeners(&self, listeners: Vec); fn get_mapped_listeners(&self) -> Vec; fn set_mapped_listeners(&self, listeners: Option>); fn get_vpn_portal_config(&self) -> Option; fn set_vpn_portal_config(&self, config: VpnPortalConfig); fn get_flags(&self) -> Flags; fn set_flags(&self, flags: Flags); fn get_exit_nodes(&self) -> Vec; fn set_exit_nodes(&self, nodes: Vec); fn get_routes(&self) -> Option>; fn set_routes(&self, routes: Option>); fn get_socks5_portal(&self) -> Option; fn set_socks5_portal(&self, addr: Option); fn get_port_forwards(&self) -> Vec; fn set_port_forwards(&self, forwards: Vec); fn get_acl(&self) -> Option; fn set_acl(&self, acl: Option); fn get_tcp_whitelist(&self) -> Vec; fn set_tcp_whitelist(&self, whitelist: Vec); fn get_udp_whitelist(&self) -> Vec; fn set_udp_whitelist(&self, whitelist: Vec); fn get_stun_servers(&self) -> Option>; fn set_stun_servers(&self, servers: Option>); fn get_stun_servers_v6(&self) -> Option>; fn set_stun_servers_v6(&self, servers: Option>); fn get_secure_mode(&self) -> Option; fn set_secure_mode(&self, secure_mode: Option); fn get_credential_file(&self) -> Option { None } fn set_credential_file(&self, _path: Option) {} fn get_network_config_source(&self) -> ConfigSource { ConfigSource::User } fn set_network_config_source(&self, _source: Option) {} fn dump(&self) -> String; } pub trait LoggingConfigLoader { fn get_file_logger_config(&self) -> FileLoggerConfig; fn get_console_logger_config(&self) -> ConsoleLoggerConfig; } pub type NetworkSecretDigest = [u8; 32]; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct NetworkIdentity { pub network_name: String, pub network_secret: Option, #[serde(skip)] pub network_secret_digest: Option, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ConfigSource { #[default] User, Webhook, } impl ConfigSource { pub fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Webhook => "webhook", } } pub fn from_rpc(source: i32) -> Option { match RpcConfigSource::try_from(source).ok() { Some(RpcConfigSource::Webhook) => Some(Self::Webhook), Some(RpcConfigSource::User) => Some(Self::User), _ => None, } } pub fn to_rpc(self) -> i32 { match self { Self::User => RpcConfigSource::User as i32, Self::Webhook => RpcConfigSource::Webhook as i32, } } } impl std::str::FromStr for ConfigSource { type Err = String; fn from_str(s: &str) -> Result { match s { "user" => Ok(Self::User), "webhook" => Ok(Self::Webhook), other => Err(format!("unknown network config source: {other}")), } } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] struct ConfigSourceConfig { source: ConfigSource, } #[derive(Eq, PartialEq, Hash)] struct NetworkIdentityWithOnlyDigest { network_name: String, network_secret_digest: Option, } impl From for NetworkIdentityWithOnlyDigest { fn from(identity: NetworkIdentity) -> Self { if identity.network_secret_digest.is_some() { Self { network_name: identity.network_name, network_secret_digest: identity.network_secret_digest, } } else if identity.network_secret.is_some() { let mut network_secret_digest = [0u8; 32]; generate_digest_from_str( &identity.network_name, identity.network_secret.as_ref().unwrap(), &mut network_secret_digest, ); Self { network_name: identity.network_name, network_secret_digest: Some(network_secret_digest), } } else { Self { network_name: identity.network_name, network_secret_digest: None, } } } } impl PartialEq for NetworkIdentity { fn eq(&self, other: &Self) -> bool { let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone()); let other_with_digest = NetworkIdentityWithOnlyDigest::from(other.clone()); self_with_digest == other_with_digest } } impl Eq for NetworkIdentity {} impl std::hash::Hash for NetworkIdentity { fn hash(&self, state: &mut H) { let self_with_digest = NetworkIdentityWithOnlyDigest::from(self.clone()); self_with_digest.hash(state); } } impl NetworkIdentity { pub fn new(network_name: String, network_secret: String) -> Self { let mut network_secret_digest = [0u8; 32]; generate_digest_from_str(&network_name, &network_secret, &mut network_secret_digest); NetworkIdentity { network_name, network_secret: Some(network_secret), network_secret_digest: Some(network_secret_digest), } } /// Create a NetworkIdentity for a credential node (no network_secret). /// The node identifies by network_name only and authenticates via credential keypair. pub fn new_credential(network_name: String) -> Self { NetworkIdentity { network_name, network_secret: None, network_secret_digest: None, } } } impl Default for NetworkIdentity { fn default() -> Self { Self::new("default".to_string(), "".to_string()) } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct PeerConfig { pub uri: url::Url, pub peer_public_key: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct ProxyNetworkConfig { pub cidr: cidr::Ipv4Cidr, // the CIDR of the proxy network pub mapped_cidr: Option, // allow remap the proxy CIDR to another CIDR pub allow: Option>, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)] pub struct FileLoggerConfig { pub level: Option, pub file: Option, pub dir: Option, pub size_mb: Option, pub count: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Default)] pub struct ConsoleLoggerConfig { pub level: Option, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, derive_builder::Builder)] pub struct LoggingConfig { #[builder(setter(into, strip_option), default = None)] pub file_logger: Option, #[builder(setter(into, strip_option), default = None)] pub console_logger: Option, } impl LoggingConfigLoader for &LoggingConfig { fn get_file_logger_config(&self) -> FileLoggerConfig { self.file_logger.clone().unwrap_or_default() } fn get_console_logger_config(&self) -> ConsoleLoggerConfig { self.console_logger.clone().unwrap_or_default() } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub struct VpnPortalConfig { pub client_cidr: cidr::Ipv4Cidr, pub wireguard_listen: SocketAddr, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] pub struct PortForwardConfig { pub bind_addr: SocketAddr, pub dst_addr: SocketAddr, pub proto: String, } impl From for PortForwardConfig { fn from(config: PortForwardConfigPb) -> Self { PortForwardConfig { bind_addr: config.bind_addr.unwrap_or_default().into(), dst_addr: config.dst_addr.unwrap_or_default().into(), proto: match SocketType::try_from(config.socket_type) { Ok(SocketType::Tcp) => "tcp".to_string(), Ok(SocketType::Udp) => "udp".to_string(), _ => "tcp".to_string(), }, } } } impl From for PortForwardConfigPb { fn from(val: PortForwardConfig) -> Self { PortForwardConfigPb { bind_addr: Some(val.bind_addr.into()), dst_addr: Some(val.dst_addr.into()), socket_type: match val.proto.to_lowercase().as_str() { "tcp" => SocketType::Tcp as i32, "udp" => SocketType::Udp as i32, _ => SocketType::Tcp as i32, }, } } } pub fn process_secure_mode_cfg(mut user_cfg: SecureModeConfig) -> anyhow::Result { if !user_cfg.enabled { return Ok(user_cfg); } let private_key = if user_cfg.local_private_key.is_none() { // if no private key, generate random one let private = x25519_dalek::StaticSecret::random_from_rng(rand::rngs::OsRng); user_cfg.local_private_key = Some(BASE64_STANDARD.encode(private.clone().as_bytes())); private } else { // check if private key is valid user_cfg.private_key()? }; let public = x25519_dalek::PublicKey::from(&private_key); match user_cfg.local_public_key { None => { user_cfg.local_public_key = Some(BASE64_STANDARD.encode(public.as_bytes())); } Some(ref user_pub) => { let public = user_cfg.public_key()?; if *user_pub != BASE64_STANDARD.encode(public.as_bytes()) { return Err(anyhow::anyhow!( "local public key {} does not match generated public key {}", user_pub, BASE64_STANDARD.encode(public.as_bytes()) )); } } } Ok(user_cfg) } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] struct Config { netns: Option, hostname: Option, instance_name: Option, instance_id: Option, ipv4: Option, ipv6: Option, ipv6_public_addr_provider: Option, ipv6_public_addr_auto: Option, ipv6_public_addr_prefix: Option, dhcp: Option, network_identity: Option, listeners: Option>, mapped_listeners: Option>, exit_nodes: Option>, peer: Option>, proxy_network: Option>, vpn_portal_config: Option, routes: Option>, socks5_proxy: Option, port_forward: Option>, secure_mode: Option, flags: Option>, #[serde(skip)] flags_struct: Option, acl: Option, tcp_whitelist: Option>, udp_whitelist: Option>, stun_servers: Option>, stun_servers_v6: Option>, credential_file: Option, source: Option, } #[derive(Debug, Clone)] pub struct TomlConfigLoader { config: Arc>, } impl Default for TomlConfigLoader { fn default() -> Self { TomlConfigLoader::new_from_str("").unwrap() } } impl TomlConfigLoader { fn normalize_config_source(config: &mut Config) { if matches!( config.source.as_ref().map(|source| source.source), Some(ConfigSource::User) ) { config.source = None; } } pub fn new_from_str(config_str: &str) -> Result { let mut config = toml::de::from_str::(config_str) .with_context(|| format!("failed to parse config file: {}", config_str))?; Self::normalize_config_source(&mut config); config.flags_struct = Some(Self::gen_flags(config.flags.clone().unwrap_or_default())); let config = TomlConfigLoader { config: Arc::new(Mutex::new(config)), }; let old_ns = config.get_network_identity(); config.set_network_identity(NetworkIdentity::new( old_ns.network_name, old_ns.network_secret.unwrap_or_default(), )); Ok(config) } pub fn new(config_path: &PathBuf) -> Result { let config_str = std::fs::read_to_string(config_path) .with_context(|| format!("failed to read config file: {:?}", config_path))?; let ret = Self::new_from_str(&config_str)?; Ok(ret) } fn gen_flags(mut flags_hashmap: serde_json::Map) -> Flags { let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap(); let default_flags_hashmap = serde_json::from_str::>(&default_flags_json) .unwrap(); let mut merged_hashmap = serde_json::Map::new(); for (key, value) in default_flags_hashmap { if let Some(v) = flags_hashmap.remove(&key) { merged_hashmap.insert(key, v); } else { merged_hashmap.insert(key, value); } } serde_json::from_value(serde_json::Value::Object(merged_hashmap)).unwrap() } } impl ConfigLoader for TomlConfigLoader { fn get_inst_name(&self) -> String { self.config .lock() .unwrap() .instance_name .clone() .unwrap_or("default".to_string()) } fn set_inst_name(&self, name: String) { self.config.lock().unwrap().instance_name = Some(name); } fn get_hostname(&self) -> String { let hostname = self.config.lock().unwrap().hostname.clone(); match hostname { Some(hostname) => { let hostname = hostname .chars() .filter(|c| !c.is_control()) .take(32) .collect::(); if !hostname.is_empty() { self.set_hostname(Some(hostname.clone())); hostname } else { self.set_hostname(None); gethostname::gethostname().to_string_lossy().to_string() } } None => gethostname::gethostname().to_string_lossy().to_string(), } } fn set_hostname(&self, name: Option) { self.config.lock().unwrap().hostname = name; } fn get_netns(&self) -> Option { self.config.lock().unwrap().netns.clone() } fn set_netns(&self, ns: Option) { self.config.lock().unwrap().netns = ns; } fn get_ipv4(&self) -> Option { let locked_config = self.config.lock().unwrap(); locked_config .ipv4 .as_ref() .and_then(|s| s.parse().ok()) .map(|c: cidr::Ipv4Inet| { if c.network_length() == 32 { cidr::Ipv4Inet::new(c.address(), 24).unwrap() } else { c } }) } fn set_ipv4(&self, addr: Option) { self.config.lock().unwrap().ipv4 = addr.map(|addr| addr.to_string()); } fn get_ipv6(&self) -> Option { let locked_config = self.config.lock().unwrap(); locked_config.ipv6.as_ref().and_then(|s| s.parse().ok()) } fn set_ipv6(&self, addr: Option) { self.config.lock().unwrap().ipv6 = addr.map(|addr| addr.to_string()); } fn get_ipv6_public_addr_provider(&self) -> bool { self.config .lock() .unwrap() .ipv6_public_addr_provider .unwrap_or_default() } fn set_ipv6_public_addr_provider(&self, enabled: bool) { self.config.lock().unwrap().ipv6_public_addr_provider = Some(enabled); } fn get_ipv6_public_addr_auto(&self) -> bool { self.config .lock() .unwrap() .ipv6_public_addr_auto .unwrap_or_default() } fn set_ipv6_public_addr_auto(&self, enabled: bool) { self.config.lock().unwrap().ipv6_public_addr_auto = Some(enabled); } fn get_ipv6_public_addr_prefix(&self) -> Option { let locked_config = self.config.lock().unwrap(); locked_config .ipv6_public_addr_prefix .as_ref() .and_then(|s| s.parse().ok()) } fn set_ipv6_public_addr_prefix(&self, prefix: Option) { self.config.lock().unwrap().ipv6_public_addr_prefix = prefix.map(|prefix| prefix.to_string()); } fn get_dhcp(&self) -> bool { self.config.lock().unwrap().dhcp.unwrap_or_default() } fn set_dhcp(&self, dhcp: bool) { self.config.lock().unwrap().dhcp = Some(dhcp); } fn add_proxy_cidr( &self, cidr: cidr::Ipv4Cidr, mapped_cidr: Option, ) -> Result<(), anyhow::Error> { let mut locked_config = self.config.lock().unwrap(); if locked_config.proxy_network.is_none() { locked_config.proxy_network = Some(vec![]); } if let Some(mapped_cidr) = mapped_cidr.as_ref() && cidr.network_length() != mapped_cidr.network_length() { return Err(anyhow::anyhow!( "Mapped CIDR must have the same network length as the original CIDR: {} != {}", cidr.network_length(), mapped_cidr.network_length() )); } // insert if no duplicate if !locked_config .proxy_network .as_ref() .unwrap() .iter() .any(|c| c.cidr == cidr && c.mapped_cidr == mapped_cidr) { locked_config .proxy_network .as_mut() .unwrap() .push(ProxyNetworkConfig { cidr, mapped_cidr, allow: None, }); } Ok(()) } fn remove_proxy_cidr(&self, cidr: cidr::Ipv4Cidr) { let mut locked_config = self.config.lock().unwrap(); if let Some(proxy_cidrs) = &mut locked_config.proxy_network { proxy_cidrs.retain(|c| c.cidr != cidr); } } fn clear_proxy_cidrs(&self) { let mut locked_config = self.config.lock().unwrap(); locked_config.proxy_network = None; } fn get_proxy_cidrs(&self) -> Vec { self.config .lock() .unwrap() .proxy_network .as_ref() .cloned() .unwrap_or_default() } fn get_id(&self) -> uuid::Uuid { let mut locked_config = self.config.lock().unwrap(); match locked_config.instance_id { Some(id) => id, None => { let id = uuid::Uuid::new_v4(); locked_config.instance_id = Some(id); id } } } fn set_id(&self, id: uuid::Uuid) { self.config.lock().unwrap().instance_id = Some(id); } fn get_network_identity(&self) -> NetworkIdentity { self.config .lock() .unwrap() .network_identity .clone() .unwrap_or_default() } fn set_network_identity(&self, identity: NetworkIdentity) { self.config.lock().unwrap().network_identity = Some(identity); } fn get_listener_uris(&self) -> Vec { self.config .lock() .unwrap() .listeners .clone() .unwrap_or_default() } fn get_peers(&self) -> Vec { self.config.lock().unwrap().peer.clone().unwrap_or_default() } fn set_peers(&self, peers: Vec) { self.config.lock().unwrap().peer = Some(peers); } fn get_listeners(&self) -> Option> { self.config.lock().unwrap().listeners.clone() } fn set_listeners(&self, listeners: Vec) { self.config.lock().unwrap().listeners = Some(listeners); } fn get_mapped_listeners(&self) -> Vec { self.config .lock() .unwrap() .mapped_listeners .clone() .unwrap_or_default() } fn set_mapped_listeners(&self, listeners: Option>) { self.config.lock().unwrap().mapped_listeners = listeners; } fn get_vpn_portal_config(&self) -> Option { self.config.lock().unwrap().vpn_portal_config.clone() } fn set_vpn_portal_config(&self, config: VpnPortalConfig) { self.config.lock().unwrap().vpn_portal_config = Some(config); } fn get_flags(&self) -> Flags { self.config .lock() .unwrap() .flags_struct .clone() .unwrap_or_default() } fn set_flags(&self, flags: Flags) { self.config.lock().unwrap().flags_struct = Some(flags); } fn get_exit_nodes(&self) -> Vec { self.config .lock() .unwrap() .exit_nodes .clone() .unwrap_or_default() } fn set_exit_nodes(&self, nodes: Vec) { self.config.lock().unwrap().exit_nodes = Some(nodes); } fn get_routes(&self) -> Option> { self.config.lock().unwrap().routes.clone() } fn set_routes(&self, routes: Option>) { self.config.lock().unwrap().routes = routes; } fn get_socks5_portal(&self) -> Option { self.config.lock().unwrap().socks5_proxy.clone() } fn set_socks5_portal(&self, addr: Option) { self.config.lock().unwrap().socks5_proxy = addr; } fn get_port_forwards(&self) -> Vec { self.config .lock() .unwrap() .port_forward .clone() .unwrap_or_default() } fn set_port_forwards(&self, forwards: Vec) { self.config.lock().unwrap().port_forward = Some(forwards); } fn get_acl(&self) -> Option { self.config.lock().unwrap().acl.clone() } fn set_acl(&self, acl: Option) { self.config.lock().unwrap().acl = acl; } fn get_tcp_whitelist(&self) -> Vec { self.config .lock() .unwrap() .tcp_whitelist .clone() .unwrap_or_default() } fn set_tcp_whitelist(&self, whitelist: Vec) { self.config.lock().unwrap().tcp_whitelist = Some(whitelist); } fn get_udp_whitelist(&self) -> Vec { self.config .lock() .unwrap() .udp_whitelist .clone() .unwrap_or_default() } fn set_udp_whitelist(&self, whitelist: Vec) { self.config.lock().unwrap().udp_whitelist = Some(whitelist); } fn get_stun_servers(&self) -> Option> { self.config.lock().unwrap().stun_servers.clone() } fn set_stun_servers(&self, servers: Option>) { self.config.lock().unwrap().stun_servers = servers; } fn get_stun_servers_v6(&self) -> Option> { self.config.lock().unwrap().stun_servers_v6.clone() } fn set_stun_servers_v6(&self, servers: Option>) { self.config.lock().unwrap().stun_servers_v6 = servers; } fn get_secure_mode(&self) -> Option { self.config.lock().unwrap().secure_mode.clone() } fn set_secure_mode(&self, secure_mode: Option) { self.config.lock().unwrap().secure_mode = secure_mode; } fn get_credential_file(&self) -> Option { self.config.lock().unwrap().credential_file.clone() } fn set_credential_file(&self, path: Option) { self.config.lock().unwrap().credential_file = path; } fn get_network_config_source(&self) -> ConfigSource { self.config .lock() .unwrap() .source .as_ref() .map(|source| source.source) .unwrap_or(ConfigSource::User) } fn set_network_config_source(&self, source: Option) { self.config.lock().unwrap().source = source.and_then(|source| match source { ConfigSource::User => None, other => Some(ConfigSourceConfig { source: other }), }); } fn dump(&self) -> String { let default_flags_json = serde_json::to_string(&gen_default_flags()).unwrap(); let default_flags_hashmap = serde_json::from_str::>(&default_flags_json) .unwrap(); let cur_flags_json = serde_json::to_string(&self.get_flags()).unwrap(); let cur_flags_hashmap = serde_json::from_str::>(&cur_flags_json) .unwrap(); let mut flag_map: serde_json::Map = Default::default(); for (key, value) in default_flags_hashmap { if let Some(v) = cur_flags_hashmap.get(&key) && *v != value { flag_map.insert(key, v.clone()); } } let mut config = self.config.lock().unwrap().clone(); Self::normalize_config_source(&mut config); config.flags = Some(flag_map); if config.stun_servers == Some(StunInfoCollector::get_default_servers()) { config.stun_servers = None; } if config.stun_servers_v6 == Some(StunInfoCollector::get_default_servers_v6()) { config.stun_servers_v6 = None; } toml::to_string_pretty(&config).unwrap() } } #[derive(Clone, Copy, Default)] pub struct ConfigFilePermission(u8); impl ConfigFilePermission { pub const READ_ONLY: u8 = 1 << 0; pub const NO_DELETE: u8 = 1 << 1; pub fn with_flag(self, flag: u8) -> Self { Self(self.0 | flag) } pub fn remove_flag(self, flag: u8) -> Self { Self(self.0 & !flag) } pub fn has_flag(&self, flag: u8) -> bool { (self.0 & flag) != 0 } } impl From for ConfigFilePermission { fn from(value: u8) -> Self { ConfigFilePermission(value) } } impl From for ConfigFilePermission { fn from(value: u32) -> Self { ConfigFilePermission(value as u8) } } impl From for u8 { fn from(value: ConfigFilePermission) -> Self { value.0 } } impl From for u32 { fn from(value: ConfigFilePermission) -> Self { value.0 as u32 } } impl std::fmt::Debug for ConfigFilePermission { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut flags = vec![]; if self.has_flag(ConfigFilePermission::READ_ONLY) { flags.push("READ_ONLY"); } else { flags.push("EDITABLE"); } if self.has_flag(ConfigFilePermission::NO_DELETE) { flags.push("NO_DELETE"); } else { flags.push("DELETABLE"); } write!(f, "{}", flags.join("|")) } } #[derive(Debug, Clone)] pub struct ConfigFileControl { pub path: Option, pub permission: ConfigFilePermission, } impl ConfigFileControl { pub const STATIC_CONFIG: ConfigFileControl = Self { path: None, permission: ConfigFilePermission( ConfigFilePermission::READ_ONLY | ConfigFilePermission::NO_DELETE, ), }; pub fn new(path: Option, permission: ConfigFilePermission) -> Self { ConfigFileControl { path, permission } } pub async fn from_path(path: PathBuf) -> Self { let read_only = if let Ok(metadata) = tokio::fs::metadata(&path).await { metadata.permissions().readonly() } else { true }; Self::new( Some(path), if read_only { ConfigFilePermission(ConfigFilePermission::READ_ONLY) } else { ConfigFilePermission(0) }, ) } pub fn is_read_only(&self) -> bool { self.permission.has_flag(ConfigFilePermission::READ_ONLY) } pub fn set_read_only(&mut self, read_only: bool) { if read_only { self.permission = self.permission.with_flag(ConfigFilePermission::READ_ONLY); } else { self.permission = self.permission.remove_flag(ConfigFilePermission::READ_ONLY); } } pub fn is_no_delete(&self) -> bool { self.permission.has_flag(ConfigFilePermission::NO_DELETE) } pub fn set_no_delete(&mut self, no_delete: bool) { if no_delete { self.permission = self.permission.with_flag(ConfigFilePermission::NO_DELETE); } else { self.permission = self.permission.remove_flag(ConfigFilePermission::NO_DELETE); } } pub fn is_deletable(&self) -> bool { !self.is_no_delete() } } pub async fn load_config_from_file( config_file: &PathBuf, config_dir: Option<&PathBuf>, disable_env_parsing: bool, ) -> Result<(TomlConfigLoader, ConfigFileControl), anyhow::Error> { if config_file.as_os_str() == "-" { let mut stdin = String::new(); _ = tokio::io::stdin() .read_to_string(&mut stdin) .await .context("failed to read config from stdin")?; let config = TomlConfigLoader::new_from_str(&stdin)?; return Ok((config, ConfigFileControl::STATIC_CONFIG)); } let config_str = tokio::fs::read_to_string(config_file) .await .with_context(|| format!("failed to read config file: {:?}", config_file))?; let (expanded_config_str, uses_env_vars) = if disable_env_parsing { (config_str.clone(), false) } else { env_parser::expand_env_vars(&config_str) }; if disable_env_parsing { tracing::info!( "Environment variable parsing is disabled for config file: {:?}", config_file ); } if uses_env_vars { tracing::info!( "Environment variables detected and expanded in config file: {:?}", config_file ); } let config = TomlConfigLoader::new_from_str(&expanded_config_str) .with_context(|| format!("failed to load config file: {:?}", config_file))?; let mut control = ConfigFileControl::from_path(config_file.clone()).await; if uses_env_vars { control.set_read_only(true); control.set_no_delete(true); tracing::info!( "Config file {:?} uses environment variables, marked as READ_ONLY and NO_DELETE", config_file ); } else if control.is_read_only() { control.set_no_delete(true); } else if let Some(config_dir) = config_dir { if let Some(config_file_dir) = config_file.parent() { // if the config file is in the config dir and named as the instance id, it can be saved remotely if config_file_dir == config_dir && config_file.file_stem() == Some(config.get_id().to_string().as_ref()) && config_file.extension() == Some(std::ffi::OsStr::new("toml")) { control.set_no_delete(false); } else { control.set_no_delete(true); } } } else { control.set_no_delete(true); } Ok((config, control)) } #[cfg(test)] pub mod tests { use super::*; use crate::tests::{remove_env_var, set_env_var}; use std::io::Write; use std::path::PathBuf; use tempfile::NamedTempFile; #[test] fn test_stun_servers_config() { let config = TomlConfigLoader::default(); let stun_servers = config.get_stun_servers(); assert!(stun_servers.is_none()); // Test setting custom stun servers let custom_servers = vec!["txt:stun.easytier.cn".to_string()]; config.set_stun_servers(Some(custom_servers.clone())); let retrieved_servers = config.get_stun_servers(); assert_eq!(retrieved_servers.unwrap(), custom_servers); } #[test] fn test_stun_servers_toml_parsing() { let config_str = r#" instance_name = "test" stun_servers = [ "stun.l.google.com:19302", "stun1.l.google.com:19302", "txt:stun.easytier.cn" ]"#; let config = TomlConfigLoader::new_from_str(config_str).unwrap(); let stun_servers = config.get_stun_servers().unwrap(); assert_eq!(stun_servers.len(), 3); assert_eq!(stun_servers[0], "stun.l.google.com:19302"); assert_eq!(stun_servers[1], "stun1.l.google.com:19302"); assert_eq!(stun_servers[2], "txt:stun.easytier.cn"); } #[test] fn test_network_config_source_toml_roundtrip() { let config = TomlConfigLoader::default(); assert_eq!(config.get_network_config_source(), ConfigSource::User); config.set_network_config_source(Some(ConfigSource::Webhook)); let dumped = config.dump(); assert!(dumped.contains("[source]")); assert!(dumped.contains("source = \"webhook\"")); let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); assert_eq!(loaded.get_network_config_source(), ConfigSource::Webhook); } #[test] fn test_parse_mapped_listener_urls_allows_ws_without_port() { let parsed = parse_mapped_listener_urls(&[ "ws://example.com".to_string(), "wss://example.com/path".to_string(), ]) .unwrap(); assert_eq!(parsed.len(), 2); assert_eq!(parsed[0].scheme(), "ws"); assert_eq!(parsed[0].port(), None); assert_eq!(parsed[1].scheme(), "wss"); assert_eq!(parsed[1].port(), None); } #[test] fn test_parse_mapped_listener_urls_allows_tcp_without_port() { let parsed = parse_mapped_listener_urls(&["tcp://127.0.0.1".to_string()]).unwrap(); assert_eq!(parsed.len(), 1); assert_eq!(parsed[0].scheme(), "tcp"); assert_eq!(parsed[0].port(), None); } #[test] fn test_parse_mapped_listener_urls_requires_port_for_non_ip_scheme() { let err = parse_mapped_listener_urls(&["ring://peer-id".to_string()]).unwrap_err(); assert!(err.to_string().contains("mapped listener port is missing")); } #[test] fn test_acl_toml_rule_uses_defaults_for_omitted_fields() { use crate::proto::acl::{Action, ChainType, Protocol}; let config_str = r#" [[acl.acl_v1.chains]] name = "subnet_proxy_protect" chain_type = 3 enabled = true default_action = 2 [[acl.acl_v1.chains.rules]] name = "allow_my_devices" priority = 1000 action = 1 source_ips = ["10.172.192.2/32"] protocol = 5 enabled = true "#; let config = TomlConfigLoader::new_from_str(config_str).unwrap(); let acl = config.get_acl().unwrap(); let acl_v1 = acl.acl_v1.unwrap(); let chain = &acl_v1.chains[0]; let rule = &chain.rules[0]; assert_eq!(chain.chain_type, ChainType::Forward as i32); assert_eq!(chain.default_action, Action::Drop as i32); assert_eq!(rule.action, Action::Allow as i32); assert_eq!(rule.protocol, Protocol::Any as i32); assert_eq!(rule.source_ips, vec!["10.172.192.2/32"]); assert!(rule.ports.is_empty()); assert!(rule.source_ports.is_empty()); assert!(rule.destination_ips.is_empty()); assert!(rule.source_groups.is_empty()); assert!(rule.destination_groups.is_empty()); assert_eq!(rule.rate_limit, 0); assert_eq!(rule.burst_limit, 0); assert!(!rule.stateful); } #[test] fn test_acl_toml_group_can_omit_declares_or_members() { let declares_only = r#" [acl.acl_v1.group] [[acl.acl_v1.group.declares]] group_name = "admin" group_secret = "admin-pw" "#; let config = TomlConfigLoader::new_from_str(declares_only).unwrap(); let group = config.get_acl().unwrap().acl_v1.unwrap().group.unwrap(); assert_eq!(group.declares.len(), 1); assert!(group.members.is_empty()); let members_only = r#" [acl.acl_v1.group] members = ["admin"] "#; let config = TomlConfigLoader::new_from_str(members_only).unwrap(); let group = config.get_acl().unwrap().acl_v1.unwrap().group.unwrap(); assert!(group.declares.is_empty()); assert_eq!(group.members, vec!["admin"]); } #[test] fn test_network_config_source_user_is_implicit() { let config = TomlConfigLoader::default(); config.set_network_config_source(Some(ConfigSource::User)); let dumped = config.dump(); assert!(!dumped.contains("[source]")); let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); assert_eq!(loaded.get_network_config_source(), ConfigSource::User); let explicit_user = TomlConfigLoader::new_from_str( r#" [source] source = "user" "#, ) .unwrap(); assert_eq!( explicit_user.get_network_config_source(), ConfigSource::User ); assert!(!explicit_user.dump().contains("[source]")); } #[test] fn test_ipv6_public_addr_config_roundtrip() { let config = TomlConfigLoader::default(); let prefix: cidr::Ipv6Cidr = "2001:db8:100::/64".parse().unwrap(); config.set_ipv6_public_addr_provider(true); config.set_ipv6_public_addr_auto(true); config.set_ipv6_public_addr_prefix(Some(prefix)); assert!(config.get_ipv6_public_addr_provider()); assert!(config.get_ipv6_public_addr_auto()); assert_eq!(config.get_ipv6_public_addr_prefix(), Some(prefix)); let dumped = config.dump(); let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); assert!(loaded.get_ipv6_public_addr_provider()); assert!(loaded.get_ipv6_public_addr_auto()); assert_eq!(loaded.get_ipv6_public_addr_prefix(), Some(prefix)); } #[tokio::test] async fn full_example_test() { let config_str = r#" instance_name = "default" instance_id = "87ede5a2-9c3d-492d-9bbe-989b9d07e742" ipv4 = "10.144.144.10" listeners = [ "tcp://0.0.0.0:11010", "udp://0.0.0.0:11010" ] routes = [ "192.168.0.0/16" ] [network_identity] network_name = "default" network_secret = "" [[peer]] uri = "tcp://public.kkrainbow.top:11010" [[peer]] uri = "udp://192.168.94.33:11010" [[proxy_network]] cidr = "10.147.223.0/24" allow = ["tcp", "udp", "icmp"] [[proxy_network]] cidr = "10.1.1.0/24" allow = ["tcp", "icmp"] [file_logger] level = "info" file = "easytier" dir = "/tmp/easytier" [console_logger] level = "warn" [[port_forward]] bind_addr = "0.0.0.0:11011" dst_addr = "192.168.94.33:11011" proto = "tcp" "#; let ret = TomlConfigLoader::new_from_str(config_str); if let Err(e) = &ret { println!("{}", e); } else { println!("{:?}", ret.as_ref().unwrap()); } assert!(ret.is_ok()); let ret = ret.unwrap(); assert_eq!("10.144.144.10/24", ret.get_ipv4().unwrap().to_string()); assert_eq!( vec!["tcp://0.0.0.0:11010", "udp://0.0.0.0:11010"], ret.get_listener_uris() .iter() .map(|u| u.to_string()) .collect::>() ); assert_eq!( vec![PortForwardConfig { bind_addr: "0.0.0.0:11011".parse().unwrap(), dst_addr: "192.168.94.33:11011".parse().unwrap(), proto: "tcp".to_string(), }], ret.get_port_forwards() ); println!("{}", ret.dump()); } /// 配置文件环境变量解析功能的集成测试 /// /// 测试范围: /// 1. 配置加载功能测试(环境变量替换、权限标记) /// 2. RPC API 安全测试(只读配置保护) /// 3. CLI 参数测试(--disable-env-parsing 开关) /// 4. 多实例隔离测试 /// 5. 实际配置字段测试(network_secret、peer.uri 等) /// 配置加载功能测试(环境变量替换、权限标记) /// /// 验证: /// - 环境变量能正确替换到配置中 /// - 包含环境变量的配置文件自动标记为只读和禁止删除 #[tokio::test] async fn test_env_var_expansion_and_readonly_flag() { // 设置测试环境变量 set_env_var("TEST_SECRET", "my-test-secret-123"); set_env_var("TEST_NETWORK", "test-network"); // 创建临时配置文件,包含环境变量占位符 let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "test-instance" [network_identity] network_name = "${TEST_NETWORK}" network_secret = "${TEST_SECRET}" "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); // 加载配置(启用环境变量解析) let (config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证环境变量已被替换 let network_identity = config.get_network_identity(); assert_eq!(network_identity.network_name, "test-network"); assert_eq!( network_identity.network_secret.as_ref().unwrap(), "my-test-secret-123" ); // 验证权限标记:包含环境变量的配置应被标记为只读和禁止删除 assert!( control.is_read_only(), "Config with env vars should be marked as READ_ONLY" ); assert!( control.is_no_delete(), "Config with env vars should be marked as NO_DELETE" ); // 清理环境变量 remove_env_var("TEST_SECRET"); remove_env_var("TEST_NETWORK"); } /// RPC API 安全测试(只读配置保护) /// /// 验证: /// - 只读配置不会通过 RPC API 暴露给远程调用 /// - 这需要测试 get_network_instance_config 拒绝返回只读配置 /// /// 注:这个测试验证权限标记的正确设置,实际的 RPC API 保护已在 /// `easytier/src/rpc_service/instance_manage.rs` 中实现 #[tokio::test] async fn test_readonly_config_api_protection() { set_env_var("API_TEST_SECRET", "secret-value"); // 创建包含环境变量的配置 let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "api-test" [network_identity] network_name = "api-network" network_secret = "${API_TEST_SECRET}" "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); // 加载配置 let (_config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证只读标记已设置(这是 RPC API 保护的前提) assert!( control.is_read_only(), "Config should be marked as READ_ONLY for RPC protection" ); assert!( control.permission.has_flag(ConfigFilePermission::READ_ONLY), "Permission flag should be set correctly" ); remove_env_var("API_TEST_SECRET"); } /// CLI 参数测试(--disable-env-parsing 开关) /// /// 验证: /// - disable_env_parsing = true 时,环境变量不会被替换 /// - 配置不会被标记为只读 #[tokio::test] async fn test_disable_env_parsing_flag() { set_env_var("DISABLED_TEST_VAR", "should-not-expand"); // 创建包含环境变量占位符的配置 let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "disable-test" [network_identity] network_name = "test" network_secret = "${DISABLED_TEST_VAR}" "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); // 以 disable_env_parsing = true 加载配置 let (config, control) = load_config_from_file(&config_path, None, true) .await .unwrap(); // 验证环境变量未被替换(保持原样) let network_identity = config.get_network_identity(); assert_eq!( network_identity.network_secret.as_ref().unwrap(), "${DISABLED_TEST_VAR}", "Env var should not be expanded when parsing is disabled" ); // 验证配置不因环境变量而被标记为只读 // 注:文件系统权限可能使其只读,但不应因环境变量而只读 // 这里我们主要验证 NO_DELETE 标记的逻辑 // 由于没有 config_dir,文件会被标记为 NO_DELETE,但不是因为环境变量 assert!( control.is_no_delete(), "Config should be NO_DELETE due to no config_dir, not env vars" ); remove_env_var("DISABLED_TEST_VAR"); } /// 多实例隔离测试 /// /// 验证: /// - 不同实例可以使用不同的环境变量值 /// - 环境变量在运行时被解析,支持动态切换 #[tokio::test] async fn test_multiple_instances_with_different_env_vars() { // 实例1:使用第一组环境变量 set_env_var("INSTANCE_SECRET", "instance1-secret"); set_env_var("INSTANCE_NAME", "instance-one"); let mut temp_file1 = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "${INSTANCE_NAME}" [network_identity] network_name = "multi-test" network_secret = "${INSTANCE_SECRET}" "#; temp_file1.write_all(config_content.as_bytes()).unwrap(); temp_file1.flush().unwrap(); let config_path1 = PathBuf::from(temp_file1.path()); let (config1, _) = load_config_from_file(&config_path1, None, false) .await .unwrap(); // 验证实例1的配置 assert_eq!(config1.get_inst_name(), "instance-one"); assert_eq!( config1 .get_network_identity() .network_secret .as_ref() .unwrap(), "instance1-secret" ); // 实例2:修改环境变量后加载同一模板 set_env_var("INSTANCE_SECRET", "instance2-secret"); set_env_var("INSTANCE_NAME", "instance-two"); let mut temp_file2 = NamedTempFile::new().unwrap(); temp_file2.write_all(config_content.as_bytes()).unwrap(); temp_file2.flush().unwrap(); let config_path2 = PathBuf::from(temp_file2.path()); let (config2, _) = load_config_from_file(&config_path2, None, false) .await .unwrap(); // 验证实例2使用了不同的环境变量值 assert_eq!(config2.get_inst_name(), "instance-two"); assert_eq!( config2 .get_network_identity() .network_secret .as_ref() .unwrap(), "instance2-secret" ); // 验证两个实例的配置确实不同 assert_ne!(config1.get_inst_name(), config2.get_inst_name()); assert_ne!( config1.get_network_identity().network_secret, config2.get_network_identity().network_secret ); // 清理 remove_env_var("INSTANCE_SECRET"); remove_env_var("INSTANCE_NAME"); } /// 实际配置字段测试(network_secret、peer.uri 等) /// /// 验证: /// - network_secret 字段支持环境变量 /// - peer.uri 字段支持环境变量 /// - listeners 字段支持环境变量 /// - 其他实际使用的配置字段 #[tokio::test] async fn test_real_config_fields_expansion() { // 设置各种实际场景的环境变量 set_env_var("ET_SECRET", "production-secret-key"); set_env_var("PEER_HOST", "peer.example.com"); set_env_var("PEER_PORT", "11011"); set_env_var("LISTEN_PORT", "11010"); set_env_var("NETWORK_NAME", "prod-network"); // 创建包含多个实际字段的完整配置 let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "production" ipv4 = "10.144.144.1" listeners = ["tcp://0.0.0.0:${LISTEN_PORT}"] [network_identity] network_name = "${NETWORK_NAME}" network_secret = "${ET_SECRET}" [[peer]] uri = "tcp://${PEER_HOST}:${PEER_PORT}" "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); let (config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证 network_identity 字段 let identity = config.get_network_identity(); assert_eq!(identity.network_name, "prod-network"); assert_eq!( identity.network_secret.as_ref().unwrap(), "production-secret-key" ); // 验证 listeners 字段 let listeners = config.get_listener_uris(); assert_eq!(listeners.len(), 1); assert_eq!(listeners[0].to_string(), "tcp://0.0.0.0:11010"); // 验证 peer 字段 let peers = config.get_peers(); assert_eq!(peers.len(), 1); assert_eq!(peers[0].uri.to_string(), "tcp://peer.example.com:11011"); // 验证配置被正确标记 assert!(control.is_read_only()); assert!(control.is_no_delete()); // 清理环境变量 remove_env_var("ET_SECRET"); remove_env_var("PEER_HOST"); remove_env_var("PEER_PORT"); remove_env_var("LISTEN_PORT"); remove_env_var("NETWORK_NAME"); } /// 带默认值的环境变量 /// /// 验证: /// - ${VAR:-default} 语法在变量未定义时使用默认值 #[tokio::test] async fn test_env_var_with_default_value() { // 确保变量未定义 remove_env_var("UNDEFINED_PORT"); remove_env_var("UNDEFINED_SECRET"); let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "default-test" listeners = ["tcp://0.0.0.0:${UNDEFINED_PORT:-11010}"] [network_identity] network_name = "test" network_secret = "${UNDEFINED_SECRET:-default-secret}" "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); let (config, _) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证使用了默认值 assert_eq!( config .get_network_identity() .network_secret .as_ref() .unwrap(), "default-secret" ); assert_eq!( config.get_listener_uris()[0].to_string(), "tcp://0.0.0.0:11010" ); } /// 环境变量未定义且无默认值的情况 /// /// 验证: /// - 未定义的环境变量保持原样(shellexpand 的默认行为) #[tokio::test] async fn test_undefined_env_var_without_default() { remove_env_var("COMPLETELY_UNDEFINED"); let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "undefined-test" [network_identity] network_name = "test" network_secret = "${COMPLETELY_UNDEFINED}" "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); let (config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证变量保持原样 assert_eq!( config .get_network_identity() .network_secret .as_ref() .unwrap(), "${COMPLETELY_UNDEFINED}" ); // 注意:由于没有实际替换发生,控制标记不应因环境变量而设置 // 但会因为其他原因(如没有 config_dir)被标记为 NO_DELETE // 这里我们主要验证 NO_DELETE 标记的逻辑 // 由于没有 config_dir,文件会被标记为 NO_DELETE,但不是因为环境变量 assert!(control.is_no_delete()); } /// 布尔类型环境变量 /// /// 验证: /// - 布尔类型的环境变量能正确解析和反序列化 /// - TOML 解析器能将字符串 "true"/"false" 转换为布尔值 #[tokio::test] async fn test_boolean_type_env_vars() { // 设置布尔类型的环境变量 set_env_var("ENABLE_DHCP", "true"); set_env_var("ENABLE_ENCRYPTION", "false"); set_env_var("ENABLE_IPV6", "true"); let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "bool-test" dhcp = ${ENABLE_DHCP} [network_identity] network_name = "test" network_secret = "secret" [flags] enable_encryption = ${ENABLE_ENCRYPTION} enable_ipv6 = ${ENABLE_IPV6} "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); let (config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证布尔值被正确解析 assert!(config.get_dhcp(), "dhcp should be true"); let flags = config.get_flags(); assert!( !flags.enable_encryption, "enable_encryption should be false" ); assert!(flags.enable_ipv6, "enable_ipv6 should be true"); // 验证使用环境变量的配置被标记为只读 assert!(control.is_read_only()); assert!(control.is_no_delete()); // 清理 remove_env_var("ENABLE_DHCP"); remove_env_var("ENABLE_ENCRYPTION"); remove_env_var("ENABLE_IPV6"); } /// 数字类型环境变量 /// /// 验证: /// - 数字类型(整数、端口号)的环境变量能正确解析和反序列化 /// - TOML 解析器能将字符串 "1380" 转换为整数 #[tokio::test] async fn test_numeric_type_env_vars() { // 设置数字类型的环境变量 set_env_var("MTU_VALUE", "1400"); set_env_var("THREAD_COUNT", "4"); let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "numeric-test" [network_identity] network_name = "test" network_secret = "secret" [flags] mtu = ${MTU_VALUE} multi_thread_count = ${THREAD_COUNT} "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); let (config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证数字值被正确解析 let flags = config.get_flags(); assert_eq!(flags.mtu, 1400, "mtu should be 1400"); assert_eq!( flags.multi_thread_count, 4, "multi_thread_count should be 4" ); // 验证使用环境变量的配置被标记为只读 assert!(control.is_read_only()); assert!(control.is_no_delete()); // 清理 remove_env_var("MTU_VALUE"); remove_env_var("THREAD_COUNT"); } /// 混合类型环境变量 /// /// 验证: /// - 字符串、布尔、数字类型的环境变量可以同时使用 /// - 所有类型都能正确解析和反序列化 /// - 模拟真实的复杂配置场景 #[tokio::test] async fn test_mixed_type_env_vars() { // 设置不同类型的环境变量 set_env_var("MIXED_SECRET", "mixed-secret-key"); set_env_var("MIXED_NETWORK", "production"); set_env_var("MIXED_DHCP", "true"); set_env_var("MIXED_MTU", "1500"); set_env_var("MIXED_ENCRYPTION", "false"); set_env_var("MIXED_LISTEN_PORT", "12345"); let mut temp_file = NamedTempFile::new().unwrap(); let config_content = r#" instance_name = "mixed-test" ipv4 = "10.0.0.1" dhcp = ${MIXED_DHCP} listeners = ["tcp://0.0.0.0:${MIXED_LISTEN_PORT}"] [network_identity] network_name = "${MIXED_NETWORK}" network_secret = "${MIXED_SECRET}" [flags] mtu = ${MIXED_MTU} enable_encryption = ${MIXED_ENCRYPTION} "#; temp_file.write_all(config_content.as_bytes()).unwrap(); temp_file.flush().unwrap(); let config_path = PathBuf::from(temp_file.path()); let (config, control) = load_config_from_file(&config_path, None, false) .await .unwrap(); // 验证字符串类型 let identity = config.get_network_identity(); assert_eq!(identity.network_name, "production"); assert_eq!( identity.network_secret.as_ref().unwrap(), "mixed-secret-key" ); // 验证布尔类型 assert!(config.get_dhcp()); let flags = config.get_flags(); assert!(!flags.enable_encryption); // 验证数字类型 assert_eq!(flags.mtu, 1500); // 验证 URL 中的端口号(数字) let listeners = config.get_listener_uris(); assert_eq!(listeners.len(), 1); assert_eq!(listeners[0].to_string(), "tcp://0.0.0.0:12345"); // 验证配置被标记为只读 assert!(control.is_read_only()); assert!(control.is_no_delete()); // 清理 remove_env_var("MIXED_SECRET"); remove_env_var("MIXED_NETWORK"); remove_env_var("MIXED_DHCP"); remove_env_var("MIXED_MTU"); remove_env_var("MIXED_ENCRYPTION"); remove_env_var("MIXED_LISTEN_PORT"); } }