From 336bd9d735588212e2821aeabda522ae6c33f7cf Mon Sep 17 00:00:00 2001 From: wonfen Date: Sat, 4 Jul 2026 07:45:02 +0800 Subject: [PATCH] fix(enhance): protect app-owned config from manual overrides --- src-tauri/src/enhance/mod.rs | 367 +++++++++++++++++++++++++++++++---- src-tauri/src/utils/init.rs | 2 + 2 files changed, 332 insertions(+), 37 deletions(-) diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index bf8ad34ee..29ef1f6d9 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -243,13 +243,12 @@ async fn collect_profile_items() -> Result { async fn process_global_items( mut config: Mapping, + mut exists_keys: Vec, + mut result_map: HashMap, global_merge: ChainItem, global_script: ChainItem, profile_name: &String, ) -> (Mapping, Vec, HashMap) { - let mut result_map = HashMap::new(); - let mut exists_keys = use_keys(&config).collect::>(); - if let ChainType::Merge(merge) = global_merge.data { exists_keys.extend(use_keys(&merge)); config = use_merge(&merge, config); @@ -259,7 +258,7 @@ async fn process_global_items( let mut logs = vec![]; match use_script(script, config.clone(), profile_name.clone()).await { Ok((res_config, res_logs)) => { - exists_keys.extend(use_keys(&res_config)); + extend_changed_keys(&mut exists_keys, &config, &res_config); config = res_config; logs.extend(res_logs); } @@ -271,18 +270,12 @@ async fn process_global_items( (config, exists_keys, result_map) } -#[allow(clippy::too_many_arguments)] -async fn process_profile_items( +fn process_seq_items( mut config: Mapping, - mut exists_keys: Vec, - mut result_map: HashMap, rules_item: ChainItem, proxies_item: ChainItem, groups_item: ChainItem, - merge_item: ChainItem, - script_item: ChainItem, - profile_name: &String, -) -> (Mapping, Vec, HashMap) { +) -> Mapping { if let ChainType::Rules(rules) = rules_item.data { config = use_seq(rules, config, "rules"); } @@ -295,6 +288,94 @@ async fn process_profile_items( config = use_seq(groups, config, "proxy-groups"); } + config +} + +fn extend_changed_keys(exists_keys: &mut Vec, config: &Mapping, res_config: &Mapping) { + exists_keys.extend(res_config.iter().filter_map(|(key, value)| { + if config.get(key) == Some(value) { + return None; + } + + key.as_str().map(|key| { + let mut key: String = key.into(); + key.make_ascii_lowercase(); + key + }) + })); +} + +/// App 权威的顶层控制面键:核心连接、监听端口、UI/托盘开关。 +/// 平台键随 cfg 门控;`dns.ipv6` 单独处理。 +const CONTROL_PLANE_KEYS: &[&str] = &[ + "external-controller", + #[cfg(unix)] + "external-controller-unix", + #[cfg(windows)] + "external-controller-pipe", + "external-controller-cors", + "secret", + "mixed-port", + "socks-port", + "port", + #[cfg(not(target_os = "windows"))] + "redir-port", + #[cfg(target_os = "linux")] + "tproxy-port", + "mode", + "allow-lan", + "log-level", + "ipv6", + "unified-delay", +]; + +/// 手动 merge/script 前保存 app 最终控制面值,只记录当前存在的键。 +fn snapshot_control_plane(config: &Mapping) -> Mapping { + let mut snapshot = Mapping::new(); + for &key in CONTROL_PLANE_KEYS { + let key = Value::from(key); + if let Some(value) = config.get(&key) { + snapshot.insert(key, value.clone()); + } + } + snapshot +} + +/// 手动覆盖后恢复控制面快照;快照缺失的控制面键从最终配置删除。 +fn enforce_control_plane(mut config: Mapping, snapshot: Mapping) -> Mapping { + for &key in CONTROL_PLANE_KEYS { + let key = Value::from(key); + if !snapshot.contains_key(&key) { + config.remove(&key); + } + } + config.extend(snapshot); + config +} + +/// DNS 页权威的嵌套开关;只在 `enable_dns_settings` 时快照。 +fn snapshot_dns_ipv6(config: &Mapping) -> Option { + config.get("dns")?.get("ipv6").cloned() +} + +/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。 +fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option) -> Mapping { + if let Some(dns_ipv6) = dns_ipv6 + && let Some(Value::Mapping(dns)) = config.get_mut("dns") + { + dns.insert(Value::from("ipv6"), dns_ipv6); + } + config +} + +async fn process_profile_items( + mut config: Mapping, + mut exists_keys: Vec, + mut result_map: HashMap, + merge_item: ChainItem, + script_item: ChainItem, + profile_name: &String, +) -> (Mapping, Vec, HashMap) { if let ChainType::Merge(merge) = merge_item.data { exists_keys.extend(use_keys(&merge)); config = use_merge(&merge, config); @@ -304,7 +385,7 @@ async fn process_profile_items( let mut logs = vec![]; match use_script(script, config.clone(), profile_name.clone()).await { Ok((res_config, res_logs)) => { - exists_keys.extend(use_keys(&res_config)); + extend_changed_keys(&mut exists_keys, &config, &res_config); config = res_config; logs.extend(res_logs); } @@ -588,23 +669,11 @@ pub async fn enhance() -> Result<(Mapping, HashSet, HashMap>(); // merge default clash config let config = merge_default_config( @@ -619,16 +688,41 @@ pub async fn enhance() -> Result<(Mapping, HashSet, HashMap Result<(Mapping, HashSet, HashMap serde_yaml_ng::Mapping { + serde_yaml_ng::from_str(yaml).expect("test config should be valid") + } + + #[tokio::test] + async fn manual_overrides_follow_expected_priority() { + let mut config = mapping( + r"{global-merge-wins: other, global-script-wins: other, profile-merge-wins: other, + profile-script-wins: other, nested: {winner: other}, dns: {enable: true}, tun: {enable: true}}", + ); + let exists_keys = use_keys(&config).collect(); + config.insert("application-only".into(), true.into()); + + let global_merge = ChainItem { + uid: "Merge".into(), + data: ChainType::Merge(mapping( + r"{global-merge-wins: global-merge, global-script-wins: global-merge, + profile-merge-wins: global-merge, profile-script-wins: global-merge, + nested: {winner: global-merge}, dns: {enable: false}, tun: {enable: false}}", + )), + }; + let global_script = ChainItem::to_script( + "Script", + r#"function main(config) { + config["global-script-wins"] = "global-script"; + config["profile-merge-wins"] = "global-script"; + config["profile-script-wins"] = "global-script"; + config.nested.winner = "global-script"; + return config; + }"#, + ); + let profile_merge = ChainItem { + uid: "profile-merge".into(), + data: ChainType::Merge(mapping( + r"{profile-merge-wins: profile-merge, profile-script-wins: profile-merge, + nested: {winner: profile-merge}}", + )), + }; + let profile_script = ChainItem::to_script( + "profile-script", + r#"function main(config) { + config["profile-script-wins"] = "profile-script"; + config.nested.winner = "profile-script"; + return config; + }"#, + ); + + let profile_name = "test-profile".into(); + let (config, exists_keys, result_map) = process_global_items( + config, + exists_keys, + HashMap::new(), + global_merge, + global_script, + &profile_name, + ) + .await; + let (config, exists_keys, _) = process_profile_items( + config, + exists_keys, + result_map, + profile_merge, + profile_script, + &profile_name, + ) + .await; + + let string_value = |key| config.get(key).and_then(serde_yaml_ng::Value::as_str); + assert_eq!(string_value("global-merge-wins"), Some("global-merge")); + assert_eq!(string_value("global-script-wins"), Some("global-script")); + assert_eq!(string_value("profile-merge-wins"), Some("profile-merge")); + assert_eq!(string_value("profile-script-wins"), Some("profile-script")); + assert_eq!( + config + .get("nested") + .and_then(|value| value.get("winner")) + .and_then(serde_yaml_ng::Value::as_str), + Some("profile-script") + ); + assert!(!exists_keys.contains(&"application-only".into())); + } + + #[test] + fn control_plane_survives_manual_overrides() { + let app_config = mapping( + r#"{external-controller: "", + external-controller-cors: {allow-origins: ["app-only"]}, + mixed-port: 7890, socks-port: 7891, secret: "app-secret", mode: rule, allow-lan: false, + log-level: info, ipv6: false, unified-delay: true, + dns: {proxy-server-nameserver: ["1.1.1.1"]}}"#, + ); + let snapshot = super::snapshot_control_plane(&app_config); + + let hijacked = mapping( + r#"{external-controller: "0.0.0.0:9090", + external-controller-cors: {allow-origins: ["*"]}, + mixed-port: 1080, socks-port: 1080, secret: "hijacked", mode: global, allow-lan: true, + log-level: debug, ipv6: true, unified-delay: false, + dns: {proxy-server-nameserver: ["8.8.8.8"]}}"#, + ); + + let result = super::enforce_control_plane(hijacked, snapshot); + + let as_str = |key| result.get(key).and_then(serde_yaml_ng::Value::as_str); + assert_eq!(as_str("external-controller"), Some("")); + assert_eq!( + result.get("mixed-port").and_then(serde_yaml_ng::Value::as_u64), + Some(7890) + ); + assert_eq!( + result.get("socks-port").and_then(serde_yaml_ng::Value::as_u64), + Some(7891) + ); + assert_eq!( + result + .get("external-controller-cors") + .and_then(|value| value.get("allow-origins")) + .and_then(serde_yaml_ng::Value::as_sequence) + .and_then(|seq| seq.first()) + .and_then(serde_yaml_ng::Value::as_str), + Some("app-only") + ); + assert_eq!(as_str("secret"), Some("app-secret")); + assert_eq!(as_str("mode"), Some("rule")); + assert_eq!( + result.get("allow-lan").and_then(serde_yaml_ng::Value::as_bool), + Some(false) + ); + assert_eq!(as_str("log-level"), Some("info")); + assert_eq!(result.get("ipv6").and_then(serde_yaml_ng::Value::as_bool), Some(false)); + assert_eq!( + result.get("unified-delay").and_then(serde_yaml_ng::Value::as_bool), + Some(true) + ); + + // DNS 数据面不属于顶层控制面。 + assert_eq!( + result + .get("dns") + .and_then(|value| value.get("proxy-server-nameserver")) + .and_then(serde_yaml_ng::Value::as_sequence) + .and_then(|seq| seq.first()) + .and_then(serde_yaml_ng::Value::as_str), + Some("8.8.8.8") + ); + } + + #[test] + fn control_plane_removes_reenabled_disabled_port() { + let app_config = mapping(r#"{mixed-port: 7890, mode: rule}"#); + let snapshot = super::snapshot_control_plane(&app_config); + + let hijacked = mapping(r#"{mixed-port: 7890, mode: rule, socks-port: 1080}"#); + let result = super::enforce_control_plane(hijacked, snapshot); + + assert!(!result.contains_key("socks-port")); + assert_eq!( + result.get("mixed-port").and_then(serde_yaml_ng::Value::as_u64), + Some(7890) + ); + } + + #[test] + fn dns_ipv6_follows_ui_but_other_dns_stays_overridable() { + let app_config = mapping(r#"{dns: {ipv6: false, proxy-server-nameserver: ["1.1.1.1"]}}"#); + let dns_ipv6 = super::snapshot_dns_ipv6(&app_config); + + let hijacked = mapping(r#"{dns: {ipv6: true, proxy-server-nameserver: ["8.8.8.8"]}}"#); + let result = super::enforce_dns_ipv6(hijacked, dns_ipv6); + + assert_eq!( + result + .get("dns") + .and_then(|value| value.get("ipv6")) + .and_then(serde_yaml_ng::Value::as_bool), + Some(false) + ); + assert_eq!( + result + .get("dns") + .and_then(|value| value.get("proxy-server-nameserver")) + .and_then(serde_yaml_ng::Value::as_sequence) + .and_then(|seq| seq.first()) + .and_then(serde_yaml_ng::Value::as_str), + Some("8.8.8.8") + ); + } + + #[test] + fn snapshot_control_plane_skips_absent_keys() { + let app_config = mapping(r#"{mode: rule, mixed-port: 7890}"#); + let snapshot = super::snapshot_control_plane(&app_config); + assert!(snapshot.contains_key("mode")); + assert!(snapshot.contains_key("mixed-port")); + assert!(!snapshot.contains_key("secret")); + assert!(!snapshot.contains_key("allow-lan")); + } #[test] fn remove_missing_proxies_from_groups() { diff --git a/src-tauri/src/utils/init.rs b/src-tauri/src/utils/init.rs index 54acd518b..5457e8b87 100644 --- a/src-tauri/src/utils/init.rs +++ b/src-tauri/src/utils/init.rs @@ -129,6 +129,8 @@ pub(super) async fn init_dns_config() -> Result<()> { // 创建DNS子配置 let dns_config = serde_yaml_ng::Mapping::from_iter([ ("enable".into(), Value::Bool(true)), + // 与前端 DNS 默认一致,并供 dns.ipv6 锁定使用 + ("ipv6".into(), Value::Bool(true)), ("listen".into(), Value::String(":53".into())), ("enhanced-mode".into(), Value::String("fake-ip".into())), ("fake-ip-range".into(), Value::String("198.18.0.1/16".into())),