//! Complete EasyTier TOML configuration model. use std::{ net::{IpAddr, SocketAddr}, path::PathBuf, sync::{Arc, Mutex}, }; pub use super::{EncryptionAlgorithm, gateway::PortForwardConfig}; use anyhow::Context; #[cfg(feature = "rich-config-errors")] use ariadne::{CharSet, Config as AriadneConfig, IndexType, Label, Report, ReportKind, Source}; use serde::{Deserialize, Serialize}; use crate::proto::{ acl::Acl, common::{CompressionAlgoPb, SecureModeConfig}, }; pub const DEFAULT_ET_DNS_ZONE: &str = "et.net."; pub type Flags = crate::proto::common::FlagsInConfig; pub(crate) fn default_instance_name() -> String { "default".to_owned() } 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, enable_udp_broadcast_relay: false, socket_mark: None, } } #[cfg(feature = "config-write")] macro_rules! define_flags_diff { ( fields: [$($field:ident),* $(,)?], u64s: [$($u64_field:ident),* $(,)?], enums: [$($enum_field:ident),* $(,)?] ) => { #[allow(deprecated)] fn flags_diff_from_default(flags: &Flags) -> serde_json::Map { let defaults = gen_default_flags(); let mut changed = serde_json::Map::new(); $( if flags.$field != defaults.$field { changed.insert( stringify!($field).to_owned(), serde_json::to_value(&flags.$field) .expect("FlagsInConfig field should serialize to JSON"), ); } )* $( if flags.$u64_field != defaults.$u64_field { changed.insert( stringify!($u64_field).to_owned(), serde_json::json!(flags.$u64_field.to_string()), ); } )* $( if flags.$enum_field != defaults.$enum_field { let value = CompressionAlgoPb::try_from(flags.$enum_field) .map(|value| serde_json::to_value(value).expect("enum should serialize")) .unwrap_or_else(|_| serde_json::json!(flags.$enum_field)); changed.insert(stringify!($enum_field).to_owned(), value); } )* changed } #[cfg(all(test, feature = "config-write"))] const FLAGS_DIFF_FIELDS: &[&str] = &[ $(stringify!($field),)* $(stringify!($u64_field),)* $(stringify!($enum_field),)* ]; }; } #[cfg(feature = "config-write")] define_flags_diff! { fields: [ default_protocol, dev_name, enable_encryption, enable_ipv6, mtu, latency_first, enable_exit_node, no_tun, use_smoltcp, relay_network_whitelist, disable_p2p, relay_all_peer_rpc, disable_udp_hole_punching, multi_thread, bind_device, enable_kcp_proxy, disable_kcp_input, disable_relay_kcp, proxy_forward_by_system, accept_dns, private_mode, enable_quic_proxy, disable_quic_input, disable_relay_quic, quic_listen_port, multi_thread_count, enable_relay_foreign_network_kcp, enable_relay_foreign_network_quic, encryption_algorithm, disable_sym_hole_punching, tld_dns_zone, p2p_only, disable_tcp_hole_punching, lazy_p2p, need_p2p, disable_upnp, disable_relay_data, enable_udp_broadcast_relay, socket_mark, ], u64s: [foreign_relay_bps_limit, instance_recv_bps_limit], enums: [data_compress_algo] } #[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_tcp_stun_servers(&self) -> Option> { None } fn set_tcp_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; fn dump_redacted(&self) -> String { self.dump() } } pub trait LoggingConfigLoader { fn get_file_logger_config(&self) -> FileLoggerConfig; fn get_console_logger_config(&self) -> ConsoleLoggerConfig; } use super::NetworkSecretDigest; #[derive(Debug, Clone, Deserialize, Serialize)] pub struct NetworkIdentity { pub network_name: String, pub network_secret: Option, #[serde(skip)] pub network_secret_digest: Option, } impl From for NetworkIdentity { fn from(value: super::NetworkIdentity) -> Self { Self { network_name: value.network_name, network_secret: value.network_secret, network_secret_digest: value.network_secret_digest, } } } impl From<&NetworkIdentity> for super::NetworkIdentity { fn from(value: &NetworkIdentity) -> Self { Self { network_name: value.network_name.clone(), network_secret: value.network_secret.clone(), network_secret_digest: value.network_secret_digest, } } } impl From for super::NetworkIdentity { fn from(value: NetworkIdentity) -> Self { Self { network_name: value.network_name, network_secret: value.network_secret, network_secret_digest: value.network_secret_digest, } } } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "snake_case")] pub enum ConfigSource { #[default] User, Web, } impl ConfigSource { pub fn as_str(self) -> &'static str { match self { Self::User => "user", Self::Web => "web", } } } impl std::str::FromStr for ConfigSource { type Err = String; fn from_str(s: &str) -> Result { match s { "user" => Ok(Self::User), "web" => Ok(Self::Web), other => Err(format!("unknown network config source: {other}")), } } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] struct ConfigSourceConfig { source: ConfigSource, } impl PartialEq for NetworkIdentity { fn eq(&self, other: &Self) -> bool { super::NetworkIdentity::from(self) == super::NetworkIdentity::from(other) } } impl Eq for NetworkIdentity {} impl std::hash::Hash for NetworkIdentity { fn hash(&self, state: &mut H) { std::hash::Hash::hash(&super::NetworkIdentity::from(self), state); } } impl NetworkIdentity { pub fn new(network_name: String, network_secret: String) -> Self { super::NetworkIdentity::new(network_name, network_secret).into() } /// 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 { super::NetworkIdentity::new_credential(network_name).into() } } impl Default for NetworkIdentity { fn default() -> Self { super::NetworkIdentity::default().into() } } #[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(Clone, Deserialize, Serialize, PartialEq)] #[serde(deny_unknown_fields)] pub struct VpnPortalConfig { pub wireguard_listen: SocketAddr, #[serde(default, skip_serializing_if = "Option::is_none")] pub wireguard_private_key: Option, #[serde(default)] pub clients: Vec, } impl std::fmt::Debug for VpnPortalConfig { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("VpnPortalConfig") .field("wireguard_listen", &self.wireguard_listen) .field( "wireguard_private_key", &self.wireguard_private_key.as_ref().map(|_| ""), ) .field("clients", &self.clients) .finish() } } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct VpnPortalClientConfig { pub name: String, pub virtual_ip: std::net::Ipv4Addr, #[serde(default)] pub groups: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize)] #[cfg_attr(feature = "config-write", derive(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>, tcp_stun_servers: Option>, stun_servers_v6: Option>, credential_file: Option, source: Option, } #[cfg(feature = "rich-config-errors")] fn format_toml_parse_error(source_name: &str, config_str: &str, error: &toml::de::Error) -> String { let message = format!("failed to parse config TOML from {source_name}"); let Some(span) = error.span() else { return format!("{message}\ndetail: {error}"); }; let mut output = Vec::new(); let report = Report::build(ReportKind::Error, (source_name, span.clone())) .with_config( AriadneConfig::default() .with_color(false) .with_char_set(CharSet::Ascii) .with_index_type(IndexType::Byte), ) .with_message(&message) .with_label(Label::new((source_name, span)).with_message(error.message())) .finish(); if report .write((source_name, Source::from(config_str)), &mut output) .is_ok() { String::from_utf8_lossy(&output).into_owned() } else { format!("{message}\ndetail: {error}") } } #[cfg(not(feature = "rich-config-errors"))] fn format_toml_parse_error( source_name: &str, _config_str: &str, error: &toml::de::Error, ) -> String { format!("failed to parse config TOML from {source_name}: {error}") } #[derive(Debug, Clone)] pub struct TomlConfig { config: Arc>, } impl Default for TomlConfig { fn default() -> Self { TomlConfig::new_from_str("").unwrap() } } impl TomlConfig { fn normalize_config_source(config: &mut Config) { if matches!( config.source.as_ref().map(|source| source.source), Some(ConfigSource::User) ) { config.source = None; } } #[cfg(feature = "config-write")] fn config_for_dump(&self) -> Config { let mut config = self.config.lock().unwrap().clone(); Self::normalize_config_source(&mut config); config.flags = Some(flags_diff_from_default(&self.get_flags())); config } #[cfg(feature = "config-write")] fn redact_secrets(config: &mut Config) { const REDACTED: &str = ""; if let Some(secret) = config .network_identity .as_mut() .and_then(|identity| identity.network_secret.as_mut()) && !secret.is_empty() { *secret = REDACTED.to_owned(); } if let Some(private_key) = config .secure_mode .as_mut() .and_then(|secure_mode| secure_mode.local_private_key.as_mut()) && !private_key.is_empty() { *private_key = REDACTED.to_owned(); } if let Some(private_key) = config .vpn_portal_config .as_mut() .and_then(|portal| portal.wireguard_private_key.as_mut()) && !private_key.is_empty() { *private_key = REDACTED.to_owned(); } if let Some(declarations) = config .acl .as_mut() .and_then(|acl| acl.acl_v1.as_mut()) .and_then(|acl| acl.group.as_mut()) .map(|group| &mut group.declares) { for declaration in declarations { if !declaration.group_secret.is_empty() { declaration.group_secret = REDACTED.to_owned(); } } } } pub fn new_from_str(config_str: &str) -> Result { Self::new_from_str_with_source("inline config", config_str) } pub fn new_from_str_with_source( source_name: &str, config_str: &str, ) -> Result { let mut config = toml::de::from_str::(config_str).map_err(|err| { let message = format_toml_parse_error(source_name, config_str, &err); anyhow::Error::new(err).context(message) })?; Self::normalize_config_source(&mut config); Self::new_from_config(config).map_err(|err| { let message = format!("failed to load config from {source_name}: {err}"); err.context(message) }) } fn new_from_config(mut config: Config) -> Result { config.flags_struct = Some( Self::gen_flags(config.flags.clone().unwrap_or_default()) .context("failed to parse flags")?, ); let has_network_identity = config.network_identity.is_some(); let config = TomlConfig { config: Arc::new(Mutex::new(config)), }; let old_ns = config.get_network_identity(); // Detect credential mode: secure_mode enabled + no network_secret in TOML let is_credential = has_network_identity && config .get_secure_mode() .map(|sm| sm.enabled) .unwrap_or(false) && old_ns .network_secret .as_deref() .is_none_or(|s| s.is_empty()); if is_credential { config.set_network_identity(NetworkIdentity::new_credential(old_ns.network_name)); } else { config.set_network_identity(NetworkIdentity::new( old_ns.network_name, old_ns.network_secret.unwrap_or_default(), )); } Ok(config) } fn gen_flags( flags_hashmap: serde_json::Map, ) -> serde_json::Result { let mut merged_hashmap = match serde_json::to_value(gen_default_flags()) { Ok(serde_json::Value::Object(map)) => map, _ => serde_json::Map::new(), }; merged_hashmap.extend(flags_hashmap); serde_json::from_value(serde_json::Value::Object(merged_hashmap)) } } #[cfg(feature = "web-client")] mod snapshot; impl ConfigLoader for TomlConfig { fn get_inst_name(&self) -> String { self.config .lock() .unwrap() .instance_name .clone() .unwrap_or_else(default_instance_name) } 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); String::new() } } None => String::new(), } } 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_tcp_stun_servers(&self) -> Option> { self.config.lock().unwrap().tcp_stun_servers.clone() } fn set_tcp_stun_servers(&self, servers: Option>) { self.config.lock().unwrap().tcp_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 { #[cfg(feature = "config-write")] { toml::to_string_pretty(&self.config_for_dump()).unwrap() } #[cfg(not(feature = "config-write"))] { panic!("this build does not include TOML configuration serialization") } } fn dump_redacted(&self) -> String { #[cfg(feature = "config-write")] { let mut config = self.config_for_dump(); Self::redact_secrets(&mut config); toml::to_string_pretty(&config).unwrap() } #[cfg(not(feature = "config-write"))] { panic!("this build does not include TOML configuration serialization") } } } /// Transitional name retained while native consumers migrate to [`TomlConfig`]. pub type TomlConfigLoader = TomlConfig; #[cfg(test)] mod tests { use super::*; #[test] fn parse_error_preserves_source_and_location() { let error = TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err(); let display = error.to_string(); assert!(display.contains("fixture.toml")); assert!(display.contains("dhcp = \"yes\"")); assert!(display.contains("invalid type: string")); assert!( error .chain() .any(|cause| cause.downcast_ref::().is_some()) ); } #[cfg(feature = "config-write")] #[test] fn toml_round_trip_preserves_config_and_non_default_flags() { let config = TomlConfig::new_from_str( r#" instance_name = "node-a" instance_id = "018f85a8-a9d0-7d4c-b73d-4ab62c048a20" hostname = "host-a" listeners = ["tcp://0.0.0.0:11010"] [network_identity] network_name = "network-a" network_secret = "secret-a" [flags] mtu = 1420 socket_mark = 0 "#, ) .unwrap(); let dumped = config.dump(); let restored = TomlConfig::new_from_str(&dumped).unwrap(); assert_eq!(restored.get_id(), config.get_id()); assert_eq!(restored.get_hostname(), "host-a"); assert_eq!( restored.get_network_identity(), config.get_network_identity() ); assert_eq!(restored.get_listener_uris(), config.get_listener_uris()); assert_eq!(restored.get_flags().mtu, 1420); assert_eq!(restored.get_flags().socket_mark, Some(0)); } #[test] fn legacy_vpn_portal_client_cidr_is_rejected_explicitly() { let error = TomlConfig::new_from_str( r#" [vpn_portal_config] client_cidr = "10.14.14.0/24" wireguard_listen = "0.0.0.0:51820" "#, ) .unwrap_err() .to_string(); assert!(error.contains("client_cidr"), "{error}"); } #[cfg(feature = "config-write")] #[test] fn vpn_portal_round_trip_and_redacted_dump_preserve_dump_semantics() { let config = TomlConfig::new_from_str( r#" [network_identity] network_name = "network-a" network_secret = "network-secret" [secure_mode] enabled = true local_private_key = "noise-private-key" [vpn_portal_config] wireguard_listen = "0.0.0.0:51820" wireguard_private_key = "wireguard-private-key" [[vpn_portal_config.clients]] name = "alice" virtual_ip = "10.144.144.10" groups = ["staff"] [acl.acl_v1.group] [[acl.acl_v1.group.declares]] group_name = "staff" group_secret = "group-secret" "#, ) .unwrap(); let dumped = config.dump(); assert!(dumped.contains("network-secret")); assert!(dumped.contains("noise-private-key")); assert!(dumped.contains("wireguard-private-key")); assert!(dumped.contains("group-secret")); assert_eq!( TomlConfig::new_from_str(&dumped) .unwrap() .get_vpn_portal_config(), config.get_vpn_portal_config() ); let redacted = config.dump_redacted(); assert!(!redacted.contains("network-secret")); assert!(!redacted.contains("noise-private-key")); assert!(!redacted.contains("wireguard-private-key")); assert!(!redacted.contains("group-secret")); assert_eq!(redacted.matches("").count(), 4); } #[test] fn hostname_normalization_is_portable_and_has_no_host_fallback() { let absent = TomlConfig::default(); assert_eq!(absent.get_hostname(), ""); let configured = TomlConfig::new_from_str("hostname = \"node\\u0007-name\"").unwrap(); assert_eq!(configured.get_hostname(), "node-name"); } #[test] fn credential_mode_does_not_synthesize_a_network_secret() { let config = TomlConfig::new_from_str( r#" [network_identity] network_name = "credential-network" [secure_mode] enabled = true "#, ) .unwrap(); let identity = config.get_network_identity(); assert_eq!(identity.network_name, "credential-network"); assert_eq!(identity.network_secret, None); } #[cfg(feature = "config-write")] #[test] fn user_source_is_implicit_while_web_source_round_trips() { let user = TomlConfig::new_from_str( r#" [source] source = "user" "#, ) .unwrap(); assert_eq!(user.get_network_config_source(), ConfigSource::User); assert!(!user.dump().contains("[source]")); let web = TomlConfig::new_from_str( r#" [source] source = "web" "#, ) .unwrap(); assert_eq!(web.get_network_config_source(), ConfigSource::Web); assert!(web.dump().contains("source = \"web\"")); } } #[cfg(test)] mod compatibility_tests { use super::*; #[cfg(feature = "config-write")] #[test] fn flags_diff_covers_every_protobuf_field() { use prost::Message as _; let descriptor_set = prost_types::FileDescriptorSet::decode(crate::proto::DESCRIPTOR_POOL_BYTES).unwrap(); let proto_fields = descriptor_set .file .iter() .find(|file| file.package.as_deref() == Some("common")) .and_then(|file| { file.message_type .iter() .find(|message| message.name.as_deref() == Some("FlagsInConfig")) }) .unwrap() .field .iter() .map(|field| field.name.as_deref().unwrap()) .collect::>(); let diff_fields = FLAGS_DIFF_FIELDS .iter() .copied() .collect::>(); assert_eq!(diff_fields, proto_fields); } #[test] fn socket_mark_config_file_roundtrip_none_some_and_zero() { // Omitting the flag leaves socket_mark unset (None) -> SO_MARK untouched. let cfg = TomlConfigLoader::new_from_str( r#" [network_identity] network_name = "n" network_secret = "s" "#, ) .unwrap(); assert_eq!(cfg.get_flags().socket_mark, None); // socket_mark = 0 is a legitimate value distinct from "unset". let cfg = TomlConfigLoader::new_from_str( r#" [network_identity] network_name = "n" network_secret = "s" [flags] socket_mark = 0 "#, ) .unwrap(); assert_eq!(cfg.get_flags().socket_mark, Some(0)); // A non-zero mark round-trips as Some(v). let cfg = TomlConfigLoader::new_from_str( r#" [network_identity] network_name = "n" network_secret = "s" [flags] socket_mark = 66 "#, ) .unwrap(); assert_eq!(cfg.get_flags().socket_mark, Some(66)); // set_flags(None) must serialize back through gen_config without // resurrecting a value (guards the gen_flags merge against dropping // the key when the serialized default is null). cfg.set_flags(Flags { socket_mark: None, ..cfg.get_flags() }); assert_eq!(cfg.get_flags().socket_mark, None); } #[cfg(feature = "config-write")] #[test] fn dump_preserves_flags_that_differ_from_easytier_defaults() { let cfg = TomlConfigLoader::default(); let mut flags = gen_default_flags(); flags.dev_name = "et_test".to_string(); flags.enable_quic_proxy = true; flags.disable_tcp_hole_punching = true; flags.disable_sym_hole_punching = true; flags.multi_thread = false; flags.bind_device = false; flags.enable_ipv6 = false; flags.relay_network_whitelist = "".to_string(); flags.mtu = 0; flags.foreign_relay_bps_limit = u64::MAX - 1; flags.instance_recv_bps_limit = u64::MAX - 2; flags.data_compress_algo = CompressionAlgoPb::Zstd.into(); flags.socket_mark = Some(0); cfg.set_flags(flags); let dumped = cfg.dump(); assert!(dumped.contains("dev_name = \"et_test\"")); assert!(dumped.contains("enable_quic_proxy = true")); assert!(dumped.contains("disable_tcp_hole_punching = true")); assert!(dumped.contains("disable_sym_hole_punching = true")); assert!(dumped.contains("multi_thread = false")); assert!(dumped.contains("bind_device = false")); assert!(dumped.contains("enable_ipv6 = false")); assert!(dumped.contains("relay_network_whitelist = \"\"")); assert!(dumped.contains("mtu = 0")); assert!(dumped.contains("foreign_relay_bps_limit = \"18446744073709551614\"")); assert!(dumped.contains("instance_recv_bps_limit = \"18446744073709551613\"")); assert!(dumped.contains("data_compress_algo = \"Zstd\"")); assert!(dumped.contains("socket_mark = 0")); let reloaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); let reloaded_flags = reloaded.get_flags(); assert_eq!(reloaded_flags.dev_name, "et_test"); assert!(reloaded_flags.enable_quic_proxy); assert!(reloaded_flags.disable_tcp_hole_punching); assert!(reloaded_flags.disable_sym_hole_punching); assert!(!reloaded_flags.multi_thread); assert!(!reloaded_flags.bind_device); assert!(!reloaded_flags.enable_ipv6); assert_eq!(reloaded_flags.relay_network_whitelist, ""); assert_eq!(reloaded_flags.mtu, 0); assert_eq!(reloaded_flags.foreign_relay_bps_limit, u64::MAX - 1); assert_eq!(reloaded_flags.instance_recv_bps_limit, u64::MAX - 2); assert_eq!( reloaded_flags.data_compress_algo, i32::from(CompressionAlgoPb::Zstd) ); assert_eq!(reloaded_flags.socket_mark, Some(0)); } #[test] fn test_stun_servers_config() { let config = TomlConfigLoader::default(); let stun_servers = config.get_stun_servers(); assert!(stun_servers.is_none()); assert!(config.get_tcp_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); let custom_tcp_servers = vec!["tcp-stun.example.com:3478".to_string()]; config.set_tcp_stun_servers(Some(custom_tcp_servers.clone())); let retrieved_tcp_servers = config.get_tcp_stun_servers(); assert_eq!(retrieved_tcp_servers.unwrap(), custom_tcp_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" ] tcp_stun_servers = [ "tcp-stun.example.com:3478" ]"#; let config = TomlConfigLoader::new_from_str(config_str).unwrap(); let stun_servers = config.get_stun_servers().unwrap(); let tcp_stun_servers = config.get_tcp_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"); assert_eq!(tcp_stun_servers, ["tcp-stun.example.com:3478"]); } #[test] fn test_empty_tcp_stun_servers_toml_parsing() { let config = TomlConfigLoader::new_from_str( r#" instance_name = "test" tcp_stun_servers = [] "#, ) .unwrap(); assert_eq!(config.get_tcp_stun_servers(), Some(Vec::new())); } #[cfg(feature = "config-write")] #[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::Web)); let dumped = config.dump(); assert!(dumped.contains("[source]")); assert!(dumped.contains("source = \"web\"")); let loaded = TomlConfigLoader::new_from_str(&dumped).unwrap(); assert_eq!(loaded.get_network_config_source(), ConfigSource::Web); } #[cfg(feature = "config-write")] #[test] fn test_toml_credential_mode_omits_network_secret() { for network_secret in ["", r#"network_secret = """#] { let config = TomlConfigLoader::new_from_str(&format!( r#" [network_identity] network_name = "credential-network" {network_secret} [secure_mode] enabled = true "# )) .unwrap(); let identity = config.get_network_identity(); assert_eq!(identity.network_name, "credential-network"); assert_eq!(identity.network_secret, None); assert_eq!(identity.network_secret_digest, None); assert!(!config.dump().contains("network_secret")); } } #[test] fn test_toml_secure_mode_without_network_identity_uses_default_secret() { let config = TomlConfigLoader::new_from_str( r#" [secure_mode] enabled = true "#, ) .unwrap(); let identity = config.get_network_identity(); assert_eq!(identity.network_name, "default"); assert_eq!(identity.network_secret.as_deref(), Some("")); assert!(identity.network_secret_digest.is_some()); } #[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"]); } #[cfg(feature = "config-write")] #[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]")); } #[cfg(feature = "config-write")] #[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)); } } #[cfg(test)] mod full_example_tests { use super::*; #[cfg(feature = "config-write")] #[test] 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()); } } #[cfg(test)] mod diagnostic_compatibility_tests { use super::*; #[test] fn stdin_source_name_and_caret_are_preserved() { let error = TomlConfig::new_from_str_with_source("stdin", "dhcp = \"yes\"") .unwrap_err() .to_string(); assert!(error.contains("stdin")); assert!(error.contains("dhcp = \"yes\"")); assert!(error.contains('^')); assert!(!error.contains("")); } #[test] fn non_ascii_before_typed_error_keeps_byte_location() { let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"") .unwrap_err() .to_string(); assert!(error.contains("dhcp = \"yes\"")); assert!(error.contains('^')); assert!(error.contains("invalid type: string")); } #[cfg(feature = "rich-config-errors")] #[test] fn non_ascii_on_syntax_error_line_keeps_source_location() { let error = TomlConfig::new_from_str("hostname = \"节点\" dhcp = \"yes\"") .unwrap_err() .to_string(); assert!(error.contains("inline config:1:")); assert!(error.contains("hostname = \"节点\" dhcp = \"yes\"")); assert!(error.contains("expected newline")); assert!(!error.contains("")); } #[test] fn flags_conversion_error_keeps_source_and_cause_chain() { let error = TomlConfig::new_from_str_with_source( "flags-fixture.toml", "[flags]\nsocket_mark = \"bad\"", ) .unwrap_err(); let display = error.to_string(); assert!(display.contains("flags-fixture.toml")); assert!(display.contains("failed to load config")); assert!(display.contains("failed to parse flags")); assert!( error .chain() .any(|cause| cause.to_string().contains("failed to parse flags")) ); } }