From 53f279f5ffba0b89671fce30665fc46da8a04b70 Mon Sep 17 00:00:00 2001 From: Mg Pig Date: Tue, 2 Dec 2025 17:54:31 +0800 Subject: [PATCH] feat(core): Support environment variable parsing in config files (#1640) --- Cargo.lock | 10 + easytier-contrib/easytier-ohrs/Cargo.lock | 10 + easytier-gui/src-tauri/src/elevate/linux.rs | 2 - easytier/Cargo.toml | 1 + easytier/locales/app.yml | 3 + easytier/src/common/config.rs | 604 +++++++++++++++++++- easytier/src/common/env_parser.rs | 247 ++++++++ easytier/src/common/mod.rs | 1 + easytier/src/core.rs | 11 +- easytier/src/rpc_service/instance_manage.rs | 15 + 10 files changed, 898 insertions(+), 6 deletions(-) create mode 100644 easytier/src/common/env_parser.rs diff --git a/Cargo.lock b/Cargo.lock index b368d8b..dd9068f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2163,6 +2163,7 @@ dependencies = [ "serial_test", "service-manager", "sha2", + "shellexpand", "smoltcp", "socket2 0.5.10", "stun_codec", @@ -7754,6 +7755,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs 6.0.0", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/easytier-contrib/easytier-ohrs/Cargo.lock b/easytier-contrib/easytier-ohrs/Cargo.lock index 7182f8c..08e9e16 100644 --- a/easytier-contrib/easytier-ohrs/Cargo.lock +++ b/easytier-contrib/easytier-ohrs/Cargo.lock @@ -1071,6 +1071,7 @@ dependencies = [ "serde_json", "service-manager", "sha2", + "shellexpand", "smoltcp", "socket2 0.5.10", "stun_codec", @@ -3925,6 +3926,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" diff --git a/easytier-gui/src-tauri/src/elevate/linux.rs b/easytier-gui/src-tauri/src/elevate/linux.rs index 5199c15..ec97a3a 100644 --- a/easytier-gui/src-tauri/src/elevate/linux.rs +++ b/easytier-gui/src-tauri/src/elevate/linux.rs @@ -7,9 +7,7 @@ use super::Command; use anyhow::{anyhow, Result}; use std::env; use std::ffi::OsStr; -use std::path::PathBuf; use std::process::{Command as StdCommand, Output}; -use std::str::FromStr; /// The implementation of state check and elevated executing varies on each platform impl Command { diff --git a/easytier/Cargo.toml b/easytier/Cargo.toml index 1c2a842..ebf22c7 100644 --- a/easytier/Cargo.toml +++ b/easytier/Cargo.toml @@ -217,6 +217,7 @@ multimap = "0.10.0" version-compare = "0.2.0" hmac = "0.12.1" sha2 = "0.10.8" +shellexpand = "3.1.1" [target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "freebsd"))'.dependencies] machine-uid = "0.5.3" diff --git a/easytier/locales/app.yml b/easytier/locales/app.yml index 916ebf6..10ef47e 100644 --- a/easytier/locales/app.yml +++ b/easytier/locales/app.yml @@ -21,6 +21,9 @@ core_clap: config_dir: en: "Load all .toml files in the directory to start network instances, and store the received configurations in this directory." zh-CN: "加载目录中的所有 .toml 文件以启动网络实例,并将下发的配置保存在此目录中。" + disable_env_parsing: + en: "disable environment variable parsing in config file" + zh-CN: "禁用配置文件中的环境变量解析" generate_completions: en: "generate shell completions" zh-CN: "生成 shell 补全脚本" diff --git a/easytier/src/common/config.rs b/easytier/src/common/config.rs index 561fb69..4277068 100644 --- a/easytier/src/common/config.rs +++ b/easytier/src/common/config.rs @@ -19,6 +19,8 @@ use crate::{ tunnel::generate_digest_from_str, }; +use super::env_parser; + pub type Flags = crate::proto::common::FlagsInConfig; pub fn gen_default_flags() -> Flags { @@ -948,6 +950,7 @@ impl ConfigFileControl { 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(); @@ -958,10 +961,44 @@ pub async fn load_config_from_file( let config = TomlConfigLoader::new_from_str(&stdin)?; return Ok((config, ConfigFileControl::STATIC_CONFIG)); } - let config = TomlConfigLoader::new(config_file) + + 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 control.is_read_only() { + + 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() { @@ -985,6 +1022,9 @@ pub async fn load_config_from_file( #[cfg(test)] pub mod tests { use super::*; + use std::io::Write; + use std::path::PathBuf; + use tempfile::NamedTempFile; #[test] fn test_stun_servers_config() { @@ -1088,4 +1128,564 @@ proto = "tcp" ); 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() { + // 设置测试环境变量 + std::env::set_var("TEST_SECRET", "my-test-secret-123"); + std::env::set_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" + ); + + // 清理环境变量 + std::env::remove_var("TEST_SECRET"); + std::env::remove_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() { + std::env::set_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" + ); + + std::env::remove_var("API_TEST_SECRET"); + } + + /// CLI 参数测试(--disable-env-parsing 开关) + /// + /// 验证: + /// - disable_env_parsing = true 时,环境变量不会被替换 + /// - 配置不会被标记为只读 + #[tokio::test] + async fn test_disable_env_parsing_flag() { + std::env::set_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" + ); + + std::env::remove_var("DISABLED_TEST_VAR"); + } + + /// 多实例隔离测试 + /// + /// 验证: + /// - 不同实例可以使用不同的环境变量值 + /// - 环境变量在运行时被解析,支持动态切换 + #[tokio::test] + async fn test_multiple_instances_with_different_env_vars() { + // 实例1:使用第一组环境变量 + std::env::set_var("INSTANCE_SECRET", "instance1-secret"); + std::env::set_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:修改环境变量后加载同一模板 + std::env::set_var("INSTANCE_SECRET", "instance2-secret"); + std::env::set_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 + ); + + // 清理 + std::env::remove_var("INSTANCE_SECRET"); + std::env::remove_var("INSTANCE_NAME"); + } + + /// 实际配置字段测试(network_secret、peer.uri 等) + /// + /// 验证: + /// - network_secret 字段支持环境变量 + /// - peer.uri 字段支持环境变量 + /// - listeners 字段支持环境变量 + /// - 其他实际使用的配置字段 + #[tokio::test] + async fn test_real_config_fields_expansion() { + // 设置各种实际场景的环境变量 + std::env::set_var("ET_SECRET", "production-secret-key"); + std::env::set_var("PEER_HOST", "peer.example.com"); + std::env::set_var("PEER_PORT", "11011"); + std::env::set_var("LISTEN_PORT", "11010"); + std::env::set_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()); + + // 清理环境变量 + std::env::remove_var("ET_SECRET"); + std::env::remove_var("PEER_HOST"); + std::env::remove_var("PEER_PORT"); + std::env::remove_var("LISTEN_PORT"); + std::env::remove_var("NETWORK_NAME"); + } + + /// 带默认值的环境变量 + /// + /// 验证: + /// - ${VAR:-default} 语法在变量未定义时使用默认值 + #[tokio::test] + async fn test_env_var_with_default_value() { + // 确保变量未定义 + std::env::remove_var("UNDEFINED_PORT"); + std::env::remove_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() { + std::env::remove_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 + assert!(control.is_no_delete()); + } + + /// 布尔类型环境变量 + /// + /// 验证: + /// - 布尔类型的环境变量能正确解析和反序列化 + /// - TOML 解析器能将字符串 "true"/"false" 转换为布尔值 + #[tokio::test] + async fn test_boolean_type_env_vars() { + // 设置布尔类型的环境变量 + std::env::set_var("ENABLE_DHCP", "true"); + std::env::set_var("ENABLE_ENCRYPTION", "false"); + std::env::set_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()); + + // 清理 + std::env::remove_var("ENABLE_DHCP"); + std::env::remove_var("ENABLE_ENCRYPTION"); + std::env::remove_var("ENABLE_IPV6"); + } + + /// 数字类型环境变量 + /// + /// 验证: + /// - 数字类型(整数、端口号)的环境变量能正确解析和反序列化 + /// - TOML 解析器能将字符串 "1380" 转换为整数 + #[tokio::test] + async fn test_numeric_type_env_vars() { + // 设置数字类型的环境变量 + std::env::set_var("MTU_VALUE", "1400"); + std::env::set_var("QUIC_PORT", "8080"); + std::env::set_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} +quic_listen_port = ${QUIC_PORT} +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.quic_listen_port, 8080, + "quic_listen_port should be 8080" + ); + assert_eq!( + flags.multi_thread_count, 4, + "multi_thread_count should be 4" + ); + + // 验证使用环境变量的配置被标记为只读 + assert!(control.is_read_only()); + assert!(control.is_no_delete()); + + // 清理 + std::env::remove_var("MTU_VALUE"); + std::env::remove_var("QUIC_PORT"); + std::env::remove_var("THREAD_COUNT"); + } + + /// 混合类型环境变量 + /// + /// 验证: + /// - 字符串、布尔、数字类型的环境变量可以同时使用 + /// - 所有类型都能正确解析和反序列化 + /// - 模拟真实的复杂配置场景 + #[tokio::test] + async fn test_mixed_type_env_vars() { + // 设置不同类型的环境变量 + std::env::set_var("MIXED_SECRET", "mixed-secret-key"); + std::env::set_var("MIXED_NETWORK", "production"); + std::env::set_var("MIXED_DHCP", "true"); + std::env::set_var("MIXED_MTU", "1500"); + std::env::set_var("MIXED_ENCRYPTION", "false"); + std::env::set_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()); + + // 清理 + std::env::remove_var("MIXED_SECRET"); + std::env::remove_var("MIXED_NETWORK"); + std::env::remove_var("MIXED_DHCP"); + std::env::remove_var("MIXED_MTU"); + std::env::remove_var("MIXED_ENCRYPTION"); + std::env::remove_var("MIXED_LISTEN_PORT"); + } } diff --git a/easytier/src/common/env_parser.rs b/easytier/src/common/env_parser.rs new file mode 100644 index 0000000..bb65b80 --- /dev/null +++ b/easytier/src/common/env_parser.rs @@ -0,0 +1,247 @@ +//! 环境变量解析模块 +//! +//! 提供配置文件中环境变量占位符的解析功能 +//! 支持 Shell 风格的语法:${VAR}、$VAR、${VAR:-default} 等 + +use std::borrow::Cow; + +/// 解析字符串中的环境变量占位符 +/// +/// 支持的语法: +/// - `${VAR_NAME}` - 标准格式(推荐) +/// - `$VAR_NAME` - 简写格式 +/// - `${VAR_NAME:-default}` - 带默认值(bash 标准语法) +/// +/// # 参数 +/// - `text`: 待解析的字符串 +/// +/// # 返回值 +/// - `String`: 替换后的字符串 +/// - `bool`: 是否检测到并替换了环境变量 +pub fn expand_env_vars(text: &str) -> (String, bool) { + // 使用 shellexpand::env() 解析环境变量 + // 该函数仅处理环境变量,不处理 tilde (~) 扩展,适合配置文件场景 + match shellexpand::env(text) { + Ok(expanded) => { + // 通过比较原始字符串和扩展后的字符串判断是否发生了替换 + let changed = match &expanded { + Cow::Borrowed(_) => false, // 未发生变化,仍是借用 + Cow::Owned(_) => true, // 发生了变化,产生了新字符串 + }; + + (expanded.into_owned(), changed) + } + Err(e) => { + // 如果解析失败(例如变量引用语法错误),记录警告并返回原字符串 + tracing::warn!("Failed to expand environment variables in config: {}", e); + (text.to_string(), false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_expand_standard_syntax() { + std::env::set_var("TEST_VAR_STANDARD", "test_value"); + let (result, changed) = expand_env_vars("secret=${TEST_VAR_STANDARD}"); + assert_eq!(result, "secret=test_value"); + assert!(changed); + } + + #[test] + fn test_expand_short_syntax() { + std::env::set_var("TEST_VAR_SHORT", "short_value"); + let (result, changed) = expand_env_vars("key=$TEST_VAR_SHORT"); + assert_eq!(result, "key=short_value"); + assert!(changed); + } + + #[test] + fn test_expand_with_default() { + // 确保变量未定义 + std::env::remove_var("UNDEFINED_VAR_WITH_DEFAULT"); + let (result, changed) = expand_env_vars("port=${UNDEFINED_VAR_WITH_DEFAULT:-8080}"); + assert_eq!(result, "port=8080"); + assert!(changed); + } + + #[test] + fn test_no_env_vars() { + let (result, changed) = expand_env_vars("plain text without variables"); + assert_eq!(result, "plain text without variables"); + assert!(!changed); + } + + #[test] + fn test_empty_string() { + let (result, changed) = expand_env_vars(""); + assert_eq!(result, ""); + assert!(!changed); + } + + #[test] + fn test_multiple_vars() { + std::env::set_var("VAR1", "value1"); + std::env::set_var("VAR2", "value2"); + let (result, changed) = expand_env_vars("${VAR1} and ${VAR2}"); + assert_eq!(result, "value1 and value2"); + assert!(changed); + } + + #[test] + fn test_undefined_var_without_default() { + // 确保变量未定义 + std::env::remove_var("COMPLETELY_UNDEFINED_VAR"); + let (result, changed) = expand_env_vars("value=${COMPLETELY_UNDEFINED_VAR}"); + // shellexpand::env 对未定义的变量会保持原样 + assert_eq!(result, "value=${COMPLETELY_UNDEFINED_VAR}"); + assert!(!changed); + } + + #[test] + fn test_complex_toml_config() { + std::env::set_var("ET_SECRET", "my-secret-key"); + std::env::set_var("ET_PORT", "11010"); + + let config = r#" +[network_identity] +network_name = "test-network" +network_secret = "${ET_SECRET}" + +[[peer]] +uri = "tcp://127.0.0.1:${ET_PORT}" +"#; + + let (result, changed) = expand_env_vars(config); + assert!(changed); + assert!(result.contains(r#"network_secret = "my-secret-key""#)); + assert!(result.contains(r#"uri = "tcp://127.0.0.1:11010""#)); + } + + #[test] + fn test_escape_syntax_double_dollar() { + std::env::set_var("ESCAPED_VAR", "should_not_expand"); + // shellexpand 使用 $$ 作为转义序列,表示字面量的单个 $ + // $$ 会被转义为单个 $,不会触发变量扩展 + let (result, changed) = expand_env_vars("value=$${ESCAPED_VAR}"); + assert_eq!(result, "value=${ESCAPED_VAR}"); + assert!(changed); // $$ -> $ 被视为一次变换 + } + + #[test] + fn test_escape_syntax_backslash() { + std::env::set_var("ESCAPED_VAR", "should_not_expand"); + // shellexpand 中反斜杠转义的行为:\$ 会展开为 \<变量值> + // 这不是推荐的转义方式,此测试仅为记录实际行为 + let (result, changed) = expand_env_vars(r"value=\${ESCAPED_VAR}"); + assert_eq!(result, r"value=\should_not_expand"); + assert!(changed); + } + + #[test] + fn test_multiple_dollar_signs() { + std::env::set_var("TEST_VAR", "value"); + // 测试多个连续的 $ 符号 + let (result1, changed1) = expand_env_vars("$$"); + assert_eq!(result1, "$"); + assert!(changed1); + + let (result2, changed2) = expand_env_vars("$$$$"); + assert_eq!(result2, "$$"); + assert!(changed2); + + // $$ 后跟变量扩展 + let (result3, changed3) = expand_env_vars("$$$TEST_VAR"); + assert_eq!(result3, "$value"); + assert!(changed3); + } + + #[test] + fn test_empty_var_value() { + std::env::set_var("EMPTY_VAR", ""); + let (result, changed) = expand_env_vars("value=${EMPTY_VAR}"); + // 变量存在但值为空 + assert_eq!(result, "value="); + assert!(changed); + } + + #[test] + fn test_default_with_special_chars() { + std::env::remove_var("UNDEFINED_SPECIAL"); + // 测试默认值包含冒号、等号、空格等特殊字符 + let (result, changed) = expand_env_vars("url=${UNDEFINED_SPECIAL:-http://localhost:8080}"); + assert_eq!(result, "url=http://localhost:8080"); + assert!(changed); + + let (result2, changed2) = expand_env_vars("key=${UNDEFINED_SPECIAL:-name=value}"); + assert_eq!(result2, "key=name=value"); + assert!(changed2); + + let (result3, changed3) = expand_env_vars("msg=${UNDEFINED_SPECIAL:-hello world}"); + assert_eq!(result3, "msg=hello world"); + assert!(changed3); + } + + #[test] + fn test_var_name_with_numbers_underscores() { + std::env::set_var("VAR_123", "num_value"); + std::env::set_var("_VAR", "underscore_prefix"); + std::env::set_var("VAR_", "underscore_suffix"); + + let (result1, changed1) = expand_env_vars("${VAR_123}"); + assert_eq!(result1, "num_value"); + assert!(changed1); + + let (result2, changed2) = expand_env_vars("${_VAR}"); + assert_eq!(result2, "underscore_prefix"); + assert!(changed2); + + let (result3, changed3) = expand_env_vars("${VAR_}"); + assert_eq!(result3, "underscore_suffix"); + assert!(changed3); + } + + #[test] + fn test_invalid_syntax() { + // 测试无效语法的处理 + let (result1, changed1) = expand_env_vars("${}"); + // shellexpand 会保留无效语法原样 + assert_eq!(result1, "${}"); + assert!(!changed1); + + // 注意:未闭合的 ${VAR 实际上 shellexpand 会当作普通文本处理 + // 它会尝试查找名为 "VAR" 的环境变量(到字符串末尾) + std::env::remove_var("VAR"); + let (result2, _changed2) = expand_env_vars("incomplete ${VAR"); + // 如果 VAR 未定义,shellexpand 会返回错误或保持原样 + assert_eq!(result2, "incomplete ${VAR"); + // 注意:changed2 的值取决于 shellexpand 是否认为这是有效语法 + // 因此不对 changed2 做断言 + } + + #[test] + fn test_mixed_defined_undefined_vars() { + std::env::set_var("DEFINED_VAR", "defined"); + std::env::remove_var("UNDEFINED_VAR"); + + // 混合已定义和未定义的变量 + // shellexpand::env 在遇到未定义变量时会返回错误(默认行为) + // 因此整个字符串会保持不变 + let (result, changed) = expand_env_vars("${DEFINED_VAR} and ${UNDEFINED_VAR}"); + assert_eq!(result, "${DEFINED_VAR} and ${UNDEFINED_VAR}"); + assert!(!changed); + } + + #[test] + fn test_nested_braces() { + std::env::set_var("OUTER", "outer_value"); + // 嵌套的大括号是无效语法,shellexpand::env 会返回错误 + let (result, changed) = expand_env_vars("${OUTER} and ${{INNER}}"); + // 由于语法错误,整个字符串保持不变 + assert_eq!(result, "${OUTER} and ${{INNER}}"); + assert!(!changed); + } +} diff --git a/easytier/src/common/mod.rs b/easytier/src/common/mod.rs index bb85b24..5b54c43 100644 --- a/easytier/src/common/mod.rs +++ b/easytier/src/common/mod.rs @@ -16,6 +16,7 @@ pub mod config; pub mod constants; pub mod defer; pub mod dns; +pub mod env_parser; pub mod error; pub mod global_ctx; pub mod idn; diff --git a/easytier/src/core.rs b/easytier/src/core.rs index da13bac..f9a1a0f 100644 --- a/easytier/src/core.rs +++ b/easytier/src/core.rs @@ -133,6 +133,9 @@ struct Cli { #[clap(long, help = t!("core_clap.daemon").to_string())] daemon: bool, + + #[clap(long, help = t!("core_clap.disable_env_parsing").to_string())] + disable_env_parsing: bool, } #[derive(Parser, Debug, Default, PartialEq, Eq)] @@ -1209,8 +1212,12 @@ async fn run_main(cli: Cli) -> anyhow::Result<()> { } }; for config_file in config_files { - let (mut cfg, mut control) = - load_config_from_file(&config_file, cli.config_dir.as_ref()).await?; + let (mut cfg, mut control) = load_config_from_file( + &config_file, + cli.config_dir.as_ref(), + cli.disable_env_parsing, + ) + .await?; if cli.network_options.can_merge(&cfg, config_file_count) { cli.network_options diff --git a/easytier/src/rpc_service/instance_manage.rs b/easytier/src/rpc_service/instance_manage.rs index 7a15143..88e06b3 100644 --- a/easytier/src/rpc_service/instance_manage.rs +++ b/easytier/src/rpc_service/instance_manage.rs @@ -213,6 +213,21 @@ impl WebClientService for InstanceManageRpcService { .inst_id .ok_or_else(|| anyhow::anyhow!("instance id is required"))? .into(); + + let control = self + .manager + .get_instance_config_control(&inst_id) + .ok_or_else(|| anyhow::anyhow!("instance config control not found"))?; + + if control.is_read_only() { + return Err(anyhow::anyhow!( + "Configuration for instance {} is read-only (uses environment variables) and cannot be retrieved via API. \ + Please access the configuration file directly on the file system.", + inst_id + ) + .into()); + } + let config = self .manager .get_instance_service(&inst_id)