Files
clash-verge-rev/src-tauri/src/enhance/mod.rs
T
wonfen ff1ce92fac feat(dns): remember override state per profile
Save the DNS override switch and session confirmation per profile so automatic protection only disables the affected profile. Preserve the legacy switch as the initial preference and show the profile scope beside the settings gear and in the DNS editor.

Refs #7993
2026-09-22 10:54:55 +08:00

1860 lines
62 KiB
Rust

mod chain;
pub mod field;
mod merge;
mod script;
pub mod seq;
mod tun;
use self::{
chain::{AsyncChainItemFrom as _, ChainItem, ChainType},
field::{use_keys, use_lowercase_owned, use_sort},
merge::use_merge,
script::use_script,
seq::{SeqMap, use_seq},
tun::use_tun,
};
use crate::config::dns::{DnsOverrideState, dns_override_source};
use crate::core::handle::Handle;
use crate::utils::dirs;
use crate::{
config::{Config, IProfiles, IVerge, PrfItem},
constants,
utils::tmpl,
};
use anyhow::{Context as _, Result};
use clash_verge_logging::{Type, logging};
use parking_lot::Mutex;
use serde_yaml_ng::{Mapping, Value};
use smartstring::alias::String;
use std::collections::{HashMap, HashSet};
use tokio::fs;
type ResultLog = Vec<(String, String)>;
#[derive(Debug)]
struct ConfigValues {
clash_config: Mapping,
clash_core: Option<String>,
enable_tun: bool,
enable_builtin: bool,
socks_enabled: bool,
http_enabled: bool,
enable_dns_settings: bool,
dns_override_confirmation: Option<String>,
enable_external_controller: bool,
#[cfg(not(target_os = "windows"))]
redir_enabled: bool,
#[cfg(target_os = "linux")]
tproxy_enabled: bool,
}
#[derive(Debug)]
struct ProfileItems {
config: Mapping,
merge_item: ChainItem,
script_item: ChainItem,
rules_item: ChainItem,
proxies_item: ChainItem,
groups_item: ChainItem,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: String,
}
impl Default for ProfileItems {
fn default() -> Self {
Self {
config: Default::default(),
profile_name: Default::default(),
merge_item: ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},
script_item: ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},
rules_item: ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},
proxies_item: ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},
groups_item: ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},
global_merge: ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},
global_script: ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},
}
}
}
async fn chain_item_or_default(item: Option<&PrfItem>, default_item: impl FnOnce() -> ChainItem) -> ChainItem {
if let Some(item) = item {
<Option<ChainItem>>::from_async(item).await.unwrap_or_else(default_item)
} else {
default_item()
}
}
async fn get_config_values(profile_uid: &str) -> ConfigValues {
let clash = Config::clash().await;
let clash_arc = clash.latest_arc();
let clash_config = clash_arc.0.clone();
drop(clash_arc);
drop(clash);
let verge = Config::verge().await;
let verge_arc = verge.latest_arc();
let IVerge {
ref enable_tun_mode,
ref enable_builtin_enhanced,
ref verge_socks_enabled,
ref verge_http_enabled,
ref enable_external_controller,
..
} = **verge_arc;
let enable_external_controller = enable_external_controller.unwrap_or(false);
let dns_settings = verge_arc.dns_settings_for(profile_uid);
let dns_override_confirmation = dns_settings.confirmation;
let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = (
Some(verge_arc.get_valid_clash_core()),
enable_tun_mode.unwrap_or(false),
enable_builtin_enhanced.unwrap_or(true),
verge_socks_enabled.unwrap_or(false),
verge_http_enabled.unwrap_or(false),
dns_settings.enabled,
);
#[cfg(not(target_os = "windows"))]
let redir_enabled = verge_arc.verge_redir_enabled.unwrap_or(false);
#[cfg(target_os = "linux")]
let tproxy_enabled = verge_arc.verge_tproxy_enabled.unwrap_or(false);
drop(verge_arc);
drop(verge);
ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
dns_override_confirmation,
enable_external_controller,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
}
}
#[allow(clippy::cognitive_complexity)]
async fn collect_profile_items(profiles: &IProfiles) -> Result<ProfileItems> {
let current_profile_uid = match profiles.current.clone() {
Some(uid) => uid,
None => return Ok(ProfileItems::default()),
};
let current = profiles
.current_mapping()
.await
.with_context(|| format!("failed to read current profile \"{current_profile_uid}\""))?;
let current_item = match profiles.get_item(&current_profile_uid) {
Ok(item) => item,
Err(err) => {
return Err(err).with_context(|| format!("failed to get current profile \"{current_profile_uid}\""));
}
};
let merge_uid = current_item.current_merge().cloned().unwrap_or_else(|| "Merge".into());
let script_uid = current_item
.current_script()
.cloned()
.unwrap_or_else(|| "Script".into());
let rules_uid = current_item.current_rules().cloned().unwrap_or_else(|| "Rules".into());
let proxies_uid = current_item
.current_proxies()
.cloned()
.unwrap_or_else(|| "Proxies".into());
let groups_uid = current_item
.current_groups()
.cloned()
.unwrap_or_else(|| "Groups".into());
let name = current_item.name.clone().unwrap_or_default();
let (merge_item, script_item, rules_item, proxies_item, groups_item, global_merge, global_script) = tokio::join!(
chain_item_or_default(profiles.get_item(&merge_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item(&script_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
chain_item_or_default(profiles.get_item(&rules_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Rules(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&proxies_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Proxies(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item(&groups_uid).ok(), || ChainItem {
uid: "".into(),
data: ChainType::Groups(SeqMap::default()),
},),
chain_item_or_default(profiles.get_item("Merge").ok(), || ChainItem {
uid: "Merge".into(),
data: ChainType::Merge(Mapping::new()),
},),
chain_item_or_default(profiles.get_item("Script").ok(), || ChainItem {
uid: "Script".into(),
data: ChainType::Script(tmpl::ITEM_SCRIPT.into()),
},),
);
Ok(ProfileItems {
config: current,
merge_item,
script_item,
rules_item,
proxies_item,
groups_item,
global_merge,
global_script,
profile_name: name,
})
}
async fn process_global_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
global_merge: ChainItem,
global_script: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = global_merge.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(global_merge.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = global_script.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(global_script.uid, logs);
}
(config, exists_keys, result_map)
}
fn process_seq_items(
mut config: Mapping,
rules_item: ChainItem,
proxies_item: ChainItem,
groups_item: ChainItem,
) -> Mapping {
if let ChainType::Rules(rules) = rules_item.data {
config = use_seq(rules, config, "rules");
}
if let ChainType::Proxies(proxies) = proxies_item.data {
config = use_seq(proxies, config, "proxies");
}
if let ChainType::Groups(groups) = groups_item.data {
config = use_seq(groups, config, "proxy-groups");
}
config
}
/// App 权威的顶层控制面键;`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",
];
/// App-owned fields: captured after the app derives them, enforced after every override.
struct AuthoritativeFields {
control_plane: Mapping,
tun: Mapping,
/// Only when the DNS page switched it on.
dns_ipv6: Option<Value>,
}
impl AuthoritativeFields {
fn capture(config: &Mapping, gui_tun_keys: &[Value], owns_dns_ipv6: bool) -> Self {
Self {
control_plane: snapshot_control_plane(config),
tun: snapshot_tun(config, gui_tun_keys),
dns_ipv6: owns_dns_ipv6.then(|| snapshot_dns_ipv6(config)).flatten(),
}
}
fn enforce(self, config: Mapping) -> Mapping {
let config = enforce_control_plane(config, self.control_plane);
let config = enforce_tun(config, self.tun);
enforce_dns_ipv6(config, self.dns_ipv6)
}
/// The owned values as `config` holds them now, for diffing one override.
fn current(&self, config: &Mapping) -> Self {
let tun_keys: Vec<Value> = self.tun.keys().cloned().collect();
Self::capture(config, &tun_keys, self.dns_ipv6.is_some())
}
/// Owned keys an override moved off the app value; `enforce` discards those writes.
fn overridden(&self, before: &Self, after: &Self) -> Vec<String> {
let mut keys = Vec::new();
for &key in CONTROL_PLANE_KEYS {
if before.control_plane.get(key) != after.control_plane.get(key)
&& after.control_plane.get(key) != self.control_plane.get(key)
{
keys.push(key.into());
}
}
for key in self.tun.keys() {
if before.tun.get(key) != after.tun.get(key) && after.tun.get(key) != self.tun.get(key) {
keys.push(format!("tun.{}", key.as_str().unwrap_or_default()).into());
}
}
if self.dns_ipv6.is_some() && before.dns_ipv6 != after.dns_ipv6 && after.dns_ipv6 != self.dns_ipv6 {
keys.push("dns.ipv6".into());
}
keys
}
}
fn discarded_note(key: String) -> (String, String) {
(
"warn".into(),
format!("`{key}` is managed by Settings; the value written here was discarded").into(),
)
}
struct DiscardedKeysNotice {
pending: Option<String>,
last: Vec<String>,
}
static DISCARDED_KEYS_NOTICE: Mutex<DiscardedKeysNotice> = Mutex::new(DiscardedKeysNotice {
pending: None,
last: Vec::new(),
});
pub(crate) fn take_discarded_keys_notice() -> Option<String> {
DISCARDED_KEYS_NOTICE.lock().pending.take()
}
/// One notice per distinct set, so regenerations with unchanged extensions stay quiet.
fn notify_discarded_keys(keys: Vec<String>) {
let mut notice = DISCARDED_KEYS_NOTICE.lock();
if notice.last == keys {
return;
}
let should_notify = !keys.is_empty();
if should_notify {
notice.pending = Some(keys.join(", ").into());
}
notice.last = keys;
drop(notice);
if should_notify {
Handle::notice_message("enhance::discarded_keys", "");
}
}
/// 手动 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
}
/// Only saved GUI fields and the verge switch override profile settings.
fn gui_tun_keys(clash_config: &Mapping) -> Vec<Value> {
let mut keys = vec![Value::from("enable")];
if let Some(Value::Mapping(tun)) = clash_config.get("tun") {
keys.extend(
constants::tun::GUI_KEYS
.iter()
.filter(|key| tun.contains_key(**key))
.map(|key| Value::from(*key)),
);
}
keys
}
fn snapshot_tun(config: &Mapping, gui_tun_keys: &[Value]) -> Mapping {
let mut snapshot = Mapping::new();
if let Some(Value::Mapping(tun)) = config.get("tun") {
for key in gui_tun_keys {
if let Some(value) = tun.get(key) {
snapshot.insert(key.clone(), value.clone());
}
}
}
snapshot
}
fn enforce_tun(mut config: Mapping, snapshot: Mapping) -> Mapping {
if snapshot.is_empty() {
return config;
}
let mut tun = config
.get("tun")
.and_then(Value::as_mapping)
.cloned()
.unwrap_or_default();
tun.extend(snapshot);
config.insert(Value::from("tun"), Value::Mapping(tun));
config
}
/// 只在 DNS 页开启 IPv6 时快照。
fn snapshot_dns_ipv6(config: &Mapping) -> Option<Value> {
config.get("dns")?.get("ipv6").cloned()
}
/// 恢复 `dns.ipv6`,但不创建缺失的 `dns` 块。
fn enforce_dns_ipv6(mut config: Mapping, dns_ipv6: Option<Value>) -> 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
}
fn is_loopback_bind_address(addr: &str) -> bool {
let addr = addr.trim();
let addr = addr
.strip_prefix('[')
.and_then(|addr| addr.strip_suffix(']'))
.unwrap_or(addr);
addr.eq_ignore_ascii_case("localhost")
|| addr.parse::<std::net::IpAddr>().is_ok_and(|addr| addr.is_loopback())
|| is_ipv4_shorthand_loopback(addr)
}
fn is_ipv4_shorthand_loopback(addr: &str) -> bool {
let parts = addr.split('.').map(str::parse::<u32>).collect::<Result<Vec<_>, _>>();
let Ok(parts) = parts else {
return false;
};
match parts.as_slice() {
[first, rest] => *first == 127 && *rest <= 0x00ff_ffff,
[first, second, rest] => *first == 127 && *second <= 0xff && *rest <= 0xffff,
[first, second, third, fourth] => *first == 127 && *second <= 0xff && *third <= 0xff && *fourth <= 0xff,
_ => false,
}
}
fn ensure_lan_bind_address(mut config: Mapping) -> Mapping {
let allow_lan = config.get("allow-lan").and_then(Value::as_bool).unwrap_or(false);
if allow_lan
&& config
.get("bind-address")
.and_then(Value::as_str)
.is_some_and(is_loopback_bind_address)
{
config.insert(Value::from("bind-address"), Value::from("*"));
}
config
}
async fn process_profile_items(
mut config: Mapping,
mut exists_keys: Vec<String>,
mut result_map: HashMap<String, ResultLog>,
merge_item: ChainItem,
script_item: ChainItem,
profile_name: &String,
authoritative: &AuthoritativeFields,
) -> (Mapping, Vec<String>, HashMap<String, ResultLog>) {
if let ChainType::Merge(merge) = merge_item.data {
exists_keys.extend(use_keys(&merge));
let before = authoritative.current(&config);
config = use_merge(&merge, config);
let notes: ResultLog = authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note)
.collect();
if !notes.is_empty() {
result_map.entry(merge_item.uid).or_default().extend(notes);
}
}
if let ChainType::Script(script) = script_item.data {
let before = authoritative.current(&config);
let (res_config, changed_keys, mut logs) = use_script(script, config, profile_name.clone()).await;
exists_keys.extend(changed_keys);
config = res_config;
logs.extend(
authoritative
.overridden(&before, &authoritative.current(&config))
.into_iter()
.map(discarded_note),
);
result_map.insert(script_item.uid, logs);
}
(config, exists_keys, result_map)
}
/// Merges the app's clash config over the profile; switches are parameters to keep it pure.
fn merge_default_config(
mut config: Mapping,
clash_config: Mapping,
socks_enabled: bool,
http_enabled: bool,
enable_external_controller: bool,
#[cfg(not(target_os = "windows"))] redir_enabled: bool,
#[cfg(target_os = "linux")] tproxy_enabled: bool,
) -> Mapping {
for (key, value) in clash_config.into_iter() {
if key.as_str() == Some("tun") {
let mut tun = config.get_mut("tun").map_or_else(Mapping::new, |val| {
val.as_mapping().cloned().unwrap_or_else(Mapping::new)
});
let patch_tun = value.as_mapping().cloned().unwrap_or_else(Mapping::new);
for (key, value) in patch_tun.into_iter() {
tun.insert(key, value);
}
config.insert("tun".into(), tun.into());
} else {
if key.as_str() == Some("socks-port") && !socks_enabled {
config.remove("socks-port");
continue;
}
if key.as_str() == Some("port") && !http_enabled {
config.remove("port");
continue;
}
#[cfg(target_os = "windows")]
{
if key.as_str() == Some("redir-port") {
continue;
}
}
#[cfg(not(target_os = "windows"))]
{
if key.as_str() == Some("redir-port") && !redir_enabled {
config.remove("redir-port");
continue;
}
}
#[cfg(target_os = "linux")]
{
if key.as_str() == Some("tproxy-port") && !tproxy_enabled {
config.remove("tproxy-port");
continue;
}
}
#[cfg(not(target_os = "linux"))]
{
if key.as_str() == Some("tproxy-port") {
config.remove("tproxy-port");
continue;
}
}
if key.as_str() == Some("external-controller") {
if enable_external_controller {
config.insert(key, value);
} else {
config.insert(key, "".into());
}
} else {
config.insert(key, value);
}
}
}
config
}
fn apply_builtin_scripts(mut config: Mapping, clash_core: Option<String>, enable_builtin: bool) -> Mapping {
if enable_builtin {
let items: Vec<_> = ChainItem::builtin()
.into_iter()
.filter(|(s, _)| s.is_support(clash_core.as_ref()))
.map(|(_, c)| c)
.collect();
if !items.is_empty() {
// The JS path saw a lowercased view; lowercase once, only when a builtin runs.
config = use_lowercase_owned(config);
for item in items {
logging!(debug, Type::Core, "run builtin script {}", item.uid);
config = match item.uid.as_str() {
"verge_hy_alpn" => builtin_hy_alpn(config),
"verge_meta_guard" => builtin_meta_guard(config),
_ => config,
};
}
}
}
config
}
fn builtin_hy_alpn(mut config: Mapping) -> Mapping {
if let Some(Value::Sequence(proxies)) = config.get_mut("proxies") {
for proxy in proxies.iter_mut() {
let Some(proxy) = proxy.as_mapping_mut() else { continue };
let is_hysteria = proxy.get("type").and_then(Value::as_str) == Some("hysteria");
if is_hysteria && let Some(Value::String(alpn)) = proxy.get("alpn").cloned() {
proxy.insert("alpn".into(), Value::Sequence(vec![Value::String(alpn)]));
}
}
}
config
}
fn builtin_meta_guard(mut config: Mapping) -> Mapping {
if config.get("mode").and_then(Value::as_str) == Some("script") {
config.insert(Value::from("mode"), Value::from("rule"));
}
config
}
fn cleanup_proxy_groups(mut config: Mapping) -> Mapping {
// built-in proxies docs: https://wiki.metacubex.one/config/proxies/built-in
const BUILTIN_POLICIES: &[&str] = &["DIRECT", "REJECT", "REJECT-DROP", "PASS", "PASS-RULE"];
let proxy_names = config
.get("proxies")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| match item {
Value::Mapping(map) => map
.get("name")
.and_then(Value::as_str)
.map(|name| name.to_owned().into()),
Value::String(name) => Some(name.to_owned().into()),
_ => None,
})
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let group_names = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.map(|seq| {
seq.iter()
.filter_map(|item| {
item.as_mapping()
.and_then(|map| map.get("name"))
.and_then(Value::as_str)
.map(std::convert::Into::into)
})
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let provider_names = config
.get("proxy-providers")
.and_then(Value::as_mapping)
.map(|map| {
map.keys()
.filter_map(Value::as_str)
.map(std::convert::Into::into)
.collect::<HashSet<String>>()
})
.unwrap_or_default();
let mut allowed_names = proxy_names;
allowed_names.extend(group_names);
allowed_names.extend(provider_names.iter().cloned());
allowed_names.extend(BUILTIN_POLICIES.iter().map(|p| (*p).into()));
if let Some(Value::Sequence(groups)) = config.get_mut("proxy-groups") {
for group in groups {
if let Some(group_map) = group.as_mapping_mut() {
let mut has_valid_provider = false;
if let Some(Value::Sequence(uses)) = group_map.get_mut("use") {
uses.retain(|provider| match provider {
Value::String(name) => {
let exists = provider_names.contains(name.as_str());
has_valid_provider = has_valid_provider || exists;
exists
}
_ => false,
});
}
if let Some(Value::Sequence(proxies)) = group_map.get_mut("proxies") {
proxies.retain(|proxy| match proxy {
Value::String(name) => allowed_names.contains(name.as_str()) || has_valid_provider,
_ => true,
});
}
}
}
}
config
}
/// fake-ip + IPv6 缺少 `fake-ip-range6` 时补默认值,否则 AAAA 无法解析(#7373)。
fn ensure_fake_ip_range6(dns: &mut Mapping) {
use serde_yaml_ng::Value;
let ipv6_enabled = dns.get("ipv6").and_then(|v| v.as_bool()).unwrap_or(false);
let is_fake_ip = dns
.get("enhanced-mode")
.and_then(|v| v.as_str())
.map(|m| m == "fake-ip")
.unwrap_or(true);
// Hand-edited YAML may leave the key present but empty.
let range6_missing = dns
.get("fake-ip-range6")
.and_then(|v| v.as_str())
.map(|s| s.trim().is_empty())
.unwrap_or(true);
if ipv6_enabled && is_fake_ip && range6_missing {
dns.insert(Value::from("fake-ip-range6"), Value::from("2001:2::0/64"));
}
}
/// On, non-blank text, or non-empty list/map; anything else keeps the profile's value.
fn is_set(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(on) => *on,
Value::String(text) => !text.trim().is_empty(),
Value::Sequence(items) => !items.is_empty(),
Value::Mapping(map) => !map.is_empty(),
Value::Number(_) | Value::Tagged(_) => true,
}
}
/// Configured DNS fields replace the whole value, including maps and lists.
fn merge_set_fields(mut base: Mapping, overlay: Mapping) -> Mapping {
for (key, value) in overlay {
if is_set(&value) {
base.insert(key, value);
}
}
base
}
fn take_mapping(config: &mut Mapping, key: &str) -> Mapping {
config
.get_mut(key)
.and_then(Value::as_mapping_mut)
.map(std::mem::take)
.unwrap_or_default()
}
fn merge_dns_config(mut config: Mapping, mut dns_config: Mapping) -> (Mapping, bool) {
let mut owns_ipv6 = false;
if let Some(Value::Mapping(hosts)) = dns_config.remove("hosts")
&& !hosts.is_empty()
{
config.insert("hosts".into(), hosts.into());
logging!(debug, Type::Core, "apply hosts configuration");
}
// Legacy layout: no `dns` root.
let dns_override = match dns_config.remove("dns") {
Some(Value::Mapping(dns)) => Some(dns),
Some(_) => None,
None => Some(dns_config),
};
if let Some(dns_override) = dns_override {
owns_ipv6 = dns_override.get("ipv6").is_some_and(is_set);
let mut dns = merge_set_fields(take_mapping(&mut config, "dns"), dns_override);
ensure_fake_ip_range6(&mut dns);
config.insert("dns".into(), dns.into());
logging!(debug, Type::Core, "apply dns_config.yaml");
}
(config, owns_ipv6)
}
/// Returns the merged config and whether the page set `dns.ipv6`.
async fn apply_dns_settings(config: Mapping, enable_dns_settings: bool) -> (Mapping, bool) {
if enable_dns_settings && let Ok(app_dir) = dirs::app_home_dir() {
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if dns_path.exists()
&& let Ok(dns_yaml) = fs::read_to_string(&dns_path).await
&& let Ok(dns_config) = serde_yaml_ng::from_str::<Mapping>(&dns_yaml)
{
return merge_dns_config(config, dns_config);
}
}
(config, false)
}
/// Returns the enhanced profile, its original keys, script logs, and DNS override decision.
pub async fn enhance(
profiles: &IProfiles,
) -> Result<(Mapping, HashSet<String>, HashMap<String, ResultLog>, DnsOverrideState)> {
let profile_uid = profiles.current.as_deref().unwrap_or_default();
let cfg_vals = get_config_values(profile_uid).await;
let ConfigValues {
clash_config,
clash_core,
enable_tun,
enable_builtin,
socks_enabled,
http_enabled,
enable_dns_settings,
dns_override_confirmation,
enable_external_controller,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
} = cfg_vals;
let profile = collect_profile_items(profiles).await?;
let dns_override = DnsOverrideState::new(
profile_uid,
dns_override_source(profile_uid, &profile.config)?,
enable_dns_settings,
dns_override_confirmation,
);
let enable_dns_settings = dns_override.enabled;
let config = profile.config;
let merge_item = profile.merge_item;
let script_item = profile.script_item;
let rules_item = profile.rules_item;
let proxies_item = profile.proxies_item;
let groups_item = profile.groups_item;
let global_merge = profile.global_merge;
let global_script = profile.global_script;
let profile_name = profile.profile_name;
let result_map = HashMap::new();
let config = process_seq_items(config, rules_item, proxies_item, groups_item);
let exists_keys = use_keys(&config).collect::<Vec<_>>();
let gui_tun_keys = gui_tun_keys(&clash_config);
let config = merge_default_config(
config,
clash_config,
socks_enabled,
http_enabled,
enable_external_controller,
#[cfg(not(target_os = "windows"))]
redir_enabled,
#[cfg(target_os = "linux")]
tproxy_enabled,
);
let config = apply_builtin_scripts(config, clash_core, enable_builtin);
let config = use_tun(config, enable_tun);
let (config, owns_dns_ipv6) = apply_dns_settings(config, enable_dns_settings).await;
let authoritative = AuthoritativeFields::capture(&config, &gui_tun_keys, owns_dns_ipv6);
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
result_map,
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, result_map) = process_profile_items(
config,
exists_keys,
result_map,
merge_item,
script_item,
&profile_name,
&authoritative,
)
.await;
notify_discarded_keys(authoritative.overridden(&authoritative, &authoritative.current(&config)));
let config = authoritative.enforce(config);
let config = ensure_lan_bind_address(config);
let config = cleanup_proxy_groups(config);
let config = use_sort(config);
let mut exists_keys_set = HashSet::new();
exists_keys_set.extend(exists_keys);
Ok((config, exists_keys_set, result_map, dns_override))
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod fake_ip_tests {
use super::{Mapping, Value, ensure_fake_ip_range6};
fn dns(pairs: &[(&str, Value)]) -> Mapping {
let mut map = Mapping::new();
for (key, value) in pairs {
map.insert(Value::from(*key), value.clone());
}
map
}
const RANGE6: &str = "2001:2::0/64";
#[test]
fn an_ipv6_fake_ip_setup_missing_its_range_gets_one() {
let mut config = dns(&[("ipv6", Value::from(true)), ("enhanced-mode", Value::from("fake-ip"))]);
ensure_fake_ip_range6(&mut config);
assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6)));
}
#[test]
fn fake_ip_is_the_assumed_mode_when_unstated() {
let mut config = dns(&[("ipv6", Value::from(true))]);
ensure_fake_ip_range6(&mut config);
assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6)));
}
#[test]
fn a_hand_edited_empty_range_counts_as_missing() {
// Hand-edited YAML may leave the key present but empty.
for blank in ["", " "] {
let mut config = dns(&[
("ipv6", Value::from(true)),
("enhanced-mode", Value::from("fake-ip")),
("fake-ip-range6", Value::from(blank)),
]);
ensure_fake_ip_range6(&mut config);
assert_eq!(
config.get(Value::from("fake-ip-range6")),
Some(&Value::from(RANGE6)),
"blank range {blank:?} should be filled in"
);
}
}
#[test]
fn an_existing_range_is_never_overwritten() {
let mut config = dns(&[
("ipv6", Value::from(true)),
("enhanced-mode", Value::from("fake-ip")),
("fake-ip-range6", Value::from("fc00::1/64")),
]);
ensure_fake_ip_range6(&mut config);
assert_eq!(
config.get(Value::from("fake-ip-range6")),
Some(&Value::from("fc00::1/64"))
);
}
#[test]
fn nothing_is_added_without_ipv6_or_outside_fake_ip() {
let mut without_ipv6 = dns(&[("enhanced-mode", Value::from("fake-ip"))]);
ensure_fake_ip_range6(&mut without_ipv6);
assert!(!without_ipv6.contains_key(Value::from("fake-ip-range6")));
let mut redir_host = dns(&[
("ipv6", Value::from(true)),
("enhanced-mode", Value::from("redir-host")),
]);
ensure_fake_ip_range6(&mut redir_host);
assert!(!redir_host.contains_key(Value::from("fake-ip-range6")));
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod use_tun_tests {
use super::{Mapping, Value, use_tun};
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
let mut map = Mapping::new();
for (key, value) in pairs {
map.insert(Value::from(*key), value.clone());
}
map
}
fn tun_of(config: &Mapping) -> &Mapping {
config
.get(Value::from("tun"))
.and_then(Value::as_mapping)
.expect("tun should be a mapping")
}
fn dns_of(config: &Mapping) -> Option<&Mapping> {
config.get(Value::from("dns")).and_then(Value::as_mapping)
}
#[tokio::test]
async fn the_switch_is_written_either_way() {
assert_eq!(
tun_of(&use_tun(Mapping::new(), true)).get(Value::from("enable")),
Some(&Value::from(true))
);
assert_eq!(
tun_of(&use_tun(Mapping::new(), false)).get(Value::from("enable")),
Some(&Value::from(false))
);
}
#[tokio::test]
async fn existing_tun_keys_survive_being_switched_on() {
let config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]);
let result = use_tun(config, true);
assert_eq!(tun_of(&result).get(Value::from("stack")), Some(&Value::from("gvisor")));
}
#[tokio::test]
async fn enabling_tun_sets_up_fake_ip_dns() {
let result = use_tun(Mapping::new(), true);
let dns = dns_of(&result).expect("enabling tun should write dns settings");
assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(true)));
assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("fake-ip")));
assert_eq!(
dns.get(Value::from("fake-ip-range")),
Some(&Value::from("198.18.0.1/16"))
);
}
#[tokio::test]
async fn a_deliberate_redir_host_setup_is_left_alone() {
// Only fake-ip DNS is ours to configure; someone who chose redir-host meant it.
let config = mapping(&[(
"dns",
Value::Mapping(mapping(&[("enhanced-mode", Value::from("redir-host"))])),
)]);
let result = use_tun(config, true);
let dns = dns_of(&result).expect("dns should still be present");
assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("redir-host")));
assert!(!dns.contains_key(Value::from("fake-ip-range")));
}
#[tokio::test]
async fn the_ipv6_fake_ip_range_follows_the_top_level_switch() {
let with_ipv6 = use_tun(mapping(&[("ipv6", Value::from(true))]), true);
let dns = dns_of(&with_ipv6).expect("dns should be written");
assert_eq!(dns.get(Value::from("ipv6")), Some(&Value::from(true)));
assert_eq!(
dns.get(Value::from("fake-ip-range6")),
Some(&Value::from("2001:2::0/64"))
);
let without_ipv6 = use_tun(Mapping::new(), true);
let dns = dns_of(&without_ipv6).expect("dns should be written");
assert!(!dns.contains_key(Value::from("fake-ip-range6")));
}
#[tokio::test]
async fn switching_tun_off_leaves_dns_untouched() {
// TUN off must not rewrite the DNS the profile chose.
let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]);
let result = use_tun(config, false);
let dns = dns_of(&result).expect("dns should be untouched");
assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(false)));
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod use_sort_tests {
use super::{Mapping, Value, use_sort};
#[test]
fn every_key_survives_sorting() {
let mut config = Mapping::new();
for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] {
config.insert(Value::from(key), Value::from(key));
}
let expected = config.len();
let sorted = use_sort(config);
assert_eq!(sorted.len(), expected, "sorting must not drop or invent keys");
for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] {
assert_eq!(sorted.get(Value::from(key)), Some(&Value::from(key)));
}
}
#[test]
fn the_bulky_list_fields_are_written_last() {
// Bulky list fields go last so the top of the file stays readable.
let mut config = Mapping::new();
config.insert(Value::from("rules"), Value::from("rules"));
config.insert(Value::from("mode"), Value::from("rule"));
let sorted = use_sort(config);
let order: Vec<_> = sorted.keys().filter_map(Value::as_str).collect();
let mode = order.iter().position(|key| *key == "mode");
let rules = order.iter().position(|key| *key == "rules");
assert!(mode < rules, "expected mode before rules, got {order:?}");
}
#[test]
fn sorting_is_stable_when_applied_twice() {
let mut config = Mapping::new();
for key in ["proxies", "mode", "unknown-key", "rules"] {
config.insert(Value::from(key), Value::from(1));
}
let once = use_sort(config);
let twice = use_sort(once.clone());
assert_eq!(once.keys().collect::<Vec<_>>(), twice.keys().collect::<Vec<_>>());
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod merge_default_config_tests {
use super::{Mapping, Value, merge_default_config};
fn mapping(pairs: &[(&str, Value)]) -> Mapping {
let mut map = Mapping::new();
for (key, value) in pairs {
map.insert(Value::from(*key), value.clone());
}
map
}
/// Call with every listener enabled, so a test only says what it is turning off.
fn merge(config: Mapping, clash_config: Mapping, external_controller: bool) -> Mapping {
merge_default_config(
config,
clash_config,
true,
true,
external_controller,
#[cfg(not(target_os = "windows"))]
true,
#[cfg(target_os = "linux")]
true,
)
}
fn merge_without_optional_listeners(config: Mapping, clash_config: Mapping) -> Mapping {
merge_default_config(
config,
clash_config,
false,
false,
true,
#[cfg(not(target_os = "windows"))]
false,
#[cfg(target_os = "linux")]
false,
)
}
#[test]
fn the_merge_config_overwrites_the_profile() {
let config = mapping(&[("mode", Value::from("rule"))]);
let clash_config = mapping(&[("mode", Value::from("global"))]);
let merged = merge(config, clash_config, true);
assert_eq!(merged.get(Value::from("mode")), Some(&Value::from("global")));
}
#[test]
fn keys_the_merge_config_says_nothing_about_survive() {
let config = mapping(&[("profile-only", Value::from(1))]);
let merged = merge(config, Mapping::new(), true);
assert_eq!(merged.get(Value::from("profile-only")), Some(&Value::from(1)));
}
#[test]
fn a_disabled_listener_is_removed_rather_than_merged() {
// Removed, not skipped: the profile's own value must not reopen a disabled listener.
let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]);
let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]);
let merged = merge_without_optional_listeners(config, clash_config);
assert!(!merged.contains_key(Value::from("socks-port")));
assert!(!merged.contains_key(Value::from("port")));
}
#[test]
fn an_enabled_listener_takes_the_merge_config_value() {
let config = mapping(&[("socks-port", Value::from(7891))]);
let clash_config = mapping(&[("socks-port", Value::from(1080))]);
let merged = merge(config, clash_config, true);
assert_eq!(merged.get(Value::from("socks-port")), Some(&Value::from(1080)));
}
#[test]
fn a_disabled_external_controller_is_blanked_rather_than_dropped() {
// Blanked, not removed: an absent key makes mihomo listen on its default.
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, false);
assert_eq!(
merged.get(Value::from("external-controller")),
Some(&Value::from("")),
"the key must remain, holding an empty value"
);
}
#[test]
fn an_enabled_external_controller_keeps_its_address() {
let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]);
let merged = merge(Mapping::new(), clash_config, true);
assert_eq!(
merged.get(Value::from("external-controller")),
Some(&Value::from("127.0.0.1:9090"))
);
}
#[test]
fn tun_settings_are_merged_key_by_key_rather_than_replaced() {
// The profile's other tun keys have to survive a merge config that sets only one.
let config = mapping(&[(
"tun",
Value::Mapping(mapping(&[
("device", Value::from("utun9")),
("stack", Value::from("system")),
])),
)]);
let clash_config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]);
let merged = merge(config, clash_config, true);
let tun = merged
.get(Value::from("tun"))
.and_then(Value::as_mapping)
.expect("tun should still be a mapping");
assert_eq!(tun.get(Value::from("device")), Some(&Value::from("utun9")));
assert_eq!(tun.get(Value::from("stack")), Some(&Value::from("gvisor")));
}
}
#[cfg(test)]
#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")]
mod authoritative_field_tests {
use super::{AuthoritativeFields, Mapping, Value};
fn config_with(pairs: &[(&str, Value)]) -> Mapping {
let mut config = Mapping::new();
for (key, value) in pairs {
config.insert(Value::from(*key), value.clone());
}
config
}
fn dns_with_ipv6(enabled: bool) -> Value {
let mut dns = Mapping::new();
dns.insert(Value::from("ipv6"), Value::from(enabled));
dns.insert(Value::from("enable"), Value::from(true));
Value::Mapping(dns)
}
#[test]
fn an_override_cannot_change_a_field_the_app_owns() {
let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]);
let result = authoritative.enforce(overridden);
assert_eq!(result.get(Value::from("mode")), Some(&Value::from("rule")));
assert_eq!(result.get(Value::from("secret")), Some(&Value::from("ours")));
}
#[test]
fn an_override_cannot_introduce_a_field_the_app_left_out() {
// The app decided not to expose the external controller; a profile must not re-add it.
let derived = Mapping::new();
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]);
let result = authoritative.enforce(overridden);
assert!(!result.contains_key(Value::from("external-controller")));
}
#[test]
fn fields_the_app_does_not_own_survive_an_override() {
let derived = config_with(&[("mode", Value::from("rule"))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], false);
let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]);
let result = authoritative.enforce(overridden);
assert_eq!(result.get(Value::from("profile-key")), Some(&Value::from(1)));
}
#[test]
fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let owned = AuthoritativeFields::capture(&derived, &[], true);
let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
Some(&Value::from(true)),
"with the DNS page on, the app's value wins"
);
let unowned = AuthoritativeFields::capture(&derived, &[], false);
let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))]));
assert_eq!(
left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")),
Some(&Value::from(false)),
"with the DNS page off, an override may set it"
);
}
#[test]
fn restoring_dns_ipv6_never_invents_a_dns_block() {
let derived = config_with(&[("dns", dns_with_ipv6(true))]);
let authoritative = AuthoritativeFields::capture(&derived, &[], true);
// An override removed DNS entirely; reinstating just `ipv6` would be a half-config.
let result = authoritative.enforce(Mapping::new());
assert!(!result.contains_key(Value::from("dns")));
}
}
#[allow(clippy::expect_used)]
#[cfg(test)]
mod tests {
use super::{
AuthoritativeFields, ChainItem, ChainType, cleanup_proxy_groups, ensure_lan_bind_address, process_global_items,
process_profile_items, use_keys,
};
use std::collections::HashMap;
fn mapping(yaml: &str) -> serde_yaml_ng::Mapping {
serde_yaml_ng::from_str(yaml).expect("test config should be valid")
}
#[test]
fn merge_replaces_dns_fields_with_or_without_dns_settings() {
let profile = mapping(
"dns: {nameserver: [9.9.9.9], nameserver-policy: {profile.example: 9.9.9.9}}\n\
hosts: {profile.example: 192.0.2.1}",
);
let settings = mapping(
"dns: {ipv6: true, nameserver: [8.8.8.8], nameserver-policy: {settings.example: 8.8.8.8}}\n\
hosts: {settings.example: 192.0.2.2}",
);
let merge = mapping(
"dns: {ipv6: false, nameserver-policy: {merge.example: 1.1.1.1}}\n\
hosts: {merge.example: 192.0.2.3}",
);
for enabled in [false, true] {
let (config, owns_ipv6) = if enabled {
super::merge_dns_config(profile.clone(), settings.clone())
} else {
(profile.clone(), false)
};
let authoritative = AuthoritativeFields::capture(&config, &[], owns_ipv6);
let result = authoritative.enforce(super::use_merge(&merge, config));
assert_eq!(result["dns"]["nameserver-policy"], merge["dns"]["nameserver-policy"]);
assert_eq!(result["hosts"], merge["hosts"]);
let source = if enabled { &settings } else { &profile };
assert_eq!(result["dns"]["nameserver"], source["dns"]["nameserver"]);
assert_eq!(result["dns"]["ipv6"], serde_yaml_ng::Value::from(enabled));
}
}
#[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 authoritative = AuthoritativeFields::capture(&config, &[], false);
let (config, exists_keys, result_map) = process_global_items(
config,
exists_keys,
HashMap::new(),
global_merge,
global_script,
&profile_name,
&authoritative,
)
.await;
let (config, exists_keys, _) = process_profile_items(
config,
exists_keys,
result_map,
profile_merge,
profile_script,
&profile_name,
&authoritative,
)
.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 lan_bind_address_loopback_is_widened() {
for bind_address in [
"localhost",
"127.0.0.1",
"127.0.0.2",
"127.1",
"::1",
"[::1]",
"0:0:0:0:0:0:0:1",
] {
let result = ensure_lan_bind_address(mapping(&format!(
r#"{{allow-lan: true, bind-address: "{bind_address}"}}"#
)));
assert_eq!(
result.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
Some("*"),
"bind-address {bind_address} should be widened"
);
}
}
#[test]
fn lan_bind_address_preserves_custom_or_disabled() {
let custom = ensure_lan_bind_address(mapping(r#"{allow-lan: true, bind-address: "192.168.1.2"}"#));
assert_eq!(
custom.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
Some("192.168.1.2")
);
let disabled = ensure_lan_bind_address(mapping(r#"{allow-lan: false, bind-address: "127.0.0.1"}"#));
assert_eq!(
disabled.get("bind-address").and_then(serde_yaml_ng::Value::as_str),
Some("127.0.0.1")
);
}
#[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() {
let config_str = r#"
proxies:
- name: "alive-node"
type: ss
proxy-groups:
- name: "manual"
type: select
proxies:
- "alive-node"
- "missing-node"
- "DIRECT"
- name: "nested"
type: select
proxies:
- "manual"
- "ghost"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let manual_proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("manual proxies should be a sequence");
assert_eq!(manual_proxies.len(), 2);
assert!(manual_proxies.iter().any(|p| p.as_str() == Some("alive-node")));
assert!(manual_proxies.iter().any(|p| p.as_str() == Some("DIRECT")));
let nested_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("nested"))
.and_then(|group| group.as_mapping())
.expect("nested group should exist");
let nested_proxies = nested_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("nested proxies should be a sequence");
assert_eq!(nested_proxies.len(), 1);
assert_eq!(nested_proxies[0].as_str(), Some("manual"));
}
#[test]
fn keep_provider_backed_groups_intact() {
let config_str = r#"
proxy-providers:
providerA:
type: http
url: https://example.com
path: ./providerA.yaml
proxies: []
proxy-groups:
- name: "manual"
type: select
use:
- "providerA"
- "ghostProvider"
proxies:
- "dynamic-node"
- "DIRECT"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let uses = manual_group
.get("use")
.and_then(|v| v.as_sequence())
.expect("use should be a sequence");
assert_eq!(uses.len(), 1);
assert_eq!(uses[0].as_str(), Some("providerA"));
let proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("proxies should be a sequence");
assert_eq!(proxies.len(), 2);
assert!(proxies.iter().any(|p| p.as_str() == Some("dynamic-node")));
assert!(proxies.iter().any(|p| p.as_str() == Some("DIRECT")));
}
#[test]
fn prune_invalid_provider_and_proxies_without_provider() {
let config_str = r#"
proxy-groups:
- name: "manual"
type: select
use:
- "ghost-provider"
proxies:
- "ghost-node"
- "DIRECT"
"#;
let mut config: serde_yaml_ng::Mapping =
serde_yaml_ng::from_str(config_str).expect("Failed to parse test yaml");
config = cleanup_proxy_groups(config);
let groups = config
.get("proxy-groups")
.and_then(|v| v.as_sequence())
.cloned()
.expect("proxy-groups should be a sequence");
let manual_group = groups
.iter()
.find(|group| group.get("name").and_then(serde_yaml_ng::Value::as_str) == Some("manual"))
.and_then(|group| group.as_mapping())
.expect("manual group should exist");
let uses = manual_group
.get("use")
.and_then(|v| v.as_sequence())
.expect("use should be a sequence");
assert_eq!(uses.len(), 0);
let proxies = manual_group
.get("proxies")
.and_then(|v| v.as_sequence())
.expect("proxies should be a sequence");
assert_eq!(proxies.len(), 1);
assert_eq!(proxies[0].as_str(), Some("DIRECT"));
}
}