From a572db1970e7ca9fcf20fb20a824438dbcc154a3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 16:04:14 +0800 Subject: [PATCH 001/445] Support direct-only configs and domain DNS policies --- .github/workflows/test.yml | 3 + docs/config.yaml | 26 ++-- docs/geodata.md | 5 +- docs/tun-icmp-dns.md | 19 +-- src/config/mod.rs | 231 +++++++++++++++++++++--------------- src/dns/mod.rs | 1 + src/dns/policy.rs | 122 +++++++++++++++++++ src/dns/runtime.rs | 111 ++++++++++++++--- src/geodata/manager.rs | 4 +- src/geodata/mod.rs | 2 +- src/geodata/tests.rs | 12 +- src/routing/dispatcher.rs | 44 +++++-- src/runtime.rs | 29 ++++- tests/proxy_group_compat.rs | 5 +- 14 files changed, 466 insertions(+), 148 deletions(-) create mode 100644 src/dns/policy.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ddec080..5d35709 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -208,6 +208,9 @@ jobs: invoke::tests::engine_workers_preserve_invoke_admission_and_shutdown invoke::tests::engine_completion_notifies_after_panic_and_join_reports_failure dns::runtime::tests::concurrent_redir_host_hint_reads_and_dns_updates_complete + dns::runtime::tests::domain_policies_use_specificity_and_preserve_geosite_order + dns::runtime::tests::dns_policy_selection_is_consistent_across_snapshot_swaps + runtime::tests::prepares_builtin_routes_without_static_nodes_or_bootstrap invoke::tests::null_invalid_utf8_and_oversized_input_return_json_failures invoke::tests::same_instance_command_is_fail_fast invoke::tests::android_protector_is_required_only_for_tun diff --git a/docs/config.yaml b/docs/config.yaml index 51247e2..55594a6 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -17,8 +17,8 @@ ipv6: true # GeoData 下载配置可省略;出现时三项必须齐全。URL 必须是以域名为主机、无 # userinfo/fragment 的绝对 HTTPS URL,更新周期固定为 24 小时。validateConfig 和 # measureDelay 不下载。公共实例启动后,只有实际规则需要资产且自动更新已启用时 -# 才后台检查。下载使用最终 MATCH route target 在创建新物理 transport 时解析到的 -# 当前节点、DIRECT 或 REJECT;代理组不自动故障转移,也不回退 DIRECT。 +# 才后台检查。下载使用最终 MATCH 动作:DIRECT、REJECT、节点或代理组;代理组在 +# 创建新物理 transport 时解析当前成员,不自动故障转移,也不隐式回退 DIRECT。 geox-url: geoip: https://assets.example.com/geoip.dat geosite: https://assets.example.com/geosite.dat @@ -91,7 +91,8 @@ sniffer: QUIC: ports: [443, 8443] -# 至少配置一个具体代理节点。节点和代理组定义名共享同一大小写敏感的命名空间, +# proxies 可省略或设为 [],允许仅使用 DIRECT、REJECT 或只包含内置动作的代理组。 +# 引用的节点和代理组必须存在。节点和代理组定义名共享同一大小写敏感的命名空间, # 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为 1–64 UTF-8 # 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符,也不能包含逗号、井号、 # 斜杠、问号、`&`、`=`、`%`、反斜杠,或使用 `.`、`..`、`DIRECT`、`REJECT`、 @@ -435,25 +436,30 @@ proxy-groups: # fragment 只接受 DIRECT、RULES、实际节点名或代理组名。`PROXY`/`#PROXY` 没有 # 默认出口含义,只有存在同名 route target 时才有效。DNS route 选中代理组后, # 仅新建物理 transport 解析当前成员;cache 与既有 TCP pool 不随切换清空。 -# nameserver-policy 按顺序匹配 -# `geosite:[,...]`,逗号项为 OR,命中后只在当前组内故障转移。 -# selector 可为 cn、cn@ads@cn、!cn@ads;多个属性为 key 存在性 AND(忽略 bool/int 值), -# 前置 ! 取反整个匹配结果,@!cn 是字面 key。相同归一 selector 不能跨项重复, -# 但相同基础分类的不同交集/正反选可共存;资产不可用不因反选变成全匹配。 +# nameserver-policy 接受域名、Clash 通配域名和 geosite;值为单个 nameserver 字符串 +# 或 1–4 项数组。域名可用逗号连接;* 匹配一层,.example.com 仅匹配子域, +# +.example.com 匹配本域及所有子域。连续域名项优先匹配字面标签,其次 *,最后 .; +# 相同归一域名模式以后项为准。GeoSite 项隔开域名区块,区块与 GeoSite 按声明顺序匹配。 +# `geosite:[,...]` 的逗号项为 OR,selector 可为 cn、cn@ads@cn、 +# !cn@ads;多个属性为 key 存在性 AND(忽略 bool/int 值),前置 ! 取反整个匹配结果, +# @!cn 是字面 key。资产不可用不因反选变成全匹配。命中后只在当前组内故障转移。 dns: enable: true ipv6: true nameserver: - "tcp://1.1.1.1:53#main-select" nameserver-policy: + "internal.example": "223.5.5.5" + "+.example.com": ["tcp://1.1.1.1:53#DIRECT"] "geosite:private,cn,apple": - "tcp://223.5.5.5:53#DIRECT" - "tcp://1.1.1.1:53#main-select" # rules 是运行配置必填项,按顺序首条命中。DIRECT 与 REJECT 是内置动作,其他 # 动作必须精确使用实际节点名或代理组名。`PROXY` 没有特殊别名。rules 不能为空, -# 必须恰好以一个指向实际节点或代理组的 MATCH 结束;不存在隐式 MATCH 或 DIRECT -# 回退。选中组时使用当前成员,组本身不做 health check、测速或自动 failover。 +# 必须恰好以一个 MATCH 结束;MATCH 可使用 DIRECT、REJECT、实际节点或代理组。 +# 不存在隐式 MATCH 或 DIRECT 回退。选中组时使用当前成员,组本身不做 health check、 +# 测速或自动 failover。 # GEOSITE 支持 [!][@...],GEOIP 支持 [!] 和可选 no-resolve。 # 例如 GEOSITE,google@ads,REJECT / GEOSITE,!cn@ads,main-select / GEOIP,!CN,DIRECT。 # geolocation-!cn 为字面分类名;GeoIP DAT reverse_match 接受但忽略,取反由 ! 控制。 diff --git a/docs/geodata.md b/docs/geodata.md index 8b71d18..df52aa6 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -35,11 +35,12 @@ GEOIP,[!],[,no-resolve] - 前置 `!` 对整个正向匹配结果取反:`!cn@ads` 是 NOT(匹配 `cn@ads`),不是“没有 ads 的记录”。 `cn@!ads` 查找字面 key `!ads`;`geolocation-!cn` 是字面分类名。 - GeoIP 支持前置 `!`,不支持属性。业务规则不支持单条多个 code、源地址 GeoIP 或隐式局域网分类。 -- 规则按顺序首条命中,最终 `MATCH` 必须指向实际代理节点或代理组。 +- 规则按顺序首条命中,最终 `MATCH` 可使用 `DIRECT`、`REJECT`、实际代理节点或代理组。 DNS `nameserver-policy` 中的 `geosite:[,...]` 接受相同的属性与反选语义, 逗号连接的 selector 为 OR。业务规则与 DNS policy 共享基础分类、value arena 与正则, 每个基础分类只解码一次;不同属性交集保留各自匹配索引,正反选不复制记录。 +普通域名与通配域名 policy 不加载 GeoData,其匹配顺序见 [DNS Policy](tun-icmp-dns.md#policy)。 存在分类但属性筛选为空时,该 selector 仍可用:正选不匹配,反选匹配所有合法非空域名。 分类缺失、资产不可用或未声明的 selector 不能通过反选变成全匹配;没有域名时所有 GeoSite @@ -78,7 +79,7 @@ geo-update-interval: 24 - `geox-url` 只能包含 `geoip` 和 `geosite`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 - `geo-update-interval` 只接受整数 `24`。 - `validateConfig` 和 `prepare` 不下载。实例启动后,只有自动更新已开启且规则实际需要资产时才运行更新任务。 -- 下载固定使用最终 `MATCH` route target。若它是代理组,每次新建下载物理 transport 时沿嵌套组解析到当时选中的具体节点、`DIRECT` 或 `REJECT`;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 +- 下载固定使用最终 `MATCH` 动作,包括 `DIRECT`、`REJECT`、具体节点和代理组。若它是代理组,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 - 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复 24 小时周期。 - ETag、SHA-256、源 URL 和下次检查时间保存在 Vole 管理的状态中。合法 304 在匹配器 已可用时只推进调度;有必需种类不可用时,先按相同重载流程从本地资产恢复匹配器。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 7dbfad6..c0c4cbe 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -61,6 +61,7 @@ dns: nameserver: - "tcp://1.1.1.1:53#main-select" nameserver-policy: + "+.example.com": "223.5.5.5" "geosite:private,cn": - "tcp://223.5.5.5:53#DIRECT" ``` @@ -83,19 +84,23 @@ dns: `nameserver-policy` 是有序映射: - 项数与 GeoSite 唯一引用数不设独立上限,仍受整个 YAML 输入大小约束; -- selector 只接受 `geosite:[,...]`,同一项内为 OR;每个 selector 可为 - `code`、`code@attr@attr` 或前置 `!` 的反选,属性为 key 存在性 AND; -- 归一后的相同 selector 不能跨项重复;相同基础 code 的不同属性交集或正反选可并存, - 与业务规则共享分类和记录;`@!cn` 是字面属性 key,不是属性排除; -- value 必须是 1–4 个 nameserver; -- 首项命中后只在该组内顺序故障转移,组内耗尽不查询主组; +- 接受普通域名和 Clash 通配域名,也可以用逗号连接多个模式;域名不区分大小写。 + `*.example.com` 匹配一层子域,`.example.com` 匹配所有子域,`+.example.com` 包含本域和所有子域; +- 连续域名项合为一个区块,按字面标签、`*`、前导 `.` 的优先级匹配;同一区块内重复的归一模式以后项为准; +- `geosite:[,...]` 同一项内为 OR,前缀不区分大小写;每个 selector 可为 + `code`、`code@attr@attr` 或前置 `!` 的反选,属性为 key 存在性 AND。与业务规则共享分类和记录, + `@!cn` 是字面属性 key,不是属性排除; +- GeoSite 项隔开域名区块,区块与 GeoSite 项按声明顺序匹配;重复 GeoSite selector 也按此顺序处理; +- value 接受单个 nameserver 字符串或 1–4 项数组;`rule-set:` 依赖尚未支持的 rule-providers; +- 命中后只在所选列表内顺序故障转移,列表耗尽不查询其他 policy 或主组; - GeoSite 资产或分类不可用时该 policy 不命中,不能因反选变成全匹配; - 存在分类但属性筛选为空时仍可用,正选不命中、反选匹配合法非空名称。 Policy 只决定当前 DNS exchange,不改写后续业务动作。分类、属性归一和语义边界见 [GeoData 规则](geodata.md#规则);所有平台均不按 GeoData 条数截断记录。 每次同步 policy 选择捕获一份不可变 GeoData 快照,所有 selector 与 policy 项共用, -不在一次求值中混用旧代、空快照和新代。捕获空快照时沿用资产缺失的主组回落语义。 +不在一次求值中混用旧代、空快照和新代。捕获空快照时跳过 GeoSite 项,继续其余 policy; +均未命中时使用主组。普通域名 policy 不依赖 GeoData。 快照在选择函数返回时释放,不复制规则,也不跨后续上游 I/O 的 `await` 持有。 Regex selector 记录与业务规则共用 `regex::bytes::Regex`;其搜索缓存由正则库管理, 可能分配 scratch 或发生缓存竞争,不承诺 DNS policy 正则搜索零分配。 diff --git a/src/config/mod.rs b/src/config/mod.rs index b423ae9..bec49aa 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -62,7 +62,7 @@ pub struct Config { pub ipv6: bool, pub proxies: Vec, pub proxy_groups: Vec, - pub default_route_target: RouteTargetId, + pub default_action: RuleAction, pub geodata_update: Option, pub external_controller: Option, pub inbounds: Vec, @@ -189,10 +189,25 @@ pub struct DnsNameserver { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DnsNameserverPolicy { - pub geosite_codes: Box<[String]>, + pub selector: DnsPolicySelector, pub nameservers: Box<[DnsNameserver]>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DnsPolicySelector { + GeoSite(Box<[String]>), + Domains(Box<[String]>), +} + +impl DnsNameserverPolicy { + pub fn geosite_codes(&self) -> &[String] { + match &self.selector { + DnsPolicySelector::GeoSite(codes) => codes, + DnsPolicySelector::Domains(_) => &[], + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DnsTransport { Udp, @@ -747,6 +762,7 @@ struct RawVoleConfig { tun: Option, #[serde(default, deserialize_with = "deserialize_present_option")] sniffer: Option, + #[serde(default, deserialize_with = "deserialize_non_null_vec")] proxies: Vec, #[serde( rename = "proxy-groups", @@ -916,6 +932,13 @@ struct RawDnsNameserverPolicy { nameservers: Vec, } +#[derive(Deserialize)] +#[serde(untagged)] +enum RawPolicyNameservers { + One(String), + Many(Vec), +} + fn deserialize_dns_nameserver_policies<'de, D>( deserializer: D, ) -> std::result::Result, D::Error> @@ -939,10 +962,15 @@ where A: MapAccess<'de>, { let mut policies = Vec::with_capacity(mapping.size_hint().unwrap_or(0)); - while let Some((selector, nameservers)) = mapping.next_entry::>()? { + while let Some((selector, nameservers)) = + mapping.next_entry::()? + { policies.push(RawDnsNameserverPolicy { selector, - nameservers, + nameservers: match nameservers { + RawPolicyNameservers::One(nameserver) => vec![nameserver], + RawPolicyNameservers::Many(nameservers) => nameservers, + }, }); } Ok(policies) @@ -1453,9 +1481,6 @@ impl RawVoleConfig { RawSnifferConfig::normalize, )?; - if self.proxies.is_empty() { - return invalid("proxies must contain at least 1 entry"); - } let (raw_proxy_groups, proxy_group_ids) = normalize_proxy_group_declarations(self.proxy_groups)?; let (proxies, proxy_ids) = @@ -1463,8 +1488,6 @@ impl RawVoleConfig { let route_targets = collect_route_targets(&proxy_ids, &proxy_group_ids); let proxy_groups = normalize_proxy_groups(raw_proxy_groups, &route_targets)?; proxy_graph_order(&proxies, &proxy_groups)?; - let default_route_target = - derive_default_route_target_from_rules(&self.rules, &route_targets)?; let external_controller = normalize_external_controller( self.external_controller, self.secret, @@ -1477,6 +1500,7 @@ impl RawVoleConfig { .map_or_else(DnsConfig::disabled, |dns| dns.normalize(&route_targets))?; dns.ipv6 &= self.ipv6; let rules = normalize_rules(self.rules, &route_targets)?; + let default_action = rules.last().expect("validated final MATCH").action; drop(proxy_ids); drop(route_targets); @@ -1503,7 +1527,7 @@ impl RawVoleConfig { ipv6: self.ipv6, proxies, proxy_groups, - default_route_target, + default_action, geodata_update, external_controller, inbounds, @@ -1811,9 +1835,6 @@ fn normalize_proxy_graph_with_groups( raw_proxies: Vec, proxy_group_ids: &HashMap, ) -> Result<(Vec, ProxyIdsByTag)> { - if raw_proxies.is_empty() { - return invalid("proxies must contain at least 1 entry"); - } let pending = raw_proxies .into_iter() .map(RawOutbound::normalize) @@ -2663,38 +2684,8 @@ fn normalize_dns_nameserver_policies( default_route: DnsRoute, ) -> Result> { let mut normalized = Vec::with_capacity(raw_policies.len()); - let mut seen_codes = Vec::::new(); for raw_policy in raw_policies { - let selector = raw_policy - .selector - .strip_prefix("geosite:") - .ok_or_else(|| { - VoleError::InvalidConfig(format!( - "invalid dns.nameserver-policy selector `{}`; expected geosite:[,...]", - raw_policy.selector - )) - })?; - if selector.is_empty() { - return invalid("dns.nameserver-policy geosite selector must not be empty"); - } - - let mut geosite_codes = Vec::new(); - for raw_code in selector.split(',') { - if raw_code.is_empty() || trim_ascii_whitespace(raw_code) != raw_code { - return invalid(format!( - "invalid dns.nameserver-policy selector `{}`; GeoSite codes must be non-empty and contain no surrounding whitespace", - raw_policy.selector - )); - } - let code = normalize_geo_code(raw_code, "dns.nameserver-policy GeoSite")?; - if seen_codes.contains(&code) { - return invalid(format!( - "dns.nameserver-policy contains duplicate GeoSite code `{code}`" - )); - } - seen_codes.push(code.clone()); - geosite_codes.push(code); - } + let selector = normalize_dns_policy_selector(&raw_policy.selector)?; if raw_policy.nameservers.is_empty() || raw_policy.nameservers.len() > MAX_DNS_NAMESERVERS { return invalid(format!( @@ -2708,13 +2699,50 @@ fn normalize_dns_nameserver_policies( .map(|nameserver| parse_dns_nameserver(nameserver, route_targets, default_route)) .collect::>>()?; normalized.push(DnsNameserverPolicy { - geosite_codes: geosite_codes.into_boxed_slice(), + selector, nameservers: nameservers.into_boxed_slice(), }); } Ok(normalized) } +fn normalize_dns_policy_selector(input: &str) -> Result { + let lower = input.to_lowercase(); + if let Some(selector) = lower.strip_prefix("geosite:") { + let codes = selector + .split(',') + .map(|code| { + if code.is_empty() || trim_ascii_whitespace(code) != code { + return invalid("DNS policy GeoSite selectors must be non-empty without surrounding whitespace"); + } + normalize_geo_code(code, "dns.nameserver-policy GeoSite") + }) + .collect::>>()?; + return Ok(DnsPolicySelector::GeoSite(codes.into_boxed_slice())); + } + if lower.starts_with("rule-set:") { + return invalid("dns.nameserver-policy rule-set requires unsupported rule-providers"); + } + let domains = lower + .split(',') + .map(|domain| { + if domain.is_empty() || domain.ends_with('.') || domain.trim() != domain { + return invalid("invalid DNS policy domain pattern"); + } + for (index, label) in domain.split('.').enumerate() { + if (label.is_empty() && index != 0) + || (label.contains('+') && (index != 0 || label != "+" || domain == "+")) + || (label.contains('*') && label != "*") + { + return invalid("invalid DNS policy domain pattern"); + } + } + Ok(domain.to_owned()) + }) + .collect::>>()?; + Ok(DnsPolicySelector::Domains(domains.into_boxed_slice())) +} + fn parse_dns_nameserver( input: &str, route_targets: &RouteTargetsByName, @@ -2816,30 +2844,6 @@ fn parse_dns_endpoint(endpoint: &str) -> Option<(IpAddr, u16)> { Some((endpoint.ip(), endpoint.port())) } -fn derive_default_route_target_from_rules( - rules: &[String], - route_targets: &RouteTargetsByName, -) -> Result { - let final_rule = rules.last().ok_or_else(|| { - VoleError::InvalidConfig( - "rules must end with MATCH targeting an exact proxy or proxy group name".to_owned(), - ) - })?; - let fields = final_rule - .split(',') - .map(trim_ascii_whitespace) - .collect::>(); - if fields.len() != 2 || !fields[0].eq_ignore_ascii_case("MATCH") { - return invalid("rules must end with MATCH targeting an exact proxy or proxy group name"); - } - route_targets.get(fields[1]).copied().ok_or_else(|| { - VoleError::InvalidConfig(format!( - "final MATCH target must be an exact configured proxy or proxy group name; found `{}`", - fields[1] - )) - }) -} - fn normalize_rules( rules: Vec, route_targets: &RouteTargetsByName, @@ -3456,8 +3460,8 @@ proxies: assert_eq!(config.proxies[0].tag, "proxy"); assert!(config.proxies[0].udp); assert_eq!( - config.default_route_target, - RouteTargetId::Proxy(ProxyId::new(0).unwrap()) + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(0).unwrap())) ); assert_eq!(first_vless(&config).address, "203.0.113.1"); assert!(matches!( @@ -4528,8 +4532,8 @@ authentication: fn parses_the_strict_anytls_subset() { let config = Config::parse_yaml(CURRENT_ANYTLS.as_bytes()).unwrap(); assert_eq!( - config.default_route_target, - RouteTargetId::Proxy(ProxyId::new(0).unwrap()) + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(0).unwrap())) ); let proxy = &config.proxies[0]; assert_eq!(proxy.tag, "anytls-node"); @@ -4568,8 +4572,8 @@ authentication: ); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!( - config.default_route_target, - RouteTargetId::Proxy(ProxyId::new(1).unwrap()) + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(1).unwrap())) ); assert_eq!( config.proxies[1].dialer_proxy, @@ -5086,7 +5090,7 @@ authentication: } #[test] - fn current_config_requires_listener_rules_and_nonempty_proxies() { + fn current_config_requires_listener_rules_and_valid_references() { for yaml in [ current_yaml("mixed-port: 0\nauthentication:\n - measure:secret"), current_yaml("tun:\n enable: false"), @@ -5144,8 +5148,8 @@ authentication: let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!(config.proxies.len(), 2); assert_eq!( - config.default_route_target, - RouteTargetId::Proxy(ProxyId::new(1).unwrap()) + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(1).unwrap())) ); let ProxyProtocol::Socks5(socks5) = &config.proxies[1].protocol else { panic!("expected SOCKS5 proxy"); @@ -5290,7 +5294,10 @@ authentication: let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); let first = ProxyId::new(0).unwrap(); let second = ProxyId::new(1).unwrap(); - assert_eq!(config.default_route_target, RouteTargetId::Proxy(second)); + assert_eq!( + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(second)) + ); assert_eq!( config.proxies[0].dialer_proxy, Some(RouteTargetId::Proxy(second)) @@ -5382,7 +5389,10 @@ rules: .collect::>(), ["proxy", "proxy", "DIRECT"] ); - assert_eq!(config.default_route_target, RouteTargetId::Group(main)); + assert_eq!( + config.default_action, + RuleAction::Route(RouteTargetId::Group(main)) + ); assert_eq!( config.rules[0].action, RuleAction::Route(RouteTargetId::Group(backup)) @@ -5530,8 +5540,8 @@ proxies: assert_eq!(config.proxies.len(), 4); assert_eq!( - config.default_route_target, - RouteTargetId::Proxy(ProxyId::from_index(3)) + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::from_index(3))) ); assert_eq!( config @@ -5572,8 +5582,8 @@ proxies: let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!(config.proxies.len(), 300); assert_eq!( - config.default_route_target, - RouteTargetId::Proxy(ProxyId::from_index(299)) + config.default_action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::from_index(299))) ); assert_eq!( config.proxies[299].dialer_proxy, @@ -5863,6 +5873,28 @@ authentication: ); } + #[test] + fn accepts_builtin_final_actions_without_static_proxies() { + for proxies in ["", "proxies: []\n"] { + for (target, action) in [ + ("DIRECT", RuleAction::Direct), + ("REJECT", RuleAction::Reject), + ] { + let yaml = format!("mixed-port: 1080\n{proxies}rules: ['MATCH,{target}']\n"); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert!(config.proxies.is_empty()); + assert_eq!(config.default_action, action); + assert_eq!(config.rules[0].action, action); + } + } + let config = Config::parse_yaml(b"mixed-port: 1080\nproxy-groups:\n - name: local\n type: select\n proxies: [DIRECT, REJECT]\nrules: ['MATCH,local']\n").unwrap(); + assert!(config.proxies.is_empty()); + assert_eq!( + config.proxy_groups[0].members[0].target, + ProxyGroupMemberTarget::Direct + ); + } + #[test] fn current_config_parses_ordered_geosite_nameserver_policies() { let yaml = current_yaml( @@ -5883,10 +5915,10 @@ dns: let dns = config.dns; assert_eq!( dns.nameservers[0].route, - DnsRoute::Route(config.default_route_target) + DnsRoute::Route(RouteTargetId::Proxy(ProxyId::new(0).unwrap())) ); assert_eq!(dns.nameserver_policies.len(), 2); - assert_eq!(&*dns.nameserver_policies[0].geosite_codes, ["apple", "cn"]); + assert_eq!(dns.nameserver_policies[0].geosite_codes(), ["apple", "cn"]); assert_eq!( dns.nameserver_policies[0].nameservers.as_ref(), [DnsNameserver { @@ -5896,7 +5928,7 @@ dns: route: DnsRoute::Direct, }] ); - assert_eq!(&*dns.nameserver_policies[1].geosite_codes, ["private"]); + assert_eq!(dns.nameserver_policies[1].geosite_codes(), ["private"]); assert_eq!( dns.nameserver_policies[1] .nameservers @@ -5939,7 +5971,7 @@ rules: assert_eq!(config.rules[2].kind, RuleKind::GeoIp("!cn".to_owned())); assert!(config.rules[2].no_resolve); assert_eq!( - &*config.dns.nameserver_policies[0].geosite_codes, + config.dns.nameserver_policies[0].geosite_codes(), ["google@ads@cn", "!google@ads"] ); } @@ -5947,9 +5979,15 @@ rules: #[test] fn current_config_strictly_validates_nameserver_policy_schema_and_limits() { for policy in [ - r#" "geosite:cn": "tcp://223.5.5.5""#, - r#" "geoip:cn": ["tcp://223.5.5.5"]"#, - r#" "GEOSITE:cn": ["tcp://223.5.5.5"]"#, + r#" "rule-set:cn": ["tcp://223.5.5.5"]"#, + r#" "example.com": null"#, + r#" "example.com": {server: 1.1.1.1}"#, + r#" "example.com": [false]"#, + r#" "foo..example": "1.1.1.1""#, + r#" "foo.*bar.example": "1.1.1.1""#, + r#" "foo.+.example": "1.1.1.1""#, + r#" "example.com.": "1.1.1.1""#, + r#" "example.com,": "1.1.1.1""#, r#" "geosite:": ["tcp://223.5.5.5"]"#, r#" "geosite:cn,": ["tcp://223.5.5.5"]"#, r#" "geosite:cn, apple": ["tcp://223.5.5.5"]"#, @@ -5976,7 +6014,14 @@ dns: "geosite:CN": ["tcp://223.5.5.5"] "geosite:cn": ["tcp://8.8.8.8"]"#, ); - assert!(Config::parse_yaml(duplicate.as_bytes()).is_err()); + assert_eq!( + Config::parse_yaml(duplicate.as_bytes()) + .unwrap() + .dns + .nameserver_policies + .len(), + 2 + ); let many_codes = (0..=16) .map(|index| format!("code{index}")) @@ -5992,7 +6037,7 @@ authentication: .unwrap() .dns .nameserver_policies[0] - .geosite_codes + .geosite_codes() .len(), 17 ); diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 132b039..49807ca 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -5,6 +5,7 @@ //! redir-host table is bounded by the constants below, while concurrent query //! activity is observed rather than rejected by a fixed local admission cap. +mod policy; pub mod resolution; pub mod runtime; diff --git a/src/dns/policy.rs b/src/dns/policy.rs new file mode 100644 index 0000000..bd54c6e --- /dev/null +++ b/src/dns/policy.rs @@ -0,0 +1,122 @@ +use std::collections::HashMap; + +use crate::{ + config::{DnsNameserverPolicy, DnsPolicySelector}, + routing::GeoMatcher, +}; + +/// Adjacent domain entries form one specificity-ordered lookup. GeoSite +/// entries separate those lookups and retain their configured ordering. +#[derive(Debug, Default)] +pub(super) struct NameserverPolicies(Vec); + +#[derive(Debug)] +enum PolicyStep { + Domains(DomainIndex), + GeoSite(usize), +} + +impl NameserverPolicies { + pub(super) fn new(policies: &[DnsNameserverPolicy]) -> Self { + let mut steps = Vec::new(); + for (index, policy) in policies.iter().enumerate() { + match &policy.selector { + DnsPolicySelector::GeoSite(_) => steps.push(PolicyStep::GeoSite(index)), + DnsPolicySelector::Domains(patterns) => { + if !matches!(steps.last(), Some(PolicyStep::Domains(_))) { + steps.push(PolicyStep::Domains(DomainIndex::new())); + } + let Some(PolicyStep::Domains(domains)) = steps.last_mut() else { + unreachable!(); + }; + for pattern in patterns { + domains.insert(pattern, index); + } + } + } + } + Self(steps) + } + + pub(super) fn select( + &self, + name: &str, + policies: &[DnsNameserverPolicy], + geo: &dyn GeoMatcher, + ) -> Option { + self.0.iter().find_map(|step| match step { + PolicyStep::Domains(domains) => domains.find(0, name), + PolicyStep::GeoSite(index) => policies[*index] + .geosite_codes() + .iter() + .any(|code| geo.matches_geosite(code, name)) + .then_some(*index), + }) + } +} + +#[derive(Debug, Default)] +struct DomainNode { + labels: HashMap, + wildcard: Option, + exact: Option, + suffix: Option, +} + +/// A flat arena keeps construction and destruction independent of pattern +/// depth; lookup depth is bounded by the validated DNS question. +#[derive(Debug)] +struct DomainIndex(Vec); + +impl DomainIndex { + fn new() -> Self { + Self(vec![DomainNode::default()]) + } + + fn insert(&mut self, pattern: &str, policy: usize) { + let (domain, exact, suffix) = if let Some(domain) = pattern.strip_prefix("+.") { + (domain, true, true) + } else if let Some(domain) = pattern.strip_prefix('.') { + (domain, false, true) + } else { + (pattern, true, false) + }; + let mut current = 0; + for label in domain.rsplit('.') { + let child = if label == "*" { + self.0[current].wildcard + } else { + self.0[current].labels.get(label).copied() + }; + current = child.unwrap_or_else(|| { + let next = self.0.len(); + self.0.push(DomainNode::default()); + if label == "*" { + self.0[current].wildcard = Some(next); + } else { + self.0[current].labels.insert(label.to_owned(), next); + } + next + }); + } + if exact { + self.0[current].exact = Some(policy); + } + if suffix { + self.0[current].suffix = Some(policy); + } + } + + fn find(&self, current: usize, name: &str) -> Option { + let node = &self.0[current]; + if name.is_empty() { + return node.exact; + } + let (rest, label) = name.rsplit_once('.').unwrap_or(("", name)); + node.labels + .get(label) + .and_then(|child| self.find(*child, rest)) + .or_else(|| node.wildcard.and_then(|child| self.find(child, rest))) + .or(node.suffix) + } +} diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 8dd391d..c1aa012 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -26,6 +26,7 @@ use tokio::{ }; use tokio_util::sync::CancellationToken; +use super::policy::NameserverPolicies; use super::{ CacheValue, ClassifiedDnsQuery, DnsCache, DnsError, DnsMessage, DnsQueryKind, MAX_ANSWERS, MAX_CACHE_ENTRIES, MAX_MESSAGE_SIZE, MAX_REDIR_HOST_ENTRIES, MAX_RESPONSE_RECORDS, @@ -89,6 +90,7 @@ pub struct RuntimeDns { ipv6: bool, nameservers: Box<[DnsNameserver]>, nameserver_policies: Box<[DnsNameserverPolicy]>, + policy_index: NameserverPolicies, geo_matcher: Arc, egress: DnsEgress, cache: Mutex, @@ -869,6 +871,7 @@ impl RuntimeDns { ipv6: config.ipv6, nameservers: config.nameservers.clone().into_boxed_slice(), nameserver_policies: config.nameserver_policies.clone().into_boxed_slice(), + policy_index: NameserverPolicies::new(&config.nameserver_policies), geo_matcher, egress, cache: Mutex::new(DnsCache::with_max_entries(address_cache_entries)), @@ -888,20 +891,17 @@ impl RuntimeDns { } let snapshot = self.geo_matcher.routing_snapshot(); let geo_matcher = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); - for (policy_index, policy) in self.nameserver_policies.iter().enumerate() { - if policy - .geosite_codes - .iter() - .any(|code| geo_matcher.matches_geosite(code, &query.question.name)) - { - tracing::debug!( - policy_index, - query_type = query.question.query_type, - event = "runtime_dns_nameserver_policy_match", - "runtime DNS selected a nameserver policy" - ); - return &policy.nameservers; - } + if let Some(policy_index) = + self.policy_index + .select(&query.question.name, &self.nameserver_policies, geo_matcher) + { + tracing::debug!( + policy_index, + query_type = query.question.query_type, + event = "runtime_dns_nameserver_policy_match", + "runtime DNS selected a nameserver policy" + ); + return &self.nameserver_policies[policy_index].nameservers; } &self.nameservers } @@ -2326,11 +2326,13 @@ mod tests { fn dns_policy(codes: &[&str], nameservers: Vec) -> DnsNameserverPolicy { DnsNameserverPolicy { - geosite_codes: codes - .iter() - .map(|code| (*code).to_owned()) - .collect::>() - .into_boxed_slice(), + selector: crate::config::DnsPolicySelector::GeoSite( + codes + .iter() + .map(|code| (*code).to_owned()) + .collect::>() + .into_boxed_slice(), + ), nameservers: nameservers.into_boxed_slice(), } } @@ -3035,6 +3037,77 @@ mod tests { assert_eq!(proxy.udp_calls.load(Ordering::Acquire), 0); } + #[tokio::test] + async fn domain_policies_use_specificity_and_preserve_geosite_order() { + let parsed = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +rules: ['MATCH,DIRECT'] +dns: + enable: true + ipv6: true + nameserver: [192.0.2.1] + nameserver-policy: + '+.example.test': 192.0.2.2 + '*.example.test': [192.0.2.3] + 'pin.example.test': 192.0.2.4 + 'api.*.example.test': 192.0.2.5 + '.only.test': 192.0.2.6 + '*.TLD': 192.0.2.7 + 'same.test': 192.0.2.8 + 'SAME.TEST': 192.0.2.9 + 'one.test,two.test': 192.0.2.10 + '+.override.cn': 192.0.2.11 + 'GEOSITE:cn': 192.0.2.12 + 'blocked.cn': 192.0.2.13 + 'late.test': [192.0.2.14] +"#, + ) + .unwrap(); + for query_type in [1, 28, 65] { + for (domain, upstream) in [ + ("example.test", 2), + ("one.example.test", 3), + ("pin.example.test", 4), + ("deep.pin.example.test", 2), + ("api.region.example.test", 5), + ("only.test", 1), + ("deep.child.only.test", 6), + ("host.tld", 7), + ("deep.host.tld", 1), + ("same.test", 9), + ("one.test", 10), + ("two.test", 10), + ("override.cn", 11), + ("blocked.cn", 12), + ("late.test", 14), + ("other.test", 1), + ] { + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![MockReply::Response(ResponseSpec::rcode(RCODE_NXDOMAIN))], + vec![], + )); + let resolver = RuntimeDns::new_with_geo_matcher( + &parsed.dns, + dispatcher.clone(), + Arc::new(PolicyGeoMatcher), + ); + resolver + .exchange(&opaque_query(1, domain, query_type)) + .await + .unwrap(); + assert_eq!( + *dispatcher.udp_destinations.lock().unwrap(), + [Destination::Ip(SocketAddr::from(( + [192, 0, 2, upstream], + 53 + )))], + "{domain} type {query_type}", + ); + } + } + } + #[tokio::test] async fn dns_policy_selection_is_consistent_across_snapshot_swaps() { for starts_empty in [false, true] { diff --git a/src/geodata/manager.rs b/src/geodata/manager.rs index fe2bab7..9da040d 100644 --- a/src/geodata/manager.rs +++ b/src/geodata/manager.rs @@ -1579,7 +1579,9 @@ mod tests { #[test] fn requirements_validate_and_deduplicate_across_dns_and_rules() { let policy = DnsNameserverPolicy { - geosite_codes: vec!["CN".to_owned(), "private".to_owned()].into_boxed_slice(), + selector: crate::config::DnsPolicySelector::GeoSite( + vec!["CN".to_owned(), "private".to_owned()].into_boxed_slice(), + ), nameservers: Vec::new().into_boxed_slice(), }; let requirements = GeoRequirements::collect( diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index d90e08e..ad4cd61 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -402,7 +402,7 @@ impl RequestedCodes { } } for policy in dns_policies { - for code in &policy.geosite_codes { + for code in policy.geosite_codes() { requested.sites.insert(GeoDataKind::GeoSite, code)?; } } diff --git a/src/geodata/tests.rs b/src/geodata/tests.rs index f5985b3..30eb2b9 100644 --- a/src/geodata/tests.rs +++ b/src/geodata/tests.rs @@ -110,11 +110,13 @@ fn rule(kind: RuleKind) -> RuleSpec { fn dns_policy(codes: &[&str]) -> DnsNameserverPolicy { DnsNameserverPolicy { - geosite_codes: codes - .iter() - .map(|code| (*code).to_owned()) - .collect::>() - .into_boxed_slice(), + selector: crate::config::DnsPolicySelector::GeoSite( + codes + .iter() + .map(|code| (*code).to_owned()) + .collect::>() + .into_boxed_slice(), + ), nameservers: Vec::new().into_boxed_slice(), } } diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 1c781f0..a8abe4e 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -87,11 +87,6 @@ impl std::fmt::Debug for ProxyDispatchers { impl ProxyDispatchers { pub fn new(proxies: Vec>) -> Result { - if proxies.is_empty() { - return Err(DispatchError::Other( - "proxy registry must contain at least one entry".to_owned(), - )); - } Ok(Self { proxies, groups: Vec::new(), @@ -1173,18 +1168,17 @@ mod tests { #[tokio::test] async fn group_route_snapshots_direct_dns_for_tcp() { - let proxy = Arc::new(RecordingDispatcher::default()); let direct = Arc::new(RecordingDispatcher::default()); let groups = ProxyGroups::new( &[select_group( "route", vec![("DIRECT", ProxyGroupMemberTarget::Direct)], )], - vec![proxy.clone()], + Vec::new(), direct.clone(), ) .unwrap(); - let targets = RouteTargetDispatchers::with_proxy_groups(vec![proxy], &groups).unwrap(); + let targets = RouteTargetDispatchers::with_proxy_groups(Vec::new(), &groups).unwrap(); let dns = Arc::new(MockDns::new(vec![IpAddr::V4(Ipv4Addr::new( 203, 0, 113, 9, ))])); @@ -1315,6 +1309,40 @@ mod tests { query } + #[tokio::test] + async fn builtin_final_actions_route_tcp_and_udp_without_proxies() { + let destination = "192.0.2.1:443".parse().unwrap(); + for action in [RuleAction::Direct, RuleAction::Reject] { + let direct = Arc::new(RecordingDispatcher::default()); + let router = dispatcher_with_proxies( + Vec::new(), + direct.clone(), + vec![rule(RuleKind::Match, action)], + ); + let result = router + .connect_tcp(stream(destination, InboundKind::Http)) + .await; + if action == RuleAction::Direct { + assert!(result.is_ok()); + } else { + assert!(matches!(result, Err(DispatchError::NotAllowed))); + } + let mut transport = router + .open_datagram(association(InboundKind::Socks5)) + .await + .unwrap(); + transport + .send(datagram(destination, b"payload")) + .await + .unwrap(); + let expected = usize::from(action == RuleAction::Direct); + assert_eq!(direct.tcp_sessions.lock().unwrap().len(), expected); + assert_eq!(direct.datagrams.lock().unwrap().len(), expected); + assert_eq!(direct.udp_opens.load(Ordering::Relaxed), expected); + transport.close().await.unwrap(); + } + } + #[tokio::test] async fn tcp_selects_proxy_direct_and_reject() { let proxy = Arc::new(RecordingDispatcher::default()); diff --git a/src/runtime.rs b/src/runtime.rs index ac4fc5f..2de8e01 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -391,7 +391,7 @@ impl PreparedCore { proxy_graph.resolution.bind_runtime(dns.as_ref())?; let geodata_rules = RuleSet::compile(vec![crate::config::RuleSpec { kind: crate::config::RuleKind::Match, - action: crate::config::RuleAction::Route(self.config.default_route_target), + action: self.config.default_action, no_resolve: false, }]) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; @@ -1694,6 +1694,33 @@ rules: ) } + #[tokio::test] + async fn prepares_builtin_routes_without_static_nodes_or_bootstrap() { + let resolver = RecordingResolver::default(); + for (groups, target) in [ + ("", "DIRECT"), + ("", "REJECT"), + ( + "proxy-groups:\n - name: local\n type: select\n proxies: [DIRECT, REJECT]\n", + "local", + ), + ] { + let yaml = format!( + "mixed-port: 1080\n{groups}dns:\n enable: true\n nameserver: [1.1.1.1]\n nameserver-policy:\n '+.example.com': 223.5.5.5\nrules: ['MATCH,{target}']\n" + ); + let prepared = + PreparedCore::prepare(yaml.as_bytes(), &resolver, ResourceLimits::default()) + .await + .unwrap(); + assert!(prepared.endpoints.is_empty()); + let parts = prepared.build_dispatcher(Dialer::default()).unwrap(); + assert_eq!(parts.proxy_graph.len(), 0); + assert!(parts.dns.is_some()); + parts.proxy_graph.shutdown().await; + } + assert!(resolver.hosts.lock().unwrap().is_empty()); + } + #[tokio::test] async fn prepare_resolves_the_sole_outbound() { let prepared = diff --git a/tests/proxy_group_compat.rs b/tests/proxy_group_compat.rs index ff303c6..b598d1e 100644 --- a/tests/proxy_group_compat.rs +++ b/tests/proxy_group_compat.rs @@ -56,7 +56,10 @@ rules: config.proxy_groups[1].members[1].target, ProxyGroupMemberTarget::Route(RouteTargetId::Group(backup)) ); - assert_eq!(config.default_route_target, RouteTargetId::Group(primary)); + assert_eq!( + config.default_action, + RuleAction::Route(RouteTargetId::Group(primary)) + ); assert_eq!( config.rules[0].action, RuleAction::Route(RouteTargetId::Group(backup)) From 9698f6b201d60fd9fef758b8ee7a169d2b025e22 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 17:10:10 +0800 Subject: [PATCH 002/445] Support routing modes, logical rules and YAML templates Add GLOBAL selection, per-runtime log levels, source predicates and bounded YAML expansion. Allow empty rule lists and direct fallback. Validation: 133 related memory tests; all-feature all-target test compilation; all-feature and CLI clippy; default/minimal library checks; locked TLS dependency audit. --- CONTEXT.md | 2 +- Cargo.lock | 8 + Cargo.toml | 1 + ...t-proxy-groups-as-session-route-targets.md | 4 +- docs/cli.md | 4 +- docs/config.yaml | 44 +- docs/controller-api.md | 10 +- docs/geodata.md | 8 +- docs/inbounds.md | 2 +- docs/invoke-api.md | 6 +- docs/runtime-resource-policy.md | 2 +- docs/vless.md | 2 +- src/config/measure.rs | 22 +- src/config/mod.rs | 526 ++++++++---------- src/config/rules.rs | 165 ++++++ src/config/yaml.rs | 161 ++++++ src/controller.rs | 6 +- src/geodata/mod.rs | 5 +- src/invoke/foreground.rs | 4 +- src/invoke/mod.rs | 10 +- src/lib.rs | 2 + src/logging.rs | 133 +++++ src/resources/observation.rs | 2 + src/routing/dispatcher.rs | 5 + src/routing/mod.rs | 411 ++++++++------ src/runtime.rs | 41 +- src/session.rs | 3 +- tests/proxy_group_compat.rs | 2 +- 28 files changed, 1045 insertions(+), 546 deletions(-) create mode 100644 src/config/rules.rs create mode 100644 src/config/yaml.rs create mode 100644 src/logging.rs diff --git a/CONTEXT.md b/CONTEXT.md index f3b6689..424f82a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -77,7 +77,7 @@ A named route or upstream target whose current-session selection identifies one _Avoid_: Folder, provider, subscription **Route target**: -A proxy node, proxy group, or reserved built-in target that routing rules, the final `MATCH`, and DNS routes can select. +A proxy node, proxy group, or reserved built-in target that routing rules, modes, and DNS routes can select. _Avoid_: Proxy when the distinction between a node and group matters, dialer **Group selection**: diff --git a/Cargo.lock b/Cargo.lock index 8d144cd..3ea3d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2495,6 +2495,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", ] [[package]] @@ -2622,6 +2623,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vole" version = "0.1.0" @@ -2674,6 +2681,7 @@ dependencies = [ "tokio-tungstenite", "tokio-util", "tracing", + "tracing-core", "tun-rs", "url", "uuid", diff --git a/Cargo.toml b/Cargo.toml index 0fa6d8d..4a583b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -127,6 +127,7 @@ tokio-rustls = { version = "=0.26.5", default-features = false, features = ["rin tokio-tungstenite = { version = "=0.30.0", default-features = false, features = ["handshake"], optional = true } tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" +tracing-core = "0.1.36" url = "2" uuid = { version = "1", features = ["serde"] } vole-netstack = { path = "crates/vole-netstack", optional = true } diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index 52a29e2..87d833e 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -4,7 +4,9 @@ status: accepted # ADR 0005:将 select 代理组建模为会话路由与上游目标 -`select` 是普通规则、最终 `MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;省略 `default-selected` 时选择第一项,显式值必须精确命中一个直接成员。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 +`select` 是普通规则、`MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;省略 `default-selected` 时选择第一项,显式值必须精确命中一个直接成员。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 + +`mode` 支持 rule、global、direct;rule 模式允许空规则和任意位置的 MATCH,未命中直连。global 使用 GLOBAL 组;未显式声明时自动生成,成员顺序为 DIRECT、REJECT、全部节点、全部声明组,默认 DIRECT。该组与普通 select 共用运行时选择和 Controller 接口。 组选择只属于当前 Vole 运行会话,由调用方在下一次配置中重新提供。新建底层 transport 时读取当前选择;同一次建链对每个上游组只读一次,SOCKS5 UDP 的控制连接和 relay 路径共享快照与绝对期限。不同组没有原子快照保证。在途握手、既有 TCP / UDP、DNS cache / singleflight / TCP pool 和旧 AnyTLS 会话不迁移;复用旧会话的新业务流仍保持原路径。被选成员失败时原样返回,不隐式 failover。 diff --git a/docs/cli.md b/docs/cli.md index 682c0f4..7463339 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,11 +27,11 @@ cat config.yaml | vole -f - `VOLE_HOME_DIR`、`VOLE_CONFIG_FILE` 提供环境默认值,显式参数优先。 Unix 用户目录取 `HOME`,Windows 取 `USERPROFILE`,缺失时使用工作目录。 默认 `.config/vole` 不存在或不可访问时,若设置 `XDG_CONFIG_HOME`,使用其中的 `vole` 目录。 -配置文件必须是普通文件,文件和标准输入上限均为 256 KiB,CLI 不写回配置。 +配置文件必须是普通文件,文件和标准输入上限均为 8 MiB,CLI 不写回配置。 正常运行执行 `initialize → createInstance → start(configYaml)`,等待退出后 `stop → destroyInstance`。Unix 支持 SIGINT/SIGTERM,Windows 支持 Ctrl+C/Ctrl+Break; -退出等待内核清理完成。诊断写入有界 stderr,不包含配置正文或凭据。 +退出等待内核清理完成。诊断写入有界 stderr,遵循配置的 `log-level`,不包含配置正文或凭据。 具体请求格式见 [Invoke API](invoke-api.md)。 ## TUN 与发布 diff --git a/docs/config.yaml b/docs/config.yaml index 55594a6..007006a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -1,6 +1,7 @@ # Vole 配置的完整示例。 # -# 配置只接受本文列出的结构;未知或已移除的字段、anchor、alias 和自定义 tag 都会失败。 +# 配置只接受本文列出的结构;未知或已移除的字段和自定义 tag 都会失败。支持 anchor、alias 和 YAML merge。 +# 原始配置上限 8 MiB,展开预算 32 MiB,嵌套最多 64 层;递归 alias 失败。 # 顶层 port、socks-port、udp 和 listeners 都不接受;代理节点的 port / udp 仍有效。 # # 库宿主先通过 Invoke API 调用 initialize({dataDir}),再通过 configYaml 内联 @@ -14,10 +15,15 @@ # IPv6 包。dns.ipv6 只有与本项同时为 true 时才生效。 ipv6: true +# mode 缺省 rule:顺序规则首条命中,未命中 DIRECT;global 使用 GLOBAL 组,direct 直连。 +# log-level 缺省 info,还支持 debug、warning、error、silent;两字段值不区分大小写。 +mode: rule +log-level: info + # GeoData 下载配置可省略;出现时三项必须齐全。URL 必须是以域名为主机、无 # userinfo/fragment 的绝对 HTTPS URL,更新周期固定为 24 小时。validateConfig 和 # measureDelay 不下载。公共实例启动后,只有实际规则需要资产且自动更新已启用时 -# 才后台检查。下载使用最终 MATCH 动作:DIRECT、REJECT、节点或代理组;代理组在 +# 才后台检查。下载按当前 mode 和业务规则选路,未命中 DIRECT;代理组在 # 创建新物理 transport 时解析当前成员,不自动故障转移,也不隐式回退 DIRECT。 geox-url: geoip: https://assets.example.com/geoip.dat @@ -33,7 +39,7 @@ geo-update-interval: 24 # ipv6: true 时同时绑定同端口 IPv6(v6-only);false 时只监听 IPv4。 # authentication 省略或 [] 表示任何绑定地址均免认证;非空时必须恰好包含一项 user:password, # 按首个冒号分隔,用户名 / 密码各 1–255 UTF-8 字节,HTTP/SOCKS5 共用。 -# 共享或非空认证要求 mixed-port 启用。 +# mixed-port 关闭时可以保留合法的 allow-lan 和 authentication,供之后启用入口使用。 # HTTP 逐请求认证、选路,支持流式正文 / 分块、Keep-Alive、CONNECT 与 Upgrade。 # 共享只适合可信网络,Basic 不加密链路;详细边界见 docs/inbounds.md#http。 # UDP 必须属于有效的 TCP 控制连接,源 IP/端口校验和资源上限见 inbounds.md#socks5。 @@ -93,12 +99,11 @@ sniffer: # proxies 可省略或设为 [],允许仅使用 DIRECT、REJECT 或只包含内置动作的代理组。 # 引用的节点和代理组必须存在。节点和代理组定义名共享同一大小写敏感的命名空间, -# 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为 1–64 UTF-8 -# 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符,也不能包含逗号、井号、 -# 斜杠、问号、`&`、`=`、`%`、反斜杠,或使用 `.`、`..`、`DIRECT`、`REJECT`、 -# `RULES`;内部普通空格、CJK 和 emoji 可用。dialer-proxy 可引用具体节点或 select -# 组。所有节点上游和全部组成员(包括未选成员)组成同一张无环图;未知引用、自引用 -# 和混合环均失败。代理数量和链深没有独立上限,仍受 YAML 256 KiB 和名称唯一约束。 +# 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为非空字符串; +# DIRECT、REJECT、REJECT-DROP、COMPATIBLE、PASS、PASS-RULE 为保留定义名。 +# 路径与 URL 中的保留字符由调用方按相应协议编码,不限制节点本身的名称。 +# dialer-proxy 可引用具体节点或 select 组。全部上游和组成员(包括未选成员)须无环。 +# 代理数量和链深没有独立上限,仍受 YAML 大小与名称唯一约束。 # 节点没有上游,或上游组经纯组成员链能到达 DIRECT 时,prepare 预解析该节点及其 # 独立下载端点,即使 DIRECT 尚未选中;纯代理路径保留逻辑域名。候选首跳 DNS 失败 # 则 prepare 失败,切换 DIRECT 不临时调用系统 DNS。物理首跳连接本机服务时,server 必须使用 127.0.0.0/8 @@ -397,9 +402,12 @@ proxies: # 可选的静态 `select` 代理组。字段外形是严格 Mihomo 子集:每组只接受 name、 # type、proxies 和可选 default-selected,type 只能是 select,proxies 至少一项。 -# 成员可精确引用具体节点、另一个 select 组、DIRECT 或 REJECT;RULES 不是成员。 +# 成员可精确引用具体节点、另一个 select 组、DIRECT 或 REJECT。 +# RULES 仅在 DNS fragment 中具有特殊含义;在成员列表中按已配置名称解析。 # 组声明顺序、成员顺序和重复成员全部保留,节点与组的完整混合图必须无环;组数、成员数和嵌套 -# 深度没有独立上限,仍受 256 KiB 配置总大小和 O(V+E) 无环校验约束。 +# 深度没有独立上限,仍受配置总大小和 O(V+E) 无环校验约束。 +# 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 +# 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 # # default-selected 省略时选中第一项;显式值必须是当前组的直接成员。重复名称按 # 第一项命中。没有 Controller 时,该初始选择在本次 session 内固定;有 Controller @@ -455,11 +463,15 @@ dns: - "tcp://223.5.5.5:53#DIRECT" - "tcp://1.1.1.1:53#main-select" -# rules 是运行配置必填项,按顺序首条命中。DIRECT 与 REJECT 是内置动作,其他 -# 动作必须精确使用实际节点名或代理组名。`PROXY` 没有特殊别名。rules 不能为空, -# 必须恰好以一个 MATCH 结束;MATCH 可使用 DIRECT、REJECT、实际节点或代理组。 -# 不存在隐式 MATCH 或 DIRECT 回退。选中组时使用当前成员,组本身不做 health check、 -# 测速或自动 failover。 +# rules 可省略或为空;rule 模式按顺序首条命中,未命中 DIRECT。MATCH 可放在任意 +# 位置,命中后其后规则不再执行。DIRECT、REJECT 是内置动作,其他动作精确引用节点或组。 +# 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 +# GEOSITE、GEOIP、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 +# AND/OR/NOT 和 MATCH。SRC-IP-CIDR 支持 IPv4/IPv6,来源信息来自实际入站会话。 +# 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 忽略大小写,支持 regex crate +# 子集,不支持 lookaround/backreference,无法编译的模式明确报错。 +# 逻辑规则例如 AND,((DOMAIN-SUFFIX,example.com),(SRC-PORT,1000-2000)),DIRECT; +# NOT 恰好一个子规则,最多嵌套 32 层。规则上限 65536 条、单条 16 KiB、合计 8 MiB。 # GEOSITE 支持 [!][@...],GEOIP 支持 [!] 和可选 no-resolve。 # 例如 GEOSITE,google@ads,REJECT / GEOSITE,!cn@ads,main-select / GEOIP,!CN,DIRECT。 # geolocation-!cn 为字面分类名;GeoIP DAT reverse_match 接受但忽略,取反由 ! 控制。 diff --git a/docs/controller-api.md b/docs/controller-api.md index fbb0301..a943cdb 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -10,8 +10,8 @@ secret: "vole-runtime-secret" ``` - 省略 `external-controller` 表示不启动 Controller,此时不能单独配置 `secret`。含代理组但没有 Controller 的配置合法,组在本次 session 内保持初始选择。 -- `external-controller` 只接受带显式非零端口的回环 IP 地址。它要求启用 TUN 或至少定义一个 `proxy-groups`;非 TUN 配置须通过 `mixed-port` 满足运行配置的入站要求。 -- 只要同时配置代理组与 Controller,`secret` 就必填;仅有 TUN 流量接口时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 +- `external-controller` 只接受带显式非零端口的回环 IP 地址。它要求启用 TUN 或至少显式定义一个 `proxy-groups`;非 TUN 配置须通过 `mixed-port` 满足运行配置的入站要求。 +- 只要同时显式配置代理组与 Controller,`secret` 就必填;仅有 TUN 流量接口时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 - `validateConfig` 只校验字段;`prepare` 不监听端口;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 @@ -61,7 +61,7 @@ TUN 按最多 8 包中有效消费/平台报告成功的包合并记账,取 ## 代理组查询 -`GET /group` 按 YAML 声明顺序返回全部代理组: +`GET /group` 按 YAML 声明顺序返回代理组,并在末尾包含自动生成的 `GLOBAL`(如未显式声明): ```json { @@ -115,9 +115,9 @@ Content-Length: 26 ## 名称与路径 -节点和代理组定义名共享一个命名空间。名称必须为 1–64 UTF-8 字节,首尾不能是任何 Unicode 空白字符,不能含控制字符或 `, # / ? & = % \\`,也不能是 `.` 或 `..`。定义名额外保留 `DIRECT`、`REJECT` 和 `RULES`;前两者仍可作为组成员,`RULES` 只作为 DNS sentinel。内部普通空格、CJK 和 emoji 可以使用。 +节点和代理组定义名共享一个大小写敏感的命名空间,名称为非空 UTF-8 字符串。保留定义名为 `DIRECT`、`REJECT`、`REJECT-DROP`、`COMPATIBLE`、`PASS` 和 `PASS-RULE`;其中当前可用的内置动作是 DIRECT 与 REJECT。 -路径中的 `{name}` 必须是单个 segment。Controller 对 percent-encoding 严格解码一次,再执行相同的 UTF-8 和名称校验;无效 `%`、无效 UTF-8、解码后的 `/`、二次编码形式、query 和 fragment 都会失败。客户端应对非 ASCII 或路径保留字节执行一次标准 percent-encoding;`+` 不会被当作空格。 +路径中的 `{name}` 对 percent-encoding 严格解码一次;原始路径分隔符、无效 `%` 和无效 UTF-8 会失败。名称中的 `/`、`?`、`#`、`%` 等字符应 percent-encode;`%252F` 表示字面名称 `%2F`,不会再次解码。`+` 保留为 `+`。请求总大小仍受 HTTP 边界约束。 ## 选择生效边界 diff --git a/docs/geodata.md b/docs/geodata.md index df52aa6..a8535f2 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -35,7 +35,7 @@ GEOIP,[!],[,no-resolve] - 前置 `!` 对整个正向匹配结果取反:`!cn@ads` 是 NOT(匹配 `cn@ads`),不是“没有 ads 的记录”。 `cn@!ads` 查找字面 key `!ads`;`geolocation-!cn` 是字面分类名。 - GeoIP 支持前置 `!`,不支持属性。业务规则不支持单条多个 code、源地址 GeoIP 或隐式局域网分类。 -- 规则按顺序首条命中,最终 `MATCH` 可使用 `DIRECT`、`REJECT`、实际代理节点或代理组。 +- rule 模式按顺序首条命中,未命中 DIRECT;可选的 `MATCH` 使用 `DIRECT`、`REJECT`、实际代理节点或代理组。 DNS `nameserver-policy` 中的 `geosite:[,...]` 接受相同的属性与反选语义, 逗号连接的 selector 为 OR。业务规则与 DNS policy 共享基础分类、value arena 与正则, @@ -79,7 +79,7 @@ geo-update-interval: 24 - `geox-url` 只能包含 `geoip` 和 `geosite`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 - `geo-update-interval` 只接受整数 `24`。 - `validateConfig` 和 `prepare` 不下载。实例启动后,只有自动更新已开启且规则实际需要资产时才运行更新任务。 -- 下载固定使用最终 `MATCH` 动作,包括 `DIRECT`、`REJECT`、具体节点和代理组。若它是代理组,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 +- 下载按当前 mode 与业务规则选路,未命中使用 DIRECT;可选目标包含内置动作、节点和代理组。选中代理组时,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 - 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复 24 小时周期。 - ETag、SHA-256、源 URL 和下次检查时间保存在 Vole 管理的状态中。合法 304 在匹配器 已可用时只推进调度;有必需种类不可用时,先按相同重载流程从本地资产恢复匹配器。 @@ -209,8 +209,8 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 所有平台均不设置 GeoData 原始记录、分类、唯一引用、累计 value/Regex 源码字节或 资产文件大小的独立额度,也不设置 GeoData 总内存预算;不因数量静默截断或丢弃记录。 -DNS GeoSite policy 没有独立项数/引用数上限。普通 YAML 256 KiB、显式 rules 1,024 条/ -合计 128 KiB/单条 1 KiB 仍是配置入口边界,不是 DAT 内记录数限制。 +DNS GeoSite policy 没有独立项数/引用数上限。普通 YAML 8 MiB、显式 rules 65,536 条/ +合计 8 MiB/单条 16 KiB 仍是配置入口边界,不是 DAT 内记录数限制。 基础分类、字面值和正则只存一份,属性交集使用共享 arena 的紧凑索引;多个交集仍会 增加索引容量与加载成本,不能把共享存储理解为内存常量。保留 checked arithmetic、 diff --git a/docs/inbounds.md b/docs/inbounds.md index 2eadbb3..8c127e5 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -19,7 +19,7 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 `authentication` 省略或空列表时,HTTP/SOCKS5 在任何绑定地址均免认证; 非空必须恰好一项 `user:password`, 按第一个冒号拆分,两项各 1–255 UTF-8 字节,不 trim。null 不视为省略。 - 配置凭据后 HTTP/SOCKS5 均校验凭据;共享或非空凭据须有启用的混合入口消费。 + 配置凭据后 HTTP/SOCKS5 均校验凭据;混合入口关闭时允许保留合法的共享和认证配置。 - Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 仍独立回环监听, 使用自己的 Bearer 认证,不随共享开关开放。 - 不设置全局业务连接/关联准入数。Stop 取消并等待全部连接、relay 和接收任务, diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 2cf865b..62a3ff0 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -53,7 +53,7 @@ void VoleFree(char *response); - 运行时级方法必须省略 `instanceId`;实例级方法必须携带 Vole 返回的非空 ID。 - 未知或已移除的 envelope 字段和 payload 字段都会失败,不静默忽略。 - 无业务数据的方法成功时返回空对象,不返回 `null`。 -- Invoke envelope 最大 3 MiB,单份 YAML 最大 256 KiB。 +- Invoke envelope 上限为 240 MiB + 64 KiB(五份 YAML 的 JSON 最坏转义及 envelope),单份 YAML 最大 8 MiB。 ## 初始化与生命周期 @@ -270,7 +270,7 @@ Stop 关闭本次持有的 fd、session 和原生句柄;Wintun 不承诺移除 若定义 `XDG_CONFIG_HOME`,使用 `/vole`。相对数据目录和显式 配置路径分别从启动工作目录解析并折叠 `.`/`..`,绝对路径原样保留; 配置默认 `/config.yaml`。 -`configPath: "-"` 从标准输入读取。文件必须是普通文件,文件和标准输入均最多 256 KiB; +`configPath: "-"` 从标准输入读取。文件必须是普通文件,文件和标准输入均最多 8 MiB; 不会下载、创建模板或改写配置。详细命令行映射见 [CLI](cli.md)。 普通 Unicode 路径使用 JSON 字符串。需要保留平台原生编码时,Unix 可使用 @@ -476,7 +476,7 @@ ProtectFd(fd) -> bool - 配置、错误和日志按 UTF-8 字节计数并受固定上限约束。 - TUN 原始数据包按 `tun.mtu` 限制;最终代理 UDP 响应最多为该 MTU 减去 48 字节。默认 9000 对应 8952 字节;显式 1500 对应 1452 字节,Windows UWP 的最高 1400 对应 1352 字节。 - 嵌套 UDP 协议可以增加有界帧头,但解封装后的最终负载仍受该平台响应上限限制。 -- 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;`DIRECT`、`REJECT` 和 `RULES` 不能用作定义名。 +- 节点和代理组定义名共享大小写敏感的严格 UTF-8 命名空间;内置动作名称保留,具体名称与编码规则见 [Controller](controller-api.md#名称与路径)。 - Secret、password、UUID、REALITY key、short ID、目标地址和完整配置不得进入日志。 - Apple Unified Logging 的 subsystem 固定为 `io.github.yuandevteam.vole`,category 为 `vole`,不继承宿主应用的 bundle identifier;Console 或 `log stream` 应按该标识筛选。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 64af05d..1150b70 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -147,7 +147,7 @@ IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不 ## 代理组与 Controller - 静态 `select` 组只拥有不可变的有序成员和每组一个可原子替换的当前索引,不拥有协议连接、后台任务、队列、缓存或独立 worker。 -- 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 256 KiB YAML 上限约束;配置期使用 O(V+E) 的迭代 DAG 校验,运行时也以迭代方式解析当前叶节点。 +- 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 8 MiB YAML 输入与 32 MiB 展开计费上限约束;配置期使用 O(V+E) 的迭代 DAG 校验,运行时也以迭代方式解析当前叶节点。 - 节点上游和全部组成员在同一 DAG 中校验;组连接器只持有声明依赖,按逆依赖顺序释放。建链快照只按需记录本次遇到的组,不成为全局缓存。Hysteria2 的已认证连接为后续端口跳跃保留该快照,直到此物理会话退出;它不再读取实时选择,也不延长原建链期限。 - 同组成功选择是线性化的,不同组独立;选择失败不改变状态,也不创建重试或自动 failover 任务。 - 切换不扫描或迁移现有 TCP、UDP、DNS transport,不刷新 DNS cache/singleflight 或 TCP pool;资源仍由原所有者按既有 timeout、EOF、取消和 stop 语义回收。 diff --git a/docs/vless.md b/docs/vless.md index c6f33a3..3f29d43 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -76,7 +76,7 @@ ech-opts: - `enable` 默认 false。启用必须同时提供非空 `config`;不查询 HTTPS RR, 不接收 `query-server-name`,没有公共 DNS 后备或自动更新配置。 - `config` 是标准带填充 Base64,不是 URL、文件路径或 PEM。解码后的完整 - ECHConfigList 最多 65,537 字节,仍受整份配置 256 KiB 上限限制。 + ECHConfigList 最多 65,537 字节,仍受整份配置 8 MiB 上限限制。 - 支持 ECH 版本 `0xfe0d`、X25519/HKDF-SHA256,以及 AES-128-GCM、 AES-256-GCM、ChaCha20-Poly1305。预检选取第一个双方后端均支持的条目, 保留其原始编码;未知版本或不兼容条目可跳过,无兼容条目、畸形长度、非法 diff --git a/src/config/measure.rs b/src/config/measure.rs index d47bd8b..64cc0dd 100644 --- a/src/config/measure.rs +++ b/src/config/measure.rs @@ -1,12 +1,8 @@ use serde::Deserialize; -use serde_yaml_ng::Value as YamlValue; use crate::{Result, VoleError}; -use super::{ - MAX_CONFIG_BYTES, ProxyConfig, ProxyId, RawOutbound, RouteTargetId, normalize_proxy_graph, - reject_yaml_anchors_and_aliases, validate_json_compatible_yaml, -}; +use super::{ProxyConfig, ProxyId, RawOutbound, RouteTargetId, normalize_proxy_graph}; /// Strict node-only configuration accepted by the built-in latency runner. /// @@ -27,18 +23,7 @@ struct RawMeasureConfig { impl MeasureConfig { pub(crate) fn parse_yaml(input: &[u8]) -> Result { - if input.len() > MAX_CONFIG_BYTES { - return Err(VoleError::InvalidConfig(format!( - "configuration exceeds the {MAX_CONFIG_BYTES}-byte limit" - ))); - } - let input = std::str::from_utf8(input) - .map_err(|_| VoleError::InvalidConfig("configuration is not UTF-8".to_owned()))?; - - reject_yaml_anchors_and_aliases(input)?; - let yaml: YamlValue = serde_yaml_ng::from_str(input) - .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; - validate_json_compatible_yaml(&yaml)?; + let yaml = super::yaml::parse(input)?; let raw: RawMeasureConfig = serde_yaml_ng::from_value(yaml) .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; raw.normalize() @@ -111,7 +96,8 @@ mod tests { b"proxies:\n - name: node\n type: wireguard\n server: example.com\n port: 443\n"; assert!(super::MeasureConfig::parse_yaml(yaml).is_err()); assert!( - super::MeasureConfig::parse_yaml(&vec![b'x'; super::MAX_CONFIG_BYTES + 1]).is_err() + super::MeasureConfig::parse_yaml(&vec![b'x'; super::super::MAX_CONFIG_BYTES + 1]) + .is_err() ); } diff --git a/src/config/mod.rs b/src/config/mod.rs index bec49aa..bbb3260 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -12,12 +12,16 @@ use serde::{ Deserialize, Deserializer, de::{MapAccess, Visitor}, }; -use serde_yaml_ng::Value as YamlValue; use url::{Host, Url}; use uuid::Uuid; use crate::{Result, VoleError}; +mod rules; +mod yaml; +pub use rules::DomainRegex; +use rules::parse_rule; + mod credentials; pub use credentials::{ProxyAccess, ProxyCredentials}; mod ech; @@ -46,10 +50,10 @@ mod measure; #[cfg(feature = "invoke")] pub(crate) use measure::MeasureConfig; -pub const MAX_CONFIG_BYTES: usize = 256 * 1024; -pub const MAX_RULES: usize = 1_024; -pub const MAX_RULE_BYTES: usize = 1_024; -pub const MAX_RULES_TOTAL_BYTES: usize = 128 * 1024; +pub const MAX_CONFIG_BYTES: usize = 8 * 1024 * 1024; +pub const MAX_RULES: usize = 65_536; +pub const MAX_RULE_BYTES: usize = 16 * 1024; +pub const MAX_RULES_TOTAL_BYTES: usize = MAX_CONFIG_BYTES; pub const MAX_DNS_NAMESERVERS: usize = 4; pub const MAX_SNIFFER_PORT_ITEMS: usize = 64; pub const MAX_GEOX_URL_BYTES: usize = 4_096; @@ -62,7 +66,8 @@ pub struct Config { pub ipv6: bool, pub proxies: Vec, pub proxy_groups: Vec, - pub default_action: RuleAction, + pub mode: Mode, + pub log_level: LogLevel, pub geodata_update: Option, pub external_controller: Option, pub inbounds: Vec, @@ -73,6 +78,52 @@ pub struct Config { pub rules: Vec, } +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(try_from = "String")] +pub enum Mode { + #[default] + Rule, + Global, + Direct, +} + +impl TryFrom for Mode { + type Error = &'static str; + fn try_from(value: String) -> std::result::Result { + match value.to_ascii_lowercase().as_str() { + "rule" => Ok(Self::Rule), + "global" => Ok(Self::Global), + "direct" => Ok(Self::Direct), + _ => Err("mode must be rule, global, or direct"), + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(try_from = "String")] +pub enum LogLevel { + Silent, + Error, + Warning, + #[default] + Info, + Debug, +} + +impl TryFrom for LogLevel { + type Error = &'static str; + fn try_from(value: String) -> std::result::Result { + match value.to_ascii_lowercase().as_str() { + "silent" => Ok(Self::Silent), + "error" => Ok(Self::Error), + "warning" => Ok(Self::Warning), + "info" => Ok(Self::Info), + "debug" => Ok(Self::Debug), + _ => Err("log-level must be silent, error, warning, info, or debug"), + } + } +} + #[derive(Clone, PartialEq, Eq)] pub struct ExternalControllerConfig { pub listen: SocketAddr, @@ -233,6 +284,13 @@ pub enum RuleKind { Domain(String), DomainSuffix(String), DomainKeyword(String), + DomainWildcard(String), + DomainRegex(DomainRegex), + And(Vec), + Or(Vec), + Not(Box), + SrcIpCidr(IpCidr), + SrcPorts(Vec), GeoSite(String), GeoIp(String), IpCidr(IpCidr), @@ -720,6 +778,10 @@ impl std::fmt::Debug for HttpInboundConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawVoleConfig { + #[serde(default)] + mode: Mode, + #[serde(rename = "log-level", default)] + log_level: LogLevel, #[serde(default = "default_true")] ipv6: bool, #[serde( @@ -772,6 +834,7 @@ struct RawVoleConfig { proxy_groups: Vec, #[serde(default, deserialize_with = "deserialize_present_option")] dns: Option, + #[serde(default)] rules: Vec, } @@ -1435,18 +1498,7 @@ where impl Config { pub fn parse_yaml(input: &[u8]) -> Result { - if input.len() > MAX_CONFIG_BYTES { - return invalid(format!( - "configuration exceeds the {MAX_CONFIG_BYTES}-byte limit" - )); - } - let input = std::str::from_utf8(input) - .map_err(|_| VoleError::InvalidConfig("configuration is not UTF-8".to_owned()))?; - - reject_yaml_anchors_and_aliases(input)?; - let yaml: YamlValue = serde_yaml_ng::from_str(input) - .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; - validate_json_compatible_yaml(&yaml)?; + let yaml = yaml::parse(input)?; // Deserialize from the YAML value directly. Mapping order is part of // nameserver-policy semantics and would be lost by the intermediate // serde_json map representation. @@ -1454,6 +1506,29 @@ impl Config { .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; raw.normalize() } + #[cfg(any(feature = "invoke", test))] + pub(crate) fn take_runtime_rules(&mut self) -> Vec { + match self.mode { + Mode::Rule => std::mem::take(&mut self.rules), + Mode::Direct | Mode::Global => { + let action = if self.mode == Mode::Direct { + RuleAction::Direct + } else { + RuleAction::Route(RouteTargetId::Group(ProxyGroupId::from_index( + self.proxy_groups + .iter() + .position(|group| group.name == "GLOBAL") + .expect("GLOBAL group exists"), + ))) + }; + vec![RuleSpec { + kind: RuleKind::Match, + action, + no_resolve: false, + }] + } + } + } } impl RawVoleConfig { @@ -1466,9 +1541,6 @@ impl RawVoleConfig { let mixed_port = self.mixed_port.filter(|port| *port != 0); let authentication = normalize_proxy_authentication(self.authentication)?; - if mixed_port.is_none() && (authentication.is_some() || self.allow_lan) { - return invalid("authentication or allow-lan requires an enabled mixed-port"); - } let tun = self .tun @@ -1481,10 +1553,33 @@ impl RawVoleConfig { RawSnifferConfig::normalize, )?; - let (raw_proxy_groups, proxy_group_ids) = - normalize_proxy_group_declarations(self.proxy_groups)?; + let has_explicit_groups = !self.proxy_groups.is_empty(); + let mut raw_groups = self.proxy_groups; + let generated_global = !raw_groups.iter().any(|group| group.name == "GLOBAL"); + if generated_global { + raw_groups.push(RawProxyGroup { + name: "GLOBAL".into(), + kind: "select".into(), + proxies: vec!["DIRECT".into(), "REJECT".into()], + default_selected: None, + }); + } + let (mut raw_proxy_groups, proxy_group_ids) = + normalize_proxy_group_declarations(raw_groups)?; let (proxies, proxy_ids) = normalize_proxy_graph_with_groups(self.proxies, &proxy_group_ids)?; + // The generated GLOBAL selector lists built-ins, nodes, then declared groups. + if generated_global { + let last = raw_proxy_groups.len() - 1; + let mut members = vec!["DIRECT".into(), "REJECT".into()]; + members.extend(proxies.iter().map(|proxy| proxy.tag.clone())); + members.extend( + raw_proxy_groups[..last] + .iter() + .map(|group| group.name.clone()), + ); + raw_proxy_groups[last].proxies = members; + } let route_targets = collect_route_targets(&proxy_ids, &proxy_group_ids); let proxy_groups = normalize_proxy_groups(raw_proxy_groups, &route_targets)?; proxy_graph_order(&proxies, &proxy_groups)?; @@ -1492,7 +1587,7 @@ impl RawVoleConfig { self.external_controller, self.secret, tun.enable, - !proxy_groups.is_empty(), + has_explicit_groups, )?; let mut dns = self @@ -1500,7 +1595,6 @@ impl RawVoleConfig { .map_or_else(DnsConfig::disabled, |dns| dns.normalize(&route_targets))?; dns.ipv6 &= self.ipv6; let rules = normalize_rules(self.rules, &route_targets)?; - let default_action = rules.last().expect("validated final MATCH").action; drop(proxy_ids); drop(route_targets); @@ -1527,7 +1621,8 @@ impl RawVoleConfig { ipv6: self.ipv6, proxies, proxy_groups, - default_action, + mode: self.mode, + log_level: self.log_level, geodata_update, external_controller, inbounds, @@ -1948,12 +2043,6 @@ fn normalize_proxy_groups( let target = match name.as_str() { "DIRECT" => ProxyGroupMemberTarget::Direct, "REJECT" => ProxyGroupMemberTarget::Reject, - "RULES" => { - return invalid(format!( - "proxy group `{}` cannot use reserved DNS target `RULES` as a member", - group.name - )); - } _ => route_targets .get(&name) .copied() @@ -2848,9 +2937,6 @@ fn normalize_rules( rules: Vec, route_targets: &RouteTargetsByName, ) -> Result> { - if rules.is_empty() { - return invalid("rules must not be empty"); - } if rules.len() > MAX_RULES { return invalid(format!("rules exceeds the {MAX_RULES}-entry limit")); } @@ -2874,108 +2960,13 @@ fn normalize_rules( normalized.push(parse_rule(&rule, route_targets)?); } - let match_count = normalized - .iter() - .filter(|rule| matches!(rule.kind, RuleKind::Match)) - .count(); - if match_count != 1 - || !matches!( - normalized.last().map(|rule| &rule.kind), - Some(RuleKind::Match) - ) - { - return invalid("explicit rules must contain exactly one MATCH as the final rule"); - } Ok(normalized) } -fn parse_rule(input: &str, route_targets: &RouteTargetsByName) -> Result { - if input.is_empty() { - return invalid("rule must not be empty"); - } - let fields = input - .split(',') - .map(trim_ascii_whitespace) - .collect::>(); - let rule_type = fields - .first() - .filter(|field| !field.is_empty()) - .ok_or_else(|| VoleError::InvalidConfig("rule type must not be empty".to_owned()))? - .to_ascii_uppercase(); - - match rule_type.as_str() { - "MATCH" => { - require_rule_field_count(&fields, 2, "MATCH")?; - Ok(RuleSpec { - kind: RuleKind::Match, - action: parse_rule_action(fields[1], route_targets)?, - no_resolve: false, - }) - } - "DOMAIN" | "DOMAIN-SUFFIX" | "DOMAIN-KEYWORD" | "GEOSITE" | "DST-PORT" | "NETWORK" => { - require_rule_field_count(&fields, 3, &rule_type)?; - let kind = match rule_type.as_str() { - "DOMAIN" => RuleKind::Domain(normalize_rule_domain(fields[1], "DOMAIN")?), - "DOMAIN-SUFFIX" => { - RuleKind::DomainSuffix(normalize_rule_domain(fields[1], "DOMAIN-SUFFIX")?) - } - "DOMAIN-KEYWORD" => RuleKind::DomainKeyword(normalize_rule_keyword(fields[1])?), - "GEOSITE" => RuleKind::GeoSite(normalize_geo_code(fields[1], "GEOSITE")?), - "DST-PORT" => RuleKind::DstPorts(parse_port_ranges(fields[1])?), - "NETWORK" => RuleKind::Network(parse_network(fields[1])?), - _ => unreachable!("matched rule type"), - }; - Ok(RuleSpec { - kind, - action: parse_rule_action(fields[2], route_targets)?, - no_resolve: false, - }) - } - "GEOIP" | "IP-CIDR" | "IP-CIDR6" => { - if fields.len() != 3 && fields.len() != 4 { - return invalid(format!( - "{rule_type} rule must contain 3 fields, or 4 fields with no-resolve" - )); - } - let kind = match rule_type.as_str() { - "GEOIP" => RuleKind::GeoIp(normalize_geo_code(fields[1], "GEOIP")?), - "IP-CIDR" => RuleKind::IpCidr(parse_ip_cidr(fields[1], false)?), - "IP-CIDR6" => RuleKind::IpCidr(parse_ip_cidr(fields[1], true)?), - _ => unreachable!("matched rule type"), - }; - let no_resolve = if fields.len() == 4 { - if fields[3] != "no-resolve" { - return invalid(format!( - "{rule_type} fourth field must be exactly `no-resolve`" - )); - } - true - } else { - false - }; - Ok(RuleSpec { - kind, - action: parse_rule_action(fields[2], route_targets)?, - no_resolve, - }) - } - _ => invalid(format!("unsupported rule type `{}`", fields[0])), - } -} - fn trim_ascii_whitespace(input: &str) -> &str { input.trim_matches(|character: char| character.is_ascii_whitespace()) } -fn require_rule_field_count(fields: &[&str], expected: usize, rule_type: &str) -> Result<()> { - if fields.len() != expected { - return invalid(format!( - "{rule_type} rule must contain exactly {expected} fields" - )); - } - Ok(()) -} - fn parse_rule_action(action: &str, route_targets: &RouteTargetsByName) -> Result { match action { "DIRECT" => Ok(RuleAction::Direct), @@ -3106,29 +3097,18 @@ fn parse_network(input: &str) -> Result { } pub(crate) fn validate_route_target_name(name: &str, field: &str) -> Result<()> { - let mut characters = name.chars(); - let first = characters.next(); - let last = characters.next_back().or(first); - if name.is_empty() - || name.len() > 64 - || first.is_some_and(char::is_whitespace) - || last.is_some_and(char::is_whitespace) - || name.chars().any(|character| { - character.is_control() - || matches!(character, ',' | '#' | '/' | '?' | '&' | '=' | '%' | '\\') - }) - || matches!(name, "." | "..") - { - return invalid(format!( - "{field} must be 1-64 UTF-8 bytes without surrounding whitespace, control characters, path separators, or reserved delimiters" - )); + if name.is_empty() { + return invalid(format!("{field} must not be empty")); } Ok(()) } fn validate_route_target_definition_name(name: &str, field: &str) -> Result<()> { validate_route_target_name(name, field)?; - if matches!(name, "DIRECT" | "REJECT" | "RULES") { + if matches!( + name, + "DIRECT" | "REJECT" | "REJECT-DROP" | "COMPATIBLE" | "PASS" | "PASS-RULE" + ) { return invalid(format!("{field} `{name}` is reserved")); } Ok(()) @@ -3173,102 +3153,6 @@ fn parse_standard_uuid(input: &str) -> Result { Ok(id) } -fn validate_json_compatible_yaml(value: &YamlValue) -> Result<()> { - match value { - YamlValue::Null | YamlValue::Bool(_) | YamlValue::Number(_) | YamlValue::String(_) => { - Ok(()) - } - YamlValue::Sequence(values) => { - for value in values { - validate_json_compatible_yaml(value)?; - } - Ok(()) - } - YamlValue::Mapping(mapping) => { - for (key, value) in mapping { - let YamlValue::String(key) = key else { - return invalid("YAML map keys must be strings"); - }; - if key == "<<" { - return invalid("YAML merge keys are not supported"); - } - validate_json_compatible_yaml(value)?; - } - Ok(()) - } - YamlValue::Tagged(_) => invalid("custom YAML tags are not supported"), - } -} - -/// Anchors and aliases disappear when `serde_yaml_ng` materializes a value, so -/// reject their node-property syntax before parsing. Quoted text and comments -/// are intentionally ignored by this small lexical pass. -fn reject_yaml_anchors_and_aliases(input: &str) -> Result<()> { - let bytes = input.as_bytes(); - let mut index = 0; - let mut single_quoted = false; - let mut double_quoted = false; - let mut comment = false; - while index < bytes.len() { - let byte = bytes[index]; - if comment { - if byte == b'\n' { - comment = false; - } - index += 1; - continue; - } - if single_quoted { - if byte == b'\'' { - if bytes.get(index + 1) == Some(&b'\'') { - index += 2; - continue; - } - single_quoted = false; - } - index += 1; - continue; - } - if double_quoted { - if byte == b'\\' { - index = (index + 2).min(bytes.len()); - continue; - } - if byte == b'"' { - double_quoted = false; - } - index += 1; - continue; - } - - match byte { - b'#' if index == 0 || bytes[index - 1].is_ascii_whitespace() => comment = true, - b'\'' => single_quoted = true, - b'"' => double_quoted = true, - b'&' | b'*' if is_yaml_node_boundary(bytes, index) => { - return invalid("YAML anchors and aliases are not supported"); - } - _ => {} - } - index += 1; - } - Ok(()) -} - -fn is_yaml_node_boundary(bytes: &[u8], index: usize) -> bool { - let previous_is_boundary = index == 0 - || bytes[index - 1].is_ascii_whitespace() - || b"[{,:?-".contains(&bytes[index - 1]); - let Some(next) = bytes.get(index + 1) else { - return false; - }; - previous_is_boundary - && !next.is_ascii_whitespace() - && !b"[]{}:,#".contains(next) - && *next != b'\n' - && *next != b'\r' -} - fn invalid(message: impl Into) -> Result { Err(VoleError::InvalidConfig(message.into())) } @@ -3277,6 +3161,75 @@ fn invalid(message: impl Into) -> Result { mod tests { use super::*; + #[test] + fn modes_and_global_group_work_without_a_final_match() { + use crate::routing::{RoutingContext, RuleEvaluation, RuleSet}; + let destination = crate::session::Destination::Ip("192.0.2.1:443".parse().unwrap()); + let context = RoutingContext::new(Network::Tcp, &destination).unwrap(); + for mode in ["rule", "DIRECT", "global"] { + let mut config = Config::parse_yaml( + format!("mixed-port: 1080\nmode: {mode}\nlog-level: Warning\n").as_bytes(), + ) + .unwrap(); + assert_eq!(config.log_level, LogLevel::Warning); + assert_eq!(config.proxy_groups[0].name, "GLOBAL"); + assert_eq!(config.proxy_groups[0].members[0].name, "DIRECT"); + let expected = if config.mode == Mode::Global { + RuleAction::Route(RouteTargetId::Group(ProxyGroupId::from_index(0))) + } else { + RuleAction::Direct + }; + let RuleEvaluation::Matched(result) = RuleSet::compile(config.take_runtime_rules()) + .unwrap() + .evaluate(&context) + else { + panic!("mode must select an action") + }; + assert_eq!(result.action, expected); + } + let config = Config::parse_yaml(b"mixed-port: 1080\nproxy-groups: [{name: GLOBAL, type: select, proxies: [REJECT, DIRECT]}]\n").unwrap(); + assert_eq!(config.proxy_groups.len(), 1); + assert_eq!(config.proxy_groups[0].members[0].name, "REJECT"); + } + + #[test] + fn yaml_templates_feed_logical_domain_and_source_rules() { + use crate::routing::{RoutingContext, RuleEvaluation, RuleSet}; + let yaml = r#" +mixed-port: 0 +allow-lan: true +authentication: [user:password] +tun: {enable: true} +proxies: + - &base {name: "first/#%", type: socks5, server: 192.0.2.1, port: 1080} + - {<<: *base, name: second, port: 1081} +rules: + - AND,((DOMAIN-WILDCARD,*.example.com),(SRC-IP-CIDR,10.0.0.0/8),(SRC-PORT,1000-2000),(NOT,((NETWORK,UDP)))),REJECT + - 'DOMAIN-REGEX,^a{1,2}\.example\.com$,second' +"#; + let mut config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!(config.proxies[1].port(), 1081); + let rules = RuleSet::compile(config.take_runtime_rules()).unwrap(); + let destination = crate::session::Destination::domain("AA.example.com", 443).unwrap(); + let mut context = RoutingContext::new(Network::Tcp, &destination).unwrap(); + context.set_source("10.1.2.3:1500".parse().unwrap()); + let RuleEvaluation::Matched(result) = rules.evaluate(&context) else { + panic!("rule match") + }; + assert_eq!(result.action, RuleAction::Reject); + context.set_source("192.0.2.2:1500".parse().unwrap()); + let RuleEvaluation::Matched(result) = rules.evaluate(&context) else { + panic!("regex match") + }; + assert_eq!( + result.action, + RuleAction::Route(RouteTargetId::Proxy(ProxyId::from_index(1))) + ); + assert!( + Config::parse_yaml(b"mixed-port: 1080\nproxies: &recursive [*recursive]\n").is_err() + ); + } + const CURRENT_TLS: &str = r#"mixed-port: 1080 authentication: - measure:secret @@ -3349,7 +3302,7 @@ proxies: fn current_yaml(fields: &str) -> String { let default_rules = if fields .lines() - .any(|line| line.trim_ascii_start() == "rules:") + .any(|line| line.trim_ascii_start().starts_with("rules:")) { "" } else { @@ -3460,7 +3413,7 @@ proxies: assert_eq!(config.proxies[0].tag, "proxy"); assert!(config.proxies[0].udp); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(0).unwrap())) ); assert_eq!(first_vless(&config).address, "203.0.113.1"); @@ -4181,14 +4134,6 @@ outbounds: [] fn validates_listener_and_tun_constraints() { for yaml in [ CURRENT_TLS.replace("mtu: 1500", "mtu: 1279"), - CURRENT_TLS.replace( - "mixed-port: 1080 -authentication: - - measure:secret", - "mixed-port: 0 -authentication: - - measure:secret", - ), CURRENT_TLS .replace("enable: true", "enable: false") .replace( @@ -4321,10 +4266,6 @@ authentication: assert!(Config::parse_yaml(max.as_bytes()).is_ok()); for (fields, expected) in [ - ( - "authentication:\n - measure:secret\ntun:\n enable: true", - "authentication or allow-lan requires an enabled mixed-port", - ), ( "mixed-port: 1080\nauthentication:\n - one:secret\n - two:secret", "authentication must contain exactly one", @@ -4474,8 +4415,6 @@ authentication: "mixed-port: 65536", "mixed-port: 1.0", "mixed-port: null", - "allow-lan: true\ntun: {enable: true}", - "mixed-port: 0\nauthentication: [u:p]\ntun: {enable: true}", "mixed-port: 1080\nallow-lan: null", ] { assert!( @@ -4532,7 +4471,7 @@ authentication: fn parses_the_strict_anytls_subset() { let config = Config::parse_yaml(CURRENT_ANYTLS.as_bytes()).unwrap(); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(0).unwrap())) ); let proxy = &config.proxies[0]; @@ -4572,7 +4511,7 @@ authentication: ); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(1).unwrap())) ); assert_eq!( @@ -4780,9 +4719,7 @@ authentication: for yaml in [ CURRENT_TLS.replacen("proxies:\n", "1: value\nproxies:\n", 1), CURRENT_TLS.replace("name: proxy", "name: !custom proxy"), - CURRENT_TLS.replace(" xhttp-opts:\n", " xhttp-opts: &defaults\n"), CURRENT_TLS.replace(" xhttp-opts:\n", " xhttp-opts: *defaults\n"), - CURRENT_TLS.replacen("proxies:\n", "<<: {}\nproxies:\n", 1), CURRENT_TLS.replacen("proxies:\n", "proxies: []\nproxies:\n", 1), format!("{CURRENT_TLS}\n---\n{CURRENT_TLS}"), ] { @@ -5090,18 +5027,13 @@ authentication: } #[test] - fn current_config_requires_listener_rules_and_valid_references() { + fn current_config_requires_listener_and_valid_references() { for yaml in [ current_yaml("mixed-port: 0\nauthentication:\n - measure:secret"), current_yaml("tun:\n enable: false"), current_yaml("tun:\n enable: true\n mtu: 1279"), current_yaml( "mixed-port: 1080 -authentication: - - measure:secret\nrules: []", - ), - current_yaml( - "mixed-port: 1080 authentication: - measure:secret\ntypo: true", ), @@ -5148,7 +5080,7 @@ authentication: let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!(config.proxies.len(), 2); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(1).unwrap())) ); let ProxyProtocol::Socks5(socks5) = &config.proxies[1].protocol else { @@ -5295,7 +5227,7 @@ authentication: let first = ProxyId::new(0).unwrap(); let second = ProxyId::new(1).unwrap(); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(second)) ); assert_eq!( @@ -5357,7 +5289,7 @@ rules: let main = ProxyGroupId::new(0).unwrap(); let backup = ProxyGroupId::new(1).unwrap(); - assert_eq!(config.proxy_groups.len(), 2); + assert_eq!(config.proxy_groups.len(), 3); assert_eq!(config.proxy_groups[0].name, "主线路"); assert_eq!(config.proxy_groups[0].initial_member, 1); assert_eq!( @@ -5390,7 +5322,7 @@ rules: ["proxy", "proxy", "DIRECT"] ); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Group(main)) ); assert_eq!( @@ -5540,7 +5472,7 @@ proxies: assert_eq!(config.proxies.len(), 4); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(ProxyId::from_index(3))) ); assert_eq!( @@ -5582,7 +5514,7 @@ proxies: let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert_eq!(config.proxies.len(), 300); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Proxy(ProxyId::from_index(299))) ); assert_eq!( @@ -5611,32 +5543,28 @@ proxies: for tag in [ "DIRECT", "REJECT", - "RULES", - "edge,one", - "edge#one", - "edge/one", - "edge?one", - "edge&one", - "edge=one", - "edge%one", - "edge\\one", - ".", - "..", - " edge", - "edge ", + "REJECT-DROP", + "COMPATIBLE", + "PASS", + "PASS-RULE", ] { let yaml = CURRENT_TLS.replace("name: proxy", &format!("name: '{tag}'")); - assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); + assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); } - for tag in ["PROXY", "-edge", "节点", "♻️ 自动选择", "+"] { + for tag in [ + "RULES", "edge,one", "edge#one", "edge/one", "edge?one", "edge%one", ".", "..", + " edge", "edge ", + ] { let yaml = CURRENT_TLS .replace("name: proxy", &format!("name: '{tag}'")) - .replace("MATCH,proxy", &format!("MATCH,{tag}")); - assert!(Config::parse_yaml(yaml.as_bytes()).is_ok(), "{yaml}"); + .replace("MATCH,proxy", "MATCH,DIRECT"); + assert!(Config::parse_yaml(yaml.as_bytes()).is_ok(), "{tag}"); } - let long_tag = "a".repeat(65); - let yaml = CURRENT_TLS.replace("name: proxy", &format!("name: {long_tag}")); - assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); + let tag = "a".repeat(256); + let yaml = CURRENT_TLS + .replace("name: proxy", &format!("name: '{tag}'")) + .replace("MATCH,proxy", &format!("MATCH,{tag}")); + assert!(Config::parse_yaml(yaml.as_bytes()).is_ok()); let duplicate = two_proxy_yaml( "mixed-port: 1080 @@ -5883,7 +5811,7 @@ authentication: let yaml = format!("mixed-port: 1080\n{proxies}rules: ['MATCH,{target}']\n"); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); assert!(config.proxies.is_empty()); - assert_eq!(config.default_action, action); + assert_eq!(config.rules.last().unwrap().action, action); assert_eq!(config.rules[0].action, action); } } @@ -6154,7 +6082,6 @@ authentication: " - NETWORK,ICMP,REJECT\n - MATCH,proxy", " - MATCH,PROXY\n - DOMAIN,example.com,DIRECT", " - MATCH,PROXY\n - MATCH,DIRECT", - " - DOMAIN,example.com,DIRECT", ] { let yaml = current_yaml(&format!( "mixed-port: 1080 @@ -6169,7 +6096,7 @@ authentication: authentication: - measure:secret\nrules: []", ); - assert!(Config::parse_yaml(explicit_empty.as_bytes()).is_err()); + assert!(Config::parse_yaml(explicit_empty.as_bytes()).is_ok()); let non_ascii_whitespace = current_yaml( "mixed-port: 1080 @@ -6208,7 +6135,6 @@ authentication: authentication: - measure:secret\nrules:\n{many_rules} - MATCH,proxy" )); - let error = Config::parse_yaml(yaml.as_bytes()).unwrap_err(); - assert!(error.to_string().contains("cumulative limit"), "{error}"); + assert!(Config::parse_yaml(yaml.as_bytes()).is_ok()); } } diff --git a/src/config/rules.rs b/src/config/rules.rs new file mode 100644 index 0000000..54f1d21 --- /dev/null +++ b/src/config/rules.rs @@ -0,0 +1,165 @@ +use regex::{Regex, RegexBuilder}; + +use super::*; + +/// Compile once; equality continues to describe the configuration pattern. +#[derive(Debug, Clone)] +pub struct DomainRegex(Regex); + +impl PartialEq for DomainRegex { + fn eq(&self, other: &Self) -> bool { + self.0.as_str() == other.0.as_str() + } +} +impl Eq for DomainRegex {} + +impl DomainRegex { + pub fn new(pattern: &str) -> Result { + RegexBuilder::new(pattern).case_insensitive(true).size_limit(MAX_CONFIG_BYTES) + .build().map(Self).map_err(|_| VoleError::InvalidConfig( + "DOMAIN-REGEX requires a valid supported regular expression; lookaround and backreferences are not supported".into() + )) + } + pub fn is_match(&self, domain: &str) -> bool { + self.0.is_match(domain) + } + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +pub(super) fn parse_rule(input: &str, targets: &RouteTargetsByName) -> Result { + parse(input, Some(targets), 0) +} + +fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Result { + if depth > 32 { + return invalid("logical rules exceed 32 nesting levels"); + } + let (kind, rest) = input + .split_once(',') + .ok_or_else(|| VoleError::InvalidConfig("rule requires a type and payload".into()))?; + let kind = trim_ascii_whitespace(kind).to_ascii_uppercase(); + if kind == "MATCH" { + let targets = targets.ok_or_else(|| { + VoleError::InvalidConfig("MATCH is not supported inside logical rules".into()) + })?; + return Ok(RuleSpec { + kind: RuleKind::Match, + action: parse_rule_action( + trim_ascii_whitespace(rest.split(',').next().unwrap_or_default()), + targets, + )?, + no_resolve: false, + }); + } + let (payload, action, no_resolve) = + if matches!(kind.as_str(), "AND" | "OR" | "NOT" | "DOMAIN-REGEX") { + match targets { + Some(targets) => { + let (payload, action) = rest + .rsplit_once(',') + .ok_or_else(|| VoleError::InvalidConfig("rule requires a target".into()))?; + ( + trim_ascii_whitespace(payload), + parse_rule_action(trim_ascii_whitespace(action), targets)?, + false, + ) + } + None => (trim_ascii_whitespace(rest), RuleAction::Direct, false), + } + } else { + let mut fields = rest.split(',').map(trim_ascii_whitespace); + let payload = fields.next().unwrap_or_default(); + let action = match targets { + Some(targets) => parse_rule_action(fields.next().unwrap_or_default(), targets)?, + None => RuleAction::Direct, + }; + let mut no_resolve = false; + for option in fields { + if option == "no-resolve" + && matches!( + kind.as_str(), + "GEOIP" | "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" + ) + { + no_resolve = true; + } else { + return invalid("unsupported rule parameter"); + } + } + (payload, action, no_resolve) + }; + let rule_kind = match kind.as_str() { + "DOMAIN" => RuleKind::Domain(normalize_rule_domain(payload, "DOMAIN")?), + "DOMAIN-SUFFIX" => RuleKind::DomainSuffix(normalize_rule_domain(payload, "DOMAIN-SUFFIX")?), + "DOMAIN-KEYWORD" => RuleKind::DomainKeyword(normalize_rule_keyword(payload)?), + "DOMAIN-WILDCARD" => RuleKind::DomainWildcard(payload.to_lowercase()), + "DOMAIN-REGEX" => RuleKind::DomainRegex(DomainRegex::new(payload)?), + "GEOSITE" => RuleKind::GeoSite(normalize_geo_code(payload, "GEOSITE")?), + "GEOIP" => RuleKind::GeoIp(normalize_geo_code(payload, "GEOIP")?), + "IP-CIDR" => RuleKind::IpCidr(parse_ip_cidr(payload, false)?), + "IP-CIDR6" => RuleKind::IpCidr(parse_ip_cidr(payload, true)?), + "SRC-IP-CIDR" => RuleKind::SrcIpCidr(parse_ip_cidr(payload, payload.contains(':'))?), + "DST-PORT" => RuleKind::DstPorts(parse_port_ranges(payload)?), + "SRC-PORT" => RuleKind::SrcPorts(parse_port_ranges(payload)?), + "NETWORK" => RuleKind::Network(parse_network(payload)?), + "AND" | "OR" | "NOT" => { + let children = logical_children(payload)? + .into_iter() + .map(|child| parse(child, None, depth + 1)) + .collect::>>()?; + match kind.as_str() { + "AND" => RuleKind::And(children), + "OR" => RuleKind::Or(children), + _ if children.len() == 1 => { + RuleKind::Not(Box::new(children.into_iter().next().unwrap())) + } + _ => return invalid("NOT requires exactly one rule"), + } + } + _ => return invalid(format!("unsupported rule type `{kind}`")), + }; + Ok(RuleSpec { + kind: rule_kind, + action, + no_resolve, + }) +} + +fn logical_children(payload: &str) -> Result> { + let inner = payload + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .ok_or_else(|| { + VoleError::InvalidConfig("logical rule payload requires parentheses".into()) + })?; + let mut children = Vec::new(); + let mut depth = 0_usize; + let mut start = 0; + for (index, byte) in inner.bytes().enumerate() { + match byte { + b'(' => { + if depth == 0 { + start = index + 1; + } + depth += 1; + } + b')' => { + depth = depth + .checked_sub(1) + .ok_or_else(|| VoleError::InvalidConfig("unbalanced logical rule".into()))?; + if depth == 0 { + children.push(&inner[start..index]); + } + } + b',' | b' ' | b'\t' | b'\r' | b'\n' if depth == 0 => {} + _ if depth == 0 => return invalid("logical rule children require parentheses"), + _ => {} + } + } + if depth != 0 { + return invalid("unbalanced logical rule"); + } + Ok(children) +} diff --git a/src/config/yaml.rs b/src/config/yaml.rs new file mode 100644 index 0000000..9ed9cbf --- /dev/null +++ b/src/config/yaml.rs @@ -0,0 +1,161 @@ +use std::fmt; + +use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor}; +use serde_yaml_ng::{Mapping, Value}; + +use crate::{Result, VoleError}; + +use super::MAX_CONFIG_BYTES; + +/// Charge aliases while they are expanded, before allocating their values. +/// An input-size limit alone does not bound the materialized YAML tree. +pub(super) fn parse(input: &[u8]) -> Result { + if input.len() > MAX_CONFIG_BYTES { + return Err(VoleError::InvalidConfig(format!( + "configuration exceeds the {MAX_CONFIG_BYTES}-byte limit" + ))); + } + let mut documents = serde_yaml_ng::Deserializer::from_slice(input); + let document = documents.next().ok_or_else(|| { + VoleError::InvalidConfig("configuration must contain one YAML document".into()) + })?; + let mut budget = MAX_CONFIG_BYTES * 4; + let mut value = BoundedValue { + budget: &mut budget, + depth: 0, + } + .deserialize(document) + .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + if documents.next().is_some() { + return Err(VoleError::InvalidConfig( + "configuration must contain one YAML document".into(), + )); + } + merge(&mut value)?; + Ok(value) +} + +struct BoundedValue<'a> { + budget: &'a mut usize, + depth: usize, +} + +impl BoundedValue<'_> { + fn charge(&mut self, bytes: usize) -> std::result::Result<(), E> { + *self.budget = self + .budget + .checked_sub(bytes) + .ok_or_else(|| E::custom("expanded YAML exceeds the configuration memory limit"))?; + Ok(()) + } +} + +impl<'de> DeserializeSeed<'de> for BoundedValue<'_> { + type Value = Value; + + fn deserialize>( + mut self, + deserializer: D, + ) -> std::result::Result { + if self.depth > 64 { + return Err(de::Error::custom("YAML nesting exceeds 64 levels")); + } + self.charge::(64)?; + deserializer.deserialize_any(self) + } +} + +impl<'de> Visitor<'de> for BoundedValue<'_> { + type Value = Value; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("YAML data without custom tags") + } + + fn visit_unit(self) -> std::result::Result { + Ok(Value::Null) + } + fn visit_none(self) -> std::result::Result { + Ok(Value::Null) + } + fn visit_bool(self, value: bool) -> std::result::Result { + Ok(Value::Bool(value)) + } + fn visit_i64(self, value: i64) -> std::result::Result { + Ok(Value::Number(value.into())) + } + fn visit_u64(self, value: u64) -> std::result::Result { + Ok(Value::Number(value.into())) + } + fn visit_f64(self, value: f64) -> std::result::Result { + Ok(Value::Number(value.into())) + } + fn visit_str(mut self, value: &str) -> std::result::Result { + self.charge::(value.len())?; + Ok(Value::String(value.to_owned())) + } + fn visit_string(mut self, value: String) -> std::result::Result { + self.charge::(value.len())?; + Ok(Value::String(value)) + } + fn visit_seq>(self, mut sequence: A) -> std::result::Result { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element_seed(BoundedValue { + budget: self.budget, + depth: self.depth + 1, + })? { + values.push(value); + } + Ok(Value::Sequence(values)) + } + fn visit_map>(self, mut map: A) -> std::result::Result { + let mut values = Mapping::new(); + while let Some(key) = map.next_key_seed(BoundedValue { + budget: self.budget, + depth: self.depth + 1, + })? { + if !matches!(key, Value::String(_)) { + return Err(de::Error::custom("YAML map keys must be strings")); + } + let value = map.next_value_seed(BoundedValue { + budget: self.budget, + depth: self.depth + 1, + })?; + if values.insert(key, value).is_some() { + return Err(de::Error::custom("duplicate YAML map key")); + } + } + Ok(Value::Mapping(values)) + } +} + +fn merge(value: &mut Value) -> Result<()> { + match value { + Value::Sequence(values) => { + for value in values { + merge(value)?; + } + } + Value::Mapping(mapping) => { + for value in mapping.values_mut() { + merge(value)?; + } + if let Some(source) = mapping.remove("<<") { + let sources = match source { + Value::Sequence(values) => values, + source => vec![source], + }; + for source in sources { + let Value::Mapping(source) = source else { + return Err(VoleError::InvalidConfig("YAML merge requires maps".into())); + }; + for (key, value) in source { + mapping.entry(key).or_insert(value); + } + } + } + } + _ => {} + } + Ok(()) +} diff --git a/src/controller.rs b/src/controller.rs index b3070ef..03a71e3 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -589,12 +589,12 @@ mod tests { "主线路" ); assert_eq!(decode_group_name("a+b").unwrap(), "a+b"); - assert!(decode_group_name("%252F").is_err()); - assert!(decode_group_name("%2F").is_err()); + assert_eq!(decode_group_name("%252F").unwrap(), "%2F"); + assert_eq!(decode_group_name("%2F").unwrap(), "/"); assert!(decode_group_name("%GG").is_err()); assert!(decode_group_name("%A").is_err()); assert!(decode_group_name("%FF").is_err()); - assert!(decode_group_name("..").is_err()); + assert_eq!(decode_group_name("..").unwrap(), ".."); } #[tokio::test] diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index ad4cd61..af93f70 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -394,8 +394,11 @@ impl RequestedCodes { sites: CodeSet::EMPTY, ips: CodeSet::EMPTY, }; - for rule in rules { + let mut pending: Vec<_> = rules.iter().collect(); + while let Some(rule) = pending.pop() { match &rule.kind { + RuleKind::And(children) | RuleKind::Or(children) => pending.extend(children), + RuleKind::Not(child) => pending.push(child), RuleKind::GeoSite(code) => requested.sites.insert(GeoDataKind::GeoSite, code)?, RuleKind::GeoIp(code) => requested.ips.insert(GeoDataKind::GeoIp, code)?, _ => (), diff --git a/src/invoke/foreground.rs b/src/invoke/foreground.rs index ef32736..738bf79 100644 --- a/src/invoke/foreground.rs +++ b/src/invoke/foreground.rs @@ -524,7 +524,7 @@ impl StderrSubscriber { fn accepts(metadata: &Metadata<'_>) -> bool { let target = metadata.target(); metadata.is_event() - && *metadata.level() <= Level::INFO + && *metadata.level() <= Level::DEBUG && (target == "vole" || target.starts_with("vole::") || target == "vole_netstack" @@ -560,7 +560,7 @@ impl Subscriber for StderrSubscriber { } fn max_level_hint(&self) -> Option { - Some(LevelFilter::INFO) + Some(LevelFilter::DEBUG) } fn new_span(&self, _attributes: &Attributes<'_>) -> Id { diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index 4b4a7d3..61eb9fe 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -64,10 +64,8 @@ mod foreground; mod measure_delay; pub use foreground::InvokePath; -// A measureDelay request may inline five independently valid 256 KiB YAML -// documents. JSON escaping can double common YAML bytes such as backslashes, -// quotes, and newlines, so the wire envelope needs a larger aggregate bound. -pub(crate) const MAX_INVOKE_BYTES: usize = 3 * 1024 * 1024; +// Five YAML documents, worst-case JSON escaping, and a bounded envelope. +pub(crate) const MAX_INVOKE_BYTES: usize = 30 * crate::config::MAX_CONFIG_BYTES + 64 * 1024; const MAX_ERROR_BYTES: usize = 4096; static REGISTRY: OnceLock = OnceLock::new(); @@ -814,6 +812,8 @@ impl CoreController { // non-TUN configurations do not depend on controller registration. let _data_directory = registry().data_directory()?; let config = parse_config_yaml(&config_yaml)?; + let dispatch = crate::logging::dispatch(config.log_level); + let _logging = tracing::dispatcher::set_default(&dispatch); drop(config_yaml); let has_tun = config .inbounds @@ -940,7 +940,7 @@ impl CoreController { has_tun, allocator_relief, }; - let dispatch = tracing::dispatcher::get_default(Clone::clone); + let dispatch = crate::logging::dispatch(prepared.core.log_level()); let spawned = thread::Builder::new() .name(format!("vole-runtime-{instance_id}")) .spawn(crate::resources::observation::inherit_thread(move || { diff --git a/src/lib.rs b/src/lib.rs index a798664..3e4c684 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,8 @@ pub mod inbound; pub mod invoke; pub mod lifecycle; pub mod limits; +#[cfg(any(feature = "invoke", test))] +mod logging; pub mod outbound; pub mod packet; pub mod platform; diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 0000000..fce07a6 --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,133 @@ +//! Per-runtime filtering around the host's subscriber; no process-wide level. + +use tracing::{ + Dispatch, Event, Metadata, Subscriber, + level_filters::LevelFilter, + span::{Attributes, Id, Record}, + subscriber::Interest, +}; + +use crate::config::LogLevel; +use tracing_core::span::Current; + +pub(crate) fn dispatch(level: LogLevel) -> Dispatch { + let level = match level { + LogLevel::Silent => LevelFilter::OFF, + LogLevel::Error => LevelFilter::ERROR, + LogLevel::Warning => LevelFilter::WARN, + LogLevel::Info => LevelFilter::INFO, + LogLevel::Debug => LevelFilter::DEBUG, + }; + Dispatch::new(Filtered { + inner: tracing::dispatcher::get_default(Clone::clone), + level, + }) +} + +struct Filtered { + inner: Dispatch, + level: LevelFilter, +} + +impl Subscriber for Filtered { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + *metadata.level() <= self.level && self.inner.enabled(metadata) + } + fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest { + if *metadata.level() <= self.level { + self.inner.register_callsite(metadata) + } else { + Interest::never() + } + } + fn max_level_hint(&self) -> Option { + Some(self.level) + } + fn new_span(&self, attrs: &Attributes<'_>) -> Id { + self.inner.new_span(attrs) + } + fn record(&self, span: &Id, values: &Record<'_>) { + self.inner.record(span, values); + } + fn record_follows_from(&self, span: &Id, follows: &Id) { + self.inner.record_follows_from(span, follows); + } + fn event(&self, event: &Event<'_>) { + if self.enabled(event.metadata()) { + self.inner.event(event); + } + } + fn enter(&self, span: &Id) { + self.inner.enter(span); + } + fn exit(&self, span: &Id) { + self.inner.exit(span); + } + fn clone_span(&self, span: &Id) -> Id { + self.inner.clone_span(span) + } + fn try_close(&self, span: Id) -> bool { + self.inner.try_close(span) + } + fn current_span(&self) -> Current { + self.inner.current_span() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + use tracing::instrument::WithSubscriber; + + struct Counter(Arc); + impl Subscriber for Counter { + fn enabled(&self, _: &Metadata<'_>) -> bool { + true + } + fn new_span(&self, _: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + fn record(&self, _: &Id, _: &Record<'_>) {} + fn record_follows_from(&self, _: &Id, _: &Id) {} + fn event(&self, _: &Event<'_>) { + self.0.fetch_add(1, Ordering::Relaxed); + } + fn enter(&self, _: &Id) {} + fn exit(&self, _: &Id) {} + } + + #[tokio::test] + async fn runtime_level_follows_spawned_work_without_changing_host_logging() { + for (level, expected) in [ + (LogLevel::Info, 3), + (LogLevel::Warning, 2), + (LogLevel::Silent, 0), + (LogLevel::Debug, 4), + ] { + let count = Arc::new(AtomicUsize::new(0)); + let host = Dispatch::new(Counter(count.clone())); + let filtered = tracing::dispatcher::with_default(&host, || dispatch(level)); + async { + crate::resources::observation::spawn(async { + tracing::debug!("debug"); + tracing::info!("info"); + tracing::warn!("warning"); + tracing::error!("error"); + }) + .await + .unwrap(); + } + .with_subscriber(filtered) + .await; + assert_eq!(count.load(Ordering::Relaxed), expected); + tracing::dispatcher::with_default(&host, || { + tracing::debug!("host logging remains active") + }); + assert_eq!(count.load(Ordering::Relaxed), expected + 1); + } + } +} diff --git a/src/resources/observation.rs b/src/resources/observation.rs index 9e49450..ad60b49 100644 --- a/src/resources/observation.rs +++ b/src/resources/observation.rs @@ -243,11 +243,13 @@ where } pub(crate) fn task(future: F) -> impl Future { + use tracing::instrument::WithSubscriber; let guard = track(ResourceKind::Task); bind(async move { let _guard = guard; future.await }) + .with_current_subscriber() } /// IO plus a test-only lifetime guard. In release builds the guard is a ZST; diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index a8abe4e..4de35da 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -264,6 +264,7 @@ impl RoutingDispatcher { &self, network: Network, inbound: InboundKind, + source: SocketAddr, destination: &Destination, sniffed_domain: Option<&str>, ) -> Result { @@ -280,6 +281,8 @@ impl RoutingDispatcher { let mut context = RoutingContext::with_domain_hint(network, destination, domain_hint) .map_err(|_| DispatchError::HostUnreachable)?; + context.set_source(source); + // All GeoData rules in this decision see the same immutable view. let snapshot = self.geo_matcher.routing_snapshot(); let geo_matcher = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); @@ -501,6 +504,7 @@ impl Dispatcher for RoutingDispatcher { .route( Network::Tcp, session.inbound, + session.source, &session.destination, session.sniffed_domain.as_deref(), ) @@ -778,6 +782,7 @@ impl DatagramTransport for RoutedDatagramTransport { .route( Network::Udp, self.session.inbound, + self.session.source, &datagram.remote, datagram.sniffed_domain.as_deref(), ) diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 26654b0..3b6c17e 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -5,7 +5,11 @@ //! match values are normalized once and the resulting ordered rule set can be //! evaluated without allocating for each rule. -use std::{fmt, net::IpAddr, sync::Arc}; +use std::{ + fmt, + net::{IpAddr, SocketAddr}, + sync::Arc, +}; use crate::{ config::{IpCidr, Network, RuleAction, RuleKind, RuleSpec}, @@ -32,6 +36,7 @@ pub const MAX_RULE_TEXT_BYTES: usize = crate::config::MAX_RULES_TOTAL_BYTES; pub struct RoutingContext { network: Network, destination_port: u16, + source: Option, normalized_domain: Option>, destination_ip: Option, pinned_ip: Option, @@ -73,6 +78,7 @@ impl RoutingContext { Ok(Self { network, destination_port: destination.port(), + source: None, normalized_domain, destination_ip, pinned_ip: None, @@ -80,6 +86,10 @@ impl RoutingContext { }) } + pub fn set_source(&mut self, source: SocketAddr) { + self.source = Some(source); + } + #[must_use] pub const fn network(&self) -> Network { self.network @@ -253,7 +263,6 @@ pub enum RuleEvaluation { #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleSetError { - Empty, TooManyRules { count: usize, maximum: usize, @@ -278,16 +287,11 @@ pub enum RuleSetError { InvalidPortRange { rule_index: usize, }, - MatchNotFinal { - rule_index: usize, - }, - MissingFinalMatch, } impl fmt::Display for RuleSetError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Empty => formatter.write_str("routing rules are empty"), Self::TooManyRules { count, maximum } => { write!(formatter, "routing rule count {count} exceeds {maximum}") } @@ -324,10 +328,6 @@ impl fmt::Display for RuleSetError { "routing rule {rule_index} has an invalid destination port" ) } - Self::MatchNotFinal { rule_index } => { - write!(formatter, "MATCH rule {rule_index} is not the final rule") - } - Self::MissingFinalMatch => formatter.write_str("routing rules do not end in MATCH"), } } } @@ -345,85 +345,16 @@ impl RuleSet { /// Validates and normalizes the ordered rules. This is the only rule-set /// construction path that allocates. pub fn compile(mut rules: Vec) -> Result { - if rules.is_empty() { - return Err(RuleSetError::Empty); - } if rules.len() > MAX_ROUTING_RULES { return Err(RuleSetError::TooManyRules { count: rules.len(), maximum: MAX_ROUTING_RULES, }); } - - let final_index = rules.len() - 1; - let mut text_bytes = 0_usize; + let mut text_bytes = 0; let mut uses_domain_routing = false; for (index, rule) in rules.iter_mut().enumerate() { - uses_domain_routing |= matches!( - &rule.kind, - RuleKind::Domain(_) - | RuleKind::DomainSuffix(_) - | RuleKind::DomainKeyword(_) - | RuleKind::GeoSite(_) - ); - match &mut rule.kind { - RuleKind::Domain(value) | RuleKind::DomainSuffix(value) => { - *value = normalize_domain_name(value).map_err(|source| { - RuleSetError::InvalidDomain { - rule_index: index, - source, - } - })?; - text_bytes = checked_text_bytes(text_bytes, value.len())?; - } - RuleKind::DomainKeyword(value) => { - if value.is_empty() || !value.is_ascii() { - return Err(RuleSetError::InvalidKeyword { rule_index: index }); - } - value.make_ascii_lowercase(); - text_bytes = checked_text_bytes(text_bytes, value.len())?; - } - RuleKind::GeoSite(selector) => { - *selector = crate::geodata::normalize_selector( - crate::geodata::GeoDataKind::GeoSite, - selector, - ) - .map_err(|_| RuleSetError::InvalidGeoCode { rule_index: index })?; - text_bytes = checked_text_bytes(text_bytes, selector.len())?; - } - RuleKind::GeoIp(selector) => { - *selector = crate::geodata::normalize_selector( - crate::geodata::GeoDataKind::GeoIp, - selector, - ) - .map_err(|_| RuleSetError::InvalidGeoCode { rule_index: index })?; - text_bytes = checked_text_bytes(text_bytes, selector.len())?; - } - RuleKind::IpCidr(cidr) => { - if !valid_cidr(cidr) { - return Err(RuleSetError::InvalidCidr { rule_index: index }); - } - } - RuleKind::DstPorts(ranges) => { - if ranges.is_empty() - || ranges - .iter() - .any(|range| range.start == 0 || range.start > range.end) - { - return Err(RuleSetError::InvalidPortRange { rule_index: index }); - } - } - RuleKind::Network(_) => {} - RuleKind::Match => { - if index != final_index { - return Err(RuleSetError::MatchNotFinal { rule_index: index }); - } - } - } - } - - if !matches!(rules.last().map(|rule| &rule.kind), Some(RuleKind::Match)) { - return Err(RuleSetError::MissingFinalMatch); + normalize_rule(rule, index, 0, &mut text_bytes, &mut uses_domain_routing)?; } Ok(Self { @@ -472,45 +403,9 @@ impl RuleSet { geo_matcher: &dyn GeoMatcher, ) -> RuleEvaluation { for (index, rule) in self.rules.iter().enumerate() { - let matched = match &rule.kind { - RuleKind::Domain(expected) => context.domain() == Some(expected.as_str()), - RuleKind::DomainSuffix(suffix) => context - .domain() - .is_some_and(|domain| domain_suffix_matches(domain, suffix)), - RuleKind::DomainKeyword(keyword) => context - .domain() - .is_some_and(|domain| domain.contains(keyword)), - RuleKind::GeoSite(code) => { - geo_matcher.geosite_available(code) - && context - .domain() - .is_some_and(|domain| geo_matcher.matches_geosite(code, domain)) - } - RuleKind::GeoIp(code) => { - if !geo_matcher.geoip_available(code) { - false - } else if let Some(address) = context.effective_ip() { - geo_matcher.matches_geoip(code, address) - } else if !rule.no_resolve && context.should_request_ip_resolution() { - return RuleEvaluation::NeedsIpResolution { rule_index: index }; - } else { - false - } - } - RuleKind::IpCidr(cidr) => { - if let Some(address) = context.effective_ip() { - cidr_contains(cidr, address) - } else if !rule.no_resolve && context.should_request_ip_resolution() { - return RuleEvaluation::NeedsIpResolution { rule_index: index }; - } else { - false - } - } - RuleKind::DstPorts(ranges) => ranges.iter().any(|range| { - range.start <= context.destination_port && context.destination_port <= range.end - }), - RuleKind::Network(network) => *network == context.network, - RuleKind::Match => true, + let matched = match matches_rule(rule, context, geo_matcher) { + Ok(matched) => matched, + Err(()) => return RuleEvaluation::NeedsIpResolution { rule_index: index }, }; if matched { @@ -520,7 +415,10 @@ impl RuleSet { }); } } - RuleEvaluation::NoMatch + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Direct, + rule_index: self.rules.len(), + }) } /// Continues evaluation at the IP rule which requested a bounded @@ -548,45 +446,20 @@ impl RuleSet { ) -> RuleEvaluation { for (index, rule) in self.rules.iter().enumerate().skip(resolution_rule_index) { let mut selected_address = None; - let matched = match &rule.kind { - RuleKind::Domain(expected) => context.domain() == Some(expected.as_str()), - RuleKind::DomainSuffix(suffix) => context - .domain() - .is_some_and(|domain| domain_suffix_matches(domain, suffix)), - RuleKind::DomainKeyword(keyword) => context - .domain() - .is_some_and(|domain| domain.contains(keyword)), - RuleKind::GeoSite(code) => { - geo_matcher.geosite_available(code) - && context - .domain() - .is_some_and(|domain| geo_matcher.matches_geosite(code, domain)) - } - RuleKind::GeoIp(code) => { - if !geo_matcher.geoip_available(code) { - false - } else if let Some(address) = context.effective_ip() { - geo_matcher.matches_geoip(code, address) - } else { - selected_address = preferred_addresses(addresses) - .find(|address| geo_matcher.matches_geoip(code, *address)); - selected_address.is_some() - } - } - RuleKind::IpCidr(cidr) => { - if let Some(address) = context.effective_ip() { - cidr_contains(cidr, address) - } else { - selected_address = preferred_addresses(addresses) - .find(|address| cidr_contains(cidr, *address)); - selected_address.is_some() - } - } - RuleKind::DstPorts(ranges) => ranges.iter().any(|range| { - range.start <= context.destination_port && context.destination_port <= range.end - }), - RuleKind::Network(network) => *network == context.network, - RuleKind::Match => true, + let matched = if context.effective_ip().is_none() + && uses_destination_ip(rule) + && !addresses.is_empty() + { + selected_address = preferred_addresses(addresses).find(|address| { + let mut candidate = context.clone(); + candidate.pin_ip(*address); + matches_rule(rule, &candidate, geo_matcher).unwrap_or(false) + }); + selected_address.is_some() + } else { + let mut candidate = context.clone(); + candidate.mark_ip_resolution_unavailable(); + matches_rule(rule, &candidate, geo_matcher).unwrap_or(false) }; if matched { @@ -599,10 +472,200 @@ impl RuleSet { }); } } - RuleEvaluation::NoMatch + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Direct, + rule_index: self.rules.len(), + }) } } +fn normalize_rule( + rule: &mut RuleSpec, + index: usize, + depth: usize, + bytes: &mut usize, + domain: &mut bool, +) -> Result<(), RuleSetError> { + if depth > 32 { + return Err(RuleSetError::TooManyRules { + count: depth, + maximum: 32, + }); + } + match &mut rule.kind { + RuleKind::Domain(value) | RuleKind::DomainSuffix(value) => { + *value = + normalize_domain_name(value).map_err(|source| RuleSetError::InvalidDomain { + rule_index: index, + source, + })?; + *bytes = checked_text_bytes(*bytes, value.len())?; + *domain = true; + } + RuleKind::DomainKeyword(value) | RuleKind::DomainWildcard(value) => { + if value.is_empty() { + return Err(RuleSetError::InvalidKeyword { rule_index: index }); + } + value.make_ascii_lowercase(); + *bytes = checked_text_bytes(*bytes, value.len())?; + *domain = true; + } + RuleKind::DomainRegex(value) => { + *bytes = checked_text_bytes(*bytes, value.as_str().len())?; + *domain = true; + } + RuleKind::GeoSite(value) => { + *value = + crate::geodata::normalize_selector(crate::geodata::GeoDataKind::GeoSite, value) + .map_err(|_| RuleSetError::InvalidGeoCode { rule_index: index })?; + *bytes = checked_text_bytes(*bytes, value.len())?; + *domain = true; + } + RuleKind::GeoIp(value) => { + *value = crate::geodata::normalize_selector(crate::geodata::GeoDataKind::GeoIp, value) + .map_err(|_| RuleSetError::InvalidGeoCode { rule_index: index })?; + *bytes = checked_text_bytes(*bytes, value.len())?; + } + RuleKind::IpCidr(cidr) | RuleKind::SrcIpCidr(cidr) => { + if !valid_cidr(cidr) { + return Err(RuleSetError::InvalidCidr { rule_index: index }); + } + } + RuleKind::DstPorts(ranges) | RuleKind::SrcPorts(ranges) => { + if ranges.is_empty() || ranges.iter().any(|range| range.start > range.end) { + return Err(RuleSetError::InvalidPortRange { rule_index: index }); + } + } + RuleKind::And(children) | RuleKind::Or(children) => { + for child in children { + normalize_rule(child, index, depth + 1, bytes, domain)?; + } + } + RuleKind::Not(child) => normalize_rule(child, index, depth + 1, bytes, domain)?, + RuleKind::Network(_) | RuleKind::Match => {} + } + Ok(()) +} + +fn uses_destination_ip(rule: &RuleSpec) -> bool { + match &rule.kind { + RuleKind::IpCidr(_) | RuleKind::GeoIp(_) => true, + RuleKind::And(children) | RuleKind::Or(children) => { + children.iter().any(uses_destination_ip) + } + RuleKind::Not(child) => uses_destination_ip(child), + _ => false, + } +} + +// Err means this exact traversal requires one lazy DNS resolution. Short +// circuiting keeps later IP predicates from triggering unnecessary queries. +fn matches_rule( + rule: &RuleSpec, + context: &RoutingContext, + geo: &dyn GeoMatcher, +) -> Result { + let port_matches = |ranges: &[crate::config::PortRange], port| { + ranges + .iter() + .any(|range| range.start <= port && port <= range.end) + }; + Ok(match &rule.kind { + RuleKind::Domain(value) => context.domain() == Some(value.as_str()), + RuleKind::DomainSuffix(value) => context + .domain() + .is_some_and(|domain| domain_suffix_matches(domain, value)), + RuleKind::DomainKeyword(value) => context + .domain() + .is_some_and(|domain| domain.contains(value)), + RuleKind::DomainWildcard(value) => context + .domain() + .is_some_and(|domain| wildcard_matches(value, domain)), + RuleKind::DomainRegex(value) => context + .domain() + .is_some_and(|domain| value.is_match(domain)), + RuleKind::GeoSite(code) => { + geo.geosite_available(code) + && context + .domain() + .is_some_and(|domain| geo.matches_geosite(code, domain)) + } + RuleKind::GeoIp(code) => { + if !geo.geoip_available(code) { + false + } else if let Some(address) = context.effective_ip() { + geo.matches_geoip(code, address) + } else if !rule.no_resolve && context.should_request_ip_resolution() { + return Err(()); + } else { + false + } + } + RuleKind::IpCidr(cidr) => { + if let Some(address) = context.effective_ip() { + cidr_contains(cidr, address) + } else if !rule.no_resolve && context.should_request_ip_resolution() { + return Err(()); + } else { + false + } + } + RuleKind::SrcIpCidr(cidr) => context + .source + .is_some_and(|source| cidr_contains(cidr, source.ip())), + RuleKind::SrcPorts(ranges) => context + .source + .is_some_and(|source| port_matches(ranges, source.port())), + RuleKind::DstPorts(ranges) => port_matches(ranges, context.destination_port), + RuleKind::Network(network) => *network == context.network, + RuleKind::And(children) => { + for child in children { + if !matches_rule(child, context, geo)? { + return Ok(false); + } + } + true + } + RuleKind::Or(children) => { + for child in children { + if matches_rule(child, context, geo)? { + return Ok(true); + } + } + false + } + RuleKind::Not(child) => !matches_rule(child, context, geo)?, + RuleKind::Match => true, + }) +} + +fn wildcard_matches(pattern: &str, value: &str) -> bool { + let (pattern, value) = (pattern.as_bytes(), value.as_bytes()); + let (mut p, mut v) = (0, 0); + let mut retry = None; + while v < value.len() { + match pattern.get(p) { + Some(b'*') => { + retry = Some((p + 1, v)); + p += 1; + } + Some(byte) if *byte == b'?' || *byte == value[v] => { + p += 1; + v += 1; + } + _ => match retry { + Some((after_star, consumed)) => { + v = consumed + 1; + p = after_star; + retry = Some((p, v)); + } + None => return false, + }, + } + } + pattern[p..].iter().all(|byte| *byte == b'*') +} + fn preferred_addresses(addresses: &[IpAddr]) -> impl Iterator + '_ { addresses .iter() @@ -862,13 +925,23 @@ mod tests { action(rules.evaluate(&domain_context(Network::Tcp, "example.com", 443))), RuleAction::Direct ); - assert!(matches!( - RuleSet::compile(vec![spec( - RuleKind::Domain("example.com".to_owned()), - RuleAction::Direct, - )]), - Err(RuleSetError::MissingFinalMatch) - )); + let rules = RuleSet::compile(vec![spec( + RuleKind::Domain("example.com".to_owned()), + RuleAction::Reject, + )]) + .unwrap(); + assert_eq!( + action(rules.evaluate(&domain_context(Network::Tcp, "other.example", 443))), + RuleAction::Direct + ); + assert_eq!( + action(RuleSet::compile(vec![]).unwrap().evaluate(&domain_context( + Network::Tcp, + "other.example", + 443 + ))), + RuleAction::Direct + ); } #[test] diff --git a/src/runtime.rs b/src/runtime.rs index 2de8e01..c77a5e9 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(any(feature = "invoke", feature = "cli")), allow(dead_code))] +use tracing::instrument::WithSubscriber; + #[cfg(all( test, unix, @@ -237,6 +239,10 @@ impl Drop for BuiltProxyGraph { } impl PreparedCore { + pub(crate) fn log_level(&self) -> crate::config::LogLevel { + self.config.log_level + } + #[cfg(test)] async fn prepare( yaml: &[u8], @@ -288,7 +294,7 @@ impl PreparedCore { pub(crate) fn validate_config(mut config: Config) -> io::Result<()> { GeoRequirements::collect(&config.rules, &config.dns.nameserver_policies) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - RuleSet::compile(std::mem::take(&mut config.rules)) + RuleSet::compile(config.take_runtime_rules()) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; Ok(()) } @@ -389,18 +395,12 @@ impl PreparedCore { )) }); proxy_graph.resolution.bind_runtime(dns.as_ref())?; - let geodata_rules = RuleSet::compile(vec![crate::config::RuleSpec { - kind: crate::config::RuleKind::Match, - action: self.config.default_action, - no_resolve: false, - }]) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let geodata_dispatcher: Arc = Arc::new(RoutingDispatcher::new_with_ipv6( route_targets.clone(), direct.clone(), dns.clone(), self.config.ipv6, - geodata_rules, + rules.clone(), geo_matcher.clone(), )); let router: Arc = Arc::new( @@ -495,6 +495,13 @@ impl PreparedCore { /// Starts configured local proxy listeners. TUN is started separately once /// a platform descriptor and netstack are available. pub(crate) async fn start_local(self, dialer: Dialer) -> io::Result { + let dispatch = crate::logging::dispatch(self.config.log_level); + self.start_local_inner(dialer) + .with_subscriber(dispatch) + .await + } + + async fn start_local_inner(self, dialer: Dialer) -> io::Result { let BuiltRuntimeParts { dispatcher, geodata_updater, @@ -548,7 +555,21 @@ impl PreparedCore { all(feature = "cli", not(feature = "invoke"), unix, not(test)), allow(dead_code) )] - pub(crate) async fn start_tun(mut self, tun: TunIo, dialer: Dialer) -> io::Result { + pub(crate) async fn start_tun(self, tun: TunIo, dialer: Dialer) -> io::Result { + let dispatch = crate::logging::dispatch(self.config.log_level); + self.start_tun_inner(tun, dialer) + .with_subscriber(dispatch) + .await + } + + #[cfg(all( + feature = "tun", + any( + unix, + all(windows, any(feature = "windows-wintun", feature = "windows-uwp")) + ) + ))] + async fn start_tun_inner(mut self, tun: TunIo, dialer: Dialer) -> io::Result { self.limits.tun_max_datagram_size = usize::from(self.config.tun.mtu); let tun_count = self .config @@ -1179,7 +1200,7 @@ fn prepare_routing( // GeoData must inspect the normalized rule specifications first. Once that // borrowing load is complete, move the rules directly into the compiled // set so PreparedCore does not retain a second owned rule graph. - let rules = RuleSet::compile(std::mem::take(&mut config.rules)) + let rules = RuleSet::compile(config.take_runtime_rules()) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; Ok((rules, geodata_registration)) } diff --git a/src/session.rs b/src/session.rs index 04f5a0a..fe736dc 100644 --- a/src/session.rs +++ b/src/session.rs @@ -107,8 +107,7 @@ pub enum InboundKind { /// Core-originated DNS traffic sent through the raw proxy dispatcher. /// This context must never be passed back through the routing dispatcher. InternalDns, - /// Core-owned GeoData HTTPS traffic sent through the raw default proxy. - /// This context must never be passed through routing or fall back to direct. + /// Core-owned GeoData HTTPS traffic routed by the current mode and rules. InternalGeoData, /// Core-owned latency probe sent through a node-only default proxy graph. /// This context never enters the public inbound or routing pipeline. diff --git a/tests/proxy_group_compat.rs b/tests/proxy_group_compat.rs index b598d1e..b264b54 100644 --- a/tests/proxy_group_compat.rs +++ b/tests/proxy_group_compat.rs @@ -57,7 +57,7 @@ rules: ProxyGroupMemberTarget::Route(RouteTargetId::Group(backup)) ); assert_eq!( - config.default_action, + config.rules.last().unwrap().action, RuleAction::Route(RouteTargetId::Group(primary)) ); assert_eq!( From f68669d2c0912e9b68914c2c0ce9c014a36719b2 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 17:35:09 +0800 Subject: [PATCH 003/445] Support DNS roles, encrypted resolvers and lazy node resolution --- AGENTS.md | 2 +- ...t-proxy-groups-as-session-route-targets.md | 2 +- docs/config.yaml | 45 +- docs/invoke-api.md | 2 +- docs/runtime-resource-policy.md | 4 +- docs/tun-icmp-dns.md | 34 +- src/config/dns.rs | 250 ++++++ src/config/mod.rs | 342 +++----- src/dns/hosts.rs | 96 +++ src/dns/https.rs | 134 +++ src/dns/mod.rs | 3 + src/dns/policy.rs | 8 +- src/dns/resolution.rs | 63 +- src/dns/runtime.rs | 773 +++++++++++------- src/dns/tests_encrypted.rs | 102 +++ src/geodata/mod.rs | 14 + src/lib.rs | 8 - src/outbound/connector.rs | 41 +- src/outbound/hysteria2/paths.rs | 2 +- src/outbound/shadowsocks/mod.rs | 2 +- src/outbound/tuic/outbound.rs | 2 +- src/outbound/vless/outbound.rs | 4 +- src/routing/dispatcher.rs | 24 +- src/runtime.rs | 100 +-- src/security/context.rs | 4 +- src/tun_runtime.rs | 2 +- src/tun_runtime/tests.rs | 2 + tests/resolution_foundations.rs | 6 +- 28 files changed, 1432 insertions(+), 639 deletions(-) create mode 100644 src/config/dns.rs create mode 100644 src/dns/hosts.rs create mode 100644 src/dns/https.rs create mode 100644 src/dns/tests_encrypted.rs diff --git a/AGENTS.md b/AGENTS.md index a5f8a66..6a892d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Read the relevant document completely before changing that area: - `src/tun_runtime.rs` connects platform raw-IP I/O to `vole-netstack` and dispatches TCP, UDP, DNS, ICMP, and sniffing. - `src/platform/` contains platform adapters. Keep framing internal, propagate configured MTU, and preserve original borrowed-fd flags and ownership. Windows backend selection occurs at compilation; keep Windows callback semantics here instead of simulating a Unix fd. - `src/dialer.rs` is the shared physical TCP/UDP socket seam. Fix socket protection or Windows `(source IP, interface index)` binding once here rather than in each outbound. -- Static `select` groups share Running Session selection between routing and `dialer-proxy`. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; prepare every potential DIRECT server endpoint. `measureDelay` remains node-only. +- Static `select` groups share Running Session selection between routing and `dialer-proxy`. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; resolve DIRECT server endpoints on demand through the proxy DNS role. `measureDelay` remains node-only. - `crates/vole-netstack` is platform-independent raw-IP state and must not depend on WinRT, JNI, Swift, or host UI frameworks. - Core runtime lifecycle does not infer App, extension, service, or daemon roles and does not implement cross-process state or IPC. The Windows-only host Invoke is the explicit package integration seam for profile/status/Session Snapshot/StartupTask operations and an optional bounded `sessionBackend`; its Session Host owns backend process liveness without interpreting arguments, files, ports, or protocols. Provider runtime state remains process-local. diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index 87d833e..619623c 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -12,4 +12,4 @@ status: accepted 路由组保留 `Dispatcher` seam;组上游在 `OutboundConnector` seam 解析,共享同一个原子选择索引,但不重新执行业务规则,不丢弃 effective peer、UDP 预算或建链期限。协议配置保持不可变,组连接器只持有声明的依赖;依赖优先构造、逆序停止与释放,避免全局节点注册表形成 Arc 环或深图递归析构。 -上游组的 DIRECT 连接当前节点服务器,不是业务目标;REJECT 直接使建链失败。节点无上游,或上游组经纯组链能到达 DIRECT 时,prepare 预解析其主端点及独立下载端点,包括未选路径;仅经代理访问的节点保持逻辑域名。潜在首跳解析失败时 prepare 失败,不在切组后临时借系统 DNS。 +上游组的 DIRECT 连接当前节点服务器,不是业务目标;REJECT 直接使建链失败。节点的主端点与独立下载端点在实际建链时通过运行时节点 resolver 解析;准备阶段不预解析潜在首跳。未选成员的 DNS 故障不阻塞启动。仅经代理访问的节点保持逻辑域名。DNS 关闭时使用系统解析,开启时使用配置的节点 nameserver/policy,不因失败切换系统解析;所有路径共享建链期限、递归依赖检查和停止取消。独立 measureDelay 的节点准备仍使用受控引导 resolver。 diff --git a/docs/config.yaml b/docs/config.yaml index 007006a..3c6d243 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -104,10 +104,10 @@ sniffer: # 路径与 URL 中的保留字符由调用方按相应协议编码,不限制节点本身的名称。 # dialer-proxy 可引用具体节点或 select 组。全部上游和组成员(包括未选成员)须无环。 # 代理数量和链深没有独立上限,仍受 YAML 大小与名称唯一约束。 -# 节点没有上游,或上游组经纯组成员链能到达 DIRECT 时,prepare 预解析该节点及其 -# 独立下载端点,即使 DIRECT 尚未选中;纯代理路径保留逻辑域名。候选首跳 DNS 失败 -# 则 prepare 失败,切换 DIRECT 不临时调用系统 DNS。物理首跳连接本机服务时,server 必须使用 127.0.0.0/8 -# 范围内的 IPv4 字面量或 ::1;域名解析到回环地址会失败关闭。 +# 实际建立物理首跳时才通过节点 resolver 解析主端点和独立下载端点。 +# 未选节点的 DNS 失败不阻塞准备;DNS 关闭时才使用系统 resolver。 +# DNS 开启时可通过 hosts 配置本机端点;关闭 DNS 或独立测速的系统 resolver +# 仍拒绝解析到回环的服务器域名,本机服务器可使用 IP 字面量。 # # VLESS 子集: # - type 固定 vless,network 默认 tcp,支持 tcp/ws/grpc/http/h2/xhttp,tls 默认 false; @@ -438,22 +438,43 @@ proxy-groups: proxies: [vless-edge, fallback-select, DIRECT, REJECT, vless-edge] default-selected: vless-edge -# 运行时 DNS 可省略;enable: true 时主 nameserver 必须有 1–4 项。ipv6 省略时 -# 默认为 true;false 时直接 AAAA 查询返回空结果,域名解析不查询 AAAA。nameserver -# 只接受裸 IP、udp://IP[:port] 或 tcp://IP[:port]。没有 fragment 时固定 DIRECT; -# fragment 只接受 DIRECT、RULES、实际节点名或代理组名。`PROXY`/`#PROXY` 没有 -# 默认出口含义,只有存在同名 route target 时才有效。DNS route 选中代理组后, -# 仅新建物理 transport 解析当前成员;cache 与既有 TCP pool 不随切换清空。 +# hosts 支持单 IP、IP 数组、域名别名与 Clash 域名通配;不支持动态 lan 展开。 +hosts: + "internal.example": [192.0.2.10] + "alias.example": internal.example + +# DNS 的 enable、ipv6 默认 false,use-hosts/use-system-hosts 默认 true。 +# enable 关闭时不接管 TUN DNS,内部解析使用系统 resolver;配置中的 hosts 仍可用于内部解析。 +# enable 开启时使用配置的 resolver,不因上游失败回退到系统 DNS。 +# nameserver 默认 [https://doh.pub/dns-query, tls://223.5.5.5:853],不能显式留空。 +# 支持裸 IP、UDP/TCP、tls://、http(s)://,允许上游域名;DoH 使用 HTTP/1.1 POST, +# DoT 与 HTTPS 校验证书与名称。default-nameserver 只接受 IP 上游,用于 bootstrap。 +# 无 fragment 时默认 DIRECT;respect-rules: true 时默认 RULES,且必须配置 +# proxy-server-nameserver。显式 fragment 可选 DIRECT、RULES 或已配置的节点/组名; +# 名称支持百分号编码。proxy-server-nameserver 专门解析节点,direct-nameserver +# 专门解析 DIRECT 业务目标;未配置时复用 nameserver。两者不隐式开启 respect-rules。 +# direct-nameserver-follow-policy 默认 false;true 让直连解析遵循 nameserver-policy。 +# 新建 DNS 连接采用当前组选择,cache 和已有连接不随选择改变而清空。 # nameserver-policy 接受域名、Clash 通配域名和 geosite;值为单个 nameserver 字符串 -# 或 1–4 项数组。域名可用逗号连接;* 匹配一层,.example.com 仅匹配子域, +# 或非空数组。域名可用逗号连接;* 匹配一层,.example.com 仅匹配子域, # +.example.com 匹配本域及所有子域。连续域名项优先匹配字面标签,其次 *,最后 .; # 相同归一域名模式以后项为准。GeoSite 项隔开域名区块,区块与 GeoSite 按声明顺序匹配。 # `geosite:[,...]` 的逗号项为 OR,selector 可为 cn、cn@ads@cn、 # !cn@ads;多个属性为 key 存在性 AND(忽略 bool/int 值),前置 ! 取反整个匹配结果, -# @!cn 是字面 key。资产不可用不因反选变成全匹配。命中后只在当前组内故障转移。 +# @!cn 是字面 key。资产不可用不因反选变成全匹配。命中后仅使用当前策略的上游。 dns: enable: true ipv6: true + enhanced-mode: redir-host + use-hosts: true + use-system-hosts: true + respect-rules: false + default-nameserver: [114.114.114.114, 223.5.5.5, 8.8.8.8, 1.0.0.1] + proxy-server-nameserver: [223.5.5.5] + proxy-server-nameserver-policy: + "+.internal.example": 192.0.2.53 + direct-nameserver: [223.5.5.5] + direct-nameserver-follow-policy: true nameserver: - "tcp://1.1.1.1:53#main-select" nameserver-policy: diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 62a3ff0..1ac815d 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -211,7 +211,7 @@ TUN 资源参数来自 YAML 的 `tun`,平台包格式由内核选择。 同一命令内先准备配置,再获取平台资源并启动: - 读取当时可用的本地 GeoData,不启动或等待下载。 -- 为所有潜在物理首跳执行引导 DNS:节点无上游,或其上游组经纯组成员链能选到 DIRECT 时,预解析节点服务器及独立 VLESS 下载端点,包括尚未选中的 DIRECT 路径。候选解析失败则启动失败;仅经代理访问的域名交给下一跳。切换 DIRECT 不新增系统 DNS。 +- 准备阶段只构建并校验代理图;实际建立物理首跳时,通过运行时节点 resolver 解析服务器及独立 VLESS 下载端点。未选中的备用节点不会因 DNS 故障阻塞启动。仅经代理访问的域名交给下一跳;系统解析仅用于 DNS 关闭的配置。 - 校验每个静态 `select` 组的初始选择;省略 `default-selected` 时使用第一项,显式值必须是直接成员。启动时创建本次 session 的可变选择状态。 - 含 TUN 的配置从内部 preparing 阶段起持有运行时本地的 TUN/protect 租约,直到停止或销毁。Android 必须在 `start` 前注册 protect controller。 - 所有监听器和关键数据面成功后才进入 running。准备、资源获取或启动失败均清理本次临时资源并回到 stopped;重试需要再次提交完整 `configYaml`。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 1150b70..41235e8 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -134,9 +134,9 @@ TUIC 与 Hysteria2 共用这一接点;TUIC 的独立会话、关联 ID 退役 - 不设置固定的活动请求或活动传输总许可数。 - 相同 key 的 cache miss 使用 singleflight;leader 取消后 follower 重新选举。 - UDP 传输属于单次请求,同一尝试的重发复用当前传输。 -- 显式 TCP nameserver 按 endpoint 和配置的 route target 复用;一条连接同时只处理一个查询。 +- TCP、DoT、DoH 按上游身份、endpoint 和 route target 复用;一条连接同时只处理一个查询。DNS TLS 不保留恢复票据,复用已认证连接。 - 活动 TCP 不设固定数量上限;空闲连接总数最多 4,同 key 最多 2,空闲超时 30 秒。 -- 查询、单次尝试和 UDP 重发期限分别为 5 秒、3 秒和 1 秒。 +- 查询、单次尝试和 UDP 重发期限分别为 5 秒、3 秒和 1 秒。每次查询最多并发 4 个上游,胜出后取消其余尝试;UDP TC 转同一上游 TCP。 - 响应最多扫描 64 条记录,类型化缓存和提示最多保留 16 个唯一 IP。 - 停止取消打开、发送、接收、重试和响应发送,并释放全部传输。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index c0c4cbe..c962861 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -68,11 +68,11 @@ dns: ### Endpoint -- 主 nameserver 必须有 1–4 项。 -- 只接受裸 IPv4/IPv6、`udp://IP[:port]` 和 `tcp://IP[:port]`。 +- `dns.enable`、`dns.ipv6` 默认 false。主 nameserver 默认使用 DoH/DoT,可显式替换为非空列表。 +- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用共享 TLS 证书验证。DoH 采用 HTTP/1.1 POST。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 -- Endpoint 必须是 IP 字面量,不支持 hostname、system 或 DHCP resolver。 -- 无 fragment 时固定使用 DIRECT。 +- 上游域名由 `default-nameserver` 引导解析,后者只允许 IP 上游。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。显式 system/DHCP nameserver 尚未实现。 +- 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 - Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或静态 `select` 组名。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 - 规则结果为 `REJECT` 时,当前尝试失败并继续当前组下一项。 @@ -91,8 +91,8 @@ dns: `code`、`code@attr@attr` 或前置 `!` 的反选,属性为 key 存在性 AND。与业务规则共享分类和记录, `@!cn` 是字面属性 key,不是属性排除; - GeoSite 项隔开域名区块,区块与 GeoSite 项按声明顺序匹配;重复 GeoSite selector 也按此顺序处理; -- value 接受单个 nameserver 字符串或 1–4 项数组;`rule-set:` 依赖尚未支持的 rule-providers; -- 命中后只在所选列表内顺序故障转移,列表耗尽不查询其他 policy 或主组; +- value 接受单个 nameserver 字符串或非空数组;`rule-set:` 依赖尚未支持的 rule-providers; +- 命中后只在所选列表内并发查询,列表耗尽不查询其他 policy 或主组; - GeoSite 资产或分类不可用时该 policy 不命中,不能因反选变成全匹配; - 存在分类但属性筛选为空时仍可用,正选不命中、反选匹配合法非空名称。 @@ -142,19 +142,25 @@ A/AAAA 缓存最多 256 项,从空容量按需增长。TUN 域名提示存储 - 每个调用方恢复自己的 transaction ID 和 endpoint,并独立应用失败策略; - 不同 key 可以并行。 +## Hosts 与解析职责 + +`hosts` 支持 IP、IP 数组和域名别名,匹配规则沿用 Clash 域名通配;默认包含 localhost,别名循环在配置阶段拒绝。`use-hosts`、`use-system-hosts` 独立控制配置与系统 hosts,默认均为 true;系统 hosts 在运行时创建时读取。TCP、UDP 节点的 TLS SNI 和逻辑目的名称不因解析为 IP 而改变。 + +`default-nameserver` 仅引导上游域名。`proxy-server-nameserver` 与其 policy 用于节点解析;`direct-nameserver` 用于直连,`direct-nameserver-follow-policy` 决定是否使用业务 policy。各角色拥有独立 cache/singleflight,未配置节点或直连 resolver 时复用主 resolver。有限并发、取消与连接池适用于各角色,依赖循环不会等待自身的 singleflight。 + ## 查询、故障转移与连接复用 - 查询总期限 5 秒;单 nameserver 尝试最多 3 秒,并受剩余时间限制。 - UDP 尝试只打开一个传输;首次发送后 1 秒无合法响应时,在同一传输和 ID 上最多重发一次。 - 前两次 peer/ID/question 不匹配只丢当前响应,第三次使当前尝试失败。 -- 超时、I/O、格式错误、SERVFAIL、REFUSED、TC 或规则 REJECT 会继续当前组下一项。 +- 当前策略最多同时发起 4 个上游尝试;优先返回有效响应并取消其余尝试。超时、I/O、格式错误、SERVFAIL、REFUSED 或 REJECT 不作为成功结果。 - NXDOMAIN 和 NODATA 是终态。 -- UDP TC 不自动切换 TCP;只有显式 `tcp://` endpoint 使用 TCP。 +- 验证来源、ID 和 question 后,UDP TC 在原期限内改用同一 endpoint 的 TCP。TCP TC 仍是失败响应。 - 全部上游失败时生成 SERVFAIL;非法 UDP 查询只丢当前数据报。 -显式 TCP nameserver 使用每运行时独立连接池: +TCP、DoT 和 DoH 使用解析器所属的连接池: -- Key 为 endpoint 和配置的 route target; +- Key 包含上游地址、认证名称、传输、HTTP 路径、endpoint 与最终 route target; - 一条连接同时只处理一个查询,不做 pipeline; - 活动连接不设固定业务数量上限;空闲连接总数最多 4、同 key 最多 2、超时 30 秒; - 完成帧和响应校验后才能归还连接; @@ -188,17 +194,17 @@ DNS 提示或 GeoData 更新改路;新流使用当前数据。每目标按同 ## IP-only协议与独立测速接点 -公共`ResolutionContext`仅在协议必须取得IP地址的边界解析业务目标或逻辑上游;原始`Destination`保持不变,HTTP Host、TLS SNI和路由仍使用逻辑名称。代理endpoint的prepare bootstrap与该运行期解析分离,不递归使用尚未建立的同一出口。当前静态ECH不查询DNS;动态ECH及其可选宿主bootstrap入口尚未实现,不属于当前Invoke接口。 +公共`ResolutionContext`仅在协议必须取得IP地址的边界解析业务目标或逻辑上游;原始`Destination`保持不变,HTTP Host、TLS SNI和路由仍使用逻辑名称。代理服务器在实际建链时通过节点 resolver 解析,准备阶段不解析备用节点。当前静态ECH不查询DNS;动态ECH及其可选宿主bootstrap入口尚未实现,不属于当前Invoke接口。 -Running Session上下文仅弱引用本session的RuntimeDns,使用上文nameserver/policy与出口;未绑定、DNS关闭、上游不可达或session已停止时明确失败,不调用系统resolver兜底。IP字面量仍执行端口/地址族政策。解析共享同一次建链期限,Stop取消当前及后续查询;在进入可能等待自身的singleflight之前拒绝同名递归依赖,嵌套依赖深度最多32层。不会因为DNS经代理出口就无条件禁止整个图。 +Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only 解析使用 nameserver/policy,代理服务器使用 proxy-server-nameserver/policy,DIRECT 目标使用 direct-nameserver。未绑定、上游不可达或 session 已停止时失败;只有配置关闭 DNS 时才使用系统解析。IP字面量仍执行端口/地址族政策。解析共享同一次建链期限,Stop取消当前及后续查询;在进入可能等待自身的singleflight之前拒绝同名递归依赖,嵌套依赖深度最多32层。不会因为DNS经代理出口就无条件禁止整个图。 独立`measureDelay`使用测量生命周期的受控bootstrap resolver,仅在IP-only边界解析域名;不创建Running Session、RuntimeDns或DNS监听服务,也不改变现有endpoint准备流程。通用 IP-only connector 覆盖最终节点和 `dialer-proxy` 上游的受控解析、期限与取消。该公共接点继续供 packetaddr 等必须 IP 的消费者使用;通用机制测试不替代各协议的实际数据闭环。 ## 不支持 - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; -- DoH、DoT、DoQ、hostname/system/DHCP nameserver; -- 代理组 provider/health check/自动 failover、并行 nameserver 竞速、DNS fallback group、fake IP 和 hosts; +- DoQ、DoH HTTP/2/3、显式 system/DHCP nameserver; +- 代理组 provider/health check/自动 failover、DNS fallback group、fake IP、动态 hosts: lan; - 用户可配置的缓存容量、超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/src/config/dns.rs b/src/config/dns.rs new file mode 100644 index 0000000..e46ebb2 --- /dev/null +++ b/src/config/dns.rs @@ -0,0 +1,250 @@ +use super::*; + +pub(super) fn default_nameservers() -> Vec { + ["https://doh.pub/dns-query", "tls://223.5.5.5:853"] + .map(str::to_owned) + .to_vec() +} +pub(super) fn default_bootstrap() -> Vec { + ["114.114.114.114", "223.5.5.5", "8.8.8.8", "1.0.0.1"] + .map(str::to_owned) + .to_vec() +} +pub(super) fn redir_host() -> String { + "redir-host".into() +} + +impl Default for DnsConfig { + fn default() -> Self { + // Empty targets are sufficient for the built-in DIRECT upstreams. + let raw: RawDnsConfig = serde_yaml_ng::from_str("{}").expect("DNS defaults"); + raw.normalize(&RouteTargetsByName::new()) + .expect("valid DNS defaults") + } +} +impl DnsConfig { + pub(super) fn disabled() -> Result { + Ok(Self::default()) + } +} + +impl RawDnsConfig { + pub(super) fn normalize(self, targets: &RouteTargetsByName) -> Result { + if self.enhanced_mode != "redir-host" { + return invalid("dns.enhanced-mode currently supports redir-host"); + } + if self.enable && self.nameserver.is_empty() { + return invalid("enabled DNS requires nameserver"); + } + if self.default_nameserver.is_empty() { + return invalid("default-nameserver requires at least one server"); + } + if (self.respect_rules || !self.proxy_policies.is_empty()) + && self.proxy_nameserver.is_empty() + { + return invalid( + "respect-rules and proxy-server-nameserver-policy require proxy-server-nameserver", + ); + } + let parse = |values: Vec, route| { + values + .iter() + .map(|value| parse_dns_nameserver(value, targets, route)) + .collect::>>() + }; + let main_route = if self.respect_rules { + DnsRoute::Rules + } else { + DnsRoute::Direct + }; + let default_nameservers = parse(self.default_nameserver, DnsRoute::Direct)?; + if default_nameservers + .iter() + .any(|server| matches!(server.address, DnsAddress::Domain(_))) + { + return invalid("default-nameserver requires literal IP addresses"); + } + Ok(DnsConfig { + enable: self.enable, + ipv6: self.ipv6, + nameservers: parse(self.nameserver, main_route)?, + nameserver_policies: normalize_dns_nameserver_policies( + self.nameserver_policies, + targets, + main_route, + )?, + default_nameservers, + proxy_nameservers: parse(self.proxy_nameserver, DnsRoute::Direct)?, + proxy_policies: normalize_dns_nameserver_policies( + self.proxy_policies, + targets, + DnsRoute::Direct, + )?, + direct_nameservers: parse(self.direct_nameserver, DnsRoute::Direct)?, + direct_follow_policy: self.direct_follow_policy, + use_hosts: self.use_hosts, + use_system_hosts: self.use_system_hosts, + hosts: vec![HostEntry { + pattern: "localhost".into(), + value: HostValue::Addresses(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]), + }], + }) + } +} + +pub(super) fn parse_dns_nameserver( + input: &str, + targets: &RouteTargetsByName, + default_route: DnsRoute, +) -> Result { + let bad = || { + VoleError::InvalidConfig( + "invalid DNS upstream; supported transports are UDP, TCP, TLS and HTTP(S)".into(), + ) + }; + let (endpoint, route) = match input.split_once('#') { + Some((endpoint, fragment)) => { + let name = percent_decode(fragment)?; + let route = match name.as_str() { + "DIRECT" => DnsRoute::Direct, + "RULES" => DnsRoute::Rules, + _ => targets + .get(&name) + .copied() + .map(DnsRoute::Route) + .ok_or_else(bad)?, + }; + (endpoint, route) + } + None => (input, default_route), + }; + if let Ok(address) = endpoint.parse::() { + return Ok(DnsNameserver { + address: address.into(), + transport: DnsTransport::Udp, + port: 53, + route, + path: None, + }); + } + let endpoint = if endpoint.contains("://") { + endpoint.to_owned() + } else { + format!("udp://{endpoint}") + }; + let url = Url::parse(&endpoint).map_err(|_| bad())?; + let (transport, default_port) = match url.scheme() { + "udp" => (DnsTransport::Udp, 53), + "tcp" => (DnsTransport::Tcp, 53), + "tls" => (DnsTransport::Tls, 853), + "https" => (DnsTransport::Https, 443), + "http" => (DnsTransport::Http, 80), + _ => return Err(bad()), + }; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(bad()); + } + let address = match url.host().ok_or_else(bad)? { + Host::Ipv4(ip) => DnsAddress::Ip(ip.into()), + Host::Ipv6(ip) => DnsAddress::Ip(ip.into()), + Host::Domain(host) => match host.parse::() { + Ok(ip) => DnsAddress::Ip(ip), + Err(_) => DnsAddress::Domain(crate::dns::canonicalize_name(host).map_err(|_| bad())?), + }, + }; + let path = if matches!(transport, DnsTransport::Http | DnsTransport::Https) { + Some(format!( + "{}{}", + if url.path().is_empty() { + "/" + } else { + url.path() + }, + url.query() + .map(|value| format!("?{value}")) + .unwrap_or_default() + )) + } else { + if !matches!(url.path(), "" | "/") || url.query().is_some() { + return Err(bad()); + } + None + }; + let port = url.port().unwrap_or(default_port); + validate_port(port, "DNS nameserver")?; + Ok(DnsNameserver { + transport, + address, + port, + route, + path, + }) +} + +fn percent_decode(input: &str) -> Result { + let mut bytes = Vec::with_capacity(input.len()); + let mut iter = input.bytes(); + while let Some(byte) = iter.next() { + if byte == b'%' { + let a = iter.next().and_then(|b| (b as char).to_digit(16)); + let b = iter.next().and_then(|b| (b as char).to_digit(16)); + match (a, b) { + (Some(a), Some(b)) => bytes.push((a * 16 + b) as u8), + _ => return invalid("invalid DNS route encoding"), + } + } else { + bytes.push(byte); + } + } + String::from_utf8(bytes) + .map_err(|_| VoleError::InvalidConfig("invalid DNS route encoding".into())) +} + +pub(super) fn normalize_hosts( + raw: std::collections::BTreeMap, +) -> Result> { + let mut hosts = vec![HostEntry { + pattern: "localhost".into(), + value: HostValue::Addresses(vec![IpAddr::V4(Ipv4Addr::LOCALHOST)]), + }]; + for (pattern, raw) in raw { + let DnsPolicySelector::Domains(patterns) = normalize_dns_policy_selector(&pattern)? else { + return invalid("hosts keys require domain patterns"); + }; + let values = match raw { + RawPolicyNameservers::One(value) => vec![value], + RawPolicyNameservers::Many(values) => values, + }; + let value = if values.len() == 1 && values[0].parse::().is_err() { + if values[0] == "lan" { + return invalid("hosts dynamic lan expansion is not supported"); + } + HostValue::Alias( + crate::dns::canonicalize_name(&values[0]) + .map_err(|_| VoleError::InvalidConfig("invalid hosts alias".into()))?, + ) + } else { + let addresses = values + .iter() + .map(|value| { + value.parse::().map_err(|_| { + VoleError::InvalidConfig("hosts requires IP addresses or one alias".into()) + }) + }) + .collect::>>()?; + if addresses.is_empty() { + return invalid("hosts requires a value"); + } + HostValue::Addresses(addresses) + }; + for pattern in patterns { + hosts.push(HostEntry { + pattern, + value: value.clone(), + }); + } + } + crate::dns::hosts::validate(&hosts) + .map_err(|_| VoleError::InvalidConfig("hosts contains a domain alias cycle".into()))?; + Ok(hosts) +} diff --git a/src/config/mod.rs b/src/config/mod.rs index bbb3260..e238c49 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -17,6 +17,7 @@ use uuid::Uuid; use crate::{Result, VoleError}; +mod dns; mod rules; mod yaml; pub use rules::DomainRegex; @@ -54,7 +55,6 @@ pub const MAX_CONFIG_BYTES: usize = 8 * 1024 * 1024; pub const MAX_RULES: usize = 65_536; pub const MAX_RULE_BYTES: usize = 16 * 1024; pub const MAX_RULES_TOTAL_BYTES: usize = MAX_CONFIG_BYTES; -pub const MAX_DNS_NAMESERVERS: usize = 4; pub const MAX_SNIFFER_PORT_ITEMS: usize = 64; pub const MAX_GEOX_URL_BYTES: usize = 4_096; pub const MAX_CONTROLLER_SECRET_BYTES: usize = 255; @@ -228,14 +228,77 @@ pub struct DnsConfig { pub ipv6: bool, pub nameservers: Vec, pub nameserver_policies: Vec, + pub default_nameservers: Vec, + pub proxy_nameservers: Vec, + pub proxy_policies: Vec, + pub direct_nameservers: Vec, + pub direct_follow_policy: bool, + pub use_hosts: bool, + pub use_system_hosts: bool, + pub hosts: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostEntry { + pub pattern: String, + pub value: HostValue, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostValue { + Addresses(Vec), + Alias(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DnsAddress { + Ip(IpAddr), + Domain(String), +} + +impl std::str::FromStr for DnsAddress { + type Err = std::convert::Infallible; + fn from_str(value: &str) -> std::result::Result { + Ok(value + .parse() + .map(Self::Ip) + .unwrap_or_else(|_| Self::Domain(value.to_owned()))) + } +} + +impl From for DnsAddress { + fn from(value: IpAddr) -> Self { + Self::Ip(value) + } +} + +impl From for DnsAddress { + fn from(ip: Ipv4Addr) -> Self { + Self::Ip(ip.into()) + } +} +impl From for DnsAddress { + fn from(ip: Ipv6Addr) -> Self { + Self::Ip(ip.into()) + } +} + +impl std::fmt::Display for DnsAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Ip(ip) => ip.fmt(f), + Self::Domain(host) => host.fmt(f), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct DnsNameserver { pub transport: DnsTransport, - pub address: IpAddr, + pub address: DnsAddress, pub port: u16, pub route: DnsRoute, + pub path: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -259,13 +322,16 @@ impl DnsNameserverPolicy { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DnsTransport { Udp, Tcp, + Tls, + Https, + Http, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DnsRoute { Direct, Rules, @@ -782,6 +848,8 @@ struct RawVoleConfig { mode: Mode, #[serde(rename = "log-level", default)] log_level: LogLevel, + #[serde(default)] + hosts: std::collections::BTreeMap, #[serde(default = "default_true")] ipv6: bool, #[serde( @@ -976,17 +1044,40 @@ enum RawSnifferPort { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawDnsConfig { + #[serde(default)] enable: bool, - #[serde(default = "default_true")] - ipv6: bool, #[serde(default)] + ipv6: bool, + #[serde(default = "dns::default_nameservers")] nameserver: Vec, + #[serde(rename = "default-nameserver", default = "dns::default_bootstrap")] + default_nameserver: Vec, + #[serde(rename = "proxy-server-nameserver", default)] + proxy_nameserver: Vec, + #[serde(rename = "direct-nameserver", default)] + direct_nameserver: Vec, + #[serde(rename = "direct-nameserver-follow-policy", default)] + direct_follow_policy: bool, + #[serde(rename = "use-hosts", default = "default_true")] + use_hosts: bool, + #[serde(rename = "use-system-hosts", default = "default_true")] + use_system_hosts: bool, + #[serde(rename = "respect-rules", default)] + respect_rules: bool, + #[serde(rename = "enhanced-mode", default = "dns::redir_host")] + enhanced_mode: String, #[serde( rename = "nameserver-policy", default, deserialize_with = "deserialize_dns_nameserver_policies" )] nameserver_policies: Vec, + #[serde( + rename = "proxy-server-nameserver-policy", + default, + deserialize_with = "deserialize_dns_nameserver_policies" + )] + proxy_policies: Vec, } #[derive(Debug)] @@ -995,7 +1086,7 @@ struct RawDnsNameserverPolicy { nameservers: Vec, } -#[derive(Deserialize)] +#[derive(Debug, Deserialize)] #[serde(untagged)] enum RawPolicyNameservers { One(String), @@ -1594,6 +1685,7 @@ impl RawVoleConfig { .dns .map_or_else(DnsConfig::disabled, |dns| dns.normalize(&route_targets))?; dns.ipv6 &= self.ipv6; + dns.hosts = dns::normalize_hosts(self.hosts)?; let rules = normalize_rules(self.rules, &route_targets)?; drop(proxy_ids); drop(route_targets); @@ -2714,59 +2806,6 @@ impl RawXHttpDownloadSettings { } } -impl DnsConfig { - fn disabled() -> Result { - Ok(Self { - enable: false, - ipv6: true, - nameservers: Vec::new(), - nameserver_policies: Vec::new(), - }) - } -} - -impl RawDnsConfig { - fn normalize(self, route_targets: &RouteTargetsByName) -> Result { - if !self.enable { - if !self.nameserver.is_empty() || !self.nameserver_policies.is_empty() { - return invalid( - "dns.nameserver and dns.nameserver-policy must be empty when dns.enable is false", - ); - } - return Ok(DnsConfig { - enable: false, - ipv6: self.ipv6, - nameservers: Vec::new(), - nameserver_policies: Vec::new(), - }); - } - - if self.nameserver.is_empty() || self.nameserver.len() > MAX_DNS_NAMESERVERS { - return invalid(format!( - "dns.nameserver must contain between 1 and {MAX_DNS_NAMESERVERS} entries when enabled" - )); - } - let default_route = DnsRoute::Direct; - let nameservers = self - .nameserver - .iter() - .map(|nameserver| parse_dns_nameserver(nameserver, route_targets, default_route)) - .collect::>>()?; - let nameserver_policies = normalize_dns_nameserver_policies( - self.nameserver_policies, - route_targets, - default_route, - )?; - - Ok(DnsConfig { - enable: true, - ipv6: self.ipv6, - nameservers, - nameserver_policies, - }) - } -} - fn normalize_dns_nameserver_policies( raw_policies: Vec, route_targets: &RouteTargetsByName, @@ -2776,9 +2815,9 @@ fn normalize_dns_nameserver_policies( for raw_policy in raw_policies { let selector = normalize_dns_policy_selector(&raw_policy.selector)?; - if raw_policy.nameservers.is_empty() || raw_policy.nameservers.len() > MAX_DNS_NAMESERVERS { + if raw_policy.nameservers.is_empty() { return invalid(format!( - "dns.nameserver-policy `{}` must contain between 1 and {MAX_DNS_NAMESERVERS} nameservers", + "dns.nameserver-policy `{}` must contain at least one nameserver", raw_policy.selector )); } @@ -2832,106 +2871,7 @@ fn normalize_dns_policy_selector(input: &str) -> Result { Ok(DnsPolicySelector::Domains(domains.into_boxed_slice())) } -fn parse_dns_nameserver( - input: &str, - route_targets: &RouteTargetsByName, - default_route: DnsRoute, -) -> Result { - let (endpoint, route) = match input.split_once('#') { - Some((endpoint, fragment)) => ( - endpoint, - parse_dns_route_fragment(input, fragment, route_targets)?, - ), - None => (input, default_route), - }; - - if let Ok(address) = endpoint.parse::() { - return Ok(DnsNameserver { - transport: DnsTransport::Udp, - address, - port: 53, - route, - }); - } - - let (transport, endpoint) = if let Some(endpoint) = endpoint.strip_prefix("udp://") { - (DnsTransport::Udp, endpoint) - } else if let Some(endpoint) = endpoint.strip_prefix("tcp://") { - (DnsTransport::Tcp, endpoint) - } else { - return invalid(format!( - "invalid DNS nameserver `{input}`; expected IP, udp://IP[:port], or tcp://IP[:port], optionally followed by a route fragment" - )); - }; - - let (address, port) = parse_dns_endpoint(endpoint).ok_or_else(|| { - VoleError::InvalidConfig(format!( - "invalid DNS nameserver `{input}`; hostnames and URL paths are not supported" - )) - })?; - validate_port(port, "DNS nameserver")?; - Ok(DnsNameserver { - transport, - address, - port, - route, - }) -} - -fn parse_dns_route_fragment( - input: &str, - fragment: &str, - route_targets: &RouteTargetsByName, -) -> Result { - if fragment.is_empty() { - return invalid(format!( - "invalid DNS nameserver `{input}`; route fragment must not be empty" - )); - } - if fragment.contains(['=', '&']) { - return invalid(format!( - "invalid DNS nameserver `{input}`; route fragment parameters and multiple tokens are not supported" - )); - } - match fragment { - "DIRECT" => Ok(DnsRoute::Direct), - "RULES" => Ok(DnsRoute::Rules), - fragment => route_targets - .get(fragment) - .copied() - .map(DnsRoute::Route) - .ok_or_else(|| { - VoleError::InvalidConfig(format!( - "invalid DNS nameserver `{input}`; route fragment must be exactly DIRECT, RULES, or a configured proxy or proxy group name" - )) - }), - } -} - -fn parse_dns_endpoint(endpoint: &str) -> Option<(IpAddr, u16)> { - if endpoint.is_empty() - || endpoint.contains(['/', '?', '#', '@']) - || endpoint.as_bytes().contains(&b'%') - { - return None; - } - if let Ok(address) = endpoint.parse::() { - return Some((address, 53)); - } - if endpoint.starts_with('[') { - let close = endpoint.find(']')?; - let address = endpoint.get(1..close)?.parse::().ok()?; - let suffix = endpoint.get(close + 1..)?; - let port = if suffix.is_empty() { - 53 - } else { - suffix.strip_prefix(':')?.parse::().ok()? - }; - return Some((IpAddr::V6(address), port)); - } - let endpoint = endpoint.parse::().ok()?; - Some((endpoint.ip(), endpoint.port())) -} +use dns::parse_dns_nameserver; fn normalize_rules( rules: Vec, @@ -4802,24 +4742,28 @@ rules: config.dns.nameservers, [ DnsNameserver { + path: None, transport: DnsTransport::Udp, address: "1.1.1.1".parse().unwrap(), port: 53, route: DnsRoute::Direct, }, DnsNameserver { + path: None, transport: DnsTransport::Udp, address: "2001:db8::1".parse().unwrap(), port: 5353, route: DnsRoute::Direct, }, DnsNameserver { + path: None, transport: DnsTransport::Tcp, address: "8.8.8.8".parse().unwrap(), port: 53, route: DnsRoute::Direct, }, DnsNameserver { + path: None, transport: DnsTransport::Tcp, address: "::1".parse().unwrap(), port: 53, @@ -5686,49 +5630,26 @@ authentication: } #[test] - fn current_config_validates_dns_schema_and_fixed_ip_nameservers() { - let valid = current_yaml( - r#"mixed-port: 1080 -authentication: - - measure:secret -dns: - enable: true - nameserver: [udp://1.1.1.1:53]"#, - ); - let config = Config::parse_yaml(valid.as_bytes()).unwrap(); - let dns = &config.dns; - assert!(dns.ipv6); - assert_eq!(dns.nameservers[0].route, DnsRoute::Direct); - - for fields in [ - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n nameserver: []", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n nameserver: []", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: false\n nameserver: [1.1.1.1]", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n enhanced-mode: fake-ip\n nameserver: [1.1.1.1]", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n nameserver: [udp://dns.example:53]", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n nameserver: [https://1.1.1.1/dns-query]", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n nameserver: [tcp://1.1.1.1:0]", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n nameserver: [1.1.1.1, 8.8.8.8, 9.9.9.9, 4.4.4.4, 208.67.222.222]", + fn current_config_validates_dns_upstreams_and_roles() { + let config = Config::parse_yaml(b"mixed-port: 1080\ndns: {enable: true}\n").unwrap(); + assert!(!config.dns.ipv6); + assert_eq!(config.dns.nameservers[0].transport, DnsTransport::Https); + assert_eq!( + config.dns.nameservers[1].address, + DnsAddress::Ip("223.5.5.5".parse().unwrap()) + ); + for dns in [ + "{enable: true, nameserver: []}", + "{default-nameserver: [dns.example]}", + "{nameserver: ['tls://1.1.1.1:0']}", + "{respect-rules: true}", ] { - let yaml = current_yaml(fields); - assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); + assert!( + Config::parse_yaml(format!("mixed-port: 1080\ndns: {dns}\n").as_bytes()).is_err() + ); } + let config = Config::parse_yaml(b"mixed-port: 1080\ndns: {enable: false, nameserver: ['udp://dns.example', 'https://dns.example/query'], enhanced-mode: redir-host}\n").unwrap(); + assert!(!config.dns.enable); } #[test] @@ -5850,6 +5771,7 @@ dns: assert_eq!( dns.nameserver_policies[0].nameservers.as_ref(), [DnsNameserver { + path: None, transport: DnsTransport::Tcp, address: "223.5.5.5".parse().unwrap(), port: 53, @@ -5920,8 +5842,6 @@ rules: r#" "geosite:cn,": ["tcp://223.5.5.5"]"#, r#" "geosite:cn, apple": ["tcp://223.5.5.5"]"#, r#" "geosite:cn": []"#, - r#" "geosite:cn": [1.1.1.1, 8.8.8.8, 9.9.9.9, 4.4.4.4, 223.5.5.5]"#, - r#" "geosite:cn": ["tcp://dns.example"]"#, ] { let yaml = current_yaml(&format!( "mixed-port: 1080 @@ -5997,7 +5917,7 @@ dns: nameserver-policy: "geosite:cn": ["tcp://223.5.5.5"]"#, ); - assert!(Config::parse_yaml(disabled.as_bytes()).is_err()); + assert!(Config::parse_yaml(disabled.as_bytes()).is_ok()); let missing_main = current_yaml( r#"mixed-port: 1080 @@ -6008,7 +5928,7 @@ dns: nameserver-policy: "geosite:cn": ["tcp://223.5.5.5"]"#, ); - assert!(Config::parse_yaml(missing_main.as_bytes()).is_err()); + assert!(Config::parse_yaml(missing_main.as_bytes()).is_ok()); } #[test] @@ -6047,12 +5967,6 @@ authentication: authentication: - measure:secret\ndns:\n enable: true\n enhanced-mode: normal\n nameserver: [1.1.1.1]", "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n enhanced-mode: redir-host\n nameserver: [1.1.1.1]", - "mixed-port: 1080 -authentication: - - measure:secret\ndns:\n enable: true\n respect-rules: false\n nameserver: [1.1.1.1]", - "mixed-port: 1080 authentication: - measure:secret\ndns:\n enable: true\n respect-rules: true\n nameserver: [1.1.1.1]", "mixed-port: 1080 diff --git a/src/dns/hosts.rs b/src/dns/hosts.rs new file mode 100644 index 0000000..3cac498 --- /dev/null +++ b/src/dns/hosts.rs @@ -0,0 +1,96 @@ +use super::policy::DomainIndex; +use crate::config::{HostEntry, HostValue, MAX_CONFIG_BYTES}; +use std::{ + collections::HashSet, + io::{self, Read}, + net::IpAddr, +}; + +pub(crate) struct Hosts { + index: DomainIndex, + system: std::collections::HashMap, + values: Vec, +} + +impl Hosts { + pub(crate) fn new(entries: &[HostEntry], configured: bool, system: bool) -> Self { + let mut hosts = Self { + index: DomainIndex::new(), + system: std::collections::HashMap::new(), + values: Vec::new(), + }; + if system { + #[cfg(windows)] + let path = std::env::var_os("SystemRoot") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| "C:\\Windows".into()) + .join("System32/drivers/etc/hosts"); + #[cfg(not(windows))] + let path = std::path::PathBuf::from("/etc/hosts"); + if let Ok(file) = std::fs::File::open(path) { + let mut text = String::new(); + if file + .take(MAX_CONFIG_BYTES as u64) + .read_to_string(&mut text) + .is_ok() + { + let mut entries = std::collections::BTreeMap::>::new(); + for line in text.lines() { + let mut words = line.split('#').next().unwrap_or("").split_whitespace(); + let Some(ip) = words.next().and_then(|word| word.parse::().ok()) + else { + continue; + }; + for name in words { + if let Ok(name) = super::canonicalize_name(name) { + entries.entry(name).or_default().push(ip); + } + } + } + for (name, addresses) in entries { + hosts.system.insert(name, HostValue::Addresses(addresses)); + } + } + } + } + if configured { + for entry in entries { + hosts.insert(&entry.pattern, entry.value.clone()); + } + } + hosts + } + fn insert(&mut self, name: &str, value: HostValue) { + self.index.insert(name, self.values.len()); + self.values.push(value); + } + pub(crate) fn lookup(&self, name: &str) -> io::Result { + let mut name = name.to_owned(); + let mut seen = HashSet::new(); + while let Some(value) = self + .index + .find(0, &name) + .map(|index| &self.values[index]) + .or_else(|| self.system.get(&name)) + { + if !seen.insert(name.clone()) || seen.len() > 32 { + return Err(io::ErrorKind::InvalidInput.into()); + } + match value { + HostValue::Addresses(addresses) => { + return Ok(HostValue::Addresses(addresses.clone())); + } + HostValue::Alias(alias) => name.clone_from(alias), + } + } + Ok(HostValue::Alias(name)) + } +} + +pub(crate) fn validate(entries: &[HostEntry]) -> io::Result<()> { + let hosts = Hosts::new(entries, true, false); + for entry in entries { + hosts.lookup(&entry.pattern)?; + } + Ok(()) +} diff --git a/src/dns/https.rs b/src/dns/https.rs new file mode 100644 index 0000000..397fc66 --- /dev/null +++ b/src/dns/https.rs @@ -0,0 +1,134 @@ +//! DNS-over-HTTP/1.1 on a caller-owned authenticated stream. No socket factory +//! or detached connection driver; cancellation discards the current lease. +use super::MAX_MESSAGE_SIZE; +use crate::{config::DnsNameserver, dispatch::BoxStream}; +use std::io; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const HEADER_LIMIT: usize = 16 * 1024; + +async fn line(stream: &mut BoxStream, remaining: &mut usize) -> io::Result { + let mut bytes = Vec::new(); + loop { + *remaining = remaining.checked_sub(1).ok_or(io::ErrorKind::InvalidData)?; + let byte = stream.read_u8().await?; + bytes.push(byte); + if bytes.ends_with(b"\r\n") { + bytes.truncate(bytes.len() - 2); + break; + } + } + String::from_utf8(bytes).map_err(|_| io::ErrorKind::InvalidData.into()) +} + +pub(super) async fn exchange( + stream: &mut BoxStream, + server: &DnsNameserver, + query: &[u8], +) -> io::Result<(Vec, bool)> { + let host = match &server.address { + crate::config::DnsAddress::Ip(std::net::IpAddr::V6(ip)) => format!("[{ip}]"), + other => other.to_string(), + }; + let request = format!( + "POST {} HTTP/1.1\r\nHost: {host}:{}\r\nAccept: application/dns-message\r\nContent-Type: application/dns-message\r\nContent-Length: {}\r\n\r\n", + server.path.as_deref().unwrap_or("/dns-query"), + server.port, + query.len() + ); + stream.write_all(request.as_bytes()).await?; + stream.write_all(query).await?; + stream.flush().await?; + let mut budget = HEADER_LIMIT; + let status = line(stream, &mut budget).await?; + let mut parts = status.split_ascii_whitespace(); + let version = parts.next().unwrap_or_default(); + if !matches!(version, "HTTP/1.0" | "HTTP/1.1") || parts.next() != Some("200") { + return Err(io::Error::other("DNS HTTP response status is not 200")); + } + let mut reusable = version == "HTTP/1.1"; + let mut length = None; + let mut chunked = false; + let mut content_type = false; + loop { + let header = line(stream, &mut budget).await?; + if header.is_empty() { + break; + } + let (name, value) = header.split_once(':').ok_or(io::ErrorKind::InvalidData)?; + let value = value.trim(); + match name.to_ascii_lowercase().as_str() { + "content-length" => { + if length.is_some() { + return Err(io::ErrorKind::InvalidData.into()); + } + length = Some( + value + .parse::() + .map_err(|_| io::ErrorKind::InvalidData)?, + ); + } + "transfer-encoding" => { + if chunked || !value.eq_ignore_ascii_case("chunked") { + return Err(io::ErrorKind::InvalidData.into()); + } + chunked = true; + } + "content-type" => { + content_type = value.split(';').next().is_some_and(|value| { + value.trim().eq_ignore_ascii_case("application/dns-message") + }) + } + "connection" + if value + .split(',') + .any(|value| value.trim().eq_ignore_ascii_case("close")) => + { + reusable = false + } + _ => {} + } + } + if !content_type || (chunked && length.is_some()) { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut body = Vec::new(); + if chunked { + loop { + let size = line(stream, &mut budget).await?; + let size = usize::from_str_radix(size.split(';').next().unwrap_or("").trim(), 16) + .map_err(|_| io::ErrorKind::InvalidData)?; + if size == 0 { + while !line(stream, &mut budget).await?.is_empty() {} + break; + } + let end = body + .len() + .checked_add(size) + .filter(|size| *size <= MAX_MESSAGE_SIZE) + .ok_or(io::ErrorKind::InvalidData)?; + let start = body.len(); + body.resize(end, 0); + stream.read_exact(&mut body[start..]).await?; + if stream.read_u16().await? != 0x0d0a { + return Err(io::ErrorKind::InvalidData.into()); + } + } + } else if let Some(length) = length { + if length > MAX_MESSAGE_SIZE { + return Err(io::ErrorKind::InvalidData.into()); + } + body.resize(length, 0); + stream.read_exact(&mut body).await?; + } else { + reusable = false; + stream + .take(MAX_MESSAGE_SIZE as u64 + 1) + .read_to_end(&mut body) + .await?; + if body.len() > MAX_MESSAGE_SIZE { + return Err(io::ErrorKind::InvalidData.into()); + } + } + Ok((body, reusable)) +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 49807ca..56feb3f 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -5,7 +5,10 @@ //! redir-host table is bounded by the constants below, while concurrent query //! activity is observed rather than rejected by a fixed local admission cap. +pub(crate) mod hosts; +mod https; mod policy; + pub mod resolution; pub mod runtime; diff --git a/src/dns/policy.rs b/src/dns/policy.rs index bd54c6e..f1cadef 100644 --- a/src/dns/policy.rs +++ b/src/dns/policy.rs @@ -66,14 +66,14 @@ struct DomainNode { /// A flat arena keeps construction and destruction independent of pattern /// depth; lookup depth is bounded by the validated DNS question. #[derive(Debug)] -struct DomainIndex(Vec); +pub(super) struct DomainIndex(Vec); impl DomainIndex { - fn new() -> Self { + pub(super) fn new() -> Self { Self(vec![DomainNode::default()]) } - fn insert(&mut self, pattern: &str, policy: usize) { + pub(super) fn insert(&mut self, pattern: &str, policy: usize) { let (domain, exact, suffix) = if let Some(domain) = pattern.strip_prefix("+.") { (domain, true, true) } else if let Some(domain) = pattern.strip_prefix('.') { @@ -107,7 +107,7 @@ impl DomainIndex { } } - fn find(&self, current: usize, name: &str) -> Option { + pub(super) fn find(&self, current: usize, name: &str) -> Option { let node = &self.0[current]; if name.is_empty() { return node.exact; diff --git a/src/dns/resolution.rs b/src/dns/resolution.rs index a76e502..97bc858 100644 --- a/src/dns/resolution.rs +++ b/src/dns/resolution.rs @@ -1,5 +1,5 @@ //! IP-only protocol boundaries use a runtime-owned resolver, never an implicit -//! system lookup. Endpoint/ECH bootstrap remains a separate prepare operation. +//! system lookup. Proxy servers use their separate runtime DNS role. use super::{canonicalize_name, runtime::RuntimeDns}; use crate::{dialer::Resolver, dispatch::DispatchError, session::Destination}; @@ -42,7 +42,7 @@ impl std::fmt::Debug for ResolutionContext { #[derive(Clone)] struct ActiveResolution { deadline: Instant, - names: Vec<(usize, String)>, + names: Vec<(usize, bool, String)>, } tokio::task_local! { @@ -102,6 +102,35 @@ impl ResolutionContext { target: &Destination, deadline: Instant, ) -> Result { + self.resolve_addresses(target, deadline, false) + .await? + .into_iter() + .next() + .ok_or(DispatchError::HostUnreachable) + } + + pub async fn resolve_proxy( + &self, + target: &Destination, + deadline: Instant, + ) -> Result { + let addresses = self.resolve_addresses(target, deadline, true).await?; + Ok(crate::dialer::ResolvedEndpoint { + logical_host: match target { + Destination::Domain { host, .. } => host.clone(), + Destination::Ip(address) => address.ip().to_string(), + }, + port: target.port(), + addresses, + }) + } + + async fn resolve_addresses( + &self, + target: &Destination, + deadline: Instant, + proxy: bool, + ) -> Result, DispatchError> { let deadline = inherited_deadline(deadline); if deadline <= Instant::now() { return Err(DispatchError::TimedOut); @@ -119,7 +148,7 @@ impl ResolutionContext { { return Err(DispatchError::HostUnreachable); } - return Ok(*address); + return Ok(vec![*address]); } let Destination::Domain { host, port } = target else { unreachable!() @@ -127,7 +156,7 @@ impl ResolutionContext { let host = canonicalize_name(host).map_err(|_| DispatchError::HostUnreachable)?; let inner = self.inner.as_ref().ok_or_else(unavailable)?; let source = inner.source.get().ok_or_else(unavailable)?; - let key = (Arc::as_ptr(inner) as usize, host.clone()); + let key = (Arc::as_ptr(inner) as usize, proxy, host.clone()); let mut active = ACTIVE.try_with(Clone::clone).unwrap_or(ActiveResolution { deadline, names: Vec::new(), @@ -150,12 +179,15 @@ impl ResolutionContext { .as_ref() .and_then(Weak::upgrade) .ok_or_else(unavailable)?; - dns.resolve(&host) - .await - .map_err(|_| DispatchError::HostUnreachable)? - .into_iter() - .map(|ip| SocketAddr::new(ip, *port)) - .collect::>() + if proxy { + dns.resolve_proxy(&host).await + } else { + dns.resolve(&host).await + } + .map_err(|_| DispatchError::HostUnreachable)? + .into_iter() + .map(|ip| SocketAddr::new(ip, *port)) + .collect::>() } Source::Measurement(bootstrap) => { let resolved = bootstrap @@ -174,10 +206,15 @@ impl ResolutionContext { resolved.addresses } }; - addresses + let addresses: Vec<_> = addresses .into_iter() - .find(|address| inner.ipv6 || address.is_ipv4()) - .ok_or(DispatchError::HostUnreachable) + .filter(|address| inner.ipv6 || address.is_ipv4()) + .collect(); + if addresses.is_empty() { + Err(DispatchError::HostUnreachable) + } else { + Ok(addresses) + } }; tokio::select! { biased; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index c1aa012..20010f3 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -1,9 +1,11 @@ //! Bounded runtime DNS service with Mihomo-compatible nameserver egress. //! -//! Upstream endpoints are literal IP addresses. Explicit nameserver fragments -//! select DIRECT, a named route target, or the ordered rules using only that -//! endpoint as routing context; a DNS question name is never injected into -//! business routing. +//! Upstream domains use a separate bootstrap resolver. DNS questions never +//! become upstream routing targets; fragments select the upstream connection. + +#[cfg(test)] +#[path = "tests_encrypted.rs"] +mod encrypted_tests; use std::{ collections::HashMap, @@ -36,8 +38,8 @@ use super::{ }; use crate::{ config::{ - DnsConfig, DnsNameserver, DnsNameserverPolicy, DnsRoute, DnsTransport, Network, - RouteTargetId, RuleAction, + DnsAddress, DnsConfig, DnsNameserver, DnsNameserverPolicy, DnsRoute, DnsTransport, + HostValue, Network, RouteTargetId, RuleAction, }, dispatch::{BoxStream, DispatchError, Dispatcher}, resources::{ResourceActivity, ResourceActivityGuard, RuntimeResourceStats}, @@ -89,6 +91,10 @@ pub struct RuntimeDns { enabled: bool, ipv6: bool, nameservers: Box<[DnsNameserver]>, + hosts: super::hosts::Hosts, + bootstrap: Option>, + proxy_resolver: Option>, + direct_resolver: Option>, nameserver_policies: Box<[DnsNameserverPolicy]>, policy_index: NameserverPolicies, geo_matcher: Arc, @@ -111,13 +117,15 @@ enum DnsEgressKey { Route(RouteTargetId), } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] struct TcpPoolKey { + upstream: DnsNameserver, endpoint: SocketAddr, egress: DnsEgressKey, } struct TcpConnectionPool { + security: crate::security::SecurityContext, state: Mutex, max_idle: usize, max_idle_per_key: usize, @@ -210,6 +218,7 @@ impl TcpConnectionPool { stats.idle_limit = max_idle; stats.idle_per_key_limit = max_idle_per_key; Arc::new(Self { + security: crate::security::SecurityContext::new(), state: Mutex::new(TcpPoolState::default()), max_idle, max_idle_per_key, @@ -222,7 +231,7 @@ impl TcpConnectionPool { async fn checkout( self: &Arc, key: TcpPoolKey, - nameserver: DnsNameserver, + nameserver: &ResolvedNameserver, egress: &Arc, ) -> Result { let (connection, expired) = { @@ -264,7 +273,7 @@ impl TcpConnectionPool { async fn open_connection( &self, - nameserver: DnsNameserver, + nameserver: &ResolvedNameserver, egress: &Arc, ) -> Result { let slot = TcpPhysicalSlot::new(Arc::clone(&self.stats)); @@ -272,11 +281,35 @@ impl TcpConnectionPool { let stream = egress .connect_tcp(StreamSession { inbound: InboundKind::InternalDns, - source: internal_source(nameserver.address), + source: internal_source(nameserver.endpoint.ip()), destination: Destination::Ip(nameserver.endpoint()), sniffed_domain: None, }) .await?; + let stream = if matches!( + nameserver.transport, + DnsTransport::Tls | DnsTransport::Https + ) { + let options = crate::security::TlsClientOptions { + alpn: if nameserver.transport == DnsTransport::Https { + vec![b"http/1.1".to_vec()] + } else { + Vec::new() + }, + ..Default::default() + }; + crate::security::StandardTlsClient::with_options( + &self.security, + nameserver.address.to_string(), + options, + 0, + 64 * 1024, + )? + .connect(stream) + .await? + } else { + stream + }; drop(connecting); self.stats.physical_opens.fetch_add(1, Ordering::Relaxed); Ok(PooledTcpConnection { @@ -291,7 +324,7 @@ impl TcpConnectionPool { async fn replace_stale( &self, lease: &mut TcpLease, - nameserver: DnsNameserver, + nameserver: &ResolvedNameserver, egress: &Arc, ) -> Result<(), AttemptError> { if lease.connection.take().is_some() { @@ -327,7 +360,7 @@ impl TcpConnectionPool { .count() >= self.max_idle_per_key { - let Some(index) = oldest_idle_index_for_key(&state.idle, lease.key) else { + let Some(index) = oldest_idle_index_for_key(&state.idle, lease.key.clone()) else { break; }; discarded.push(state.idle.swap_remove(index).connection); @@ -342,7 +375,7 @@ impl TcpConnectionPool { } state.idle.push(IdleTcpConnection { - key: lease.key, + key: lease.key.clone(), connection, idle_since: now, }); @@ -741,6 +774,7 @@ enum FailureMode { BoundedError, } +#[derive(Clone)] enum DnsEgress { /// Kept for focused unit tests and embedders that deliberately provide one /// already-selected dispatcher. @@ -859,6 +893,51 @@ impl RuntimeDns { } fn assemble( + config: &DnsConfig, + egress: DnsEgress, + geo: Arc, + cache: usize, + hints: usize, + ) -> Self { + let mut bootstrap_config = config.clone(); + bootstrap_config.enable = true; + bootstrap_config.nameservers = config.default_nameservers.clone(); + bootstrap_config.nameserver_policies.clear(); + let bootstrap = Arc::new(Self::assemble_leaf( + &bootstrap_config, + egress.clone(), + geo.clone(), + cache, + 0, + )); + let mut root = Self::assemble_leaf(config, egress.clone(), geo.clone(), cache, hints); + root.bootstrap = Some(bootstrap.clone()); + let role = |nameservers: &[DnsNameserver], policies: &[DnsNameserverPolicy]| { + let mut role_config = config.clone(); + role_config.nameservers = nameservers.to_vec(); + role_config.nameserver_policies = policies.to_vec(); + let mut resolver = + Self::assemble_leaf(&role_config, egress.clone(), geo.clone(), cache, 0); + resolver.bootstrap = Some(bootstrap.clone()); + Arc::new(resolver) + }; + if !config.proxy_nameservers.is_empty() { + root.proxy_resolver = Some(role(&config.proxy_nameservers, &config.proxy_policies)); + } + if !config.direct_nameservers.is_empty() { + root.direct_resolver = Some(role( + &config.direct_nameservers, + if config.direct_follow_policy { + &config.nameserver_policies + } else { + &[] + }, + )); + } + root + } + + fn assemble_leaf( config: &DnsConfig, egress: DnsEgress, geo_matcher: Arc, @@ -869,6 +948,14 @@ impl RuntimeDns { Self { enabled: config.enable, ipv6: config.ipv6, + hosts: super::hosts::Hosts::new( + &config.hosts, + config.use_hosts, + config.use_system_hosts, + ), + bootstrap: None, + proxy_resolver: None, + direct_resolver: None, nameservers: config.nameservers.clone().into_boxed_slice(), nameserver_policies: config.nameserver_policies.clone().into_boxed_slice(), policy_index: NameserverPolicies::new(&config.nameserver_policies), @@ -908,7 +995,7 @@ impl RuntimeDns { fn egress_for( &self, - nameserver: DnsNameserver, + nameserver: &ResolvedNameserver, network: Network, ) -> Result<(DnsEgressKey, &Arc), AttemptError> { let DnsEgress::Routed { @@ -930,12 +1017,18 @@ impl RuntimeDns { .map(|dispatcher| (DnsEgressKey::Route(id), dispatcher)) .map_err(AttemptError::Dispatch), DnsRoute::Rules => { - let destination = Destination::Ip(nameserver.endpoint()); - let context = RoutingContext::new(network, &destination).map_err(|error| { + let destination = match &nameserver.address { + DnsAddress::Ip(_) => Destination::Ip(nameserver.endpoint()), + DnsAddress::Domain(host) => { + Destination::domain(host, nameserver.port).map_err(AttemptError::Io)? + } + }; + let mut context = RoutingContext::new(network, &destination).map_err(|error| { AttemptError::Dispatch(DispatchError::Other(format!( "DNS nameserver routing context is invalid: {error}" ))) })?; + context.pin_ip(nameserver.endpoint.ip()); match rules.evaluate_with_geo(&context, self.geo_matcher.as_ref()) { RuleEvaluation::Matched(rule_match) => match rule_match.action { RuleAction::Route(id) => proxies @@ -960,10 +1053,40 @@ impl RuntimeDns { /// Resolves a canonical domain through A followed by AAAA. AAAA is skipped /// completely when `dns.ipv6` is false. pub async fn resolve(&self, host: &str) -> Result, RuntimeDnsError> { + let host = canonicalize_name(host).map_err(RuntimeDnsError::InvalidQuery)?; + let host = + match self + .hosts + .lookup(&host) + .map_err(|_| RuntimeDnsError::UpstreamsExhausted { + last_error: "hosts alias cycle".into(), + })? { + HostValue::Addresses(mut addresses) => { + addresses.retain(|ip| self.ipv6 || ip.is_ipv4()); + return Ok(addresses); + } + HostValue::Alias(host) => host, + }; if !self.enabled { - return Err(RuntimeDnsError::Disabled); + use crate::dialer::Resolver; + let endpoint = tokio::time::timeout( + QUERY_TIMEOUT, + crate::dialer::SystemResolver.resolve(&host, 53), + ) + .await + .map_err(|_| RuntimeDnsError::UpstreamsExhausted { + last_error: "system resolver timed out".into(), + })? + .map_err(|_| RuntimeDnsError::UpstreamsExhausted { + last_error: "system resolver failed".into(), + })?; + return Ok(endpoint + .addresses + .into_iter() + .map(|address| address.ip()) + .filter(|ip| self.ipv6 || ip.is_ipv4()) + .collect()); } - let host = canonicalize_name(host).map_err(RuntimeDnsError::InvalidQuery)?; let mut addresses = Vec::new(); match self.resolve_type(&host, QueryType::A).await? { @@ -982,6 +1105,49 @@ impl RuntimeDns { Ok(addresses) } + pub fn is_enabled(&self) -> bool { + self.enabled + } + + pub async fn resolve_proxy(&self, host: &str) -> Result, RuntimeDnsError> { + self.proxy_resolver + .as_deref() + .unwrap_or(self) + .resolve(host) + .await + } + pub async fn resolve_direct(&self, host: &str) -> Result, RuntimeDnsError> { + self.direct_resolver + .as_deref() + .unwrap_or(self) + .resolve(host) + .await + } + async fn resolve_nameserver( + &self, + nameserver: &DnsNameserver, + ) -> Result { + let address = match &nameserver.address { + DnsAddress::Ip(ip) => *ip, + DnsAddress::Domain(host) => { + let resolver = self + .bootstrap + .as_ref() + .ok_or_else(|| io::Error::other("DNS bootstrap is unavailable"))?; + Box::pin(resolver.resolve(host)) + .await + .map_err(|_| io::Error::other("DNS bootstrap failed"))? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("DNS bootstrap returned no address"))? + } + }; + Ok(ResolvedNameserver { + config: nameserver.clone(), + endpoint: SocketAddr::new(address, nameserver.port), + }) + } + /// Exchanges one structurally valid single-question IN query. A/AAAA keep /// the typed address cache and redir-host behavior; every other qtype uses /// the bounded opaque wire cache and never creates a routing hint. @@ -1093,6 +1259,29 @@ impl RuntimeDns { return synthesize_response(&parsed_query, &CacheValue::Negative { rcode: 0 }, 0); } + let addresses = match self + .hosts + .lookup(&parsed_query.question.name) + .map_err(|_| RuntimeDnsError::UpstreamsExhausted { + last_error: "hosts alias cycle".into(), + })? { + HostValue::Addresses(addresses) => Some(addresses), + HostValue::Alias(alias) if alias != parsed_query.question.name => { + Some(Box::pin(self.resolve(&alias)).await?) + } + _ => None, + }; + if let Some(addresses) = addresses { + let addresses: Vec<_> = addresses + .into_iter() + .filter(|ip| match parsed_query.question.query_type { + QueryType::A => ip.is_ipv4(), + QueryType::Aaaa => ip.is_ipv6(), + }) + .collect(); + return synthesize_response(&parsed_query, &CacheValue::Positive(addresses), 600); + } + loop { let key = SingleflightKey::from(classified); let role = { @@ -1321,69 +1510,63 @@ impl RuntimeDns { &self, nameserver: DnsNameserver, query: &[u8], - parsed_query: &DnsMessage, + parsed: &DnsMessage, ) -> Result<(Vec, DnsMessage), AttemptError> { - let route_network = match nameserver.transport { - DnsTransport::Udp => Network::Udp, - DnsTransport::Tcp => Network::Tcp, - }; - let (egress_key, egress) = self.egress_for(nameserver, route_network)?; - let egress = Arc::clone(egress); - match nameserver.transport { - DnsTransport::Udp => { - let response = self - .exchange_udp(nameserver, &egress, query, |response| { - udp_response_matches_query(parsed_query, response) - }) - .await?; - let parsed_response = validate_complete_response(parsed_query, &response)?; - Ok((response, parsed_response)) - } - DnsTransport::Tcp => { - self.exchange_tcp(nameserver, egress_key, &egress, query, |response| { - validate_complete_response(parsed_query, response) - }) - .await - } - } + self.exchange_validated( + nameserver, + query, + |wire| udp_response_matches_query(parsed, wire), + |wire| validate_complete_response(parsed, wire), + ) + .await } async fn exchange_opaque_nameserver( &self, nameserver: DnsNameserver, query: &[u8], - classified: &ClassifiedDnsQuery, + parsed: &ClassifiedDnsQuery, ) -> Result { - let route_network = match nameserver.transport { - DnsTransport::Udp => Network::Udp, - DnsTransport::Tcp => Network::Tcp, - }; - let (egress_key, egress) = self.egress_for(nameserver, route_network)?; - let egress = Arc::clone(egress); - match nameserver.transport { - DnsTransport::Udp => { - let response = self - .exchange_udp(nameserver, &egress, query, |response| { - udp_response_matches_classified_query(classified, response) - }) - .await?; - validate_opaque_response(classified, &response).map_err(AttemptError::from) + self.exchange_validated( + nameserver, + query, + |wire| udp_response_matches_classified_query(parsed, wire), + |wire| validate_opaque_response(parsed, wire).map_err(AttemptError::from), + ) + .await + .map(|(_, parsed)| parsed) + } + + async fn exchange_validated( + &self, + nameserver: DnsNameserver, + query: &[u8], + matches: M, + validate: V, + ) -> Result<(Vec, R), AttemptError> + where + M: Fn(&[u8]) -> Result, + V: Fn(&[u8]) -> Result, + { + let mut server = self.resolve_nameserver(&nameserver).await?; + if server.transport == DnsTransport::Udp { + let (_, egress) = self.egress_for(&server, Network::Udp)?; + let response = self.exchange_udp(&server, egress, query, matches).await?; + if response.get(2).is_some_and(|flags| flags & 0x02 != 0) { + server.config.transport = DnsTransport::Tcp; + } else { + let parsed = validate(&response)?; + return Ok((response, parsed)); } - DnsTransport::Tcp => self - .exchange_tcp(nameserver, egress_key, &egress, query, |response| { - validate_opaque_response(classified, response).map_err(|error| match error { - DnsError::ResponseMismatch => AttemptError::ResponseMismatch, - error => AttemptError::InvalidResponse(error), - }) - }) - .await - .map(|(_, response)| response), } + let (key, egress) = self.egress_for(&server, Network::Tcp)?; + self.exchange_tcp(&server, key, egress, query, validate) + .await } async fn exchange_udp( &self, - nameserver: DnsNameserver, + nameserver: &ResolvedNameserver, egress: &Arc, query: &[u8], response_matches: F, @@ -1395,7 +1578,7 @@ impl RuntimeDns { let mut transport = egress .open_datagram(DatagramSession::new( InboundKind::InternalDns, - internal_source(nameserver.address), + internal_source(nameserver.endpoint.ip()), )) .await?; let payload = Bytes::copy_from_slice(query); @@ -1462,7 +1645,7 @@ impl RuntimeDns { async fn exchange_tcp( &self, - nameserver: DnsNameserver, + nameserver: &ResolvedNameserver, egress_key: DnsEgressKey, egress: &Arc, query: &[u8], @@ -1472,18 +1655,20 @@ impl RuntimeDns { F: Fn(&[u8]) -> Result, { let key = TcpPoolKey { + upstream: nameserver.config.clone(), endpoint: nameserver.endpoint(), egress: egress_key, }; let mut lease = self.tcp_pool.checkout(key, nameserver, egress).await?; let first_was_reused = lease.reused; - let mut result = exchange_tcp_validated( + let mut result = exchange_stream_validated( &mut lease .connection .as_mut() .expect("TCP lease always owns a connection") .stream, + nameserver, query, &validate, ) @@ -1496,20 +1681,23 @@ impl RuntimeDns { self.tcp_pool .replace_stale(&mut lease, nameserver, egress) .await?; - result = exchange_tcp_validated( + result = exchange_stream_validated( &mut lease .connection .as_mut() .expect("replacement TCP lease owns a connection") .stream, + nameserver, query, &validate, ) .await; } - let (response, validated) = result?; - self.tcp_pool.check_in(lease).await; + let (response, validated, reusable) = result?; + if reusable { + self.tcp_pool.check_in(lease).await; + } Ok((response, validated)) } @@ -1577,6 +1765,26 @@ async fn exchange_tcp_framed( Ok(response) } +async fn exchange_stream_validated( + stream: &mut BoxStream, + server: &ResolvedNameserver, + query: &[u8], + validate: &F, +) -> Result<(Vec, R, bool), AttemptError> +where + F: Fn(&[u8]) -> Result, +{ + if matches!(server.transport, DnsTransport::Http | DnsTransport::Https) { + let (wire, reusable) = super::https::exchange(stream, server, query).await?; + let response = validate(&wire)?; + Ok((wire, response, reusable)) + } else { + exchange_tcp_validated(stream, query, validate) + .await + .map(|(wire, response)| (wire, response, true)) + } +} + async fn exchange_tcp_validated( stream: &mut BoxStream, query: &[u8], @@ -1626,75 +1834,63 @@ where AttemptFuture: Future>, RetryableResponse: Fn(&T) -> Option, { + use futures_util::{StreamExt, stream}; let mut last_error: Box = "no nameserver attempted".into(); - for (nameserver_index, nameserver) in nameservers.iter().copied().enumerate() { - let now = TokioInstant::now(); - if now >= deadline { - tracing::warn!( - nameserver_index, - event = "runtime_dns_total_deadline", - "runtime DNS deadline expired before nameserver attempt" - ); - last_error = "runtime DNS total query deadline expired".into(); - break; - } - let attempt_deadline = std::cmp::min(deadline, now + UPSTREAM_TIMEOUT); - match timeout_at(attempt_deadline, attempt(nameserver)).await { - Err(_) => { - tracing::warn!( - nameserver_index, - event = "runtime_dns_attempt_timeout", - "runtime DNS nameserver attempt timed out" - ); - last_error = format!("nameserver {nameserver_index} attempt timed out").into(); - } - Ok(Err(error)) => { - tracing::debug!( - nameserver_index, - error_kind = error.category(), - "runtime DNS nameserver attempt failed" - ); - last_error = error.summary(nameserver_index); - } - Ok(Ok(response)) => { - let Some(rcode) = retryable_response(&response) else { - return Ok(response); - }; - tracing::debug!( - nameserver_index, - rcode, - "runtime DNS nameserver returned a retryable response" - ); - last_error = format!( - "nameserver {nameserver_index} returned retryable response code {rcode}" + let mut attempts = stream::iter(nameservers.iter().cloned().enumerate()) + .map(|(index, server)| { + let future = attempt(server); + async move { + ( + index, + timeout_at(deadline.min(TokioInstant::now() + UPSTREAM_TIMEOUT), future).await, ) - .into(); } - } - if nameserver_index + 1 < nameservers.len() { - tracing::debug!( - nameserver_index, - event = "runtime_dns_nameserver_failover", - "runtime DNS is trying the next nameserver" - ); + }) + .buffer_unordered(4); + while let Some((index, result)) = attempts.next().await { + match result { + Ok(Ok(response)) if retryable_response(&response).is_none() => return Ok(response), + Ok(Ok(_)) => { + last_error = format!("nameserver {index} returned a retryable response").into() + } + Ok(Err(error)) => last_error = error.summary(index), + Err(_) => last_error = format!("nameserver {index} timed out").into(), } } Err(RuntimeDnsError::UpstreamsExhausted { last_error }) } +struct ResolvedNameserver { + config: DnsNameserver, + endpoint: SocketAddr, +} +impl std::ops::Deref for ResolvedNameserver { + type Target = DnsNameserver; + fn deref(&self) -> &Self::Target { + &self.config + } +} +impl ResolvedNameserver { + fn endpoint(&self) -> SocketAddr { + self.endpoint + } +} +#[cfg(test)] impl DnsNameserver { - fn endpoint(self) -> SocketAddr { - SocketAddr::new(self.address, self.port) + fn endpoint(&self) -> SocketAddr { + match self.address { + DnsAddress::Ip(ip) => SocketAddr::new(ip, self.port), + _ => panic!("test requires literal DNS endpoint"), + } } } - impl fmt::Display for DnsNameserver { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - let transport = match self.transport { - DnsTransport::Udp => "udp", - DnsTransport::Tcp => "tcp", - }; - write!(formatter, "{transport}://{}", self.endpoint()) + write!( + formatter, + "{:?}://{}:{}", + self.transport, self.address, self.port + ) } } @@ -1740,7 +1936,7 @@ impl AttemptError { fn udp_response_matches_query(query: &DnsMessage, response: &[u8]) -> Result { match validate_response_identity(query, response) { - Ok(_) => Ok(true), + Ok(_) | Err(DnsError::TruncatedResponse) => Ok(true), Err(DnsError::ResponseMismatch | DnsError::NotAResponse | DnsError::UnsupportedOpcode) => { Ok(false) } @@ -1757,6 +1953,7 @@ fn udp_response_matches_classified_query( Err(DnsError::ResponseMismatch | DnsError::NotAResponse | DnsError::UnsupportedOpcode) => { Ok(false) } + Err(DnsError::TruncatedResponse) => Ok(true), Err(error) => Err(AttemptError::InvalidResponse(error)), } } @@ -2315,8 +2512,9 @@ mod tests { fn nameserver(index: u8, transport: DnsTransport) -> DnsNameserver { DnsNameserver { + path: None, transport, - address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, index)), + address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, index)).into(), port: 53, route: crate::config::DnsRoute::Route(crate::config::RouteTargetId::Proxy( crate::config::ProxyId::new(0).unwrap(), @@ -2358,6 +2556,7 @@ mod tests { ipv6, nameservers, nameserver_policies: Vec::new(), + ..DnsConfig::default() } } @@ -2418,6 +2617,103 @@ mod tests { } } + #[tokio::test] + async fn runtime_dns_separates_bootstrap_proxy_direct_and_hosts() { + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +hosts: + '+.local.example': [192.0.2.99] + alias.example: target.local.example +dns: + enable: true + use-system-hosts: false + default-nameserver: [192.0.2.10] + nameserver: [udp://dns.example] + proxy-server-nameserver: [192.0.2.20] + direct-nameserver: [192.0.2.30] +"#, + ) + .unwrap(); + let answers: Vec = ["192.0.2.1", "203.0.113.1", "203.0.113.2", "203.0.113.3"] + .map(|ip| ip.parse().unwrap()) + .to_vec(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + answers + .iter() + .copied() + .map(|ip| MockReply::Response(ResponseSpec::answer(ip))) + .collect(), + vec![], + )); + let dns = Arc::new(RuntimeDns::new(&config.dns, dispatcher.clone())); + assert_eq!(dns.resolve("business.example").await.unwrap(), [answers[1]]); + let resolution = crate::dns::resolution::ResolutionContext::runtime(false); + resolution.bind_runtime(Some(&dns)).unwrap(); + let node = Destination::domain("proxy.example", 443).unwrap(); + assert_eq!( + resolution + .resolve_proxy(&node, TokioInstant::now() + QUERY_TIMEOUT) + .await + .unwrap() + .addresses[0] + .ip(), + answers[2] + ); + assert_eq!( + dns.resolve_direct("direct.example").await.unwrap(), + [answers[3]] + ); + assert_eq!( + dns.resolve("alias.example").await.unwrap(), + ["192.0.2.99".parse::().unwrap()] + ); + assert_eq!( + *dispatcher.udp_destinations.lock().unwrap(), + [ + "192.0.2.10:53", + "192.0.2.1:53", + "192.0.2.20:53", + "192.0.2.30:53" + ] + .map(|address| Destination::Ip(address.parse().unwrap())) + ); + resolution.close(); + assert!( + resolution + .resolve_proxy(&node, TokioInstant::now() + QUERY_TIMEOUT) + .await + .is_err() + ); + } + + #[tokio::test] + async fn concurrent_upstreams_drop_the_losing_attempt() { + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Pending, + MockReply::Response(ResponseSpec::rcode(RCODE_NOERROR)), + ], + vec![], + )); + let dns = RuntimeDns::new( + &config( + false, + vec![ + nameserver(1, DnsTransport::Udp), + nameserver(2, DnsTransport::Udp), + ], + ), + dispatcher.clone(), + ); + tokio::time::timeout(Duration::from_millis(100), dns.resolve("race.example")) + .await + .unwrap() + .unwrap(); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); + assert_eq!(dns.singleflight.len(), 0); + } + #[tokio::test] async fn udp_exchange_uses_internal_context_and_fixed_ip_endpoint() { let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 10)); @@ -2544,7 +2840,7 @@ mod tests { } #[test] - fn typed_udp_ignores_a_mismatched_tc_response_before_rejecting_matching_tc() { + fn typed_udp_ignores_a_mismatched_tc_response_before_retrying_matching_tc() { let query = build_query(88, "mismatch.example", QueryType::A).unwrap(); let parsed_query = parse_query(&query).unwrap(); let mut response = response_for_query(&query, &ResponseSpec::truncated()); @@ -2552,10 +2848,7 @@ mod tests { assert!(!udp_response_matches_query(&parsed_query, &response).unwrap()); response[..2].copy_from_slice(&88_u16.to_be_bytes()); - assert!(matches!( - udp_response_matches_query(&parsed_query, &response), - Err(AttemptError::InvalidResponse(DnsError::TruncatedResponse)) - )); + assert!(udp_response_matches_query(&parsed_query, &response).unwrap()); } #[tokio::test] @@ -2668,21 +2961,18 @@ mod tests { vec![TcpScriptReply::Response(ResponseSpec::answer(address))], ])); let truncated_resolver = RuntimeDns::new( - &config( - false, - vec![ - nameserver(1, DnsTransport::Tcp), - nameserver(2, DnsTransport::Tcp), - ], - ), + &config(false, vec![nameserver(1, DnsTransport::Tcp)]), truncated_dispatcher.clone(), ); truncated_resolver.resolve("warm.example").await.unwrap(); - assert_eq!( + assert!( truncated_resolver .resolve("truncated.example") .await - .unwrap(), + .is_err() + ); + assert_eq!( + truncated_resolver.resolve("fresh.example").await.unwrap(), [address] ); let snapshot = truncated_resolver.tcp_pool.snapshot(); @@ -2702,12 +2992,12 @@ mod tests { ]])); let mut direct_nameserver = nameserver(1, DnsTransport::Tcp); direct_nameserver.route = DnsRoute::Direct; - let mut proxy_nameserver = direct_nameserver; + let mut proxy_nameserver = direct_nameserver.clone(); proxy_nameserver.route = DnsRoute::Route(crate::config::RouteTargetId::Proxy( crate::config::ProxyId::new(0).expect("proxy zero is valid"), )); let resolver = RuntimeDns::new_routed( - &config(false, vec![direct_nameserver]), + &config(false, vec![direct_nameserver.clone()]), proxy.clone(), direct.clone(), RuleSet::compile(vec![rule(RuleKind::Match, RuleAction::Direct)]).unwrap(), @@ -2827,6 +3117,7 @@ mod tests { let mut tasks = Vec::new(); for index in 0..ACTIVE { let resolver = Arc::clone(&resolver); + let nameserver = nameserver.clone(); tasks.push(tokio::spawn(async move { let query = build_query( u16::try_from(index + 1).unwrap(), @@ -2956,7 +3247,7 @@ mod tests { let mut direct_nameserver = nameserver(1, DnsTransport::Udp); direct_nameserver.route = DnsRoute::Direct; let resolver = RuntimeDns::new_routed( - &config(true, vec![direct_nameserver]), + &config(true, vec![direct_nameserver.clone()]), proxy.clone(), direct.clone(), RuleSet::compile(vec![rule( @@ -2981,7 +3272,7 @@ mod tests { let mut rules_nameserver = nameserver(2, DnsTransport::Udp); rules_nameserver.route = DnsRoute::Rules; let resolver = RuntimeDns::new_routed( - &config(false, vec![rules_nameserver]), + &config(false, vec![rules_nameserver.clone()]), proxy.clone(), direct.clone(), RuleSet::compile(vec![ @@ -3010,7 +3301,7 @@ mod tests { )); let proxy = Arc::new(MockDispatcher::default()); let resolver = RuntimeDns::new_routed( - &config(false, vec![rules_nameserver]), + &config(false, vec![rules_nameserver.clone()]), proxy.clone(), direct.clone(), RuleSet::compile(vec![ @@ -3115,14 +3406,14 @@ dns: for query_type in [1, 65] { let main = nameserver(1, DnsTransport::Udp); let policy = nameserver(2, DnsTransport::Udp); - let mut dns_config = config(false, vec![main]); + let mut dns_config = config(false, vec![main.clone()]); dns_config.nameserver_policies = if separate_policies { vec![ dns_policy(&["cn@ads"], vec![nameserver(3, DnsTransport::Udp)]), - dns_policy(&["cn"], vec![policy]), + dns_policy(&["cn"], vec![policy.clone()]), ] } else { - vec![dns_policy(&["cn@ads", "cn"], vec![policy])] + vec![dns_policy(&["cn@ads", "cn"], vec![policy.clone()])] }; let root = tempfile::tempdir().unwrap(); // One CN Domain record: example.cn, with no attributes. @@ -3182,13 +3473,10 @@ dns: assert_eq!(response[3] & 0x0f, RCODE_NOERROR); assert!(released_before_upstream_reply); - let expected = if starts_empty { main } else { policy }; + let expected = if starts_empty { &main } else { &policy }; assert_eq!( *dispatcher.udp_destinations.lock().unwrap(), - vec![Destination::Ip(SocketAddr::new( - expected.address, - expected.port - ))], + vec![Destination::Ip(expected.endpoint())], "starts_empty={starts_empty}, separate_policies={separate_policies}, query_type={query_type}", ); @@ -3198,15 +3486,12 @@ dns: .exchange(&opaque_query(61, "next.example.cn", query_type)) .await .unwrap(); - let next_expected = if starts_empty { policy } else { main }; + let next_expected = if starts_empty { &policy } else { &main }; assert_eq!( *dispatcher.udp_destinations.lock().unwrap(), vec![ - Destination::Ip(SocketAddr::new(expected.address, expected.port)), - Destination::Ip(SocketAddr::new( - next_expected.address, - next_expected.port - )), + Destination::Ip(expected.endpoint()), + Destination::Ip(next_expected.endpoint()), ], ); } @@ -3233,6 +3518,7 @@ dns: vec![MockReply::Response(ResponseSpec::answer(v4_answer))], )); let main = DnsNameserver { + path: None, transport: DnsTransport::Tcp, address: "1.1.1.1".parse().unwrap(), port: 53, @@ -3241,12 +3527,13 @@ dns: )), }; let policy_nameserver = DnsNameserver { + path: None, transport: DnsTransport::Tcp, address: "223.5.5.5".parse().unwrap(), port: 53, route: DnsRoute::Direct, }; - let mut dns_config = config(true, vec![main]); + let mut dns_config = config(true, vec![main.clone()]); dns_config.nameserver_policies = vec![dns_policy( &["private", "cn", "apple"], vec![policy_nameserver], @@ -3479,7 +3766,7 @@ dns: let mut rules_nameserver = nameserver(2, DnsTransport::Udp); rules_nameserver.route = DnsRoute::Rules; let resolver = RuntimeDns::new_routed( - &config(false, vec![rules_nameserver]), + &config(false, vec![rules_nameserver.clone()]), proxy.clone(), direct.clone(), RuleSet::compile(vec![ @@ -3522,8 +3809,9 @@ dns: let proxy = Arc::new(MockDispatcher::default()); let direct: Arc = Arc::new(DirectOutbound::new(Dialer::default())); let udp_nameserver = DnsNameserver { + path: None, transport: DnsTransport::Udp, - address: udp_address.ip(), + address: udp_address.ip().into(), port: udp_address.port(), route: DnsRoute::Direct, }; @@ -3564,8 +3852,9 @@ dns: stream.write_all(&response).await.unwrap(); }); let tcp_nameserver = DnsNameserver { + path: None, transport: DnsTransport::Tcp, - address: tcp_address.ip(), + address: tcp_address.ip().into(), port: tcp_address.port(), route: DnsRoute::Direct, }; @@ -3666,74 +3955,26 @@ dns: } #[tokio::test] - async fn opaque_udp_tc_fails_over_without_opening_tcp() { - let dispatcher = Arc::new(MockDispatcher::with_replies( - vec![ - MockReply::Response(ResponseSpec::truncated()), - MockReply::Response(ResponseSpec::rcode(RCODE_NOERROR)), - ], - vec![], - )); - let resolver = RuntimeDns::new( - &config( - false, - vec![ - nameserver(7, DnsTransport::Udp), - nameserver(8, DnsTransport::Udp), - ], - ), - dispatcher.clone(), - ); - let query = opaque_query(20, "txt.example", 16); - let classified = classify_query(&query).unwrap(); - let response = resolver.exchange(&query).await.unwrap(); - assert_eq!( - validate_opaque_response(&classified, &response) - .unwrap() - .rcode(), - u16::from(RCODE_NOERROR) - ); - assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); - assert_eq!(dispatcher.tcp_calls.load(Ordering::Acquire), 0); - assert_eq!( - *dispatcher.udp_destinations.lock().unwrap(), - vec![ - Destination::Ip("192.0.2.7:53".parse().unwrap()), - Destination::Ip("192.0.2.8:53".parse().unwrap()), - ] - ); - } - - #[tokio::test] - async fn all_opaque_udp_tc_responses_converge_to_servfail_without_tcp() { - let dispatcher = Arc::new(MockDispatcher::with_replies( - vec![ - MockReply::Response(ResponseSpec::truncated()), - MockReply::Response(ResponseSpec::truncated()), - ], - vec![], - )); - let resolver = RuntimeDns::new( - &config( - false, - vec![ - nameserver(7, DnsTransport::Udp), - nameserver(8, DnsTransport::Udp), - ], - ), - dispatcher.clone(), - ); - let query = opaque_query(21, "txt.example", 16); - let classified = classify_query(&query).unwrap(); - let response = resolver.exchange(&query).await.unwrap(); - assert_eq!( - validate_opaque_response(&classified, &response) - .unwrap() - .rcode(), - u16::from(RCODE_SERVFAIL) - ); - assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); - assert_eq!(dispatcher.tcp_calls.load(Ordering::Acquire), 0); + async fn udp_truncation_retries_the_same_upstream_over_tcp() { + for query_type in [1, 16] { + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![MockReply::Response(ResponseSpec::truncated())], + vec![MockReply::Response(ResponseSpec::rcode(RCODE_NOERROR))], + )); + let resolver = RuntimeDns::new( + &config(false, vec![nameserver(7, DnsTransport::Udp)]), + dispatcher.clone(), + ); + let query = opaque_query(20, "answer.example", query_type); + let response = resolver.exchange(&query).await.unwrap(); + assert_eq!(response[3] & 0xf, 0); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 1); + assert_eq!(dispatcher.tcp_calls.load(Ordering::Acquire), 1); + assert_eq!( + *dispatcher.udp_destinations.lock().unwrap(), + *dispatcher.tcp_destinations.lock().unwrap() + ); + } } #[tokio::test] @@ -4054,66 +4295,6 @@ dns: ); } - #[tokio::test] - async fn udp_tc_fails_over_without_opening_tcp() { - let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 16)); - let dispatcher = Arc::new(MockDispatcher::with_replies( - vec![ - MockReply::Response(ResponseSpec::truncated()), - MockReply::Response(ResponseSpec::answer(address)), - ], - vec![], - )); - let resolver = RuntimeDns::new( - &config( - false, - vec![ - nameserver(7, DnsTransport::Udp), - nameserver(8, DnsTransport::Udp), - ], - ), - dispatcher.clone(), - ); - - assert_eq!(resolver.resolve("example.com").await.unwrap(), [address]); - assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); - assert_eq!(dispatcher.tcp_calls.load(Ordering::Acquire), 0); - assert_eq!( - *dispatcher.udp_destinations.lock().unwrap(), - vec![ - Destination::Ip("192.0.2.7:53".parse().unwrap()), - Destination::Ip("192.0.2.8:53".parse().unwrap()), - ] - ); - } - - #[tokio::test] - async fn all_udp_tc_responses_converge_to_servfail_without_tcp() { - let dispatcher = Arc::new(MockDispatcher::with_replies( - vec![ - MockReply::Response(ResponseSpec::truncated()), - MockReply::Response(ResponseSpec::truncated()), - ], - vec![], - )); - let resolver = RuntimeDns::new( - &config( - false, - vec![ - nameserver(7, DnsTransport::Udp), - nameserver(8, DnsTransport::Udp), - ], - ), - dispatcher.clone(), - ); - - let query = build_query(7, "example.com", QueryType::A).unwrap(); - let response = resolver.exchange(&query).await.unwrap(); - assert_eq!(parse_response(&response).unwrap().rcode, RCODE_SERVFAIL); - assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); - assert_eq!(dispatcher.tcp_calls.load(Ordering::Acquire), 0); - } - #[tokio::test] async fn configured_tcp_tc_response_fails_over() { let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 18)); diff --git a/src/dns/tests_encrypted.rs b/src/dns/tests_encrypted.rs new file mode 100644 index 0000000..17ffe59 --- /dev/null +++ b/src/dns/tests_encrypted.rs @@ -0,0 +1,102 @@ +use super::*; +use crate::dispatch::DatagramTransport; +use async_trait::async_trait; + +struct Peer { + tls: Arc, + https: bool, + opens: AtomicUsize, + tasks: StdMutex>>, +} +#[async_trait] +impl Dispatcher for Peer { + async fn connect_tcp(&self, _: StreamSession) -> Result { + self.opens.fetch_add(1, Ordering::Relaxed); + let (client, peer) = tokio::io::duplex(64 * 1024); + let tls = self.tls.clone(); + let https = self.https; + self.tasks.lock().unwrap().push(tokio::spawn(async move { + let Ok(mut stream) = tokio_rustls::TlsAcceptor::from(tls).accept(peer).await else { return; }; + for _ in 0..2 { + let length = if https { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + assert!(head.len() < 4096); + } + let head = String::from_utf8(head).unwrap(); + assert!(head.starts_with("POST /dns-query HTTP/1.1\r\n")); + head.lines().find_map(|line| line.strip_prefix("Content-Length: ")).unwrap().parse::().unwrap() + } else { stream.read_u16().await.unwrap() as usize }; + let mut query = vec![0; length]; + stream.read_exact(&mut query).await.unwrap(); + let question = classify_query(&query).unwrap(); + let response = super::super::synthesize_empty_response(&question, 0).unwrap(); + if https { + // Chunked replies exercise persistence without detached HTTP drivers. + stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/dns-message\r\nTransfer-Encoding: chunked\r\n\r\n{:x}\r\n", response.len()).as_bytes()).await.unwrap(); + stream.write_all(&response).await.unwrap(); + stream.write_all(b"\r\n0\r\n\r\n").await.unwrap(); + } else { + stream.write_u16(response.len() as u16).await.unwrap(); + stream.write_all(&response).await.unwrap(); + } + stream.flush().await.unwrap(); + } + })); + Ok(Box::new(client)) + } + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} +impl Peer { + async fn join(&self) { + let tasks = std::mem::take(&mut *self.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + } +} + +#[tokio::test] +async fn encrypted_dns_authenticates_and_reuses_tls_and_https_streams() { + let certificate = rcgen::generate_simple_self_signed(vec!["dns.example".into()]).unwrap(); + let server = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![certificate.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into()), + ) + .unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(certificate.cert.der().clone()).unwrap(); + for upstream in ["tls://dns.example", "https://dns.example/dns-query"] { + let config = crate::config::Config::parse_yaml(format!("mixed-port: 1080\nhosts: {{dns.example: 192.0.2.1}}\ndns:\n enable: true\n use-system-hosts: false\n nameserver: ['{upstream}']\n").as_bytes()).unwrap(); + let peer = Arc::new(Peer { + tls: Arc::new(server.clone()), + https: upstream.starts_with("https"), + opens: AtomicUsize::new(0), + tasks: StdMutex::new(Vec::new()), + }); + let mut dns = RuntimeDns::new(&config.dns, peer.clone()); + Arc::get_mut(&mut dns.tcp_pool).unwrap().security = + crate::security::SecurityContext::with_tls_roots(roots.clone()); + assert!(dns.resolve("first.example").await.unwrap().is_empty()); + assert!(dns.resolve("second.example").await.unwrap().is_empty()); + assert_eq!(peer.opens.load(Ordering::Relaxed), 1); + assert_eq!(dns.tcp_pool.snapshot().reuse_hits, 1); + peer.join().await; + // The same peer's untrusted certificate cannot reach a DNS exchange. + let untrusted = RuntimeDns::new(&config.dns, peer.clone()); + assert!(untrusted.resolve("untrusted.example").await.is_err()); + peer.join().await; + } +} diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index af93f70..3a68fb2 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -431,6 +431,20 @@ pub struct GeoRequirements { } impl GeoRequirements { + pub(crate) fn for_config(config: &crate::config::Config) -> Result { + let policies: Vec<_> = config + .dns + .nameserver_policies + .iter() + .chain(&config.dns.proxy_policies) + .map(|policy| DnsNameserverPolicy { + selector: policy.selector.clone(), + nameservers: Box::new([]), + }) + .collect(); + Self::collect(&config.rules, &policies) + } + /// Collects all GeoSite and GeoIP selectors used by business rules and DNS /// nameserver policies. Each underlying category is decoded only once. pub fn collect( diff --git a/src/lib.rs b/src/lib.rs index 3e4c684..0d22dbc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,14 +46,6 @@ pub mod routing; any(feature = "invoke", test) ))] mod runtime; -#[cfg(any( - feature = "outbound-anytls", - feature = "outbound-vless", - feature = "stream-transport", - feature = "outbound-hysteria2", - feature = "outbound-tuic", - feature = "shadow-tls-v3" -))] pub mod security; pub mod session; #[cfg(any( diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index ac845ba..3e1e732 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -476,12 +476,15 @@ impl SelectUpstream { enum ResolvedUpstream<'a> { Direct(&'a ResolvedEndpoint, &'a Dialer), + LazyDirect(&'a Dialer), Proxy(&'a Arc), } /// The path used by one configured proxy to reach its own server. #[derive(Clone)] pub enum UpstreamPath { + /// Physical endpoint resolution belongs to the current runtime DNS role. + LazyDirect { dialer: Dialer }, /// The physical first hop. Its endpoint was resolved during prepare. Direct { endpoint: ResolvedEndpoint, @@ -507,6 +510,7 @@ impl std::fmt::Debug for UpstreamPath { .field("endpoint", endpoint) .field("dialer", dialer) .finish(), + Self::LazyDirect { .. } => formatter.write_str("LazyDirectUpstream(..)"), Self::Proxy(_) => formatter.write_str("ProxyUpstream(..)"), Self::Group { .. } => formatter.write_str("GroupUpstream(..)"), } @@ -527,13 +531,14 @@ impl UpstreamPath { pub(crate) fn is_direct(&self, context: &EstablishContext) -> Result { Ok(matches!( self.resolve(context)?, - ResolvedUpstream::Direct(..) + ResolvedUpstream::Direct(..) | ResolvedUpstream::LazyDirect(..) )) } fn resolve(&self, context: &EstablishContext) -> Result, DispatchError> { match self { Self::Direct { endpoint, dialer } => Ok(ResolvedUpstream::Direct(endpoint, dialer)), + Self::LazyDirect { dialer } => Ok(ResolvedUpstream::LazyDirect(dialer)), Self::Proxy(connector) => Ok(ResolvedUpstream::Proxy(connector)), Self::Group { group, @@ -544,9 +549,7 @@ impl UpstreamPath { SelectUpstreamMember::Direct => endpoint .as_ref() .map(|endpoint| ResolvedUpstream::Direct(endpoint, dialer)) - .ok_or_else(|| { - DispatchError::Other("DIRECT upstream has no prepared endpoint".to_owned()) - }), + .map_or_else(|| Ok(ResolvedUpstream::LazyDirect(dialer)), Ok), SelectUpstreamMember::Reject => Err(DispatchError::NotAllowed), SelectUpstreamMember::Group(_) => { unreachable!("upstream resolution returns a leaf") @@ -575,6 +578,21 @@ impl UpstreamPath { effective_peer, }) } + ResolvedUpstream::LazyDirect(dialer) => { + let endpoint = context + .resolution + .resolve_proxy(server, context.deadline) + .await?; + let stream = context + .run_io("physical proxy-server connect", dialer.connect(&endpoint)) + .await?; + let effective_peer = + Destination::Ip(stream.peer_addr().map_err(DispatchError::from)?); + Ok(ConnectedStream { + io: Box::new(stream), + effective_peer, + }) + } ResolvedUpstream::Proxy(connector) => connector.connect_stream(session, context).await, } } @@ -585,7 +603,7 @@ impl UpstreamPath { context: &EstablishContext, ) -> Result, DispatchError> { match self.resolve(context)? { - ResolvedUpstream::Direct(_, dialer) => { + ResolvedUpstream::Direct(_, dialer) | ResolvedUpstream::LazyDirect(dialer) => { let direct = DirectOutbound::new(dialer.clone()); OutboundConnector::open_datagram(&direct, request, context).await } @@ -593,7 +611,7 @@ impl UpstreamPath { } } - /// Pins a native UDP proxy server without performing DNS after prepare. + /// Pins a native UDP server using the node resolver when first needed. /// Resolving here and opening IO below share the context's group snapshot. #[cfg(any( feature = "outbound-shadowsocks", @@ -601,7 +619,7 @@ impl UpstreamPath { feature = "outbound-hysteria2", feature = "outbound-tuic" ))] - pub(crate) fn datagram_server( + pub(crate) async fn datagram_server( &self, server: &Destination, context: &EstablishContext, @@ -616,6 +634,15 @@ impl UpstreamPath { .map(Destination::Ip) .ok_or(DispatchError::HostUnreachable) } + ResolvedUpstream::LazyDirect(_) => context + .resolution + .resolve_proxy(server, context.deadline) + .await? + .addresses + .first() + .copied() + .map(Destination::Ip) + .ok_or(DispatchError::HostUnreachable), ResolvedUpstream::Proxy(_) => Ok(server.clone()), } } diff --git a/src/outbound/hysteria2/paths.rs b/src/outbound/hysteria2/paths.rs index dd95189..5875cd1 100644 --- a/src/outbound/hysteria2/paths.rs +++ b/src/outbound/hysteria2/paths.rs @@ -34,7 +34,7 @@ impl Paths { context: EstablishContext, bandwidth: Arc, ) -> Result<(Self, Arc), DispatchError> { - let destination = upstream.datagram_server(server, &context)?; + let destination = upstream.datagram_server(server, &context).await?; let peer = context.resolve_ip(&destination).await?; let mut paths = Self { config, diff --git a/src/outbound/shadowsocks/mod.rs b/src/outbound/shadowsocks/mod.rs index 6ce6f7f..40bb744 100644 --- a/src/outbound/shadowsocks/mod.rs +++ b/src/outbound/shadowsocks/mod.rs @@ -141,7 +141,7 @@ impl OutboundConnector for ShadowsocksOutbound { if let Some(uot) = &self.uot { return uot.open(self, request, context).await; } - let server = self.upstream.datagram_server(&self.server, context)?; + let server = self.upstream.datagram_server(&self.server, context).await?; let maximum = request.max_response_payload_size(); let wire_maximum = maximum.saturating_add(datagram::MAX_RESPONSE_HEADER); let inner = self diff --git a/src/outbound/tuic/outbound.rs b/src/outbound/tuic/outbound.rs index 36c0a36..b3c94ef 100644 --- a/src/outbound/tuic/outbound.rs +++ b/src/outbound/tuic/outbound.rs @@ -245,7 +245,7 @@ async fn drive( let activity = Arc::new(Activity::default()); let operation = async { let handshake = observation::track(ResourceKind::Handshake); - let destination = upstream.datagram_server(&server, &context)?; + let destination = upstream.datagram_server(&server, &context).await?; let peer = context.resolve_ip(&destination).await?; let budget = DatagramBudget::new( crate::limits::TUIC_QUIC_PAYLOAD as u16, diff --git a/src/outbound/vless/outbound.rs b/src/outbound/vless/outbound.rs index 7e604e8..732c422 100644 --- a/src/outbound/vless/outbound.rs +++ b/src/outbound/vless/outbound.rs @@ -360,7 +360,7 @@ impl VlessOutbound { return Ok(None); }; match primary { - UpstreamPath::Proxy(_) => Ok(Some(primary.clone())), + UpstreamPath::Proxy(_) | UpstreamPath::LazyDirect { .. } => Ok(Some(primary.clone())), UpstreamPath::Direct { endpoint, .. } | UpstreamPath::Group { endpoint: Some(endpoint), @@ -661,7 +661,7 @@ impl VlessOutbound { .map(TransportIo::Stream); } let (tls, server_name) = leg.security.quic_config()?; - let server = leg.upstream.datagram_server(&leg.server, context)?; + let server = leg.upstream.datagram_server(&leg.server, context).await?; let peer = context.resolve_ip(&server).await?; let budget = DatagramBudget::new(1400, 1400); let request = diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 4de35da..fa0986a 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -37,6 +37,12 @@ const UDP_FLOW_CLEANUP_INTERVAL: Duration = Duration::from_secs(10); #[async_trait] trait RoutingDns: Send + Sync { + fn is_enabled(&self) -> bool { + true + } + async fn resolve_direct(&self, host: &str) -> Result, DispatchError> { + self.resolve(host).await + } async fn resolve(&self, host: &str) -> Result, DispatchError>; async fn exchange(&self, query: &[u8]) -> Result; async fn domain_hint(&self, address: IpAddr) -> Option; @@ -44,6 +50,14 @@ trait RoutingDns: Send + Sync { #[async_trait] impl RoutingDns for RuntimeDns { + fn is_enabled(&self) -> bool { + RuntimeDns::is_enabled(self) + } + async fn resolve_direct(&self, host: &str) -> Result, DispatchError> { + RuntimeDns::resolve_direct(self, host) + .await + .map_err(runtime_dns_error) + } async fn resolve(&self, host: &str) -> Result, DispatchError> { RuntimeDns::resolve(self, host) .await @@ -349,7 +363,13 @@ impl RoutingDispatcher { preferred_address(addresses) } else { let addresses = match &self.dns { - Some(dns) => self.resolve_addresses(dns, &host).await, + Some(dns) => dns + .resolve_direct(&host) + .await + .unwrap_or_default() + .into_iter() + .filter(|ip| self.ipv6 || ip.is_ipv4()) + .collect(), None => Vec::new(), }; let address = preferred_address(&addresses); @@ -495,7 +515,7 @@ impl Dispatcher for RoutingDispatcher { self.ensure_destination_allowed(&session.destination)?; if session.inbound == InboundKind::Tun && matches!(&session.destination, Destination::Ip(address) if self.tun_config.hijacks_dns(*address)) - && let Some(dns) = &self.dns + && let Some(dns) = self.dns.as_ref().filter(|dns| dns.is_enabled()) { return Ok(self.dns_tcp_stream(dns.clone())); } diff --git a/src/runtime.rs b/src/runtime.rs index c77a5e9..6f6ce04 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -96,7 +96,7 @@ use crate::security::StandardTlsClient; ))] use crate::security::{SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}; -/// Parsed configuration plus the bootstrap-resolved physical proxy roots. +/// Validated configuration; physical server names resolve on first use. #[derive(Debug)] pub(crate) struct PreparedCore { config: Config, @@ -126,12 +126,9 @@ pub(crate) struct PreparedMeasurement { limits: ResourceLimits, } -/// Bootstrap-resolved physical destinations for one proxy graph node. -/// -/// Proxy-only upstreams keep both entries empty. Nodes with a possible DIRECT -/// group path are also physical roots, regardless of initial selection. They retain a -/// primary endpoint and, when XHTTP download settings are present, a second -/// endpoint for the independent download leg. +/// Pre-resolved physical destinations used by standalone measurements. +/// Running sessions leave these empty and resolve node endpoints on demand. +/// XHTTP measurements can retain a separate endpoint for the download leg. #[derive(Debug, Clone, Default)] struct PreparedProxyEndpoints { upload: Option, @@ -271,9 +268,8 @@ impl PreparedCore { .validate() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let (rules, geodata_registration) = prepare_routing(&mut config, &geodata_manager)?; - let endpoints = - prepare_proxy_endpoints(&config.proxies, &config.proxy_groups, resolver, config.ipv6) - .await?; + let _ = resolver; + let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; let traffic_stats = config .tun .enable @@ -292,7 +288,7 @@ impl PreparedCore { /// Performs every configuration-owned validation step without resolving /// the proxy, opening GeoData files, or retaining a matcher. pub(crate) fn validate_config(mut config: Config) -> io::Result<()> { - GeoRequirements::collect(&config.rules, &config.dns.nameserver_policies) + GeoRequirements::for_config(&config) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; RuleSet::compile(config.take_runtime_rules()) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; @@ -383,17 +379,19 @@ impl PreparedCore { let rules = self.rules.clone(); let redir_host_entries = self.dns_redir_host_entries(); let geo_matcher: Arc = self.geodata_registration.matcher(); - let dns = self.config.dns.enable.then(|| { - Arc::new(RuntimeDns::new_routed_proxies_with_cache_limits( - &self.config.dns, - route_targets.clone(), - direct.clone(), - rules.clone(), - geo_matcher.clone(), - self.limits.dns_address_cache_entries, - redir_host_entries, - )) - }); + let mut dns_config = self.config.dns.clone(); + if !dns_config.enable { + dns_config.ipv6 = self.config.ipv6; + } + let dns = Some(Arc::new(RuntimeDns::new_routed_proxies_with_cache_limits( + &dns_config, + route_targets.clone(), + direct.clone(), + rules.clone(), + geo_matcher.clone(), + self.limits.dns_address_cache_entries, + redir_host_entries, + ))); proxy_graph.resolution.bind_runtime(dns.as_ref())?; let geodata_dispatcher: Arc = Arc::new(RoutingDispatcher::new_with_ipv6( route_targets.clone(), @@ -1011,14 +1009,12 @@ fn build_upstream_path( endpoint, dialer: dialer.clone(), }), - None => endpoint - .map(|endpoint| UpstreamPath::direct(endpoint, dialer.clone())) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - "physical proxy root has no prepared endpoint", - ) - }), + None => Ok(endpoint.map_or_else( + || UpstreamPath::LazyDirect { + dialer: dialer.clone(), + }, + |endpoint| UpstreamPath::direct(endpoint, dialer.clone()), + )), } } @@ -1192,7 +1188,7 @@ fn prepare_routing( config: &mut Config, geodata_manager: &Arc, ) -> io::Result<(RuleSet, GeoDataRegistration)> { - let requirements = GeoRequirements::collect(&config.rules, &config.dns.nameserver_policies) + let requirements = GeoRequirements::for_config(config) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let geodata_registration = geodata_manager .register(requirements) @@ -1743,7 +1739,7 @@ rules: } #[tokio::test] - async fn prepare_resolves_the_sole_outbound() { + async fn prepare_defers_proxy_endpoint_resolution() { let prepared = PreparedCore::prepare(CONFIG.as_bytes(), &FixedResolver, ResourceLimits::default()) .await @@ -1754,7 +1750,7 @@ rules: prepared.proxy(id).protocol, ProxyProtocol::Vless(_) )); - assert_eq!(prepared.endpoint(id).unwrap().logical_host, "server.test"); + assert!(prepared.endpoint(id).is_none()); assert!(!prepared.has_tun()); assert!( prepared.config.rules.is_empty(), @@ -1771,7 +1767,7 @@ rules: } #[tokio::test] - async fn prepare_reuses_one_resolution_for_an_identical_download_server() { + async fn prepare_defers_identical_download_endpoints() { let resolver = RecordingResolver::default(); let yaml = CONFIG.replace( " mode: packet-up\n", @@ -1780,12 +1776,11 @@ rules: let prepared = PreparedCore::prepare(yaml.as_bytes(), &resolver, ResourceLimits::default()) .await .unwrap(); - assert_eq!(*resolver.hosts.lock().unwrap(), ["server.test".to_owned()]); + assert!(resolver.hosts.lock().unwrap().is_empty()); let id = ProxyId::new(0).unwrap(); - let upload = prepared.endpoint(id).unwrap(); - let download = prepared.download_endpoint(id).unwrap(); - assert_eq!(upload, download); + assert!(prepared.endpoint(id).is_none()); + assert!(prepared.download_endpoint(id).is_none()); assert_eq!( prepared.build_proxy_graph(Dialer::default()).unwrap().len(), 1 @@ -1793,19 +1788,17 @@ rules: } #[tokio::test] - async fn prepare_resolves_distinct_download_server_concurrently() { + async fn prepare_defers_distinct_download_endpoints() { let resolver = ConcurrentResolver::default(); let yaml = config_with_download(" server: download.test\n port: 8443"); let prepared = PreparedCore::prepare(yaml.as_bytes(), &resolver, ResourceLimits::default()) .await .unwrap(); - assert_eq!(resolver.peak.load(Ordering::Acquire), 2); + assert_eq!(resolver.peak.load(Ordering::Acquire), 0); let id = ProxyId::new(0).unwrap(); - assert_eq!(prepared.endpoint(id).unwrap().logical_host, "server.test"); - let download = prepared.download_endpoint(id).unwrap(); - assert_eq!(download.logical_host, "download.test"); - assert_eq!(download.port, 8443); + assert!(prepared.endpoint(id).is_none()); + assert!(prepared.download_endpoint(id).is_none()); assert_eq!( prepared.build_proxy_graph(Dialer::default()).unwrap().len(), 1 @@ -2088,7 +2081,7 @@ rules: } #[tokio::test] - async fn prepare_resolves_both_independent_proxy_roots() { + async fn prepare_defers_independent_proxy_roots() { let resolver = RecordingResolver::default(); let prepared = PreparedCore::prepare( two_socks_config(false).as_bytes(), @@ -2099,14 +2092,14 @@ rules: .unwrap(); let mut hosts = resolver.hosts.lock().unwrap().clone(); hosts.sort(); - assert_eq!(hosts, ["a.test".to_owned(), "b.test".to_owned()]); - assert!(prepared.endpoint(ProxyId::new(0).unwrap()).is_some()); - assert!(prepared.endpoint(ProxyId::new(1).unwrap()).is_some()); + assert!(hosts.is_empty()); + assert!(prepared.endpoint(ProxyId::new(0).unwrap()).is_none()); + assert!(prepared.endpoint(ProxyId::new(1).unwrap()).is_none()); } #[cfg(feature = "outbound-socks5")] #[tokio::test] - async fn prepare_resolves_only_the_physical_chain_root() { + async fn prepare_defers_the_physical_chain_root() { let resolver = RecordingResolver::default(); let prepared = PreparedCore::prepare( two_socks_config(true).as_bytes(), @@ -2115,9 +2108,9 @@ rules: ) .await .unwrap(); - assert_eq!(*resolver.hosts.lock().unwrap(), ["b.test".to_owned()]); + assert!(resolver.hosts.lock().unwrap().is_empty()); assert!(prepared.endpoint(ProxyId::new(0).unwrap()).is_none()); - assert!(prepared.endpoint(ProxyId::new(1).unwrap()).is_some()); + assert!(prepared.endpoint(ProxyId::new(1).unwrap()).is_none()); assert_eq!( prepared.build_proxy_graph(Dialer::default()).unwrap().len(), 2 @@ -2161,10 +2154,7 @@ rules: let prepared = PreparedCore::prepare(yaml.as_bytes(), &resolver, ResourceLimits::default()) .await .unwrap(); - assert_eq!( - *resolver.hosts.lock().unwrap(), - ["socks-root.test".to_owned()] - ); + assert!(resolver.hosts.lock().unwrap().is_empty()); let child = ProxyId::new(0).unwrap(); assert!(prepared.endpoint(child).is_none()); assert!(prepared.download_endpoint(child).is_none()); diff --git a/src/security/context.rs b/src/security/context.rs index 256d143..f119279 100644 --- a/src/security/context.rs +++ b/src/security/context.rs @@ -30,8 +30,8 @@ impl SecurityContext { } } - #[cfg(feature = "interop-test")] - pub(super) fn with_tls_roots(tls_roots: RootCertStore) -> Self { + #[cfg(any(test, feature = "interop-test"))] + pub(crate) fn with_tls_roots(tls_roots: RootCertStore) -> Self { Self { provider: Arc::new(rustls::crypto::ring::default_provider()), tls_roots: Arc::new(tls_roots), diff --git a/src/tun_runtime.rs b/src/tun_runtime.rs index 6605be9..0c99959 100644 --- a/src/tun_runtime.rs +++ b/src/tun_runtime.rs @@ -151,7 +151,7 @@ impl TunRuntime { limits, config: config.clone(), dispatcher, - dns, + dns: dns.filter(|dns| dns.is_enabled()), ipv6, fake_icmp_echo, sniffer, diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index 40d1762..21e74e5 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -2244,12 +2244,14 @@ fn test_runtime_dns(dispatcher: Arc) -> Arc { enable: true, ipv6: true, nameservers: vec![DnsNameserver { + path: None, transport: DnsTransport::Udp, address: Ipv4Addr::new(198, 51, 100, 53).into(), port: 53, route: DnsRoute::Direct, }], nameserver_policies: Vec::new(), + ..DnsConfig::default() }; let proxies = ProxyDispatchers::new(vec![dispatcher.clone()]).unwrap(); let rules = RuleSet::compile(vec![RuleSpec { diff --git a/tests/resolution_foundations.rs b/tests/resolution_foundations.rs index 63d0c66..c50fea1 100644 --- a/tests/resolution_foundations.rs +++ b/tests/resolution_foundations.rs @@ -180,12 +180,14 @@ async fn runtime_uses_configured_dns_and_weak_binding_releases_owner() { enable: true, ipv6: false, nameservers: vec![DnsNameserver { + path: None, transport: DnsTransport::Udp, - address: address.ip(), + address: address.ip().into(), port: address.port(), route: DnsRoute::Direct, }], nameserver_policies: vec![], + ..DnsConfig::default() }; let dns = Arc::new(RuntimeDns::new( &config, @@ -263,12 +265,14 @@ async fn recursive_dns_dependency_fails_before_connecting_or_joining_its_own_fli enable: true, ipv6: false, nameservers: vec![DnsNameserver { + path: None, transport: DnsTransport::Udp, address: "192.0.2.1".parse().unwrap(), port: 53, route: DnsRoute::Direct, }], nameserver_policies: vec![], + ..DnsConfig::default() }; let dns = Arc::new(RuntimeDns::new(&config, egress)); resolution.bind_runtime(Some(&dns)).unwrap(); From ede0d77cea675d67b40bbcbf9e146ba6e0258b75 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 17:42:39 +0800 Subject: [PATCH 004/445] Share resource downloads and configure GeoData updates --- docs/config.yaml | 11 +- docs/geodata.md | 12 +- src/config/mod.rs | 116 ++-- src/geodata/manager.rs | 58 +- src/geodata/mod.rs | 1 - src/geodata/service.rs | 63 +- src/lib.rs | 1 + src/{geodata/updater.rs => resource/http.rs} | 587 ++++++++++++------- src/resource/mod.rs | 2 + src/runtime.rs | 24 +- tests/README.md | 1 + 11 files changed, 563 insertions(+), 313 deletions(-) rename src/{geodata/updater.rs => resource/http.rs} (71%) create mode 100644 src/resource/mod.rs diff --git a/docs/config.yaml b/docs/config.yaml index 3c6d243..b4c1ed2 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -20,11 +20,12 @@ ipv6: true mode: rule log-level: info -# GeoData 下载配置可省略;出现时三项必须齐全。URL 必须是以域名为主机、无 -# userinfo/fragment 的绝对 HTTPS URL,更新周期固定为 24 小时。validateConfig 和 -# measureDelay 不下载。公共实例启动后,只有实际规则需要资产且自动更新已启用时 -# 才后台检查。下载按当前 mode 和业务规则选路,未命中 DIRECT;代理组在 -# 创建新物理 transport 时解析当前成员,不自动故障转移,也不隐式回退 DIRECT。 +# 资源下载使用 global-ua(缺省 Vole/ 加 Core 版本);etag-support 缺省 true。 +# GeoData 字段独立可选;URL 缺省 MetaCubeX meta-rules-dat latest DAT。 +# URL 以域名为主机、无 userinfo/fragment,生产构建使用 HTTPS。 +# 自动更新默认 false;启动后仍获取缺失的必需资产。周期单位小时,默认 24,0 关闭周期检查。 +global-ua: "Vole/0.1.0" +etag-support: true geox-url: geoip: https://assets.example.com/geoip.dat geosite: https://assets.example.com/geosite.dat diff --git a/docs/geodata.md b/docs/geodata.md index a8535f2..438c5d3 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -64,7 +64,9 @@ Unicode 版本的 Mihomo 在所有新字符上完全等价。 ## 自动更新 -更新配置必须整体出现或整体省略: +HTTP 传输由共享资源模块维护,所有跳转经过当前 Core dispatcher。下载、管理阻塞任务和解析均在停止屏障内;取消及下载 future 被丢弃时清理未发布的暂存文件。 + +三个字段独立可选。缺省使用 MetaCubeX meta-rules-dat 的 latest GeoIP/GeoSite DAT URL;自动更新默认关闭,周期默认 24 小时: ```yaml geox-url: @@ -77,11 +79,11 @@ geo-update-interval: 24 约束: - `geox-url` 只能包含 `geoip` 和 `geosite`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 -- `geo-update-interval` 只接受整数 `24`。 -- `validateConfig` 和 `prepare` 不下载。实例启动后,只有自动更新已开启且规则实际需要资产时才运行更新任务。 +- `geo-update-interval` 单位为小时,支持非负整数;0 停用周期更新。 +- `validateConfig` 和准备阶段不下载。运行后仅为当前引用的资产启动资源任务;缺失或不可用时获取默认或配置的 URL,自动更新控制可用资产的周期检查。 - 下载按当前 mode 与业务规则选路,未命中使用 DIRECT;可选目标包含内置动作、节点和代理组。选中代理组时,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 -- 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复 24 小时周期。 -- ETag、SHA-256、源 URL 和下次检查时间保存在 Vole 管理的状态中。合法 304 在匹配器 +- 缺失资产立即检查;失败后按 1 分钟、5 分钟、15 分钟、1 小时退避,之后保持 1 小时上限。成功后恢复配置的更新周期。 +- `global-ua` 控制资源请求的 User-Agent,默认 `Vole/` 加 Core 版本;`etag-support` 默认 true,false 时不发送条件请求。ETag、SHA-256、源 URL 和下次检查时间保存在 Vole 管理的状态中。合法 304 在匹配器 已可用时只推进调度;有必需种类不可用时,先按相同重载流程从本地资产恢复匹配器。 - 下载以固定大小缓冲流式写入同目录暂存文件,不限制资产总字节;保留 90 秒期限、取消、HTTPS/HTTP 解析边界和字节计数溢出检查。流式落盘期间保留当前快照;文件大小、wire 结构和 staging 检查通过后,先卸载并排空旧匹配器,再构建候选。分类需求校验成功后原子替换文件,SHA-256 随状态保存;失败保留上一份磁盘资产,不保证恢复旧内存快照。 - 跨进程更新锁和原子重命名只保护共享数据目录,不提供多实例调度协议。 diff --git a/src/config/mod.rs b/src/config/mod.rs index e238c49..6124348 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -68,6 +68,8 @@ pub struct Config { pub proxy_groups: Vec, pub mode: Mode, pub log_level: LogLevel, + pub global_ua: String, + pub etag_support: bool, pub geodata_update: Option, pub external_controller: Option, pub inbounds: Vec, @@ -848,6 +850,10 @@ struct RawVoleConfig { mode: Mode, #[serde(rename = "log-level", default)] log_level: LogLevel, + #[serde(rename = "global-ua", default = "default_global_ua")] + global_ua: String, + #[serde(rename = "etag-support", default = "default_true")] + etag_support: bool, #[serde(default)] hosts: std::collections::BTreeMap, #[serde(default = "default_true")] @@ -924,9 +930,28 @@ struct RawProxyGroup { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawGeoDataUrls { + #[serde(default = "default_geoip_url")] geoip: String, + #[serde(default = "default_geosite_url")] geosite: String, } +fn default_global_ua() -> String { + concat!("Vole/", env!("CARGO_PKG_VERSION")).into() +} +fn default_geoip_url() -> String { + "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat".into() +} +fn default_geosite_url() -> String { + "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat".into() +} +impl Default for RawGeoDataUrls { + fn default() -> Self { + Self { + geoip: default_geoip_url(), + geosite: default_geosite_url(), + } + } +} #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] @@ -1624,6 +1649,14 @@ impl Config { impl RawVoleConfig { fn normalize(self) -> Result { + if self.global_ua.len() > 1024 + || !self + .global_ua + .bytes() + .all(|b| b == b'\t' || (0x20..=0x7e).contains(&b)) + { + return invalid("global-ua must be a bounded HTTP header value"); + } let geodata_update = normalize_geodata_update( self.geox_url, self.geo_auto_update, @@ -1715,6 +1748,8 @@ impl RawVoleConfig { proxy_groups, mode: self.mode, log_level: self.log_level, + global_ua: self.global_ua, + etag_support: self.etag_support, geodata_update, external_controller, inbounds, @@ -1771,27 +1806,19 @@ fn normalize_geodata_update( auto_update: Option, interval_hours: Option, ) -> Result> { - match (urls, auto_update, interval_hours) { - (None, None, None) => Ok(None), - (Some(urls), Some(auto_update), Some(interval_hours)) => { - if interval_hours != GEO_UPDATE_INTERVAL_HOURS { - return invalid(format!( - "geo-update-interval must be {GEO_UPDATE_INTERVAL_HOURS}" - )); - } - Ok(Some(GeoDataUpdateConfig { - urls: GeoDataUrls { - geoip: normalize_geox_url(urls.geoip, "geox-url.geoip")?, - geosite: normalize_geox_url(urls.geosite, "geox-url.geosite")?, - }, - auto_update, - interval_hours, - })) - } - _ => invalid( - "geox-url, geo-auto-update, and geo-update-interval must be configured together", - ), - } + let urls = urls.unwrap_or_default(); + let interval_hours = interval_hours.unwrap_or(GEO_UPDATE_INTERVAL_HOURS); + if interval_hours.checked_mul(3600).is_none() { + return invalid("geo-update-interval is too large"); + } + Ok(Some(GeoDataUpdateConfig { + urls: GeoDataUrls { + geoip: normalize_geox_url(urls.geoip, "geox-url.geoip")?, + geosite: normalize_geox_url(urls.geosite, "geox-url.geosite")?, + }, + auto_update: auto_update.unwrap_or(false), + interval_hours, + })) } fn normalize_geox_url(raw: String, field: &str) -> Result { @@ -3412,31 +3439,29 @@ geo-update-interval: 24"#, .expect("disabled updater remains explicit configuration") .auto_update ); - assert!( - Config::parse_yaml(CURRENT_TLS.as_bytes()) - .unwrap() - .geodata_update - .is_none() - ); + let defaults = Config::parse_yaml(CURRENT_TLS.as_bytes()).unwrap(); + assert!(!defaults.geodata_update.unwrap().auto_update); + assert!(defaults.etag_support); } #[test] - fn geodata_update_fields_are_all_present_or_all_absent() { - let urls = "geox-url:\n geoip: https://geo.example.test/geoip.dat\n geosite: https://geo.example.test/geosite.dat"; - for fields in [ - urls.to_owned(), - "geo-auto-update: true".to_owned(), - "geo-update-interval: 24".to_owned(), - format!("{urls}\ngeo-auto-update: true"), - format!("{urls}\ngeo-update-interval: 24"), - "geo-auto-update: true\ngeo-update-interval: 24".to_owned(), - ] { - let error = Config::parse_yaml(with_geodata(&fields).as_bytes()).unwrap_err(); - assert!( - error.to_string().contains("must be configured together"), - "{fields}: {error}" - ); - } + fn geodata_fields_have_independent_defaults_and_configurable_intervals() { + let config = Config::parse_yaml(with_geodata("geo-auto-update: true\ngeo-update-interval: 6\netag-support: false\nglobal-ua: custom-agent\ngeox-url:\n geosite: https://rules.example.test/site.dat").as_bytes()).unwrap(); + assert!(!config.etag_support); + assert_eq!(config.global_ua, "custom-agent"); + let update = config.geodata_update.unwrap(); + assert!(update.auto_update); + assert_eq!(update.interval_hours, 6); + assert_eq!(update.urls.geoip, default_geoip_url()); + assert_eq!(update.urls.geosite, "https://rules.example.test/site.dat"); + assert_eq!( + Config::parse_yaml(with_geodata("geo-update-interval: 0").as_bytes()) + .unwrap() + .geodata_update + .unwrap() + .interval_hours, + 0 + ); } #[test] @@ -3447,11 +3472,6 @@ geo-update-interval: 24"#, )) }; for yaml in [ - complete( - "https://geo.example.test/geoip.dat", - "https://geo.example.test/geosite.dat", - "23", - ), complete( "https://geo.example.test/geoip.dat", "https://geo.example.test/geosite.dat", diff --git a/src/geodata/manager.rs b/src/geodata/manager.rs index 9da040d..07bd87c 100644 --- a/src/geodata/manager.rs +++ b/src/geodata/manager.rs @@ -39,7 +39,7 @@ struct SnapshotReleaseState { impl SnapshotReleaseState { fn wait(&self, cancellation: &CancellationToken) -> Result<(), GeoDataManagerError> { - let deadline = Instant::now() + super::updater::DEFAULT_DOWNLOAD_TIMEOUT; + let deadline = Instant::now() + crate::resource::http::DEFAULT_DOWNLOAD_TIMEOUT; let mut released = lock(&self.released); while !*released { check_cancellation(cancellation)?; @@ -383,7 +383,7 @@ fn check_cancellation(cancellation: &CancellationToken) -> Result<(), GeoDataMan #[derive(Debug)] pub struct GeoDataManager { store_dir: PathBuf, - update_interval: Duration, + update_interval: AtomicU64, state: Mutex, registration: Mutex>, } @@ -404,7 +404,7 @@ impl GeoDataManager { let state = read_initial_state(&store_dir)?; let manager = Arc::new(Self { store_dir, - update_interval, + update_interval: AtomicU64::new(update_interval.as_secs()), state: Mutex::new(state), registration: Mutex::new(None), }); @@ -426,11 +426,24 @@ impl GeoDataManager { pub fn register( self: &Arc, requirements: GeoRequirements, + ) -> Result { + self.register_with_interval( + requirements, + Duration::from_secs(self.update_interval.load(Ordering::Relaxed)), + ) + } + + pub(crate) fn register_with_interval( + self: &Arc, + requirements: GeoRequirements, + interval: Duration, ) -> Result { let mut registration = lock(&self.registration); if registration.is_some() { return Err(GeoDataManagerError::RegistrationActive); } + self.update_interval + .store(interval.as_secs(), Ordering::Relaxed); let (snapshot, report) = load_snapshot(&self.store_dir, &requirements); let matcher = Arc::new(DynamicGeoData::new(snapshot)); let lease = Arc::new(()); @@ -523,7 +536,7 @@ impl GeoDataManager { /// Returns the durable scheduling state for the active registration. /// `None` means the supplied lease is stale or already dropped. - fn status_for_active_registration( + pub(super) fn status_for_active_registration( &self, lease: &GeoDataRegistrationLease, cancellation: &CancellationToken, @@ -586,7 +599,13 @@ impl GeoDataManager { Ok(sources .into_iter() .filter_map(|(kind, source_url)| { - resource_due(status.resource(kind), source_url, now).then_some(kind) + resource_due( + status.resource(kind), + source_url, + now, + self.update_interval.load(Ordering::Relaxed), + ) + .then_some(kind) }) .collect()) } @@ -650,7 +669,12 @@ impl GeoDataManager { let Some(status) = status else { return Ok(None); }; - if !resource_due(status.resource(kind), source_url, unix_seconds(now)) { + if !resource_due( + status.resource(kind), + source_url, + unix_seconds(now), + self.update_interval.load(Ordering::Relaxed), + ) { return Ok(None); } let resource = status.resource(kind); @@ -776,7 +800,7 @@ impl GeoDataManager { hash: String, ) -> Result<(), GeoDataManagerError> { let now = unix_seconds(SystemTime::now()); - let interval = self.update_interval.as_secs(); + let interval = self.update_interval.load(Ordering::Relaxed); let mut state = read_state(&self.store_dir)?; let resource = state.resource_mut(kind); resource.available = true; @@ -802,7 +826,9 @@ impl GeoDataManager { let mut state = read_state(&self.store_dir)?; let resource = state.resource_mut(kind); resource.updating = false; - resource.next_check = Some(now.saturating_add(self.update_interval.as_secs())); + resource.last_success = Some(now); + resource.next_check = + Some(now.saturating_add(self.update_interval.load(Ordering::Relaxed))); resource.last_error = None; if etag.is_some() { resource.etag = etag; @@ -1260,14 +1286,14 @@ fn report_error(report: &GeoDataResourceReport) -> Option<&str> { .flatten() } -fn resource_due(resource: &GeoResourceState, source_url: &str, now: u64) -> bool { +fn resource_due(resource: &GeoResourceState, source_url: &str, now: u64, interval: u64) -> bool { resource.required && !resource.updating && (!resource.available || resource.source_url.as_deref() != Some(source_url) || resource - .next_check - .is_none_or(|next_check| next_check <= now)) + .last_success + .is_none_or(|last| last.saturating_add(interval) <= now)) } fn asset_matches_hash(path: &Path, expected: &str) -> bool { @@ -1884,7 +1910,10 @@ mod tests { let manager = GeoDataManager::open(root.path(), Duration::from_secs(3_600)).unwrap(); let peer = GeoDataManager::open(root.path(), Duration::from_secs(3_600)).unwrap(); let registration = manager - .register(requirements(&[rule(RuleKind::GeoSite("cn".to_owned()))])) + .register_with_interval( + requirements(&[rule(RuleKind::GeoSite("cn".to_owned()))]), + Duration::from_secs(6 * 3600), + ) .unwrap(); let matcher = registration.matcher(); @@ -1909,7 +1938,10 @@ mod tests { assert_eq!(status.geosite.etag.as_deref(), Some("\"v1\"")); assert_eq!(status.geosite.hash.as_deref().map(str::len), Some(64)); assert!(status.geosite.last_success.is_some()); - assert!(status.geosite.next_check > status.geosite.last_success); + assert_eq!( + status.geosite.next_check.unwrap() - status.geosite.last_success.unwrap(), + 6 * 3600 + ); assert!(manager.due_resources(SystemTime::now()).unwrap().is_empty()); let peer_status = peer.status().unwrap(); assert!(peer_status.geosite.available); diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 3a68fb2..9eb1b5c 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -26,7 +26,6 @@ use crate::{ mod manager; mod selectors; pub(crate) mod service; -pub(crate) mod updater; pub(crate) use selectors::normalize_selector; use selectors::{fold_attribute, selector_attributes, selector_code}; diff --git a/src/geodata/service.rs b/src/geodata/service.rs index 04f6bf2..38a791e 100644 --- a/src/geodata/service.rs +++ b/src/geodata/service.rs @@ -2,7 +2,7 @@ //! //! The service is attached to a running instance, but the manager and its //! cross-process lock own the shared resource state. Network traffic is sent -//! only through the final MATCH route dispatcher supplied by the runtime. +//! through the shared runtime route dispatcher. use std::{ io, @@ -12,15 +12,14 @@ use std::{ use tokio_util::sync::CancellationToken; -use super::{ - GeoDataKind, GeoDataManager, GeoDataManagerError, - manager::GeoDataRegistrationLease, - updater::{ - DEFAULT_DOWNLOAD_TIMEOUT, GeoDataDownloadOutcome, GeoDataDownloadRequest, - download_geodata_via_proxy, +use super::{GeoDataKind, GeoDataManager, GeoDataManagerError, manager::GeoDataRegistrationLease}; +use crate::{ + config::GeoDataUrls, + dispatch::Dispatcher, + resource::http::{ + DEFAULT_DOWNLOAD_TIMEOUT, DownloadOutcome, DownloadRequest, HttpOptions, download, }, }; -use crate::{config::GeoDataUrls, dispatch::Dispatcher}; const STATUS_POLL_INTERVAL: Duration = Duration::from_secs(15); const MIN_LOOP_DELAY: Duration = Duration::from_secs(1); @@ -37,6 +36,9 @@ pub(crate) struct GeoDataUpdateService { dispatcher: Arc, registration: GeoDataRegistrationLease, urls: GeoDataUrls, + options: HttpOptions, + etag_support: bool, + auto_update: bool, } impl GeoDataUpdateService { @@ -46,12 +48,21 @@ impl GeoDataUpdateService { dispatcher: Arc, registration: GeoDataRegistrationLease, urls: GeoDataUrls, + user_agent: String, + etag_support: bool, + auto_update: bool, ) -> Self { Self { manager, dispatcher, registration, urls, + options: HttpOptions { + user_agent, + ..HttpOptions::default() + }, + etag_support, + auto_update, } } @@ -125,8 +136,9 @@ impl GeoDataUpdateService { let manager = self.manager.clone(); let registration = self.registration.clone(); let urls = self.urls.clone(); + let auto_update = self.auto_update; blocking_operation(move || { - manager.due_resources_for_active_registration_with_cancellation( + let due = manager.due_resources_for_active_registration_with_cancellation( ®istration, [ (GeoDataKind::GeoSite, urls.geosite.as_str()), @@ -134,10 +146,22 @@ impl GeoDataUpdateService { ], SystemTime::now(), &cancellation, - ) + )?; + if auto_update { + return Ok(due); + } + let status = manager.status_for_active_registration(®istration, &cancellation)?; + Ok(due + .into_iter() + .filter(|kind| { + status + .as_ref() + .is_some_and(|status| !status.resource(*kind).available) + }) + .collect()) }) .await? - .map_err(|error| error.to_string()) + .map_err(|error: GeoDataManagerError| error.to_string()) } async fn update_one( @@ -166,17 +190,21 @@ impl GeoDataUpdateService { Err(GeoDataManagerError::UpdateBusy) => return Ok(UpdateAttempt::Busy), Err(error) => return Err(error.to_string()), }; - let etag = session.request_etag().map(ToOwned::to_owned); - let request = GeoDataDownloadRequest { + let etag = self + .etag_support + .then(|| session.request_etag().map(ToOwned::to_owned)) + .flatten(); + let request = DownloadRequest { dispatcher: self.dispatcher.clone(), url: source_url, etag: etag.clone(), temporary_path: session.temporary_path().to_path_buf(), timeout: DEFAULT_DOWNLOAD_TIMEOUT, cancellation: cancellation.clone(), + options: self.options.clone(), }; - match download_geodata_via_proxy(request).await { - Ok(GeoDataDownloadOutcome::NotModified) => { + match download(request).await { + Ok(DownloadOutcome::NotModified) => { blocking_operation(move || { session.not_modified_with_cancellation(etag, &cancellation) }) @@ -185,7 +213,7 @@ impl GeoDataUpdateService { tracing::info!(geodata_kind = %kind, "Vole GeoData is current"); Ok(UpdateAttempt::Completed) } - Ok(GeoDataDownloadOutcome::Downloaded { + Ok(DownloadOutcome::Downloaded { etag, sha256, size, .. }) => { let report = blocking_operation(move || { @@ -391,6 +419,9 @@ mod tests { geoip: "https://rules.example.test/geoip.dat".to_owned(), geosite: "https://rules.example.test/geosite.dat".to_owned(), }, + "fixture".into(), + true, + true, ); let run = tokio::spawn(service.run(cancellation.clone())); tokio::time::timeout(Duration::from_secs(1), async { diff --git a/src/lib.rs b/src/lib.rs index 0d22dbc..4425ef3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,7 @@ pub mod packet; pub mod platform; #[cfg(any(feature = "tun", test))] mod quic_sniffer; +pub(crate) mod resource; pub mod resources; pub mod routing; #[cfg(all( diff --git a/src/geodata/updater.rs b/src/resource/http.rs similarity index 71% rename from src/geodata/updater.rs rename to src/resource/http.rs index 515b335..e966a7c 100644 --- a/src/geodata/updater.rs +++ b/src/resource/http.rs @@ -1,8 +1,5 @@ -//! Streaming GeoData downloads forced through one raw proxy dispatcher. -//! -//! This module deliberately does not know about routing or DIRECT. The caller -//! must pass the raw dispatcher for the selected default proxy. Every hop, -//! including redirects, opens a domain destination through that dispatcher. +//! Shared streaming resource transport. Callers own content validation and +//! publication; every HTTP hop uses the supplied Core dispatcher. use std::{ fs::{self, File, OpenOptions}, @@ -41,7 +38,7 @@ const MAX_TRAILER_BYTES: usize = 8 * 1024; const MAX_TRAILER_HEADERS: usize = 32; const IO_BUFFER_BYTES: usize = 16 * 1024; -pub(crate) struct GeoDataDownloadRequest { +pub(crate) struct DownloadRequest { pub dispatcher: Arc, pub url: String, pub etag: Option, @@ -50,10 +47,31 @@ pub(crate) struct GeoDataDownloadRequest { pub temporary_path: PathBuf, pub timeout: Duration, pub cancellation: CancellationToken, + pub options: HttpOptions, +} + +#[derive(Debug, Clone)] +pub(crate) struct HttpOptions { + pub user_agent: String, + pub headers: Vec<(String, String)>, + pub size_limit: Option, + pub allow_http: bool, + pub allow_ip: bool, +} +impl Default for HttpOptions { + fn default() -> Self { + Self { + user_agent: concat!("Vole/", env!("CARGO_PKG_VERSION")).into(), + headers: Vec::new(), + size_limit: None, + allow_http: cfg!(feature = "benchmark-geodata-http"), + allow_ip: false, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum GeoDataDownloadOutcome { +pub(crate) enum DownloadOutcome { NotModified, Downloaded { etag: Option, @@ -64,51 +82,49 @@ pub(crate) enum GeoDataDownloadOutcome { } #[derive(Debug, Error)] -pub(crate) enum GeoDataDownloadError { - #[error("invalid GeoData URL: {0}")] +pub(crate) enum DownloadError { + #[error("invalid resource URL: {0}")] InvalidUrl(String), - #[error("GeoData download URL must use HTTPS")] + #[error("resource download URL must use HTTPS")] HttpsRequired, - #[error("GeoData download URL must contain a domain host")] + #[error("resource download URL must contain a domain host")] DomainRequired, - #[error("GeoData download URL must not contain credentials")] + #[error("resource download URL must not contain credentials")] CredentialsNotAllowed, - #[error("GeoData download ETag is not a bounded HTTP header value")] + #[error("resource download ETag is not a bounded HTTP header value")] InvalidEtag, - #[error("GeoData proxy dispatch failed: {0}")] + #[error("resource proxy dispatch failed: {0}")] Dispatch(#[source] DispatchError), - #[error("GeoData TLS setup failed: {0}")] + #[error("resource TLS setup failed: {0}")] TlsSetup(String), - #[error("GeoData TLS handshake failed: {0}")] + #[error("resource TLS handshake failed: {0}")] TlsHandshake(String), - #[error("GeoData HTTP protocol error: {0}")] + #[error("resource HTTP protocol error: {0}")] Protocol(String), - #[error("GeoData server returned HTTP status {0}")] + #[error("resource server returned HTTP status {0}")] HttpStatus(u16), - #[error("GeoData download followed more than {MAX_REDIRECTS} redirects")] + #[error("resource download followed more than {MAX_REDIRECTS} redirects")] TooManyRedirects, - #[error("GeoData download timed out after {0:?}")] + #[error("resource download timed out after {0:?}")] TimedOut(Duration), - #[error("GeoData download was cancelled")] + #[error("resource download was cancelled")] Cancelled, - #[error("failed to {operation} GeoData temporary file {path}: {source}")] + #[error("failed to {operation} resource temporary file {path}: {source}")] File { operation: &'static str, path: PathBuf, #[source] source: io::Error, }, - #[error("GeoData network I/O failed: {0}")] + #[error("resource network I/O failed: {0}")] NetworkIo(#[source] io::Error), } -/// Downloads one GeoData asset exclusively through `request.dispatcher`. +/// Downloads one resource asset exclusively through `request.dispatcher`. /// /// The successful `Downloaded` result leaves a fully flushed temporary file at /// `request.temporary_path`. All other outcomes remove the temporary file. -pub(crate) async fn download_geodata_via_proxy( - request: GeoDataDownloadRequest, -) -> Result { +pub(crate) async fn download(request: DownloadRequest) -> Result { let connector = WebPkiHttpsConnector::new()?; download_with_connector(request, &connector).await } @@ -119,7 +135,7 @@ trait HttpsConnector: Send + Sync { &self, server_name: &str, stream: BoxStream, - ) -> Result; + ) -> Result; } struct WebPkiHttpsConnector { @@ -127,12 +143,12 @@ struct WebPkiHttpsConnector { } impl WebPkiHttpsConnector { - fn new() -> Result { + fn new() -> Result { let provider = Arc::new(rustls::crypto::ring::default_provider()); let roots: RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); let mut config = ClientConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() - .map_err(|error| GeoDataDownloadError::TlsSetup(error.to_string()))? + .map_err(|error| DownloadError::TlsSetup(error.to_string()))? .with_root_certificates(Arc::new(roots)) .with_no_client_auth(); config.alpn_protocols = vec![b"http/1.1".to_vec()]; @@ -149,22 +165,23 @@ impl HttpsConnector for WebPkiHttpsConnector { &self, server_name: &str, stream: BoxStream, - ) -> Result { + ) -> Result { let server_name = ServerName::try_from(server_name.to_owned()) - .map_err(|error| GeoDataDownloadError::TlsSetup(error.to_string()))?; + .map_err(|error| DownloadError::TlsSetup(error.to_string()))?; self.connector .connect(server_name, stream) .await .map(|stream| Box::new(stream) as BoxStream) - .map_err(|error| GeoDataDownloadError::TlsHandshake(error.to_string())) + .map_err(|error| DownloadError::TlsHandshake(error.to_string())) } } async fn download_with_connector( - request: GeoDataDownloadRequest, + request: DownloadRequest, connector: &dyn HttpsConnector, -) -> Result { - let initial_url = parse_geodata_url(&request.url)?; +) -> Result { + let initial_url = parse_url(&request.url, &request.options)?; + validate_options(&request.options)?; validate_etag(request.etag.as_deref())?; let temporary_path = request.temporary_path.clone(); @@ -172,12 +189,13 @@ async fn download_with_connector( .write(true) .create_new(true) .open(&temporary_path) - .map_err(|source| GeoDataDownloadError::File { + .map_err(|source| DownloadError::File { operation: "create", path: temporary_path.clone(), source, })?; + let mut cleanup = TemporaryDownload(Some(temporary_path.clone())); let cancellation = request.cancellation.clone(); let timeout = request.timeout; let result = { @@ -185,88 +203,92 @@ async fn download_with_connector( tokio::pin!(operation); tokio::select! { biased; - () = cancellation.cancelled() => Err(GeoDataDownloadError::Cancelled), + () = cancellation.cancelled() => Err(DownloadError::Cancelled), timed = tokio::time::timeout(timeout, &mut operation) => { match timed { Ok(result) => result, - Err(_) => Err(GeoDataDownloadError::TimedOut(timeout)), + Err(_) => Err(DownloadError::TimedOut(timeout)), } } } }; - if !matches!(result, Ok(GeoDataDownloadOutcome::Downloaded { .. })) { - let _ = fs::remove_file(&temporary_path); + if matches!(result, Ok(DownloadOutcome::Downloaded { .. })) { + cleanup.0 = None; } result } async fn download_inner( - request: GeoDataDownloadRequest, + request: DownloadRequest, connector: &dyn HttpsConnector, mut current_url: Url, file: File, -) -> Result { +) -> Result { let mut file = Some(file); + let initial_origin = current_url.origin(); for redirects_followed in 0..=MAX_REDIRECTS { let endpoint = DownloadEndpoint::from_url(¤t_url)?; let session = StreamSession { inbound: InboundKind::InternalGeoData, source: SocketAddr::from(([0, 0, 0, 0], 0)), - destination: Destination::domain(endpoint.host.clone(), endpoint.port) - .map_err(|error| GeoDataDownloadError::InvalidUrl(error.to_string()))?, + destination: match endpoint.host.parse() { + Ok(ip) => Destination::Ip(SocketAddr::new(ip, endpoint.port)), + Err(_) => Destination::domain(endpoint.host.clone(), endpoint.port) + .map_err(|error| DownloadError::InvalidUrl(error.to_string()))?, + }, sniffed_domain: None, }; let stream = request .dispatcher .connect_tcp(session) .await - .map_err(GeoDataDownloadError::Dispatch)?; - #[cfg(feature = "benchmark-geodata-http")] + .map_err(DownloadError::Dispatch)?; let stream = if current_url.scheme() == "http" { stream } else { connector.connect(&endpoint.host, stream).await? }; - #[cfg(not(feature = "benchmark-geodata-http"))] - let stream = connector.connect(&endpoint.host, stream).await?; let mut response = HttpReader::new(stream); response .stream - .write_all(&build_request(&endpoint, request.etag.as_deref())) + .write_all(&build_request( + &endpoint, + request.etag.as_deref(), + &request.options, + current_url.origin() == initial_origin, + )) .await - .map_err(GeoDataDownloadError::NetworkIo)?; + .map_err(DownloadError::NetworkIo)?; response .stream .flush() .await - .map_err(GeoDataDownloadError::NetworkIo)?; + .map_err(DownloadError::NetworkIo)?; let head = response.read_final_head().await?; if is_redirect(head.status) { if redirects_followed == MAX_REDIRECTS { - return Err(GeoDataDownloadError::TooManyRedirects); + return Err(DownloadError::TooManyRedirects); } let location = head.location.ok_or_else(|| { - GeoDataDownloadError::Protocol( - "redirect response does not contain Location".to_owned(), - ) + DownloadError::Protocol("redirect response does not contain Location".to_owned()) })?; - current_url = resolve_redirect(¤t_url, &location)?; + current_url = redirect(¤t_url, &location, &request.options)?; continue; } match head.status { - 304 if request.etag.is_some() => return Ok(GeoDataDownloadOutcome::NotModified), + 304 if request.etag.is_some() => return Ok(DownloadOutcome::NotModified), 304 => { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "received 304 without sending If-None-Match".to_owned(), )); } 200 => {} - status => return Err(GeoDataDownloadError::HttpStatus(status)), + status => return Err(DownloadError::HttpStatus(status)), } validate_content_encoding(head.content_encoding.as_deref())?; @@ -274,9 +296,18 @@ async fn download_inner( let target = file .take() .expect("temporary file must be consumed by one final response"); - let mut sink = BodySink::new(target); + let mut sink = BodySink::new(target, request.options.size_limit); match framing { BodyFraming::ContentLength(length) => { + if request + .options + .size_limit + .is_some_and(|limit| length > limit) + { + return Err(DownloadError::Protocol( + "resource exceeds size-limit".into(), + )); + } response.copy_exact_body(length, &mut sink).await?; } BodyFraming::Chunked => { @@ -287,7 +318,7 @@ async fn download_inner( } } let (sha256, size) = sink.finish(&request.temporary_path)?; - return Ok(GeoDataDownloadOutcome::Downloaded { + return Ok(DownloadOutcome::Downloaded { etag: head.etag, sha256, size, @@ -295,7 +326,7 @@ async fn download_inner( }); } - Err(GeoDataDownloadError::TooManyRedirects) + Err(DownloadError::TooManyRedirects) } #[derive(Debug)] @@ -307,19 +338,28 @@ struct DownloadEndpoint { } impl DownloadEndpoint { - fn from_url(url: &Url) -> Result { + fn from_url(url: &Url) -> Result { let host = match url.host() { - Some(Host::Domain(host)) => host.to_owned(), - _ => return Err(GeoDataDownloadError::DomainRequired), + Some(host) => match host { + Host::Domain(host) => host.to_owned(), + Host::Ipv4(ip) => ip.to_string(), + Host::Ipv6(ip) => ip.to_string(), + }, + None => return Err(DownloadError::DomainRequired), }; let port = url .port_or_known_default() - .ok_or_else(|| GeoDataDownloadError::InvalidUrl("missing HTTP(S) port".to_owned()))?; + .ok_or_else(|| DownloadError::InvalidUrl("missing HTTP(S) port".to_owned()))?; let default_port = if url.scheme() == "http" { 80 } else { 443 }; - let authority = if port == default_port { + let authority_host = if host.contains(':') { + format!("[{host}]") + } else { host.clone() + }; + let authority = if port == default_port { + authority_host } else { - format!("{host}:{port}") + format!("{authority_host}:{port}") }; let mut origin_form = if url.path().is_empty() { "/".to_owned() @@ -339,27 +379,28 @@ impl DownloadEndpoint { } } -fn parse_geodata_url(raw: &str) -> Result { +#[cfg(test)] +fn parse_resource_url(raw: &str) -> Result { + parse_url(raw, &HttpOptions::default()) +} + +pub(crate) fn parse_url(raw: &str, options: &HttpOptions) -> Result { if raw.len() > MAX_URL_BYTES { - return Err(GeoDataDownloadError::InvalidUrl(format!( + return Err(DownloadError::InvalidUrl(format!( "URL exceeds {MAX_URL_BYTES} bytes" ))); } - let mut url = - Url::parse(raw).map_err(|error| GeoDataDownloadError::InvalidUrl(error.to_string()))?; - let allowed_scheme = url.scheme() == "https"; - #[cfg(feature = "benchmark-geodata-http")] - let allowed_scheme = allowed_scheme || url.scheme() == "http"; + let mut url = Url::parse(raw).map_err(|error| DownloadError::InvalidUrl(error.to_string()))?; + let allowed_scheme = url.scheme() == "https" || options.allow_http && url.scheme() == "http"; if !allowed_scheme { - return Err(GeoDataDownloadError::HttpsRequired); + return Err(DownloadError::HttpsRequired); } if !url.username().is_empty() || url.password().is_some() { - return Err(GeoDataDownloadError::CredentialsNotAllowed); + return Err(DownloadError::CredentialsNotAllowed); } - if !matches!(url.host(), Some(Host::Domain(_))) { - return Err(GeoDataDownloadError::DomainRequired); + if url.host().is_none() || (!options.allow_ip && !matches!(url.host(), Some(Host::Domain(_)))) { + return Err(DownloadError::DomainRequired); } - #[cfg(feature = "benchmark-geodata-http")] if url.scheme() == "http" { let authority = raw .split_once("://") @@ -367,10 +408,10 @@ fn parse_geodata_url(raw: &str) -> Result { .and_then(|suffix| suffix.split(['/', '?', '#']).next()) .unwrap_or_default(); if authority.contains('@') { - return Err(GeoDataDownloadError::CredentialsNotAllowed); + return Err(DownloadError::CredentialsNotAllowed); } if url.fragment().is_some() { - return Err(GeoDataDownloadError::InvalidUrl( + return Err(DownloadError::InvalidUrl( "URL must not contain a fragment".to_owned(), )); } @@ -379,37 +420,107 @@ fn parse_geodata_url(raw: &str) -> Result { Ok(url) } -fn resolve_redirect(base: &Url, location: &str) -> Result { +#[cfg(test)] +fn resolve_redirect(base: &Url, location: &str) -> Result { + redirect(base, location, &HttpOptions::default()) +} + +fn redirect(base: &Url, location: &str, options: &HttpOptions) -> Result { if location.len() > MAX_URL_BYTES || !is_header_value(location.as_bytes()) { - return Err(GeoDataDownloadError::InvalidUrl( + return Err(DownloadError::InvalidUrl( "redirect Location is not a bounded HTTP header value".to_owned(), )); } let joined = base .join(location) - .map_err(|error| GeoDataDownloadError::InvalidUrl(error.to_string()))?; + .map_err(|error| DownloadError::InvalidUrl(error.to_string()))?; if base.scheme() == "https" && joined.scheme() != "https" { - return Err(GeoDataDownloadError::HttpsRequired); + return Err(DownloadError::HttpsRequired); } - parse_geodata_url(joined.as_str()) + parse_url(joined.as_str(), options) } -fn validate_etag(etag: Option<&str>) -> Result<(), GeoDataDownloadError> { +fn validate_etag(etag: Option<&str>) -> Result<(), DownloadError> { if let Some(etag) = etag && (etag.len() > MAX_REQUEST_ETAG_BYTES || !is_header_value(etag.as_bytes())) { - return Err(GeoDataDownloadError::InvalidEtag); + return Err(DownloadError::InvalidEtag); + } + Ok(()) +} + +fn validate_options(options: &HttpOptions) -> Result<(), DownloadError> { + if options.user_agent.len() > 1024 || !is_header_value(options.user_agent.as_bytes()) { + return Err(DownloadError::Protocol("invalid User-Agent".into())); + } + let mut size = 0usize; + for (name, value) in &options.headers { + size = size.saturating_add(name.len()).saturating_add(value.len()); + if name.is_empty() + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~".contains(&b)) + || !is_header_value(value.as_bytes()) + || size > MAX_RESPONSE_HEAD_BYTES + || [ + "host", + "connection", + "content-length", + "transfer-encoding", + "if-none-match", + "accept-encoding", + ] + .iter() + .any(|reserved| name.eq_ignore_ascii_case(reserved)) + { + return Err(DownloadError::Protocol( + "invalid resource request header".into(), + )); + } } Ok(()) } -fn build_request(endpoint: &DownloadEndpoint, etag: Option<&str>) -> Vec { +fn build_request( + endpoint: &DownloadEndpoint, + etag: Option<&str>, + options: &HttpOptions, + same_origin: bool, +) -> Vec { let conditional = etag.map_or_else(String::new, |etag| format!("If-None-Match: {etag}\r\n")); + let mut headers = String::new(); + for (name, value) in &options.headers { + if !same_origin + && ["authorization", "cookie", "proxy-authorization"] + .iter() + .any(|s| name.eq_ignore_ascii_case(s)) + { + continue; + } + headers.push_str(&format!("{name}: {value}\r\n")); + } + let user_agent = if options + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("user-agent")) + { + String::new() + } else { + format!("User-Agent: {}\r\n", options.user_agent) + }; format!( - "GET {} HTTP/1.1\r\nHost: {}\r\nAccept: application/octet-stream\r\nAccept-Encoding: identity\r\nUser-Agent: Vole/0.1\r\nConnection: close\r\n{}\r\n", - endpoint.origin_form, endpoint.authority, conditional - ) - .into_bytes() + "GET {} HTTP/1.1\r\nHost: {}\r\nAccept: application/octet-stream\r\nAccept-Encoding: identity\r\n{}Connection: close\r\n{}{}\r\n", + endpoint.origin_form, endpoint.authority, user_agent, conditional, headers + ).into_bytes() +} + +struct TemporaryDownload(Option); +impl Drop for TemporaryDownload { + fn drop(&mut self) { + if let Some(path) = &self.0 { + let _ = fs::remove_file(path); + } + } } fn is_redirect(status: u16) -> bool { @@ -433,15 +544,15 @@ enum BodyFraming { UntilEof, } -fn body_framing(head: &ResponseHead) -> Result { +fn body_framing(head: &ResponseHead) -> Result { if head.transfer_encoding.is_some() && head.content_length.is_some() { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "response contains both Transfer-Encoding and Content-Length".to_owned(), )); } if let Some(encoding) = &head.transfer_encoding { if !encoding.eq_ignore_ascii_case("chunked") { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "unsupported Transfer-Encoding `{encoding}`" ))); } @@ -453,13 +564,13 @@ fn body_framing(head: &ResponseHead) -> Result) -> Result<(), GeoDataDownloadError> { +fn validate_content_encoding(encoding: Option<&str>) -> Result<(), DownloadError> { if let Some(encoding) = encoding && !encoding .split(',') .all(|value| value.trim().eq_ignore_ascii_case("identity")) { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "unsupported Content-Encoding `{encoding}`" ))); } @@ -479,7 +590,7 @@ impl HttpReader { } } - async fn read_final_head(&mut self) -> Result { + async fn read_final_head(&mut self) -> Result { for informational_count in 0..=MAX_INFORMATIONAL_RESPONSES { let block = self.read_head_block().await?; let head = parse_response_head(&block)?; @@ -487,12 +598,12 @@ impl HttpReader { return Ok(head); } if head.status == 101 { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "HTTP protocol switching is not supported".to_owned(), )); } if informational_count == MAX_INFORMATIONAL_RESPONSES { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "too many informational responses".to_owned(), )); } @@ -500,11 +611,11 @@ impl HttpReader { unreachable!("informational response loop always returns"); } - async fn read_head_block(&mut self) -> Result, GeoDataDownloadError> { + async fn read_head_block(&mut self) -> Result, DownloadError> { loop { if let Some(end) = find_bytes(&self.buffered, b"\r\n\r\n") { if end + 4 > MAX_RESPONSE_HEAD_BYTES { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "response head exceeds {MAX_RESPONSE_HEAD_BYTES} bytes" ))); } @@ -513,7 +624,7 @@ impl HttpReader { return Ok(block); } if self.buffered.len() >= MAX_RESPONSE_HEAD_BYTES { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "response head exceeds {MAX_RESPONSE_HEAD_BYTES} bytes" ))); } @@ -522,9 +633,9 @@ impl HttpReader { .stream .read(&mut scratch) .await - .map_err(GeoDataDownloadError::NetworkIo)?; + .map_err(DownloadError::NetworkIo)?; if read == 0 { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "response ended before its HTTP head".to_owned(), )); } @@ -536,14 +647,14 @@ impl HttpReader { &mut self, mut remaining: u64, sink: &mut BodySink, - ) -> Result<(), GeoDataDownloadError> { + ) -> Result<(), DownloadError> { let mut scratch = [0_u8; IO_BUFFER_BYTES]; while remaining > 0 { let wanted = usize::try_from(remaining.min(IO_BUFFER_BYTES as u64)) .expect("bounded read size fits usize"); let read = self.read_some(&mut scratch[..wanted]).await?; if read == 0 { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "response body ended with {remaining} bytes missing" ))); } @@ -553,10 +664,7 @@ impl HttpReader { Ok(()) } - async fn copy_body_until_eof( - &mut self, - sink: &mut BodySink, - ) -> Result<(), GeoDataDownloadError> { + async fn copy_body_until_eof(&mut self, sink: &mut BodySink) -> Result<(), DownloadError> { let mut scratch = [0_u8; IO_BUFFER_BYTES]; loop { let read = self.read_some(&mut scratch).await?; @@ -567,11 +675,11 @@ impl HttpReader { } } - async fn copy_chunked_body(&mut self, sink: &mut BodySink) -> Result<(), GeoDataDownloadError> { + async fn copy_chunked_body(&mut self, sink: &mut BodySink) -> Result<(), DownloadError> { loop { let line = self.read_line(MAX_CHUNK_LINE_BYTES).await?; if !is_header_value(&line) { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "chunk-size line contains invalid bytes".to_owned(), )); } @@ -581,15 +689,12 @@ impl HttpReader { .expect("split always returns one value"); let size_text = trim_ascii_whitespace(size_text); if size_text.is_empty() || size_text.len() > 16 { - return Err(GeoDataDownloadError::Protocol( - "invalid chunk size".to_owned(), - )); + return Err(DownloadError::Protocol("invalid chunk size".to_owned())); } - let size_text = std::str::from_utf8(size_text).map_err(|_| { - GeoDataDownloadError::Protocol("chunk size is not ASCII".to_owned()) - })?; + let size_text = std::str::from_utf8(size_text) + .map_err(|_| DownloadError::Protocol("chunk size is not ASCII".to_owned()))?; let size = u64::from_str_radix(size_text, 16).map_err(|_| { - GeoDataDownloadError::Protocol("invalid hexadecimal chunk size".to_owned()) + DownloadError::Protocol("invalid hexadecimal chunk size".to_owned()) })?; if size == 0 { self.read_trailers().await?; @@ -598,22 +703,22 @@ impl HttpReader { self.copy_exact_body(size, sink).await?; let terminator = self.read_exact_small(2).await?; if terminator != b"\r\n" { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "chunk data is not followed by CRLF".to_owned(), )); } } } - async fn read_trailers(&mut self) -> Result<(), GeoDataDownloadError> { + async fn read_trailers(&mut self) -> Result<(), DownloadError> { let mut total = 0_usize; for count in 0..=MAX_TRAILER_HEADERS { let line = self.read_line(MAX_TRAILER_BYTES).await?; - total = total.checked_add(line.len() + 2).ok_or_else(|| { - GeoDataDownloadError::Protocol("trailer size overflow".to_owned()) - })?; + total = total + .checked_add(line.len() + 2) + .ok_or_else(|| DownloadError::Protocol("trailer size overflow".to_owned()))?; if total > MAX_TRAILER_BYTES { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "trailers exceed {MAX_TRAILER_BYTES} bytes" ))); } @@ -621,7 +726,7 @@ impl HttpReader { return Ok(()); } if count == MAX_TRAILER_HEADERS { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "trailers contain more than {MAX_TRAILER_HEADERS} fields" ))); } @@ -630,11 +735,11 @@ impl HttpReader { unreachable!("trailer loop always returns"); } - async fn read_line(&mut self, maximum: usize) -> Result, GeoDataDownloadError> { + async fn read_line(&mut self, maximum: usize) -> Result, DownloadError> { loop { if let Some(end) = find_bytes(&self.buffered, b"\r\n") { if end > maximum { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "HTTP line exceeds {maximum} bytes" ))); } @@ -643,7 +748,7 @@ impl HttpReader { return Ok(line); } if self.buffered.len() > maximum { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "HTTP line exceeds {maximum} bytes" ))); } @@ -652,9 +757,9 @@ impl HttpReader { .stream .read(&mut scratch) .await - .map_err(GeoDataDownloadError::NetworkIo)?; + .map_err(DownloadError::NetworkIo)?; if read == 0 { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "response ended in the middle of an HTTP line".to_owned(), )); } @@ -662,7 +767,7 @@ impl HttpReader { } } - async fn read_exact_small(&mut self, length: usize) -> Result, GeoDataDownloadError> { + async fn read_exact_small(&mut self, length: usize) -> Result, DownloadError> { let mut output = vec![0_u8; length]; let mut written = 0; while written < length { @@ -672,9 +777,9 @@ impl HttpReader { .stream .read(&mut scratch) .await - .map_err(GeoDataDownloadError::NetworkIo)?; + .map_err(DownloadError::NetworkIo)?; if read == 0 { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "response body ended unexpectedly".to_owned(), )); } @@ -688,7 +793,7 @@ impl HttpReader { Ok(output) } - async fn read_some(&mut self, output: &mut [u8]) -> Result { + async fn read_some(&mut self, output: &mut [u8]) -> Result { if !self.buffered.is_empty() { let take = output.len().min(self.buffered.len()); let bytes = self.buffered.split_to(take); @@ -698,39 +803,37 @@ impl HttpReader { self.stream .read(output) .await - .map_err(GeoDataDownloadError::NetworkIo) + .map_err(DownloadError::NetworkIo) } } -fn parse_response_head(block: &[u8]) -> Result { +fn parse_response_head(block: &[u8]) -> Result { if !block.is_ascii() { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "response head contains non-ASCII bytes".to_owned(), )); } let text = std::str::from_utf8(block) - .map_err(|_| GeoDataDownloadError::Protocol("invalid response head".to_owned()))?; + .map_err(|_| DownloadError::Protocol("invalid response head".to_owned()))?; let mut lines = text.split("\r\n"); let status_line = lines .next() - .ok_or_else(|| GeoDataDownloadError::Protocol("missing HTTP status line".to_owned()))?; + .ok_or_else(|| DownloadError::Protocol("missing HTTP status line".to_owned()))?; let mut status_parts = status_line.splitn(3, ' '); if status_parts.next() != Some("HTTP/1.1") { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "server did not respond with HTTP/1.1".to_owned(), )); } let status_text = status_parts .next() - .ok_or_else(|| GeoDataDownloadError::Protocol("missing HTTP status".to_owned()))?; + .ok_or_else(|| DownloadError::Protocol("missing HTTP status".to_owned()))?; if status_text.len() != 3 || !status_text.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(GeoDataDownloadError::Protocol( - "invalid HTTP status".to_owned(), - )); + return Err(DownloadError::Protocol("invalid HTTP status".to_owned())); } let status = status_text .parse::() - .map_err(|_| GeoDataDownloadError::Protocol("invalid HTTP status".to_owned()))?; + .map_err(|_| DownloadError::Protocol("invalid HTTP status".to_owned()))?; let mut content_length = None; let mut transfer_encoding = None; @@ -742,34 +845,36 @@ fn parse_response_head(block: &[u8]) -> Result MAX_RESPONSE_HEADERS { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "response contains more than {MAX_RESPONSE_HEADERS} headers" ))); } if line.starts_with(' ') || line.starts_with('\t') { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "obsolete folded headers are not supported".to_owned(), )); } - let (name, value) = line.split_once(':').ok_or_else(|| { - GeoDataDownloadError::Protocol("malformed HTTP response header".to_owned()) - })?; + let (name, value) = line + .split_once(':') + .ok_or_else(|| DownloadError::Protocol("malformed HTTP response header".to_owned()))?; if !is_header_name(name.as_bytes()) { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "invalid HTTP response header name".to_owned(), )); } let value = value.trim_matches([' ', '\t']); if !is_header_value(value.as_bytes()) { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "invalid HTTP response header value".to_owned(), )); } if name.eq_ignore_ascii_case("content-length") { set_once(&mut content_length, "Content-Length")?; - content_length = Some(value.parse::().map_err(|_| { - GeoDataDownloadError::Protocol("invalid Content-Length".to_owned()) - })?); + content_length = Some( + value + .parse::() + .map_err(|_| DownloadError::Protocol("invalid Content-Length".to_owned()))?, + ); } else if name.eq_ignore_ascii_case("transfer-encoding") { set_string_once(&mut transfer_encoding, value, "Transfer-Encoding")?; } else if name.eq_ignore_ascii_case("content-encoding") { @@ -791,12 +896,9 @@ fn parse_response_head(block: &[u8]) -> Result( - slot: &mut Option, - header_name: &'static str, -) -> Result<(), GeoDataDownloadError> { +fn set_once(slot: &mut Option, header_name: &'static str) -> Result<(), DownloadError> { if slot.is_some() { - return Err(GeoDataDownloadError::Protocol(format!( + return Err(DownloadError::Protocol(format!( "duplicate {header_name} header" ))); } @@ -807,40 +909,34 @@ fn set_string_once( slot: &mut Option, value: &str, header_name: &'static str, -) -> Result<(), GeoDataDownloadError> { +) -> Result<(), DownloadError> { set_once(slot, header_name)?; *slot = Some(value.to_owned()); Ok(()) } -fn validate_trailer_line(line: &[u8]) -> Result<(), GeoDataDownloadError> { +fn validate_trailer_line(line: &[u8]) -> Result<(), DownloadError> { if !line.is_ascii() || line .first() .is_some_and(|byte| matches!(byte, b' ' | b'\t')) { - return Err(GeoDataDownloadError::Protocol( - "invalid HTTP trailer".to_owned(), - )); + return Err(DownloadError::Protocol("invalid HTTP trailer".to_owned())); } let Some(colon) = line.iter().position(|byte| *byte == b':') else { - return Err(GeoDataDownloadError::Protocol( - "malformed HTTP trailer".to_owned(), - )); + return Err(DownloadError::Protocol("malformed HTTP trailer".to_owned())); }; let name = &line[..colon]; let value = trim_ascii_whitespace(&line[colon + 1..]); if !is_header_name(name) || !is_header_value(value) { - return Err(GeoDataDownloadError::Protocol( - "invalid HTTP trailer".to_owned(), - )); + return Err(DownloadError::Protocol("invalid HTTP trailer".to_owned())); } if name.eq_ignore_ascii_case(b"content-length") || name.eq_ignore_ascii_case(b"transfer-encoding") || name.eq_ignore_ascii_case(b"content-encoding") || name.eq_ignore_ascii_case(b"location") { - return Err(GeoDataDownloadError::Protocol( + return Err(DownloadError::Protocol( "forbidden framing field in HTTP trailer".to_owned(), )); } @@ -897,36 +993,43 @@ struct BodySink { file: File, hasher: Sha256, size: u64, + limit: Option, } impl BodySink { - fn new(file: File) -> Self { + fn new(file: File, limit: Option) -> Self { Self { file, hasher: Sha256::new(), + limit, size: 0, } } - fn write(&mut self, bytes: &[u8]) -> Result<(), GeoDataDownloadError> { + fn write(&mut self, bytes: &[u8]) -> Result<(), DownloadError> { let added = u64::try_from(bytes.len()).expect("buffer length fits u64"); let next = self .size .checked_add(added) - .ok_or_else(|| GeoDataDownloadError::Protocol("body size overflows u64".to_owned()))?; + .ok_or_else(|| DownloadError::Protocol("body size overflows u64".to_owned()))?; + if self.limit.is_some_and(|limit| next > limit) { + return Err(DownloadError::Protocol( + "resource exceeds size-limit".into(), + )); + } self.file .write_all(bytes) - .map_err(GeoDataDownloadError::NetworkIo)?; + .map_err(DownloadError::NetworkIo)?; self.hasher.update(bytes); self.size = next; Ok(()) } - fn finish(mut self, path: &std::path::Path) -> Result<([u8; 32], u64), GeoDataDownloadError> { + fn finish(mut self, path: &std::path::Path) -> Result<([u8; 32], u64), DownloadError> { self.file .flush() .and_then(|()| self.file.sync_all()) - .map_err(|source| GeoDataDownloadError::File { + .map_err(|source| DownloadError::File { operation: "flush", path: path.to_path_buf(), source, @@ -961,7 +1064,7 @@ mod tests { &self, _server_name: &str, stream: BoxStream, - ) -> Result { + ) -> Result { Ok(stream) } } @@ -977,7 +1080,7 @@ mod tests { &self, server_name: &str, stream: BoxStream, - ) -> Result { + ) -> Result { self.0.lock().unwrap().push(server_name.to_owned()); Ok(stream) } @@ -1067,14 +1170,15 @@ mod tests { dispatcher: Arc, url: &str, temporary_path: PathBuf, - ) -> GeoDataDownloadRequest { - GeoDataDownloadRequest { + ) -> DownloadRequest { + DownloadRequest { dispatcher, url: url.to_owned(), etag: Some("\"old\"".to_owned()), temporary_path, timeout: Duration::from_secs(2), cancellation: CancellationToken::new(), + options: HttpOptions::default(), } } @@ -1101,7 +1205,7 @@ mod tests { let dispatcher = Arc::new(ScriptedDispatcher::new([response.to_vec()])); // The real connector would reject this plaintext in-memory peer if // HTTP accidentally attempted a TLS handshake. - let outcome = download_geodata_via_proxy(request( + let outcome = download(request( dispatcher.clone(), "http://rules.example.test/geoip.dat", path.clone(), @@ -1111,16 +1215,16 @@ mod tests { "download" => { assert!(matches!( outcome, - Ok(GeoDataDownloadOutcome::Downloaded { size: 4, .. }) + Ok(DownloadOutcome::Downloaded { size: 4, .. }) )); assert_eq!(fs::read(&path).unwrap(), b"data"); } "not-modified" => { - assert_eq!(outcome.unwrap(), GeoDataDownloadOutcome::NotModified); + assert_eq!(outcome.unwrap(), DownloadOutcome::NotModified); assert!(!path.exists()); } "truncated" => { - assert!(matches!(outcome, Err(GeoDataDownloadError::Protocol(_)))); + assert!(matches!(outcome, Err(DownloadError::Protocol(_)))); assert!(!path.exists()); } _ => unreachable!(), @@ -1162,7 +1266,7 @@ mod tests { .await .unwrap(); assert!( - matches!(outcome, GeoDataDownloadOutcome::Downloaded { final_url, .. } if final_url == "https://secure.example.test/final.dat") + matches!(outcome, DownloadOutcome::Downloaded { final_url, .. } if final_url == "https://secure.example.test/final.dat") ); assert_eq!(*connector.0.lock().unwrap(), ["secure.example.test"]); let destinations: Vec<_> = dispatcher @@ -1191,14 +1295,14 @@ mod tests { let directory = tempdir().unwrap(); let path = directory.path().join("http.new"); let dispatcher = Arc::new(ScriptedDispatcher::new([])); - let error = download_geodata_via_proxy(request( + let error = download(request( dispatcher.clone(), "http://rules.example.test/geoip.dat", path.clone(), )) .await .unwrap_err(); - assert!(matches!(error, GeoDataDownloadError::HttpsRequired)); + assert!(matches!(error, DownloadError::HttpsRequired)); assert!(dispatcher.sessions().is_empty()); assert!(!path.exists()); } @@ -1225,7 +1329,7 @@ mod tests { let expected_sha256: [u8; 32] = Sha256::digest(b"geo-data").into(); assert_eq!( outcome, - GeoDataDownloadOutcome::Downloaded { + DownloadOutcome::Downloaded { etag: Some("\"new\"".to_owned()), sha256: expected_sha256, size: 8, @@ -1286,7 +1390,7 @@ mod tests { .await .expect("conditional request succeeds"); - assert_eq!(outcome, GeoDataDownloadOutcome::NotModified); + assert_eq!(outcome, DownloadOutcome::NotModified); assert!(!temporary_path.exists()); } @@ -1309,7 +1413,7 @@ mod tests { .expect("content-length body"); assert!(matches!( content_length, - GeoDataDownloadOutcome::Downloaded { size: 5, .. } + DownloadOutcome::Downloaded { size: 5, .. } )); assert_eq!( fs::read(content_length_path).expect("length file"), @@ -1330,10 +1434,7 @@ mod tests { ) .await .expect("EOF body"); - assert!(matches!( - eof, - GeoDataDownloadOutcome::Downloaded { size: 8, .. } - )); + assert!(matches!(eof, DownloadOutcome::Downloaded { size: 8, .. })); assert_eq!(fs::read(eof_path).expect("EOF file"), b"eof-body"); } @@ -1356,7 +1457,7 @@ mod tests { .await .unwrap(); assert!( - matches!(outcome, GeoDataDownloadOutcome::Downloaded { size, .. } if size == length as u64) + matches!(outcome, DownloadOutcome::Downloaded { size, .. } if size == length as u64) ); assert_eq!(fs::metadata(temporary_path).unwrap().len(), length as u64); } @@ -1378,7 +1479,7 @@ mod tests { ) .await .unwrap_err(); - assert!(matches!(error, GeoDataDownloadError::Protocol(_))); + assert!(matches!(error, DownloadError::Protocol(_))); assert!(!temporary_path.exists()); } @@ -1400,7 +1501,7 @@ mod tests { .await .expect_err("HTTP redirect must be rejected"); - assert!(matches!(error, GeoDataDownloadError::HttpsRequired)); + assert!(matches!(error, DownloadError::HttpsRequired)); assert_eq!(dispatcher.sessions().len(), 1); assert!(!temporary_path.exists()); } @@ -1422,7 +1523,7 @@ mod tests { .await .expect_err("cancelled request"); - assert!(matches!(error, GeoDataDownloadError::Cancelled)); + assert!(matches!(error, DownloadError::Cancelled)); assert!(dispatcher.sessions().is_empty()); assert!(!temporary_path.exists()); } @@ -1443,12 +1544,58 @@ mod tests { assert!(matches!( error, - GeoDataDownloadError::TimedOut(duration) + DownloadError::TimedOut(duration) if duration == Duration::from_millis(10) )); assert!(!temporary_path.exists()); } + #[tokio::test] + async fn shared_transport_applies_headers_size_limit_and_drop_cleanup() { + let root = tempdir().unwrap(); + let dispatcher = Arc::new(ScriptedDispatcher::new([ + b"HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\ndata".to_vec(), + ])); + let path = root.path().join("candidate"); + let mut input = request( + dispatcher.clone(), + "http://192.0.2.1/provider", + path.clone(), + ); + input.etag = None; + input.options = HttpOptions { + user_agent: "shared-fixture".into(), + headers: vec![("X-Fixture".into(), "enabled".into())], + size_limit: Some(3), + allow_http: true, + allow_ip: true, + }; + assert!( + download_with_connector(input, &PlainHttpsConnector) + .await + .is_err() + ); + assert!(!path.exists()); + let requests = dispatcher.requests.lock().unwrap(); + let headers = String::from_utf8_lossy(&requests[0]); + assert!(headers.contains("User-Agent: shared-fixture\r\n")); + assert!(headers.contains("X-Fixture: enabled\r\n")); + assert!(!headers.contains("If-None-Match")); + drop(requests); + let mut future = Box::pin(download_with_connector( + request( + Arc::new(HangingDispatcher), + "https://rules.example.test/resource", + path.clone(), + ), + &PlainHttpsConnector, + )); + assert!(futures_util::poll!(&mut future).is_pending()); + assert!(path.exists()); + drop(future); + assert!(!path.exists()); + } + #[test] fn parser_rejects_ambiguous_or_unbounded_framing() { let both = parse_response_head( @@ -1457,7 +1604,7 @@ mod tests { .expect("head parses"); assert!(matches!( body_framing(&both), - Err(GeoDataDownloadError::Protocol(_)) + Err(DownloadError::Protocol(_)) )); let length = parse_response_head(b"HTTP/1.1 200 OK\r\nContent-Length: 33554433") @@ -1476,27 +1623,27 @@ mod tests { fn url_and_etag_validation_preserve_the_geodata_contract() { #[cfg(not(feature = "benchmark-geodata-http"))] assert!(matches!( - parse_geodata_url("http://example.test/file"), - Err(GeoDataDownloadError::HttpsRequired) + parse_resource_url("http://example.test/file"), + Err(DownloadError::HttpsRequired) )); assert!(matches!( - parse_geodata_url("https://127.0.0.1/file"), - Err(GeoDataDownloadError::DomainRequired) + parse_resource_url("https://127.0.0.1/file"), + Err(DownloadError::DomainRequired) )); assert!(matches!( - parse_geodata_url("https://user@example.test/file"), - Err(GeoDataDownloadError::CredentialsNotAllowed) + parse_resource_url("https://user@example.test/file"), + Err(DownloadError::CredentialsNotAllowed) )); assert_eq!( - parse_geodata_url("https://example.test/file#fragment") + parse_resource_url("https://example.test/file#fragment") .unwrap() .as_str(), "https://example.test/file" ); - assert!(parse_geodata_url("ftp://example.test/file").is_err()); + assert!(parse_resource_url("ftp://example.test/file").is_err()); assert!(matches!( validate_etag(Some("\"ok\"\r\nX-Evil: yes")), - Err(GeoDataDownloadError::InvalidEtag) + Err(DownloadError::InvalidEtag) )); } @@ -1507,7 +1654,7 @@ mod tests { ("https://example.test:80/file", 80, "example.test:80"), ("https://example.test:8443/file", 8443, "example.test:8443"), ] { - let endpoint = DownloadEndpoint::from_url(&parse_geodata_url(raw).unwrap()).unwrap(); + let endpoint = DownloadEndpoint::from_url(&parse_resource_url(raw).unwrap()).unwrap(); assert_eq!(endpoint.port, port); assert_eq!(endpoint.authority, authority); } @@ -1518,7 +1665,7 @@ mod tests { ("http://example.test:443/file", 443, "example.test:443"), ] { let endpoint = - DownloadEndpoint::from_url(&parse_geodata_url(raw).unwrap()).unwrap(); + DownloadEndpoint::from_url(&parse_resource_url(raw).unwrap()).unwrap(); assert_eq!(endpoint.port, port); assert_eq!(endpoint.authority, authority); } @@ -1529,9 +1676,9 @@ mod tests { "http://@example.test/file", "http://example.test/file#fragment", ] { - assert!(parse_geodata_url(raw).is_err()); + assert!(parse_resource_url(raw).is_err()); } - let base = parse_geodata_url("http://example.test/start").unwrap(); + let base = parse_resource_url("http://example.test/start").unwrap(); assert_eq!( resolve_redirect(&base, "/next").unwrap().as_str(), "http://example.test/next" diff --git a/src/resource/mod.rs b/src/resource/mod.rs new file mode 100644 index 0000000..dbd3392 --- /dev/null +++ b/src/resource/mod.rs @@ -0,0 +1,2 @@ +//! Resource acquisition shared by GeoData and providers. +pub(crate) mod http; diff --git a/src/runtime.rs b/src/runtime.rs index 6f6ce04..1bda7ac 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -422,13 +422,16 @@ impl PreparedCore { self.config .geodata_update .as_ref() - .filter(|update| update.auto_update && required) + .filter(|_| required) .map(|update| { GeoDataUpdateService::new( self.geodata_manager.clone(), geodata_dispatcher, self.geodata_registration.updater_lease(), update.urls.clone(), + self.config.global_ua.clone(), + self.config.etag_support, + update.auto_update && update.interval_hours > 0, ) }) }, @@ -1191,7 +1194,18 @@ fn prepare_routing( let requirements = GeoRequirements::for_config(config) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let geodata_registration = geodata_manager - .register(requirements) + .register_with_interval( + requirements, + Duration::from_secs( + config + .geodata_update + .as_ref() + .map_or(crate::config::GEO_UPDATE_INTERVAL_HOURS, |update| { + update.interval_hours + }) + * 3600, + ), + ) .map_err(io::Error::other)?; // GeoData must inspect the normalized rule specifications first. Once that // borrowing load is complete, move the rules directly into the compiled @@ -1954,7 +1968,7 @@ dns: #[cfg(feature = "outbound-vless")] #[tokio::test] - async fn geodata_updater_requires_enabled_config_and_actual_demand() { + async fn geodata_updater_fetches_missing_assets_and_requires_actual_demand() { fn configured(auto_update: bool, geodata_rule: bool) -> String { let fields = format!( r#"geox-url: @@ -1990,8 +2004,8 @@ geo-update-interval: 24 let missing_config_with_demand = config_with_rules("rules:\n - GEOIP,cn,proxy\n - MATCH,proxy\n"); - assert!(updater_for(&missing_config_with_demand).await.is_none()); - assert!(updater_for(&configured(false, true)).await.is_none()); + assert!(updater_for(&missing_config_with_demand).await.is_some()); + assert!(updater_for(&configured(false, true)).await.is_some()); assert!(updater_for(&configured(true, false)).await.is_none()); let updater = updater_for(&configured(true, true)) diff --git a/tests/README.md b/tests/README.md index d7112bb..aa46d0e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,7 @@ cargo test --locked --no-default-features --features cli --bin vole cli::tests:: cargo test --locked --no-default-features --features cli --lib invoke::foreground::tests:: cargo test --locked --lib config::tests:: cargo test --locked --lib geodata::tests:: +cargo test --locked --all-features --lib resource::http::tests:: cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 34d99408de2d353c65342de51a83c95148be27fc Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 18:14:19 +0800 Subject: [PATCH 005/445] Add provider generations and dynamic select groups Support proxy and rule resources, MRS, RULE-SET DNS policies, transactional refresh and generation-owned sessions. Preserve usable resources on failed updates. Validated 88 config cases, three new provider/MRS memory regressions, existing group memory cases, all-target compilation, all-feature clippy, minimal CLI check and the locked TLS graph audit. Network and device paths were not run. --- .github/workflows/test.yml | 2 +- AGENTS.md | 2 +- CONTEXT.md | 5 +- Cargo.lock | 29 + Cargo.toml | 1 + ...t-proxy-groups-as-session-route-targets.md | 2 + docs/config.yaml | 45 +- docs/controller-api.md | 23 +- src/config/measure.rs | 4 +- src/config/mod.rs | 68 +- src/config/providers.rs | 658 ++++++++++++++++++ src/config/rule_provider.rs | 408 +++++++++++ src/config/rules.rs | 10 +- src/controller.rs | 80 ++- src/dns/mod.rs | 2 +- src/dns/policy.rs | 22 +- src/dns/runtime.rs | 18 +- src/geodata/manager.rs | 24 + src/geodata/mod.rs | 28 + src/outbound/connector.rs | 23 + src/resource/cache.rs | 476 +++++++++++++ src/resource/control.rs | 20 + src/resource/http.rs | 2 +- src/resource/mod.rs | 2 + src/routing/dispatcher.rs | 3 + src/routing/mod.rs | 18 +- src/routing/proxy_group.rs | 30 +- src/runtime.rs | 333 ++++++--- src/runtime/providers.rs | 584 ++++++++++++++++ src/runtime_group_tests.rs | 126 +--- src/runtime_lifecycle_tests.rs | 1 + src/tun_runtime.rs | 55 +- src/tun_runtime/tests.rs | 5 + tests/README.md | 2 + 34 files changed, 2832 insertions(+), 279 deletions(-) create mode 100644 src/config/providers.rs create mode 100644 src/config/rule_provider.rs create mode 100644 src/resource/cache.rs create mode 100644 src/resource/control.rs create mode 100644 src/runtime/providers.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d35709..4a23dbf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -191,7 +191,7 @@ jobs: transport::sing_mux::smux_driver::tests:: transport::sing_mux::tests:: platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: platform::windows_wintun_io::tests:: tun_runtime::tests:: - invoke::foreground::tests:: runtime::shutdown_tests:: + invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: ) for filter in "${filters[@]}"; do run_test 1 --lib "$filter"; done run_test 1 --bin vole cli::tests:: diff --git a/AGENTS.md b/AGENTS.md index 6a892d0..666c939 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Read the relevant document completely before changing that area: - `src/tun_runtime.rs` connects platform raw-IP I/O to `vole-netstack` and dispatches TCP, UDP, DNS, ICMP, and sniffing. - `src/platform/` contains platform adapters. Keep framing internal, propagate configured MTU, and preserve original borrowed-fd flags and ownership. Windows backend selection occurs at compilation; keep Windows callback semantics here instead of simulating a Unix fd. - `src/dialer.rs` is the shared physical TCP/UDP socket seam. Fix socket protection or Windows `(source IP, interface index)` binding once here rather than in each outbound. -- Static `select` groups share Running Session selection between routing and `dialer-proxy`. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; resolve DIRECT server endpoints on demand through the proxy DNS role. `measureDelay` remains node-only. +- Static and provider-backed `select` groups share Running Session selection between routing and `dialer-proxy`. Provider candidates validate the complete graph before publication; existing sessions retain their old resource generation until release. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; resolve DIRECT server endpoints on demand through the proxy DNS role. `measureDelay` remains node-only. - `crates/vole-netstack` is platform-independent raw-IP state and must not depend on WinRT, JNI, Swift, or host UI frameworks. - Core runtime lifecycle does not infer App, extension, service, or daemon roles and does not implement cross-process state or IPC. The Windows-only host Invoke is the explicit package integration seam for profile/status/Session Snapshot/StartupTask operations and an optional bounded `sessionBackend`; its Session Host owns backend process liveness without interpreting arguments, files, ports, or protocols. Provider runtime state remains process-local. diff --git a/CONTEXT.md b/CONTEXT.md index 424f82a..05b8fa5 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -73,7 +73,7 @@ A named, concrete outbound protocol path that can be selected as a route target _Avoid_: Proxy when a proxy group or route target is intended, server profile **Proxy group**: -A named route or upstream target whose current-session selection identifies one direct member. Routing and `dialer-proxy` share the same selection; an upstream group's DIRECT member connects the current node's prepared server. +A named route or upstream target whose current-session selection identifies one direct member. Routing and `dialer-proxy` share the same selection; an upstream group's DIRECT member connects the current node's server resolved on demand. _Avoid_: Folder, provider, subscription **Route target**: @@ -83,3 +83,6 @@ _Avoid_: Proxy when the distinction between a node and group matters, dialer **Group selection**: The running-session state identifying the direct member route target currently chosen by a proxy group. _Avoid_: Variant, configuration switch, active profile + +**Resource generation**: +A validated combination of provider content, proxy nodes, groups, rules and DNS policies. New sessions use the published generation; existing sessions retain their dependencies until completion. Failed candidates preserve the usable generation and its HTTP cache. diff --git a/Cargo.lock b/Cargo.lock index 3ea3d09..de3d454 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2693,6 +2693,7 @@ dependencies = [ "windows-core 0.62.2", "yamux", "zeroize", + "zstd", ] [[package]] @@ -3350,3 +3351,31 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 4a583b7..7d0c91f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -135,6 +135,7 @@ vole-blake3-raw = { path = "crates/vole-blake3-raw", optional = true } webpki-roots = "1" yamux = { version = "=0.14.1", optional = true } zeroize = { version = "=1.9.0", optional = true } +zstd = { version = "0.14.0", default-features = false } [target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "macos"))'.dependencies] oslog = { version = "0.2", default-features = false } diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index 619623c..e490c52 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -13,3 +13,5 @@ status: accepted 路由组保留 `Dispatcher` seam;组上游在 `OutboundConnector` seam 解析,共享同一个原子选择索引,但不重新执行业务规则,不丢弃 effective peer、UDP 预算或建链期限。协议配置保持不可变,组连接器只持有声明的依赖;依赖优先构造、逆序停止与释放,避免全局节点注册表形成 Arc 环或深图递归析构。 上游组的 DIRECT 连接当前节点服务器,不是业务目标;REJECT 直接使建链失败。节点的主端点与独立下载端点在实际建链时通过运行时节点 resolver 解析;准备阶段不预解析潜在首跳。未选成员的 DNS 故障不阻塞启动。仅经代理访问的节点保持逻辑域名。DNS 关闭时使用系统解析,开启时使用配置的节点 nameserver/policy,不因失败切换系统解析;所有路径共享建链期限、递归依赖检查和停止取消。独立 measureDelay 的节点准备仍使用受控引导 resolver。 + +Provider 成员通过完整资源候选生成。候选重新校验名称及节点/组依赖图后发布,新连接使用当前资源,既有 TCP/UDP 与在途 DNS 保留仍使用的旧资源;不再使用的资源按原停止屏障释放。失败候选不替换可用配置或 HTTP 缓存。动态组移除当前成员时回到有效默认项或首成员,空组按 empty-fallback 处理。下载使用已发布资源,不依赖候选发布完成。 diff --git a/docs/config.yaml b/docs/config.yaml index b4c1ed2..c42c8b5 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -401,12 +401,14 @@ proxies: # name-cert-verify: verified.example.com # 可选独立证书验证名 # client-fingerprint: chrome # 仅有插件时允许;共享七值,缺省/none/空串关闭命名模板 -# 可选的静态 `select` 代理组。字段外形是严格 Mihomo 子集:每组只接受 name、 -# type、proxies 和可选 default-selected,type 只能是 select,proxies 至少一项。 -# 成员可精确引用具体节点、另一个 select 组、DIRECT 或 REJECT。 -# RULES 仅在 DNS fragment 中具有特殊含义;在成员列表中按已配置名称解析。 -# 组声明顺序、成员顺序和重复成员全部保留,节点与组的完整混合图必须无环;组数、成员数和嵌套 -# 深度没有独立上限,仍受配置总大小和 O(V+E) 无环校验约束。 +# select 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, +# 也可分别使用 include-all-proxies / include-all-providers。后者启用时忽略 use。 +# filter 筛选动态成员;exclude-filter / exclude-type 同时排除显式成员。 +# 正则支持 Rust regex 子集,区分大小写;反引号分隔的多表达式按匹配顺序排列。 +# lookaround/backreference 明确拒绝。静态成员保留顺序与重复项。 +# 动态组为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 +# disable-udp 缺省 false;true 拒绝通过该组的业务 UDP。hidden/icon 为 Controller 元数据。 +# 节点与组的完整混合图必须无环。资源更新后保留仍存在的当前选择,否则使用默认/首成员。 # 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 # 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 # @@ -439,6 +441,35 @@ proxy-groups: proxies: [vless-edge, fallback-select, DIRECT, REJECT, vless-edge] default-selected: vless-edge +# 资源声明及使用示例。http 支持 HTTP(S),file 读取本地文件,inline 使用 payload。 +# path 必须位于 dataDir 内;HTTP 缺省路径在 proxies/ 或 rules/ 下按 URL 分配。 +# HTTP interval 单位秒,0 只获取一次;file 监测文件变化。下载失败退避重试。 +# proxy 指定当前运行配置中的出口;缺省按规则选路。不会等待新资源才能建立下载连接。 +# header 值为字符串数组;size-limit 单位字节,0 不设下载上限,解析仍有容量边界。 +# proxy provider 文件为 {proxies: [...]};可用 filter/exclude-filter/exclude-type。 +# override 支持 udp、udp-over-tcp、up/down、dialer-proxy、skip-cert-verify、 +# additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]; +# 协议专属覆盖只用于支持该字段的节点。未实现的覆盖字段严格拒绝。 +# 更新先解析并校验整个依赖图,成功才替换;失败保留旧配置与 HTTP 缓存。 +# 新连接使用新资源,旧 TCP/UDP 会话保留原资源;停止等待下载、解析和资源释放。 +# rule providers 支持 domain/ipcidr/classical,format 为 yaml/text/mrs;MRS 仅 domain/ipcidr。 +# YAML 规则文件为 {payload: [...]},text 每行一条,支持空行和 # 注释。 +# classical 接受已支持的无动作规则,不接受 MATCH、RULE-SET、SUB-RULE。 +# 文本/MRS 解压上限 64 MiB;YAML 沿用 8 MiB 输入和 32 MiB 展开预算。 +proxy-providers: + local-nodes: + type: inline + payload: [] +rule-providers: + local-domains: + type: inline + behavior: domain + payload: ["+.internal.example"] +# 动态组写法:{name: automatic-members, type: select, use: [local-nodes]} +# 规则写法:RULE-SET,local-domains,DIRECT;ipcidr/classical 可追加 no-resolve。 +# DNS policy 支持 rule-set:local-domains(逗号分隔多个集合);只接受 domain/classical, +# classical 在 DNS 场景仅使用域名与 GeoSite 条件。 + # hosts 支持单 IP、IP 数组、域名别名与 Clash 域名通配;不支持动态 lan 展开。 hosts: "internal.example": [192.0.2.10] @@ -489,7 +520,7 @@ dns: # 位置,命中后其后规则不再执行。DIRECT、REJECT 是内置动作,其他动作精确引用节点或组。 # 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 # GEOSITE、GEOIP、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 -# AND/OR/NOT 和 MATCH。SRC-IP-CIDR 支持 IPv4/IPv6,来源信息来自实际入站会话。 +# RULE-SET、AND/OR/NOT 和 MATCH。SRC-IP-CIDR 支持 IPv4/IPv6,来源信息来自实际入站会话。 # 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 忽略大小写,支持 regex crate # 子集,不支持 lookaround/backreference,无法编译的模式明确报错。 # 逻辑规则例如 AND,((DOMAIN-SUFFIX,example.com),(SRC-PORT,1000-2000)),DIRECT; diff --git a/docs/controller-api.md b/docs/controller-api.md index a943cdb..6b89ea0 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -1,6 +1,6 @@ # 运行时 Controller API -Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态 `select` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 +Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态与动态 `select` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 ## 配置与生命周期 @@ -121,7 +121,7 @@ Content-Length: 26 ## 选择生效边界 -代理组成员列表在配置期固定,只有当前选择可在 Running Session 内改变: +静态成员来自配置,动态成员随 provider 成功更新;Controller 读取当前可用的成员和选择: - 新建的物理 TCP 连接使用切换后的当前叶节点;既有 TCP 连接保持原路径; - 新建的 UDP association/物理 transport 使用切换后的当前叶节点;既有 association 不迁移; @@ -129,10 +129,25 @@ Content-Length: 26 - 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML 或下一次 session 的默认值; - 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。失败按原样返回,不自动选择其他成员。 -`dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点预解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。 +`dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点按需解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。 Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包括复用该会话的新业务流和端口跳跃所需的新物理 socket;这些不是新的认证会话,不能因切组而迁移原路径。只有重新建立的认证会话才读取新的上游组选择。节点本身作为路由组成员时,后续业务的路由选节点仍按当前组选择执行。 +## Provider 资源 + +`GET /providers/proxies` 与 `GET /providers/rules` 返回 `{"providers":{"name":{...}}}`; +`GET /providers/{proxies|rules}/{name}` 查询单个资源,`PUT` 同一路径主动刷新,成功返回 204。 +配置缺失返回 404,下载、解析或候选校验失败返回 503,旧资源继续服务。 + +状态包含 `name/type/vehicleType`、`available`、`downloaded`、`updating`、`updatedAt`、 +`count` 与脱敏的 `error`。`updatedAt` 为 Unix 秒,未成功获取时为空;inline 或 bundled payload +可以在未下载时可用。响应不包含订阅 URL、认证头或节点凭据。 + +更新将节点、组和规则作为完整候选检查,发布时保留仍存在的当前选择。 +既有会话持有旧资源,最后一个使用者释放后回收;下载通过当前可用的运行配置建立出口, +不等待候选资源。空动态组使用配置的 `empty-fallback`,默认 COMPATIBLE(直连)。 +组响应另含 `hidden` 与 `icon`,`disable-udp` 会拒绝该组的业务 UDP。 + ## HTTP 状态与协议边界 | 状态 | 含义 | @@ -149,7 +164,7 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 | `413 Payload Too Large` | PUT 正文超过 1 KiB | | `415 Unsupported Media Type` | PUT 不是严格 `application/json` | -第一版不提供 provider/`use`、health check、自动 failover、`url-test`/`fallback`/`load-balance`、delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 +当前不提供 health check、自动 failover、`url-test`/`fallback`/`load-balance`、delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的 provider 路由、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; diff --git a/src/config/measure.rs b/src/config/measure.rs index 64cc0dd..f650c86 100644 --- a/src/config/measure.rs +++ b/src/config/measure.rs @@ -200,10 +200,10 @@ proxies: } #[test] - fn rejects_non_json_yaml() { + fn accepts_bounded_yaml_templates() { assert!( MeasureConfig::parse_yaml(NODE.replace("name: proxy", "name: &tag proxy").as_bytes()) - .is_err() + .is_ok() ); } diff --git a/src/config/mod.rs b/src/config/mod.rs index 6124348..b61789f 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -18,7 +18,10 @@ use uuid::Uuid; use crate::{Result, VoleError}; mod dns; +pub mod providers; +mod rule_provider; mod rules; +pub use rule_provider::RuleCollection; mod yaml; pub use rules::DomainRegex; use rules::parse_rule; @@ -78,6 +81,7 @@ pub struct Config { pub sniffer: SnifferConfig, pub dns: DnsConfig, pub rules: Vec, + pub providers: Option>, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] @@ -313,13 +317,17 @@ pub struct DnsNameserverPolicy { pub enum DnsPolicySelector { GeoSite(Box<[String]>), Domains(Box<[String]>), + RuleSets { + names: Box<[String]>, + sets: Vec>, + }, } impl DnsNameserverPolicy { pub fn geosite_codes(&self) -> &[String] { match &self.selector { DnsPolicySelector::GeoSite(codes) => codes, - DnsPolicySelector::Domains(_) => &[], + DnsPolicySelector::Domains(_) | DnsPolicySelector::RuleSets { .. } => &[], } } } @@ -349,6 +357,10 @@ pub struct RuleSpec { #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleKind { + RuleSet { + name: String, + content: Option>, + }, Domain(String), DomainSuffix(String), DomainKeyword(String), @@ -440,6 +452,9 @@ pub struct SelectProxyGroupConfig { pub name: String, pub members: Vec, pub initial_member: usize, + pub disable_udp: bool, + pub hidden: bool, + pub icon: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -915,6 +930,12 @@ struct RawVoleConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawProxyGroup { + #[serde(rename = "disable-udp", default)] + disable_udp: bool, + #[serde(default)] + hidden: bool, + #[serde(default)] + icon: String, name: String, #[serde(rename = "type")] kind: String, @@ -1615,12 +1636,8 @@ where impl Config { pub fn parse_yaml(input: &[u8]) -> Result { let yaml = yaml::parse(input)?; - // Deserialize from the YAML value directly. Mapping order is part of - // nameserver-policy semantics and would be lost by the intermediate - // serde_json map representation. - let raw: RawVoleConfig = serde_yaml_ng::from_value(yaml) - .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; - raw.normalize() + let template = std::sync::Arc::new(providers::ProviderTemplate::extract(yaml)?); + template.materialize(&providers::ProviderContents::new()) } #[cfg(any(feature = "invoke", test))] pub(crate) fn take_runtime_rules(&mut self) -> Vec { @@ -1682,6 +1699,9 @@ impl RawVoleConfig { let generated_global = !raw_groups.iter().any(|group| group.name == "GLOBAL"); if generated_global { raw_groups.push(RawProxyGroup { + disable_udp: false, + hidden: false, + icon: String::new(), name: "GLOBAL".into(), kind: "select".into(), proxies: vec!["DIRECT".into(), "REJECT".into()], @@ -1758,6 +1778,7 @@ impl RawVoleConfig { sniffer, dns, rules, + providers: None, }) } } @@ -2160,7 +2181,7 @@ fn normalize_proxy_groups( .map(|name| { validate_route_target_name(&name, "proxy group member")?; let target = match name.as_str() { - "DIRECT" => ProxyGroupMemberTarget::Direct, + "DIRECT" | "COMPATIBLE" => ProxyGroupMemberTarget::Direct, "REJECT" => ProxyGroupMemberTarget::Reject, _ => route_targets .get(&name) @@ -2192,6 +2213,9 @@ fn normalize_proxy_groups( name: group.name, members, initial_member, + disable_udp: group.disable_udp, + hidden: group.hidden, + icon: group.icon, }) }) .collect::>>()?; @@ -2875,8 +2899,15 @@ fn normalize_dns_policy_selector(input: &str) -> Result { .collect::>>()?; return Ok(DnsPolicySelector::GeoSite(codes.into_boxed_slice())); } - if lower.starts_with("rule-set:") { - return invalid("dns.nameserver-policy rule-set requires unsupported rule-providers"); + if let Some(selector) = input.strip_prefix("rule-set:") { + let names = selector.split(',').map(str::to_owned).collect::>(); + if names.iter().any(|name| name.is_empty()) { + return invalid("DNS policy rule-set requires provider names"); + } + return Ok(DnsPolicySelector::RuleSets { + names: names.into_boxed_slice(), + sets: Vec::new(), + }); } let domains = lower .split(',') @@ -3551,23 +3582,14 @@ geo-update-interval: 24"#, } #[test] - fn rejects_incomplete_or_extended_geox_url_maps() { - for fields in [ - r#"geox-url: - geoip: https://geo.example.test/geoip.dat -geo-auto-update: true -geo-update-interval: 24"#, - r#"geox-url: - geosite: https://geo.example.test/geosite.dat -geo-auto-update: true -geo-update-interval: 24"#, - r#"geox-url: + fn rejects_extended_geox_url_maps() { + for fields in [r#"geox-url: geoip: https://geo.example.test/geoip.dat geosite: https://geo.example.test/geosite.dat mmdb: https://geo.example.test/country.mmdb geo-auto-update: true -geo-update-interval: 24"#, - ] { +geo-update-interval: 24"#] + { assert!( Config::parse_yaml(with_geodata(fields).as_bytes()).is_err(), "{fields}" diff --git a/src/config/providers.rs b/src/config/providers.rs new file mode 100644 index 0000000..30f4ba9 --- /dev/null +++ b/src/config/providers.rs @@ -0,0 +1,658 @@ +//! Provider declarations and deterministic materialization of one resource generation. +use super::*; +use serde::Deserialize; +use serde_yaml_ng::{Mapping, Value}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProviderType { + Http, + File, + Inline, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleBehavior { + Domain, + Ipcidr, + Classical, +} +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RuleFormat { + #[default] + Yaml, + Text, + Mrs, +} + +#[derive(Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct ProviderSpec { + #[serde(rename = "type")] + pub kind: ProviderType, + #[serde(default)] + pub url: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub interval: u64, + #[serde(default)] + pub proxy: String, + #[serde(default)] + pub header: BTreeMap>, + #[serde(default)] + pub size_limit: u64, + #[serde(default)] + pub payload: Vec, + pub behavior: Option, + #[serde(default)] + pub format: RuleFormat, + #[serde(default)] + pub filter: String, + #[serde(default)] + pub exclude_filter: String, + #[serde(default)] + pub exclude_type: String, + #[serde(default, rename = "override")] + pub overrides: BTreeMap, +} +impl std::fmt::Debug for ProviderSpec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderSpec") + .field("kind", &self.kind) + .field("behavior", &self.behavior) + .finish_non_exhaustive() + } +} +impl ProviderSpec { + fn validate(&self, is_rule: bool) -> Result<()> { + if is_rule != self.behavior.is_some() { + return invalid( + "rule providers require behavior; proxy providers do not accept behavior", + ); + } + if self.behavior == Some(RuleBehavior::Classical) && self.format == RuleFormat::Mrs { + return invalid("classical providers do not support mrs"); + } + if !is_rule && self.format != RuleFormat::Yaml { + return invalid("proxy providers use YAML"); + } + if is_rule + && (!self.filter.is_empty() + || !self.exclude_filter.is_empty() + || !self.exclude_type.is_empty() + || !self.overrides.is_empty()) + { + return invalid("rule providers do not accept proxy filters or overrides"); + } + match self.kind { + ProviderType::Http => { + let options = crate::resource::http::HttpOptions { + allow_http: true, + allow_ip: true, + ..Default::default() + }; + crate::resource::http::parse_url(&self.url, &options).map_err(|_| { + VoleError::InvalidConfig( + "provider requires an absolute HTTP(S) URL without credentials".into(), + ) + })?; + } + ProviderType::File if self.path.is_empty() => { + return invalid("file provider requires path"); + } + _ => {} + } + Filter::new(&self.filter, &self.exclude_filter, &self.exclude_type)?; + for key in self.overrides.keys() { + if ![ + "udp", + "udp-over-tcp", + "up", + "down", + "dialer-proxy", + "skip-cert-verify", + "additional-prefix", + "additional-suffix", + "proxy-name", + ] + .contains(&key.as_str()) + { + return invalid(format!("unsupported provider override `{key}`")); + } + } + validate_overrides(&self.overrides)?; + // Validate request metadata even when the initial content comes from cache. + crate::resource::http::validate_options(&crate::resource::http::HttpOptions { + headers: self.headers(), + ..Default::default() + }) + .map_err(|_| VoleError::InvalidConfig("invalid provider header".into()))?; + Ok(()) + } + pub(crate) fn headers(&self) -> Vec<(String, String)> { + self.header + .iter() + .flat_map(|(name, values)| { + values + .iter() + .map(move |value| (name.clone(), value.clone())) + }) + .collect() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ProviderTemplate { + base: Value, + pub proxies: Vec<(String, ProviderSpec)>, + pub rules: Vec<(String, ProviderSpec)>, +} +impl std::fmt::Debug for ProviderTemplate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderTemplate") + .field("proxies", &self.proxies.len()) + .field("rules", &self.rules.len()) + .finish_non_exhaustive() + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProviderContent { + Proxies(Vec), + Rules(Arc), +} +pub type ProviderContents = BTreeMap; + +impl ProviderTemplate { + pub(super) fn extract(mut base: Value) -> Result { + let map = base + .as_mapping_mut() + .ok_or_else(|| VoleError::InvalidConfig("configuration must be a mapping".into()))?; + let mut take = |key: &str, is_rule| -> Result> { + let Some(value) = map.remove(key) else { + return Ok(Vec::new()); + }; + let entries = value + .as_mapping() + .ok_or_else(|| VoleError::InvalidConfig("providers must be a mapping".into()))?; + entries + .iter() + .map(|(name, spec)| { + let name = name + .as_str() + .filter(|name| !name.is_empty()) + .ok_or_else(|| { + VoleError::InvalidConfig( + "provider name must be a nonempty string".into(), + ) + })? + .to_owned(); + let spec: ProviderSpec = serde_yaml_ng::from_value(spec.clone()) + .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + spec.validate(is_rule)?; + Ok((name, spec)) + }) + .collect() + }; + let proxies = take("proxy-providers", false)?; + let rules = take("rule-providers", true)?; + Ok(Self { + base, + proxies, + rules, + }) + } + pub fn has_resources(&self) -> bool { + !self.proxies.is_empty() || !self.rules.is_empty() + } + pub(crate) fn key(is_rule: bool, name: &str) -> String { + format!("{}/{name}", if is_rule { "rules" } else { "proxies" }) + } + pub(crate) fn parse_content( + &self, + spec: &ProviderSpec, + bytes: &[u8], + ) -> Result { + if let Some(behavior) = spec.behavior { + return RuleCollection::parse(bytes, behavior, spec.format) + .map(|data| ProviderContent::Rules(Arc::new(data))); + } + #[derive(Deserialize)] + struct Payload { + proxies: Vec, + } + let raw = yaml::parse(bytes)?; + let parsed: Payload = serde_yaml_ng::from_value(raw) + .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + Ok(ProviderContent::Proxies(parsed.proxies)) + } + fn bundled(&self, spec: &ProviderSpec) -> Result { + if let Some(behavior) = spec.behavior { + let items = spec + .payload + .iter() + .map(|v| { + v.as_str().map(str::to_owned).ok_or_else(|| { + VoleError::InvalidConfig( + "rule provider payload must contain strings".into(), + ) + }) + }) + .collect::>>()?; + Ok(ProviderContent::Rules(Arc::new( + RuleCollection::from_payload(items, behavior)?, + ))) + } else { + Ok(ProviderContent::Proxies(spec.payload.clone())) + } + } + pub fn materialize(self: &Arc, contents: &ProviderContents) -> Result { + let mut value = self.base.clone(); + let base = value.as_mapping_mut().expect("validated mapping"); + let mut proxies: Vec = base + .get("proxies") + .map(|v| serde_yaml_ng::from_value(v.clone())) + .transpose() + .map_err(|error| VoleError::InvalidConfig(error.to_string()))? + .unwrap_or_default(); + let static_names = proxies.iter().map(node_name).collect::>>()?; + let mut providers = HashMap::new(); + for (name, spec) in &self.proxies { + let fallback = self.bundled(spec)?; + let ProviderContent::Proxies(nodes) = + contents.get(&Self::key(false, name)).unwrap_or(&fallback) + else { + return invalid("provider content kind mismatch"); + }; + let filter = Filter::new(&spec.filter, &spec.exclude_filter, &spec.exclude_type)?; + let nodes = filter.select(nodes.clone(), false)?; + let mut names = Vec::new(); + for mut node in nodes { + apply_overrides(&mut node, &spec.overrides)?; + names.push(node_name(&node)?); + proxies.push(node); + } + providers.insert(name.clone(), names); + } + let types = proxies + .iter() + .map(|node| { + Ok(( + node_name(node)?, + node.get("type") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + )) + }) + .collect::>>()?; + let group_values = base + .get_mut("proxy-groups") + .and_then(Value::as_sequence_mut); + if let Some(groups) = group_values { + for group in groups { + let map = group.as_mapping_mut().ok_or_else(|| { + VoleError::InvalidConfig("proxy group must be a mapping".into()) + })?; + let declared: Vec = take_or(map, "proxies", Vec::new())?; + let mut used: Vec = take_or(map, "use", Vec::new())?; + let all = take_or(map, "include-all", false)?; + let all_proxies = take_or(map, "include-all-proxies", false)? || all; + let all_providers = take_or(map, "include-all-providers", false)? || all; + let fallback: String = take_or(map, "empty-fallback", "COMPATIBLE".to_owned())?; + let filter = Filter::new( + &take_or(map, "filter", String::new())?, + &take_or(map, "exclude-filter", String::new())?, + &take_or(map, "exclude-type", String::new())?, + )?; + if all_providers { + used = self.proxies.iter().map(|(name, _)| name.clone()).collect(); + } + let dynamic = !used.is_empty() || all_proxies || all_providers; + let mut names = declared; + if all_proxies { + names.extend( + static_names + .iter() + .filter(|name| filter.includes(name)) + .cloned(), + ); + } + let mut dynamic_names = Vec::new(); + for provider in &used { + let members = providers.get(provider).ok_or_else(|| { + VoleError::InvalidConfig(format!("unknown proxy provider `{provider}`")) + })?; + dynamic_names + .extend(members.iter().filter(|name| filter.includes(name)).cloned()); + } + filter.sort_matches(&mut dynamic_names); + names.extend(dynamic_names); + names.retain(|name| { + !filter.excludes( + name, + types.get(name).map(String::as_str).unwrap_or("Selector"), + ) + }); + if names.is_empty() { + if !dynamic { + return invalid("proxy group requires proxies or use"); + } + names.push(fallback.clone()); + } + if let Some(default) = map.get("default-selected").and_then(Value::as_str) + && dynamic + && !names.iter().any(|name| name == default) + { + map.remove("default-selected"); + } + map.insert( + "proxies".into(), + serde_yaml_ng::to_value(names).expect("string sequence"), + ); + // An empty fallback cannot itself be a group, avoiding hidden dependency cycles. + if fallback != "COMPATIBLE" + && fallback != "DIRECT" + && fallback != "REJECT" + && !types.contains_key(&fallback) + { + return invalid("empty-fallback must be a proxy node or built-in target"); + } + } + } + base.insert("proxies".into(), Value::Sequence(proxies)); + let raw: RawVoleConfig = serde_yaml_ng::from_value(value) + .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + let mut config = raw.normalize()?; + let mut sets = HashMap::new(); + for (name, spec) in &self.rules { + let fallback = self.bundled(spec)?; + let ProviderContent::Rules(rules) = + contents.get(&Self::key(true, name)).unwrap_or(&fallback) + else { + return invalid("provider content kind mismatch"); + }; + sets.insert(name.clone(), rules.clone()); + } + bind_rules(&mut config.rules, &sets)?; + for policy in config + .dns + .nameserver_policies + .iter_mut() + .chain(&mut config.dns.proxy_policies) + { + if let DnsPolicySelector::RuleSets { names, sets: bound } = &mut policy.selector { + for name in names.iter() { + let set = sets.get(name).ok_or_else(|| { + VoleError::InvalidConfig(format!("unknown rule provider `{name}`")) + })?; + if set.behavior() == RuleBehavior::Ipcidr { + return invalid("DNS policy requires a domain or classical rule provider"); + } + bound.push(set.clone()); + } + } + } + if self.has_resources() { + config.providers = Some(self.clone()); + } + Ok(config) + } +} +fn bind_rules(rules: &mut [RuleSpec], sets: &HashMap>) -> Result<()> { + for rule in rules { + match &mut rule.kind { + RuleKind::RuleSet { name, content } => { + *content = Some( + sets.get(name) + .ok_or_else(|| { + VoleError::InvalidConfig(format!("unknown rule provider `{name}`")) + })? + .clone(), + ) + } + RuleKind::And(children) | RuleKind::Or(children) => bind_rules(children, sets)?, + RuleKind::Not(child) => bind_rules(std::slice::from_mut(child), sets)?, + _ => {} + } + } + Ok(()) +} +fn take_or(map: &mut Mapping, key: &str, default: T) -> Result { + map.remove(key) + .map(|v| { + serde_yaml_ng::from_value(v) + .map_err(|error| VoleError::InvalidConfig(error.to_string())) + }) + .unwrap_or(Ok(default)) +} +fn node_name(value: &Value) -> Result { + value + .get("name") + .and_then(Value::as_str) + .filter(|v| !v.is_empty()) + .map(str::to_owned) + .ok_or_else(|| VoleError::InvalidConfig("provider node requires name".into())) +} + +struct Filter { + include: Vec, + exclude: Vec, + types: Vec, +} +impl Filter { + fn new(include: &str, exclude: &str, types: &str) -> Result { + let compile = |s: &str| -> Result> { + if s.is_empty() { + return Ok(Vec::new()); + } + s.split('`').map(|pattern| regex::Regex::new(pattern).map_err(|_| VoleError::InvalidConfig("unsupported provider/group regular expression (lookaround and backreferences are not supported)".into()))).collect() + }; + Ok(Self { + include: compile(include)?, + exclude: compile(exclude)?, + types: types + .split('|') + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(), + }) + } + fn includes(&self, name: &str) -> bool { + self.include.is_empty() || self.include.iter().any(|r| r.is_match(name)) + } + fn excludes(&self, name: &str, kind: &str) -> bool { + self.exclude.iter().any(|r| r.is_match(name)) + || self + .types + .iter() + .any(|ty| ty.eq_ignore_ascii_case(protocol_type(kind))) + } + fn sort_matches(&self, names: &mut Vec) { + if self.include.len() > 1 { + names.sort_by_key(|name| { + self.include + .iter() + .position(|r| r.is_match(name)) + .unwrap_or(usize::MAX) + }); + let mut seen = HashSet::new(); + names.retain(|name| seen.insert(name.clone())); + } + } + fn select(&self, nodes: Vec, _group: bool) -> Result> { + let mut result = Vec::new(); + for node in nodes { + let name = node_name(&node)?; + let kind = node.get("type").and_then(Value::as_str).unwrap_or_default(); + if self.includes(&name) && !self.excludes(&name, kind) { + result.push(node); + } + } + if self.include.len() > 1 { + result.sort_by_key(|node| { + self.include + .iter() + .position(|r| { + r.is_match(node.get("name").and_then(Value::as_str).unwrap_or_default()) + }) + .unwrap_or(usize::MAX) + }); + } + Ok(result) + } +} +fn protocol_type(kind: &str) -> &str { + match kind { + "ss" => "Shadowsocks", + "socks5" => "Socks5", + "http" => "Http", + other => other, + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Replacement { + pattern: String, + target: String, +} +fn validate_overrides(overrides: &BTreeMap) -> Result<()> { + for (key, value) in overrides { + let valid = match key.as_str() { + "udp" | "udp-over-tcp" | "skip-cert-verify" => value.is_bool(), + "proxy-name" => { + let replacements: Vec = serde_yaml_ng::from_value(value.clone()) + .map_err(|_| { + VoleError::InvalidConfig("invalid provider name replacements".into()) + })?; + for replacement in replacements { + regex::Regex::new(&replacement.pattern).map_err(|_| { + VoleError::InvalidConfig( + "unsupported provider name replacement expression".into(), + ) + })?; + } + true + } + _ => value.is_string(), + }; + if !valid { + return invalid(format!("invalid provider override `{key}`")); + } + } + Ok(()) +} +fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Result<()> { + let original_name = node_name(node)?; + let map = node + .as_mapping_mut() + .ok_or_else(|| VoleError::InvalidConfig("provider node must be a mapping".into()))?; + let mut name = original_name; + for (key, value) in overrides { + if ["additional-prefix", "additional-suffix", "proxy-name"].contains(&key.as_str()) { + continue; + } + let kind = map.get("type").and_then(Value::as_str).unwrap_or_default(); + let applicable = match key.as_str() { + "up" | "down" => kind == "hysteria2", + "udp-over-tcp" => kind == "ss", + "skip-cert-verify" => matches!( + kind, + "vless" | "vmess" | "trojan" | "anytls" | "hysteria2" | "tuic" | "http" + ), + _ => true, + }; + if applicable { + map.insert(Value::String(key.clone()), value.clone()); + } + } + if let Some(replacements) = overrides.get("proxy-name") { + let replacements: Vec = serde_yaml_ng::from_value(replacements.clone()) + .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + for replacement in replacements { + let regex = regex::Regex::new(&replacement.pattern).map_err(|_| { + VoleError::InvalidConfig("unsupported provider name replacement expression".into()) + })?; + name = regex + .replace_all(&name, replacement.target.as_str()) + .into_owned(); + } + } + let text = |key| -> Result<&str> { + overrides + .get(key) + .map(|v| { + v.as_str().ok_or_else(|| { + VoleError::InvalidConfig("provider name affix must be a string".into()) + }) + }) + .unwrap_or(Ok("")) + }; + map.insert( + "name".into(), + format!( + "{}{}{}", + text("additional-prefix")?, + name, + text("additional-suffix")? + ) + .into(), + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn inline_provider_groups_and_rule_policies_share_materialized_content() { + let config = Config::parse_yaml( + br#" +mixed-port: 1080 +proxy-providers: + nodes: + type: inline + filter: '^keep' + override: {udp: true, skip-cert-verify: true, additional-prefix: 'P-'} + payload: + - {name: keep-one, type: socks5, server: 192.0.2.1, port: 1080} + - {name: discard, type: socks5, server: 192.0.2.2, port: 1080} +proxy-groups: + - {name: choose, type: select, use: [nodes], hidden: true, icon: icon, disable-udp: true} + - {name: empty, type: select, use: [nodes], filter: absent} +rule-providers: + domains: {type: inline, behavior: domain, payload: ['+.example.com']} + custom: {type: inline, behavior: classical, payload: ['DOMAIN,internal.example']} +dns: + enable: true + nameserver: [1.1.1.1] + nameserver-policy: {'rule-set:domains,custom': [8.8.8.8]} +rules: ['RULE-SET,domains,choose', 'RULE-SET,custom,DIRECT,no-resolve'] +"#, + ) + .unwrap(); + assert_eq!(config.proxies.len(), 1); + assert_eq!(config.proxies[0].tag, "P-keep-one"); + assert!(config.proxies[0].udp); + assert!(config.proxy_groups[0].disable_udp); + assert!(config.proxy_groups[0].hidden); + assert_eq!(config.proxy_groups[1].members[0].name, "COMPATIBLE"); + let RuleKind::RuleSet { + content: Some(set), .. + } = &config.rules[0].kind + else { + panic!("unbound set") + }; + assert!(set.matches_domain("sub.example.com", &crate::routing::EmptyGeoMatcher)); + let DnsPolicySelector::RuleSets { sets, .. } = &config.dns.nameserver_policies[0].selector + else { + panic!("unbound policy") + }; + assert!(Arc::ptr_eq(set, &sets[0])); + } +} diff --git a/src/config/rule_provider.rs b/src/config/rule_provider.rs new file mode 100644 index 0000000..99c6660 --- /dev/null +++ b/src/config/rule_provider.rs @@ -0,0 +1,408 @@ +//! Immutable provider rule data. MRSv1 is decoded as an interoperable file format. +use super::{ + providers::{RuleBehavior, RuleFormat}, + *, +}; +use crate::{ + dns::policy::DomainIndex, + routing::{GeoMatcher, RoutingContext}, +}; +use serde::Deserialize; +use std::{ + io::{Cursor, Read}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, +}; + +const MAX_RESOURCE_BYTES: usize = 64 * 1024 * 1024; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuleCollection { + behavior: RuleBehavior, + domains: Arc, + ranges: Vec<(IpAddr, IpAddr)>, + pub(crate) classical: Vec, + count: usize, +} +impl RuleCollection { + pub fn behavior(&self) -> RuleBehavior { + self.behavior + } + pub fn len(&self) -> usize { + self.count + } + pub fn is_empty(&self) -> bool { + self.count == 0 + } + pub(crate) fn uses_ip(&self) -> bool { + self.behavior == RuleBehavior::Ipcidr + || self + .classical + .iter() + .any(crate::routing::uses_destination_ip) + } + pub(crate) fn uses_domain(&self) -> bool { + self.behavior != RuleBehavior::Ipcidr + } + pub fn parse(bytes: &[u8], behavior: RuleBehavior, format: RuleFormat) -> Result { + if bytes.len() > MAX_RESOURCE_BYTES { + return invalid("rule provider exceeds parser byte limit"); + } + if format == RuleFormat::Mrs { + return parse_mrs(bytes, behavior); + } + let values = match format { + RuleFormat::Yaml => { + #[derive(Deserialize)] + struct Payload { + payload: Vec, + } + let payload: Payload = serde_yaml_ng::from_value(yaml::parse(bytes)?) + .map_err(|_| VoleError::InvalidConfig("invalid rule provider YAML".into()))?; + payload.payload + } + RuleFormat::Text => std::str::from_utf8(bytes) + .map_err(|_| VoleError::InvalidConfig("rule provider text requires UTF-8".into()))? + .lines() + .map(str::trim) + .filter(|s| !s.is_empty() && !s.starts_with('#')) + .map(str::to_owned) + .collect(), + RuleFormat::Mrs => unreachable!(), + }; + Self::from_payload(values, behavior) + } + pub fn from_payload(values: Vec, behavior: RuleBehavior) -> Result { + let mut data = Self { + behavior, + domains: Arc::new(DomainIndex::new()), + ranges: Vec::new(), + classical: Vec::new(), + count: values.len(), + }; + for value in values { + match behavior { + RuleBehavior::Domain => { + let selector = normalize_dns_policy_selector(&value)?; + let DnsPolicySelector::Domains(patterns) = selector else { + return invalid("domain provider requires domain patterns"); + }; + if value.contains('/') || patterns.len() != 1 { + return invalid("invalid rule provider domain"); + } + Arc::get_mut(&mut data.domains) + .unwrap() + .insert(&patterns[0], 0); + } + RuleBehavior::Ipcidr => { + let cidr = parse_ip_cidr(&value, value.contains(':'))?; + let (start, width) = match cidr.network { + IpAddr::V4(ip) => (u128::from(u32::from(ip)), 32u8), + IpAddr::V6(ip) => (u128::from(ip), 128u8), + }; + let host_bits = width - cidr.prefix_len; + let mask = if host_bits == 128 { + u128::MAX + } else { + (1u128 << host_bits) - 1 + }; + let a = start & !mask; + let b = a | mask; + data.ranges.push(if width == 32 { + ( + Ipv4Addr::from(a as u32).into(), + Ipv4Addr::from(b as u32).into(), + ) + } else { + (Ipv6Addr::from(a).into(), Ipv6Addr::from(b).into()) + }); + } + RuleBehavior::Classical => { + let kind = value + .split(',') + .next() + .unwrap_or_default() + .trim() + .to_ascii_uppercase(); + if ["MATCH", "RULE-SET", "SUB-RULE"].contains(&kind.as_str()) { + return invalid("unsupported classical provider rule"); + } + data.classical.push(rules::parse_predicate(&value)?); + } + } + } + // Reuse normal compilation to validate classical matcher semantics. + crate::routing::RuleSet::compile(data.classical.clone()) + .map_err(|_| VoleError::InvalidConfig("invalid classical provider rules".into()))?; + data.merge_ranges(); + Ok(data) + } + fn merge_ranges(&mut self) { + self.ranges.sort_unstable(); + let mut merged: Vec<(IpAddr, IpAddr)> = Vec::new(); + for (start, end) in self.ranges.drain(..) { + if let Some(last) = merged.last_mut() + && start.is_ipv4() == last.0.is_ipv4() + && start <= last.1 + { + last.1 = last.1.max(end); + continue; + } + merged.push((start, end)); + } + self.ranges = merged; + } + pub(crate) fn matches( + &self, + context: &RoutingContext, + geo: &dyn GeoMatcher, + no_resolve: bool, + ) -> std::result::Result { + match self.behavior { + RuleBehavior::Domain => Ok(context + .domain() + .is_some_and(|name| self.domains.find(0, name).is_some())), + RuleBehavior::Ipcidr => { + let Some(ip) = context.effective_ip() else { + return if !no_resolve && context.should_request_ip_resolution() { + Err(()) + } else { + Ok(false) + }; + }; + let index = self.ranges.partition_point(|(start, _)| *start <= ip); + Ok(index > 0 + && self.ranges[index - 1].0.is_ipv4() == ip.is_ipv4() + && ip <= self.ranges[index - 1].1) + } + RuleBehavior::Classical => { + let mut context = context.clone(); + if no_resolve { + context.disable_ip_resolution(); + } + for rule in &self.classical { + if crate::routing::matches_rule(rule, &context, geo)? { + return Ok(true); + } + } + Ok(false) + } + } + } + pub(crate) fn matches_domain(&self, domain: &str, geo: &dyn GeoMatcher) -> bool { + let Ok(destination) = crate::session::Destination::domain(domain, 53) else { + return false; + }; + let Ok(context) = RoutingContext::new(Network::Tcp, &destination) else { + return false; + }; + // DNS policy uses domain predicates only, never port/IP/source criteria. + if self.behavior == RuleBehavior::Classical { + self.classical.iter().any(|rule| { + matches!( + rule.kind, + RuleKind::Domain(_) + | RuleKind::DomainSuffix(_) + | RuleKind::DomainKeyword(_) + | RuleKind::DomainWildcard(_) + | RuleKind::DomainRegex(_) + | RuleKind::GeoSite(_) + ) && crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) + }) + } else { + self.matches(&context, geo, true).unwrap_or(false) + } + } +} + +fn corrupt() -> VoleError { + VoleError::InvalidConfig("invalid MRSv1 rule provider".into()) +} +fn read(reader: &mut Cursor>) -> Result<[u8; N]> { + let mut bytes = [0; N]; + reader.read_exact(&mut bytes).map_err(|_| corrupt())?; + Ok(bytes) +} +fn length(reader: &mut Cursor>, unit: usize) -> Result { + let n = usize::try_from(i64::from_be_bytes(read(reader)?)).map_err(|_| corrupt())?; + let remaining = reader + .get_ref() + .len() + .saturating_sub(reader.position() as usize); + if n > remaining / unit { + return Err(corrupt()); + } + Ok(n) +} +fn words(reader: &mut Cursor>) -> Result> { + let n = length(reader, 8)?; + (0..n) + .map(|_| read(reader).map(u64::from_be_bytes)) + .collect() +} +fn parse_mrs(bytes: &[u8], behavior: RuleBehavior) -> Result { + let decoder = zstd::stream::read::Decoder::new(bytes).map_err(|_| corrupt())?; + let mut decoded = Vec::new(); + decoder + .take((MAX_RESOURCE_BYTES + 1) as u64) + .read_to_end(&mut decoded) + .map_err(|_| corrupt())?; + if decoded.len() > MAX_RESOURCE_BYTES { + return Err(corrupt()); + } + let mut input = Cursor::new(decoded); + if read::<4>(&mut input)? != *b"MRS\x01" { + return Err(corrupt()); + } + let expected = match behavior { + RuleBehavior::Domain => 0, + RuleBehavior::Ipcidr => 1, + RuleBehavior::Classical => return Err(corrupt()), + }; + if read::<1>(&mut input)?[0] != expected { + return Err(corrupt()); + } + let count = usize::try_from(i64::from_be_bytes(read(&mut input)?)).map_err(|_| corrupt())?; + let extra = length(&mut input, 1)?; + input.set_position(input.position() + extra as u64); + if read::<1>(&mut input)? != [1] { + return Err(corrupt()); + } + let mut data = RuleCollection::from_payload(Vec::new(), behavior)?; + data.count = count; + if behavior == RuleBehavior::Ipcidr { + let n = length(&mut input, 32)?; + for _ in 0..n { + let convert = |bytes| { + let ip = Ipv6Addr::from(bytes); + ip.to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)) + }; + let start = convert(read::<16>(&mut input)?); + let end = convert(read::<16>(&mut input)?); + if start.is_ipv4() != end.is_ipv4() || start > end { + return Err(corrupt()); + } + data.ranges.push((start, end)); + } + data.merge_ranges(); + } else { + let leaves = words(&mut input)?; + let bitmap = words(&mut input)?; + let n = length(&mut input, 1)?; + let mut labels = vec![0; n]; + input.read_exact(&mut labels).map_err(|_| corrupt())?; + let bit = |bits: &[u64], i: usize| { + bits.get(i / 64) + .is_some_and(|w| w & (1u64 << (i % 64)) != 0) + }; + let nodes = n.checked_add(1).ok_or_else(corrupt)?; + if leaves.len().saturating_mul(64) < nodes { + return Err(corrupt()); + } + // LOUDS stores every node's outgoing labels, followed by a one bit. + let mut edges = Vec::with_capacity(nodes); + let (mut position, mut label) = (0usize, 0usize); + for _ in 0..nodes { + let start = label; + while position < bitmap.len() * 64 && !bit(&bitmap, position) { + position += 1; + label += 1; + if label > n { + return Err(corrupt()); + } + } + if position >= bitmap.len() * 64 { + return Err(corrupt()); + } + position += 1; + edges.push(start..label); + } + if label != n { + return Err(corrupt()); + } + let mut stack = vec![(0usize, Vec::::new())]; + while let Some((node, key)) = stack.pop() { + if key.len() > 253 { + return Err(corrupt()); + } + if bit(&leaves, node) { + let key = std::str::from_utf8(&key) + .map_err(|_| corrupt())? + .trim_end_matches('+'); + let domain: String = key.chars().rev().collect(); + Arc::get_mut(&mut data.domains).unwrap().insert(&domain, 0); + } + for edge in edges[node].clone().rev() { + let child = edge + 1; + if child <= node || child >= nodes { + return Err(corrupt()); + } + let mut next = key.clone(); + next.push(labels[edge]); + stack.push((child, next)); + } + } + } + if input.position() != input.get_ref().len() as u64 { + return Err(corrupt()); + } + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn mrs_domain_and_ip_ranges_match_yaml_and_text() { + fn header(behavior: u8) -> Vec { + let mut bytes = b"MRS\x01".to_vec(); + bytes.push(behavior); + bytes.extend(1i64.to_be_bytes()); + bytes.extend(0i64.to_be_bytes()); + bytes.push(1); + bytes + } + let mut bytes = header(0); + let labels = b"tset.cba"; + bytes.extend(1i64.to_be_bytes()); + bytes.extend((1u64 << labels.len()).to_be_bytes()); + bytes.extend(1i64.to_be_bytes()); + let bitmap = + (0..labels.len()).fold(1u64 << (labels.len() * 2), |b, i| b | (1 << (i * 2 + 1))); + bytes.extend(bitmap.to_be_bytes()); + bytes.extend((labels.len() as i64).to_be_bytes()); + bytes.extend(labels); + let compressed = zstd::encode_all(bytes.as_slice(), 1).unwrap(); + let mrs = + RuleCollection::parse(&compressed, RuleBehavior::Domain, RuleFormat::Mrs).unwrap(); + let yaml = RuleCollection::parse( + b"payload: [abc.test]", + RuleBehavior::Domain, + RuleFormat::Yaml, + ) + .unwrap(); + for domain in ["abc.test", "no.test", "www.abc.test"] { + assert_eq!( + mrs.matches_domain(domain, &crate::routing::EmptyGeoMatcher), + yaml.matches_domain(domain, &crate::routing::EmptyGeoMatcher) + ); + } + let mut bytes = header(1); + bytes.extend(1i64.to_be_bytes()); + for ip in [Ipv4Addr::new(10, 0, 0, 0), Ipv4Addr::new(10, 255, 255, 255)] { + bytes.extend(ip.to_ipv6_mapped().octets()); + } + let compressed = zstd::encode_all(bytes.as_slice(), 1).unwrap(); + let mrs = + RuleCollection::parse(&compressed, RuleBehavior::Ipcidr, RuleFormat::Mrs).unwrap(); + let text = RuleCollection::parse( + b"# private\n10.0.0.0/8\n", + RuleBehavior::Ipcidr, + RuleFormat::Text, + ) + .unwrap(); + assert_eq!(mrs.ranges, text.ranges); + assert!(RuleCollection::parse(&compressed, RuleBehavior::Domain, RuleFormat::Mrs).is_err()); + } +} diff --git a/src/config/rules.rs b/src/config/rules.rs index 54f1d21..b8b78cc 100644 --- a/src/config/rules.rs +++ b/src/config/rules.rs @@ -32,6 +32,10 @@ pub(super) fn parse_rule(input: &str, targets: &RouteTargetsByName) -> Result Result { + parse(input, None, 0) +} + fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Result { if depth > 32 { return invalid("logical rules exceed 32 nesting levels"); @@ -80,7 +84,7 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res if option == "no-resolve" && matches!( kind.as_str(), - "GEOIP" | "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" + "RULE-SET" | "GEOIP" | "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" ) { no_resolve = true; @@ -91,6 +95,10 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res (payload, action, no_resolve) }; let rule_kind = match kind.as_str() { + "RULE-SET" => RuleKind::RuleSet { + name: payload.to_owned(), + content: None, + }, "DOMAIN" => RuleKind::Domain(normalize_rule_domain(payload, "DOMAIN")?), "DOMAIN-SUFFIX" => RuleKind::DomainSuffix(normalize_rule_domain(payload, "DOMAIN-SUFFIX")?), "DOMAIN-KEYWORD" => RuleKind::DomainKeyword(normalize_rule_keyword(payload)?), diff --git a/src/controller.rs b/src/controller.rs index 03a71e3..9a94847 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -25,6 +25,7 @@ pub(crate) struct RuntimeController { secret: Option>, traffic: Option>, proxy_groups: Arc, + resources: Option>, } impl std::fmt::Debug for RuntimeController { @@ -49,9 +50,18 @@ impl RuntimeController { secret: config.secret.as_deref().map(Arc::from), traffic, proxy_groups, + resources: None, }) } + pub(crate) fn with_resources( + mut self, + resources: Option>, + ) -> Self { + self.resources = resources; + self + } + #[cfg(test)] pub(crate) fn local_addr(&self) -> io::Result { self.listener.local_addr() @@ -77,6 +87,7 @@ impl RuntimeController { let secret = self.secret.clone(); let traffic = self.traffic.clone(); let proxy_groups = self.proxy_groups.clone(); + let resources = self.resources.clone(); let child = cancellation.clone(); tasks.spawn(crate::resources::observation::task(async move { if let Err(error) = handle_connection( @@ -85,6 +96,7 @@ impl RuntimeController { secret, traffic, proxy_groups, + resources, child, ) .await @@ -110,6 +122,7 @@ async fn handle_connection( secret: Option>, traffic: Option>, proxy_groups: Arc, + resources: Option>, cancellation: CancellationToken, ) -> io::Result<()> { if !peer.ip().is_loopback() { @@ -165,6 +178,59 @@ async fn handle_connection( }; } + if let Some(tail) = request.target.strip_prefix("/providers/") { + let (kind, raw_name) = tail + .split_once('/') + .map_or((tail, None), |(kind, name)| (kind, Some(name))); + let rules = match kind { + "rules" => true, + "proxies" => false, + _ => return write_error(&mut stream, 404, "Resource not found", &[]).await, + }; + let name = match raw_name { + Some(raw) => match decode_group_name(raw) { + Ok(name) => Some(name), + Err(()) => return write_error(&mut stream, 400, "Bad request", &[]).await, + }, + None => None, + }; + let state = resources.as_ref().map_or_else( + || serde_json::json!({"providers":{}}), + |resources| resources.providers(rules), + ); + if request.method == "GET" { + if let Some(name) = name { + return match state["providers"].get(&name) { + Some(value) => write_json(&mut stream, 200, value, &[]).await, + None => write_error(&mut stream, 404, "Resource not found", &[]).await, + }; + } + return write_json(&mut stream, 200, &state, &[]).await; + } + if request.method == "PUT" + && let (Some(resources), Some(name)) = (&resources, name) + { + if state["providers"].get(&name).is_none() { + return write_error(&mut stream, 404, "Resource not found", &[]).await; + } + let result = tokio::select! { biased; ()=cancellation.cancelled()=>return Ok(()), result=resources.refresh(rules,&name)=>result }; + return match result { + Ok(()) => write_response(&mut stream, 204, &[], &[]).await, + Err(_) => write_error(&mut stream, 503, "Provider update failed", &[]).await, + }; + } + return write_error( + &mut stream, + 405, + "Method not allowed", + &[("Allow", "GET, PUT")], + ) + .await; + } + let proxy_groups = resources + .as_ref() + .map_or(proxy_groups, |resources| resources.groups()); + if request.target == "/group" { if request.method != "GET" { return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; @@ -211,6 +277,7 @@ async fn handle_connection( request.buffered, &name, &proxy_groups, + resources.as_deref(), cancellation, ) .await @@ -236,6 +303,7 @@ async fn handle_select( mut body: Vec, group: &str, proxy_groups: &ProxyGroups, + resources: Option<&dyn crate::resource::control::RuntimeResources>, cancellation: CancellationToken, ) -> io::Result<()> { if header_values(headers, "transfer-encoding").next().is_some() { @@ -302,7 +370,10 @@ async fn handle_select( Ok(request) => request, Err(_) => return write_error(stream, 400, "Body invalid", &[]).await, }; - match proxy_groups.select(group, &request.name) { + match resources.map_or_else( + || proxy_groups.select(group, &request.name), + |resources| resources.select(group, &request.name), + ) { Ok(()) => write_response(stream, 204, &[], b"").await, Err(ProxyGroupError::UnknownGroup) => { write_error(stream, 404, "Resource not found", &[]).await @@ -413,6 +484,8 @@ struct GroupResponse { kind: &'static str, all: Vec, now: String, + hidden: bool, + icon: String, } impl From for GroupResponse { @@ -422,6 +495,8 @@ impl From for GroupResponse { kind: "Selector", all: state.all, now: state.now, + hidden: state.hidden, + icon: state.icon, } } } @@ -524,6 +599,9 @@ mod tests { let dispatcher: Arc = Arc::new(UnusedDispatcher); ProxyGroups::new( &[SelectProxyGroupConfig { + disable_udp: false, + hidden: false, + icon: String::new(), name: "主线路".to_owned(), members: vec![ ProxyGroupMemberConfig { diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 56feb3f..75a66e5 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -7,7 +7,7 @@ pub(crate) mod hosts; mod https; -mod policy; +pub(crate) mod policy; pub mod resolution; pub mod runtime; diff --git a/src/dns/policy.rs b/src/dns/policy.rs index f1cadef..d6dce5c 100644 --- a/src/dns/policy.rs +++ b/src/dns/policy.rs @@ -14,6 +14,7 @@ pub(super) struct NameserverPolicies(Vec); enum PolicyStep { Domains(DomainIndex), GeoSite(usize), + RuleSet(usize), } impl NameserverPolicies { @@ -21,6 +22,7 @@ impl NameserverPolicies { let mut steps = Vec::new(); for (index, policy) in policies.iter().enumerate() { match &policy.selector { + DnsPolicySelector::RuleSets { .. } => steps.push(PolicyStep::RuleSet(index)), DnsPolicySelector::GeoSite(_) => steps.push(PolicyStep::GeoSite(index)), DnsPolicySelector::Domains(patterns) => { if !matches!(steps.last(), Some(PolicyStep::Domains(_))) { @@ -46,6 +48,14 @@ impl NameserverPolicies { ) -> Option { self.0.iter().find_map(|step| match step { PolicyStep::Domains(domains) => domains.find(0, name), + PolicyStep::RuleSet(index) => { + let DnsPolicySelector::RuleSets { sets, .. } = &policies[*index].selector else { + unreachable!() + }; + sets.iter() + .any(|set| set.matches_domain(name, geo)) + .then_some(*index) + } PolicyStep::GeoSite(index) => policies[*index] .geosite_codes() .iter() @@ -55,7 +65,7 @@ impl NameserverPolicies { } } -#[derive(Debug, Default)] +#[derive(Debug, Default, PartialEq, Eq)] struct DomainNode { labels: HashMap, wildcard: Option, @@ -65,15 +75,15 @@ struct DomainNode { /// A flat arena keeps construction and destruction independent of pattern /// depth; lookup depth is bounded by the validated DNS question. -#[derive(Debug)] -pub(super) struct DomainIndex(Vec); +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct DomainIndex(Vec); impl DomainIndex { - pub(super) fn new() -> Self { + pub(crate) fn new() -> Self { Self(vec![DomainNode::default()]) } - pub(super) fn insert(&mut self, pattern: &str, policy: usize) { + pub(crate) fn insert(&mut self, pattern: &str, policy: usize) { let (domain, exact, suffix) = if let Some(domain) = pattern.strip_prefix("+.") { (domain, true, true) } else if let Some(domain) = pattern.strip_prefix('.') { @@ -107,7 +117,7 @@ impl DomainIndex { } } - pub(super) fn find(&self, current: usize, name: &str) -> Option { + pub(crate) fn find(&self, current: usize, name: &str) -> Option { let node = &self.0[current]; if name.is_empty() { return node.exact; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 20010f3..dfc0266 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -103,7 +103,7 @@ pub struct RuntimeDns { opaque_cache: Mutex, // Hint lookup and insertion are bounded synchronous work. Keep this // shared TUN hot path free of asynchronous mutex scheduling. - hints: Option>, + hints: Option>>, singleflight: Arc, tcp_pool: Arc, next_id: AtomicU16, @@ -800,6 +800,12 @@ impl fmt::Debug for RuntimeDns { } impl RuntimeDns { + pub(crate) fn with_mapping_from(mut self, previous: Option<&Self>) -> Self { + if let Some(previous) = previous { + self.hints = previous.hints.clone(); + } + self + } /// Creates a resolver whose upstream traffic uses one preselected /// dispatcher and no GeoData matcher. Production runtimes use /// [`Self::new_routed`]. @@ -963,8 +969,11 @@ impl RuntimeDns { egress, cache: Mutex::new(DnsCache::with_max_entries(address_cache_entries)), opaque_cache: Mutex::new(OpaqueDnsCache::new()), - hints: (redir_host_entries != 0) - .then(|| StdMutex::new(RedirHostHints::with_max_entries(redir_host_entries))), + hints: (redir_host_entries != 0).then(|| { + Arc::new(StdMutex::new(RedirHostHints::with_max_entries( + redir_host_entries, + ))) + }), singleflight: SingleflightRegistry::new(resource_stats.clone()), tcp_pool: TcpConnectionPool::new(), next_id: AtomicU16::new(1), @@ -3041,6 +3050,9 @@ dns: let proxy_dispatchers: Vec> = vec![old.clone(), new.clone()]; let groups = ProxyGroups::new( &[SelectProxyGroupConfig { + disable_udp: false, + hidden: false, + icon: String::new(), name: "group".to_owned(), members: vec![ ProxyGroupMemberConfig { diff --git a/src/geodata/manager.rs b/src/geodata/manager.rs index 07bd87c..4c6a976 100644 --- a/src/geodata/manager.rs +++ b/src/geodata/manager.rs @@ -495,6 +495,30 @@ impl GeoDataManager { Ok(reload_report(registration.as_ref())) } + pub(crate) fn replace_requirements( + &self, + lease: &GeoDataRegistrationLease, + requirements: GeoRequirements, + cancellation: &CancellationToken, + ) -> Result<(), GeoDataManagerError> { + let mut registration = lock(&self.registration); + let entry = registration + .as_mut() + .filter(|entry| lease.matches(entry)) + .ok_or(GeoDataManagerError::RegistrationActive)?; + if entry.requirements == requirements { + return Ok(()); + } + check_cancellation(cancellation)?; + entry.unload(); + entry.drain(cancellation)?; + entry.requirements = requirements; + let (snapshot, report) = load_snapshot(&self.store_dir, &entry.requirements); + check_cancellation(cancellation)?; + entry.publish(snapshot, report); + Ok(()) + } + fn refresh_durable_state( &self, cancellation: &CancellationToken, diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 9eb1b5c..4f4c4b5 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -30,6 +30,7 @@ pub(crate) mod service; pub(crate) use selectors::normalize_selector; use selectors::{fold_attribute, selector_attributes, selector_code}; +pub(crate) use manager::GeoDataRegistrationLease; pub use manager::{ DynamicGeoData, GeoDataLoadReport, GeoDataManager, GeoDataManagerError, GeoDataRegistration, GeoDataReloadReport, GeoDataResourceReport, GeoDataStatus, GeoResourceState, GeoUpdateSession, @@ -397,6 +398,10 @@ impl RequestedCodes { while let Some(rule) = pending.pop() { match &rule.kind { RuleKind::And(children) | RuleKind::Or(children) => pending.extend(children), + RuleKind::RuleSet { + content: Some(content), + .. + } => pending.extend(&content.classical), RuleKind::Not(child) => pending.push(child), RuleKind::GeoSite(code) => requested.sites.insert(GeoDataKind::GeoSite, code)?, RuleKind::GeoIp(code) => requested.ips.insert(GeoDataKind::GeoIp, code)?, @@ -404,6 +409,19 @@ impl RequestedCodes { } } for policy in dns_policies { + if let crate::config::DnsPolicySelector::RuleSets { sets, .. } = &policy.selector { + let rules = sets + .iter() + .flat_map(|set| set.classical.iter().cloned()) + .collect::>(); + let nested = Self::collect(&rules, &[])?; + for code in nested.sites.selectors { + requested.sites.insert(GeoDataKind::GeoSite, &code)?; + } + for code in nested.ips.selectors { + requested.ips.insert(GeoDataKind::GeoIp, &code)?; + } + } for code in policy.geosite_codes() { requested.sites.insert(GeoDataKind::GeoSite, code)?; } @@ -444,6 +462,16 @@ impl GeoRequirements { Self::collect(&config.rules, &policies) } + pub(crate) fn extend(&mut self, other: &Self) -> Result<(), GeoDataError> { + for selector in &other.sites.selectors { + self.sites.insert(GeoDataKind::GeoSite, selector)?; + } + for selector in &other.ips.selectors { + self.ips.insert(GeoDataKind::GeoIp, selector)?; + } + Ok(()) + } + /// Collects all GeoSite and GeoIP selectors used by business rules and DNS /// nameserver policies. Each underlying category is decoded only once. pub fn collect( diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index 3e1e732..ff82362 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -440,6 +440,7 @@ pub trait OutboundConnector: Send + Sync { pub struct SelectUpstream { selection: Arc, members: Box<[SelectUpstreamMember]>, + disable_udp: bool, } pub(crate) enum SelectUpstreamMember { @@ -454,6 +455,25 @@ impl SelectUpstream { Self { selection, members: members.into_boxed_slice(), + disable_udp: false, + } + } + + pub(crate) fn with_udp_disabled(mut self, disabled: bool) -> Self { + self.disable_udp = disabled; + self + } + fn check_udp(&self, context: &EstablishContext) -> Result<(), DispatchError> { + let mut group = self; + loop { + if group.disable_udp { + return Err(DispatchError::NotAllowed); + } + match group.members.get(context.selected_member(&group.selection)) { + Some(SelectUpstreamMember::Group(next)) => group = next, + Some(_) => return Ok(()), + None => return Err(DispatchError::NotAllowed), + } } } @@ -602,6 +622,9 @@ impl UpstreamPath { request: DatagramRequest, context: &EstablishContext, ) -> Result, DispatchError> { + if let Self::Group { group, .. } = self { + group.check_udp(context)?; + } match self.resolve(context)? { ResolvedUpstream::Direct(_, dialer) | ResolvedUpstream::LazyDirect(dialer) => { let direct = DirectOutbound::new(dialer.clone()); diff --git a/src/resource/cache.rs b/src/resource/cache.rs new file mode 100644 index 0000000..727ffbc --- /dev/null +++ b/src/resource/cache.rs @@ -0,0 +1,476 @@ +//! Provider files and candidate updates. Publication belongs to the runtime graph owner. +use super::http::{self, DownloadOutcome, DownloadRequest, HttpOptions}; +use crate::{ + config::providers::{ + ProviderContent, ProviderContents, ProviderSpec, ProviderTemplate, ProviderType, + }, + dispatch::Dispatcher, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{self, Read}, + path::{Component, Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio_util::sync::CancellationToken; + +static NEXT_FILE: AtomicU64 = AtomicU64::new(1); +const MAX_PARSE_BYTES: u64 = 64 * 1024 * 1024; +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct Metadata { + url: String, + etag: Option, + hash: String, + checked: u64, +} +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ProviderState { + pub name: String, + pub vehicle_type: &'static str, + pub rule: bool, + pub available: bool, + pub downloaded: bool, + pub updating: bool, + pub updated_at: Option, + pub error: Option<&'static str>, + pub count: usize, +} +pub(crate) struct ResourceEntry { + pub key: String, + pub spec: ProviderSpec, + pub state: ProviderState, + path: Option, + metadata: Metadata, + pub next: tokio::time::Instant, + modified: Option, + failures: u32, +} +pub(crate) struct ProviderCache { + pub template: Arc, + pub entries: Vec, + pub contents: ProviderContents, +} +pub(crate) struct Candidate { + pub content: ProviderContent, + temporary: Option, + metadata: Metadata, +} +impl Drop for Candidate { + fn drop(&mut self) { + if let Some(path) = &self.temporary { + let _ = fs::remove_file(path); + } + } +} +impl ProviderCache { + pub fn open(root: &Path, template: Arc) -> io::Result { + let mut entries = Vec::new(); + let mut contents = ProviderContents::new(); + for (rule, (name, spec)) in template + .proxies + .iter() + .map(|item| (false, item)) + .chain(template.rules.iter().map(|item| (true, item))) + { + let key = ProviderTemplate::key(rule, name); + let mut entry = ResourceEntry { + key: key.clone(), + spec: spec.clone(), + state: ProviderState { + name: name.clone(), + rule, + vehicle_type: match spec.kind { + ProviderType::Http => "HTTP", + ProviderType::File => "File", + ProviderType::Inline => "Inline", + }, + available: spec.kind == ProviderType::Inline || !spec.payload.is_empty(), + downloaded: false, + updating: false, + updated_at: None, + error: None, + count: spec.payload.len(), + }, + path: None, + metadata: Metadata::default(), + next: tokio::time::Instant::now(), + modified: None, + failures: 0, + }; + if spec.kind != ProviderType::Inline { + let fallback = format!( + "{}/{}", + if rule { "rules" } else { "proxies" }, + hex(&Sha256::digest(spec.url.as_bytes())) + ); + let path = resource_path( + root, + if spec.path.is_empty() { + &fallback + } else { + &spec.path + }, + spec.kind == ProviderType::Http, + )?; + let metadata = fs::read(metadata_path(&path)) + .ok() + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .unwrap_or_default(); + if let Ok(bytes) = read_bounded(&path, spec.size_limit) { + match template.parse_content(spec, &bytes) { + Ok(content) => { + entry.state.available = true; + entry.state.downloaded = spec.kind == ProviderType::Http; + entry.state.count = content_len(&content); + entry.state.updated_at = fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok()) + .map(unix_time); + entry.modified = + fs::metadata(&path).ok().and_then(|m| m.modified().ok()); + if metadata.url == spec.url + && metadata.hash == hex(&Sha256::digest(&bytes)) + { + entry.metadata = metadata; + } + contents.insert(key, content); + if spec.kind == ProviderType::Http { + entry.next = next_update( + spec.interval, + entry + .metadata + .checked + .max(entry.state.updated_at.unwrap_or(0)), + ); + } + } + Err(_) => entry.state.error = Some("invalid cached content"), + } + } + entry.path = Some(path); + } + entries.push(entry); + } + Ok(Self { + template, + entries, + contents, + }) + } + pub fn next_due(&self) -> Option { + self.entries.iter().position(|entry| { + entry.spec.kind != ProviderType::Inline + && !(entry.spec.kind == ProviderType::Http + && entry.spec.interval == 0 + && entry.state.downloaded) + && entry.next <= tokio::time::Instant::now() + }) + } + pub async fn fetch( + &mut self, + index: usize, + dispatcher: Arc, + user_agent: String, + etag_support: bool, + cancellation: CancellationToken, + ) -> io::Result> { + let entry = &mut self.entries[index]; + if cancellation.is_cancelled() { + return Err(io::ErrorKind::Interrupted.into()); + } + if entry.spec.kind == ProviderType::Inline { + return Ok(None); + } + let path = entry.path.as_ref().expect("file resource path").clone(); + entry.state.updating = true; + let operation = async { + if entry.spec.kind == ProviderType::File { + let modified = fs::metadata(&path)?.modified()?; + if Some(modified) == entry.modified && entry.state.available { + return Ok(None); + } + let spec = entry.spec.clone(); + let template = self.template.clone(); + let path = path.clone(); + let content = tokio::task::spawn_blocking(move || { + let bytes = read_bounded(&path, spec.size_limit)?; + template.parse_content(&spec, &bytes).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid provider content") + }) + }) + .await + .map_err(io::Error::other)??; + return Ok(Some(Candidate { + content, + temporary: None, + metadata: Metadata::default(), + })); + } + let temporary = temporary_path(&path); + let outcome = http::download(DownloadRequest { + dispatcher, + url: entry.spec.url.clone(), + etag: etag_support.then(|| entry.metadata.etag.clone()).flatten(), + temporary_path: temporary.clone(), + timeout: http::DEFAULT_DOWNLOAD_TIMEOUT, + cancellation: cancellation.clone(), + options: HttpOptions { + user_agent, + headers: entry.spec.headers(), + size_limit: (entry.spec.size_limit > 0).then_some(entry.spec.size_limit), + allow_http: true, + allow_ip: true, + }, + }) + .await + .map_err(|_| io::Error::other("provider download failed"))?; + match outcome { + DownloadOutcome::NotModified => { + entry.metadata.checked = unix_time(SystemTime::now()); + write_metadata(&path, &entry.metadata)?; + Ok(None) + } + DownloadOutcome::Downloaded { etag, sha256, .. } => { + let mut cleanup = TemporaryFile(Some(temporary.clone())); + let spec = entry.spec.clone(); + let template = self.template.clone(); + let input = temporary.clone(); + let parsed = tokio::task::spawn_blocking(move || { + let bytes = read_bounded(&input, spec.size_limit)?; + template.parse_content(&spec, &bytes).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid provider content") + }) + }) + .await + .map_err(io::Error::other)?; + match parsed { + Ok(content) => { + cleanup.0 = None; + Ok(Some(Candidate { + content, + temporary: Some(temporary), + metadata: Metadata { + url: entry.spec.url.clone(), + etag, + hash: sha256.iter().map(|byte| format!("{byte:02x}")).collect(), + checked: unix_time(SystemTime::now()), + }, + })) + } + Err(error) => { + let _ = fs::remove_file(temporary); + Err(error) + } + } + } + } + } + .await; + entry.state.updating = false; + if cancellation.is_cancelled() { + drop(operation); + return Err(io::ErrorKind::Interrupted.into()); + } + if operation.is_err() { + Self::failed_entry(entry, "resource fetch or parse failed"); + } else if matches!(operation, Ok(None)) { + Self::schedule(entry); + entry.state.error = None; + } + operation + } + pub fn reject(&mut self, index: usize) { + Self::failed_entry(&mut self.entries[index], "candidate graph rejected"); + } + pub fn commit( + &mut self, + index: usize, + mut candidate: Candidate, + cancellation: &CancellationToken, + ) -> io::Result<()> { + if cancellation.is_cancelled() { + return Err(io::ErrorKind::Interrupted.into()); + } + let entry = &mut self.entries[index]; + if let Some(temporary) = &candidate.temporary { + let path = entry.path.as_ref().expect("cache path"); + write_metadata(path, &candidate.metadata)?; + fs::rename(temporary, path)?; + candidate.temporary = None; + entry.metadata = candidate.metadata.clone(); + entry.state.downloaded = true; + } + entry.modified = entry + .path + .as_ref() + .and_then(|path| fs::metadata(path).ok()) + .and_then(|m| m.modified().ok()); + entry.state.available = true; + entry.state.updated_at = Some(unix_time(SystemTime::now())); + entry.state.error = None; + entry.state.count = content_len(&candidate.content); + self.contents + .insert(entry.key.clone(), candidate.content.clone()); + Self::schedule(entry); + Ok(()) + } + fn failed_entry(entry: &mut ResourceEntry, error: &'static str) { + entry.state.error = Some(error); + entry.failures = entry.failures.saturating_add(1); + entry.next = tokio::time::Instant::now() + + Duration::from_secs( + (10u64.saturating_mul(1u64.checked_shl(entry.failures.min(8) - 1).unwrap_or(256))) + .min(3600), + ); + } + fn schedule(entry: &mut ResourceEntry) { + entry.failures = 0; + entry.next = if entry.spec.kind == ProviderType::File { + tokio::time::Instant::now() + Duration::from_secs(1) + } else { + next_update(entry.spec.interval, unix_time(SystemTime::now())) + }; + } +} +fn content_len(content: &ProviderContent) -> usize { + match content { + ProviderContent::Proxies(values) => values.len(), + ProviderContent::Rules(set) => set.len(), + } +} +fn next_update(interval: u64, checked: u64) -> tokio::time::Instant { + let seconds = if interval == 0 { + 365 * 24 * 3600 + } else { + checked + .saturating_add(interval) + .saturating_sub(unix_time(SystemTime::now())) + .min(i64::MAX as u64 / 1_000_000_000) + }; + tokio::time::Instant::now() + Duration::from_secs(seconds) +} +fn unix_time(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +fn read_bounded(path: &Path, limit: u64) -> io::Result> { + let limit = if limit == 0 { + MAX_PARSE_BYTES + } else { + limit.min(MAX_PARSE_BYTES) + }; + let file = fs::File::open(path)?; + if !file.metadata()?.is_file() { + return Err(io::Error::other("provider path must be a regular file")); + } + let mut bytes = Vec::new(); + file.take(limit + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > limit { + return Err(io::Error::other("provider exceeds parser byte limit")); + } + Ok(bytes) +} +fn metadata_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(".metadata.json"); + PathBuf::from(name) +} +fn temporary_path(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_owned(); + name.push(format!( + ".{}.{}.tmp", + std::process::id(), + NEXT_FILE.fetch_add(1, Ordering::Relaxed) + )); + PathBuf::from(name) +} +fn write_metadata(path: &Path, metadata: &Metadata) -> io::Result<()> { + let path = metadata_path(path); + let temporary = temporary_path(&path); + let result = (|| { + fs::write( + &temporary, + serde_json::to_vec(metadata).map_err(io::Error::other)?, + )?; + fs::rename(&temporary, &path) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} +pub(crate) fn resource_path(root: &Path, path: &str, create: bool) -> io::Result { + let root = fs::canonicalize(root)?; + let requested = Path::new(path); + let target = if requested.is_absolute() { + requested.to_path_buf() + } else { + root.join(requested) + }; + if !target.starts_with(&root) + || target + .strip_prefix(&root) + .unwrap() + .components() + .any(|c| matches!(c, Component::ParentDir)) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "provider path escapes dataDir", + )); + } + let parent = target + .parent() + .ok_or_else(|| io::Error::other("provider path requires parent"))?; + let mut ancestor = parent; + while !ancestor.exists() { + ancestor = ancestor + .parent() + .ok_or_else(|| io::Error::other("invalid provider path"))?; + } + if !fs::canonicalize(ancestor)?.starts_with(&root) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "provider path escapes dataDir", + )); + } + if create { + fs::create_dir_all(parent)?; + } + if target.exists() && !fs::canonicalize(&target)?.starts_with(&root) { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "provider path escapes dataDir", + )); + } + Ok(target) +} + +impl std::fmt::Debug for ProviderCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderCache") + .field("entries", &self.entries.len()) + .finish_non_exhaustive() + } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +struct TemporaryFile(Option); +impl Drop for TemporaryFile { + fn drop(&mut self) { + if let Some(path) = &self.0 { + let _ = fs::remove_file(path); + } + } +} diff --git a/src/resource/control.rs b/src/resource/control.rs new file mode 100644 index 0000000..9c40ba3 --- /dev/null +++ b/src/resource/control.rs @@ -0,0 +1,20 @@ +use crate::{ + dns::runtime::RuntimeDns, + routing::{ProxyGroupError, ProxyGroups}, +}; +use async_trait::async_trait; +use std::sync::Arc; + +/// A query holds its resource generation through completion and output admission. +pub(crate) struct DnsSnapshot { + pub dns: Arc, + pub _generation: Option>, +} +#[async_trait] +pub(crate) trait RuntimeResources: Send + Sync { + fn groups(&self) -> Arc; + fn select(&self, group: &str, member: &str) -> Result<(), ProxyGroupError>; + fn providers(&self, rules: bool) -> serde_json::Value; + fn dns(&self) -> Option; + async fn refresh(&self, rules: bool, name: &str) -> Result<(), &'static str>; +} diff --git a/src/resource/http.rs b/src/resource/http.rs index e966a7c..92e1ea7 100644 --- a/src/resource/http.rs +++ b/src/resource/http.rs @@ -449,7 +449,7 @@ fn validate_etag(etag: Option<&str>) -> Result<(), DownloadError> { Ok(()) } -fn validate_options(options: &HttpOptions) -> Result<(), DownloadError> { +pub(crate) fn validate_options(options: &HttpOptions) -> Result<(), DownloadError> { if options.user_agent.len() > 1024 || !is_header_value(options.user_agent.as_bytes()) { return Err(DownloadError::Protocol("invalid User-Agent".into())); } diff --git a/src/resource/mod.rs b/src/resource/mod.rs index dbd3392..5d6be0f 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -1,2 +1,4 @@ //! Resource acquisition shared by GeoData and providers. +pub(crate) mod cache; +pub(crate) mod control; pub(crate) mod http; diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index fa0986a..323aa1d 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -1120,6 +1120,9 @@ mod tests { members: Vec<(&str, ProxyGroupMemberTarget)>, ) -> SelectProxyGroupConfig { SelectProxyGroupConfig { + disable_udp: false, + hidden: false, + icon: String::new(), name: name.to_owned(), members: members .into_iter() diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 3b6c17e..9202ba6 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -86,6 +86,10 @@ impl RoutingContext { }) } + pub(crate) fn disable_ip_resolution(&mut self) { + self.ip_resolution_unavailable = true; + } + pub fn set_source(&mut self, source: SocketAddr) { self.source = Some(source); } @@ -130,7 +134,7 @@ impl RoutingContext { } #[must_use] - fn should_request_ip_resolution(&self) -> bool { + pub(crate) fn should_request_ip_resolution(&self) -> bool { self.destination_ip.is_none() && self.pinned_ip.is_none() && !self.ip_resolution_unavailable } } @@ -493,6 +497,9 @@ fn normalize_rule( }); } match &mut rule.kind { + RuleKind::RuleSet { content, .. } => { + *domain |= content.as_ref().is_some_and(|set| set.uses_domain()); + } RuleKind::Domain(value) | RuleKind::DomainSuffix(value) => { *value = normalize_domain_name(value).map_err(|source| RuleSetError::InvalidDomain { @@ -547,8 +554,9 @@ fn normalize_rule( Ok(()) } -fn uses_destination_ip(rule: &RuleSpec) -> bool { +pub(crate) fn uses_destination_ip(rule: &RuleSpec) -> bool { match &rule.kind { + RuleKind::RuleSet { content, .. } => content.as_ref().is_some_and(|set| set.uses_ip()), RuleKind::IpCidr(_) | RuleKind::GeoIp(_) => true, RuleKind::And(children) | RuleKind::Or(children) => { children.iter().any(uses_destination_ip) @@ -560,7 +568,7 @@ fn uses_destination_ip(rule: &RuleSpec) -> bool { // Err means this exact traversal requires one lazy DNS resolution. Short // circuiting keeps later IP predicates from triggering unnecessary queries. -fn matches_rule( +pub(crate) fn matches_rule( rule: &RuleSpec, context: &RoutingContext, geo: &dyn GeoMatcher, @@ -571,6 +579,10 @@ fn matches_rule( .any(|range| range.start <= port && port <= range.end) }; Ok(match &rule.kind { + RuleKind::RuleSet { content, .. } => match content { + Some(set) => set.matches(context, geo, rule.no_resolve)?, + None => false, + }, RuleKind::Domain(value) => context.domain() == Some(value.as_str()), RuleKind::DomainSuffix(value) => context .domain() diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index 19ba8fe..5c325b0 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -20,6 +20,8 @@ pub(crate) struct ProxyGroupState { pub(crate) name: String, pub(crate) all: Vec, pub(crate) now: String, + pub(crate) hidden: bool, + pub(crate) icon: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,6 +45,9 @@ struct SelectProxyGroup { name: String, members: Box<[ProxyGroupMember]>, selected: Arc, + disable_udp: bool, + hidden: bool, + icon: String, } struct ProxyGroupMember { @@ -160,6 +165,9 @@ impl ProxyGroups { }) .collect(), selected: selection.clone(), + disable_udp: config.disable_udp, + hidden: config.hidden, + icon: config.icon.clone(), }) .collect(), ids_by_name, @@ -201,13 +209,24 @@ impl ProxyGroups { } pub(crate) fn resolve_leaf( + &self, + group_id: ProxyGroupId, + ) -> Result { + self.resolve_leaf_for(group_id, false) + } + + fn resolve_leaf_for( &self, mut group_id: ProxyGroupId, + udp: bool, ) -> Result { for _ in 0..=self.groups.len() { let group = self.groups.get(group_id.index()).ok_or_else(|| { DispatchError::Other(format!("unknown proxy group id {}", group_id.index())) })?; + if udp && group.disable_udp { + return Err(DispatchError::NotAllowed); + } let selected = group.selected.load(Ordering::Acquire); let member = group.members.get(selected).ok_or_else(|| { DispatchError::Other(format!( @@ -268,6 +287,8 @@ impl SelectProxyGroup { .map(|member| member.name.clone()) .collect(), now: self.members[selected].name.clone(), + hidden: self.hidden, + icon: self.icon.clone(), } } } @@ -292,7 +313,7 @@ impl Dispatcher for SelectProxyGroupDispatcher { session: DatagramSession, ) -> Result, DispatchError> { self.groups - .resolve_leaf(self.id)? + .resolve_leaf_for(self.id, true)? .dispatcher .open_datagram(session) .await @@ -450,6 +471,9 @@ mod tests { initial_member: usize, ) -> SelectProxyGroupConfig { SelectProxyGroupConfig { + disable_udp: false, + hidden: false, + icon: String::new(), name: name.to_owned(), members, initial_member, @@ -537,11 +561,15 @@ mod tests { name: "child".to_owned(), all: vec!["first".to_owned(), "first".to_owned(), "second".to_owned()], now: "second".to_owned(), + hidden: false, + icon: String::new(), }, ProxyGroupState { name: "parent".to_owned(), all: vec!["child".to_owned(), "DIRECT".to_owned(), "REJECT".to_owned()], now: "child".to_owned(), + hidden: false, + icon: String::new(), }, ] ); diff --git a/src/runtime.rs b/src/runtime.rs index 1bda7ac..43a038e 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -96,10 +96,14 @@ use crate::security::StandardTlsClient; ))] use crate::security::{SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}; +mod providers; +use providers::ProviderRuntime; + /// Validated configuration; physical server names resolve on first use. #[derive(Debug)] pub(crate) struct PreparedCore { config: Config, + provider_cache: std::sync::Mutex>, endpoints: Vec, limits: ResourceLimits, rules: RuleSet, @@ -138,7 +142,7 @@ struct PreparedProxyEndpoints { #[cfg(feature = "invoke")] pub(crate) struct MeasurementRuntime { dispatcher: Arc, - proxy_graph: BuiltProxyGraph, + proxy_graph: Arc, } #[cfg(feature = "invoke")] @@ -171,7 +175,10 @@ struct BuiltRuntimeParts { dns: Option>, geodata_updater: Option, proxy_groups: Arc, - proxy_graph: BuiltProxyGraph, + proxy_graph: Arc, + resources: Option>, + resource_targets: RouteTargetDispatchers, + direct: Arc, } struct BuiltProxyGraph { @@ -267,6 +274,25 @@ impl PreparedCore { limits .validate() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let provider_cache = if let Some(template) = config.providers.clone() { + let root = geodata_manager + .store_dir() + .parent() + .ok_or_else(|| io::Error::other("dataDir is missing"))? + .to_path_buf(); + let cache = tokio::task::spawn_blocking(move || { + crate::resource::cache::ProviderCache::open(&root, template) + }) + .await + .map_err(io::Error::other)??; + config = cache + .template + .materialize(&cache.contents) + .map_err(io::Error::other)?; + Some(cache) + } else { + None + }; let (rules, geodata_registration) = prepare_routing(&mut config, &geodata_manager)?; let _ = resolver; let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; @@ -276,6 +302,7 @@ impl PreparedCore { .then(|| Arc::new(TunTrafficStats::default())); Ok(Self { config, + provider_cache: std::sync::Mutex::new(provider_cache), endpoints, limits, rules, @@ -335,6 +362,7 @@ impl PreparedCore { self.traffic_stats.clone() } + #[cfg(test)] fn dns_redir_host_entries(&self) -> usize { if self.has_tun() && self.rules.uses_domain_routing() { self.limits.dns_redir_host_entries @@ -353,93 +381,7 @@ impl PreparedCore { .then(|| Arc::new(self.config.sniffer.clone())) } - fn build_dispatcher(&self, dialer: Dialer) -> io::Result { - let dialer = dialer - .with_ipv6(self.config.ipv6) - .with_fresh_initialization(); - let handshake_stats = RuntimeResourceStats::new("runtime_handshake_observation"); - let proxy_graph = self.build_proxy_graph(dialer.clone())?; - let proxy_dispatchers = self.wrap_proxy_dispatchers( - proxy_graph.connectors(), - &handshake_stats, - &proxy_graph.resolution, - )?; - let direct_raw: Arc = Arc::new(DirectOutbound::new(dialer)); - let direct = observe_handshakes_with_stats(direct_raw, handshake_stats.clone()); - let proxy_groups = ProxyGroups::with_selections( - &self.config.proxy_groups, - proxy_dispatchers.clone(), - direct.clone(), - &proxy_graph.selections, - ) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let route_targets = - RouteTargetDispatchers::with_proxy_groups(proxy_dispatchers, &proxy_groups) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - let rules = self.rules.clone(); - let redir_host_entries = self.dns_redir_host_entries(); - let geo_matcher: Arc = self.geodata_registration.matcher(); - let mut dns_config = self.config.dns.clone(); - if !dns_config.enable { - dns_config.ipv6 = self.config.ipv6; - } - let dns = Some(Arc::new(RuntimeDns::new_routed_proxies_with_cache_limits( - &dns_config, - route_targets.clone(), - direct.clone(), - rules.clone(), - geo_matcher.clone(), - self.limits.dns_address_cache_entries, - redir_host_entries, - ))); - proxy_graph.resolution.bind_runtime(dns.as_ref())?; - let geodata_dispatcher: Arc = Arc::new(RoutingDispatcher::new_with_ipv6( - route_targets.clone(), - direct.clone(), - dns.clone(), - self.config.ipv6, - rules.clone(), - geo_matcher.clone(), - )); - let router: Arc = Arc::new( - RoutingDispatcher::new_with_ipv6( - route_targets, - direct, - dns.clone(), - self.config.ipv6, - rules, - geo_matcher, - ) - .with_tun_config(&self.config.tun), - ); - let session_stats = RuntimeResourceStats::new("runtime_session_observation"); - Ok(BuiltRuntimeParts { - dispatcher: observe_sessions_with_stats(router, session_stats), - dns, - geodata_updater: { - let report = self.geodata_registration.initial_report(); - let required = report.geosite.required || report.geoip.required; - self.config - .geodata_update - .as_ref() - .filter(|_| required) - .map(|update| { - GeoDataUpdateService::new( - self.geodata_manager.clone(), - geodata_dispatcher, - self.geodata_registration.updater_lease(), - update.urls.clone(), - self.config.global_ua.clone(), - self.config.etag_support, - update.auto_update && update.interval_hours > 0, - ) - }) - }, - proxy_groups, - proxy_graph, - }) - } - + #[cfg(test)] fn build_proxy_graph(&self, dialer: Dialer) -> io::Result { build_proxy_graph( &self.config.proxies, @@ -451,22 +393,42 @@ impl PreparedCore { ) } - fn wrap_proxy_dispatchers( - &self, - connectors: &[Arc], - handshake_stats: &RuntimeResourceStats, - resolution: &ResolutionContext, - ) -> io::Result>> { - let mut dispatchers = Vec::with_capacity(connectors.len()); - for (proxy, connector) in self.config.proxies.iter().zip(connectors) { - let mut dispatcher: Arc = Arc::new( - ConnectorDispatcher::with_udp_capability(connector.clone(), proxy.udp) - .with_resolution(resolution.clone()), - ); - dispatcher = observe_handshakes_with_stats(dispatcher, handshake_stats.clone()); - dispatchers.push(dispatcher); + fn build_dispatcher(&self, dialer: Dialer) -> io::Result { + let mut parts = GraphBuilder { + config: self.config.clone(), + endpoints: self.endpoints.clone(), + limits: self.limits, + rules: self.rules.clone(), + geo_matcher: self.geodata_registration.matcher(), + previous_dns: None, } - Ok(dispatchers) + .build(dialer.clone())?; + if let Some(cache) = self.provider_cache.lock().unwrap().take() { + let resources = ProviderRuntime::new(cache, self, &parts, dialer); + parts.dispatcher = resources.clone(); + parts.resources = Some(resources); + } + let report = self.geodata_registration.initial_report(); + if let Some(update) = self.config.geodata_update.as_ref().filter(|_| { + report.geosite.required + || report.geoip.required + || self + .config + .providers + .as_ref() + .is_some_and(|providers| !providers.rules.is_empty()) + }) { + parts.geodata_updater = Some(GeoDataUpdateService::new( + self.geodata_manager.clone(), + parts.dispatcher.clone(), + self.geodata_registration.updater_lease(), + update.urls.clone(), + self.config.global_ua.clone(), + self.config.etag_support, + update.auto_update && update.interval_hours > 0, + )); + } + Ok(parts) } async fn bind_controller( @@ -508,9 +470,16 @@ impl PreparedCore { geodata_updater, proxy_groups, proxy_graph, + resources, .. } = self.build_dispatcher(dialer)?; - let controller = self.bind_controller(None, proxy_groups).await?; + let control = resources.as_ref().map(|resources| { + resources.clone() as Arc + }); + let controller = self + .bind_controller(None, proxy_groups) + .await? + .map(|controller| controller.with_resources(control)); RunningCore::start_components( &self.config.inbounds, dispatcher, @@ -520,6 +489,7 @@ impl PreparedCore { geodata_updater, self.geodata_registration, proxy_graph, + resources, ) .await } @@ -590,14 +560,20 @@ impl PreparedCore { geodata_updater, proxy_groups, proxy_graph, + resources, + .. } = self.build_dispatcher(dialer)?; let sniffer = self.domain_sniffer_config(); let traffic_stats = self .traffic_stats() .unwrap_or_else(|| Arc::new(TunTrafficStats::default())); + let control = resources.as_ref().map(|resources| { + resources.clone() as Arc + }); let controller = self .bind_controller(Some(traffic_stats.clone()), proxy_groups) - .await?; + .await? + .map(|controller| controller.with_resources(control.clone())); let tun_runtime = TunRuntime::new_with_stats( tun, self.limits, @@ -608,7 +584,8 @@ impl PreparedCore { true, sniffer, traffic_stats.clone(), - )?; + )? + .with_resources(control); RunningCore::start_components( &self.config.inbounds, dispatcher, @@ -618,11 +595,122 @@ impl PreparedCore { geodata_updater, self.geodata_registration, proxy_graph, + resources, ) .await } } +struct GraphBuilder { + config: Config, + endpoints: Vec, + limits: ResourceLimits, + rules: RuleSet, + geo_matcher: Arc, + previous_dns: Option>, +} +impl GraphBuilder { + fn build(&self, dialer: Dialer) -> io::Result { + let dialer = dialer + .with_ipv6(self.config.ipv6) + .with_fresh_initialization(); + let handshake_stats = RuntimeResourceStats::new("runtime_handshake_observation"); + let proxy_graph = self.build_proxy_graph(dialer.clone())?; + let proxy_dispatchers = self.wrap_proxy_dispatchers( + proxy_graph.connectors(), + &handshake_stats, + &proxy_graph.resolution, + )?; + let direct_raw: Arc = Arc::new(DirectOutbound::new(dialer)); + let direct = observe_handshakes_with_stats(direct_raw, handshake_stats.clone()); + let proxy_groups = ProxyGroups::with_selections( + &self.config.proxy_groups, + proxy_dispatchers.clone(), + direct.clone(), + &proxy_graph.selections, + ) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let route_targets = + RouteTargetDispatchers::with_proxy_groups(proxy_dispatchers, &proxy_groups) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + let rules = self.rules.clone(); + let redir_host_entries = if self.config.tun.enable && self.rules.uses_domain_routing() { + self.limits.dns_redir_host_entries + } else { + 0 + }; + let geo_matcher = self.geo_matcher.clone(); + let mut dns_config = self.config.dns.clone(); + if !dns_config.enable { + dns_config.ipv6 = self.config.ipv6; + } + let dns = Some(Arc::new( + RuntimeDns::new_routed_proxies_with_cache_limits( + &dns_config, + route_targets.clone(), + direct.clone(), + rules.clone(), + geo_matcher.clone(), + self.limits.dns_address_cache_entries, + redir_host_entries, + ) + .with_mapping_from(self.previous_dns.as_deref()), + )); + proxy_graph.resolution.bind_runtime(dns.as_ref())?; + let router: Arc = Arc::new( + RoutingDispatcher::new_with_ipv6( + route_targets.clone(), + direct.clone(), + dns.clone(), + self.config.ipv6, + rules, + geo_matcher, + ) + .with_tun_config(&self.config.tun), + ); + let session_stats = RuntimeResourceStats::new("runtime_session_observation"); + Ok(BuiltRuntimeParts { + dispatcher: observe_sessions_with_stats(router, session_stats), + dns, + geodata_updater: None, + proxy_groups, + proxy_graph: Arc::new(proxy_graph), + resources: None, + resource_targets: route_targets, + direct, + }) + } + + fn build_proxy_graph(&self, dialer: Dialer) -> io::Result { + build_proxy_graph( + &self.config.proxies, + &self.config.proxy_groups, + &self.endpoints, + self.limits, + dialer, + ResolutionContext::runtime(self.config.ipv6), + ) + } + + fn wrap_proxy_dispatchers( + &self, + connectors: &[Arc], + handshake_stats: &RuntimeResourceStats, + resolution: &ResolutionContext, + ) -> io::Result>> { + let mut dispatchers = Vec::with_capacity(connectors.len()); + for (proxy, connector) in self.config.proxies.iter().zip(connectors) { + let mut dispatcher: Arc = Arc::new( + ConnectorDispatcher::with_udp_capability(connector.clone(), proxy.udp) + .with_resolution(resolution.clone()), + ); + dispatcher = observe_handshakes_with_stats(dispatcher, handshake_stats.clone()); + dispatchers.push(dispatcher); + } + Ok(dispatchers) + } +} + #[cfg(feature = "invoke")] impl PreparedMeasurement { pub(crate) async fn prepare_config( @@ -676,7 +764,7 @@ impl PreparedMeasurement { let session_stats = RuntimeResourceStats::new("measurement_session_observation"); Ok(MeasurementRuntime { dispatcher: observe_sessions_with_stats(dispatcher, session_stats), - proxy_graph, + proxy_graph: Arc::new(proxy_graph), }) } } @@ -771,10 +859,10 @@ fn build_proxy_graph( ProxyGroupMemberTarget::Reject => SelectUpstreamMember::Reject, }) .collect(); - let group = Arc::new(SelectUpstream::new( - graph.selections[id.index()].clone(), - members, - )); + let group = Arc::new( + SelectUpstream::new(graph.selections[id.index()].clone(), members) + .with_udp_disabled(config.disable_udp), + ); group_nodes[id.index()] = Some(group.clone()); graph.lifecycle_order.push(BuiltRouteTarget::Group(group)); continue; @@ -1219,7 +1307,8 @@ pub(crate) struct RunningCore { cancellation: CancellationToken, tasks: Vec>>, traffic_stats: Option>, - proxy_graph: BuiltProxyGraph, + proxy_graph: Arc, + resources: Option>, _geodata_registration: GeoDataRegistration, } @@ -1246,7 +1335,8 @@ impl RunningCore { controller: Option, geodata_updater: Option, geodata_registration: GeoDataRegistration, - proxy_graph: BuiltProxyGraph, + proxy_graph: Arc, + resources: Option>, ) -> io::Result { let cancellation = CancellationToken::new(); let mut tasks = Vec::with_capacity( @@ -1326,7 +1416,13 @@ impl RunningCore { )); } + if let Some(resources) = &resources { + tasks.push(crate::resources::observation::spawn( + resources.clone().run(cancellation.clone()), + )); + } Ok(Self { + resources, cancellation, tasks, traffic_stats, @@ -1353,6 +1449,9 @@ impl RunningCore { completed = wait_first_task(&mut self.tasks), if !self.tasks.is_empty() => (completed, None), }; self.proxy_graph.begin_shutdown(); + if let Some(resources) = &self.resources { + resources.begin_shutdown(); + } let result = stop_components( &self.cancellation, &mut self.tasks, @@ -1360,6 +1459,9 @@ impl RunningCore { ) .await; self.proxy_graph.shutdown().await; + if let Some(resources) = &self.resources { + resources.shutdown().await; + } result } } @@ -1407,6 +1509,9 @@ fn component_completion_error(result: Result, tokio::task::JoinEr impl Drop for RunningCore { fn drop(&mut self) { self.proxy_graph.begin_shutdown(); + if let Some(resources) = &self.resources { + resources.begin_shutdown(); + } self.cancellation.cancel(); for task in &self.tasks { task.abort(); diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs new file mode 100644 index 0000000..298a16e --- /dev/null +++ b/src/runtime/providers.rs @@ -0,0 +1,584 @@ +//! Transactional provider generations. Existing sessions pin their original graph. +use super::*; +use crate::config::ProxyId; +use crate::{ + config::{RuleAction, RuleKind, RuleSpec}, + dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError}, + resource::{ + cache::{ProviderCache, ProviderState}, + control::{DnsSnapshot, RuntimeResources}, + }, + routing::ProxyGroupError, + session::{Datagram, DatagramSession, Destination, StreamSession}, +}; +use arc_swap::ArcSwap; +use async_trait::async_trait; +use std::{ + collections::BTreeMap, + pin::Pin, + sync::{Mutex, RwLock}, + task::{Context, Poll}, +}; +use tokio::{ + io::{AsyncRead, AsyncWrite, ReadBuf}, + sync::{mpsc, oneshot}, +}; + +struct Generation { + requirements: GeoRequirements, + config: Config, + dispatcher: Arc, + dns: Option>, + groups: Arc, + graph: Arc, + targets: RouteTargetDispatchers, + direct: Arc, +} +impl Generation { + fn from_parts( + config: Config, + parts: &BuiltRuntimeParts, + requirements: GeoRequirements, + ) -> Self { + Self { + requirements, + config, + dispatcher: parts.dispatcher.clone(), + dns: parts.dns.clone(), + groups: parts.proxy_groups.clone(), + graph: parts.proxy_graph.clone(), + targets: parts.resource_targets.clone(), + direct: parts.direct.clone(), + } + } +} +struct Refresh { + rules: bool, + name: String, + reply: oneshot::Sender>, +} +pub(super) struct ProviderRuntime { + current: ArcSwap, + retired: Mutex>>, + publication: Mutex<()>, + cache: tokio::sync::Mutex, + states: RwLock>, + requests: mpsc::Sender, + receiver: tokio::sync::Mutex>>, + cancellation: CancellationToken, + dialer: Dialer, + limits: ResourceLimits, + manager: Arc, + lease: crate::geodata::GeoDataRegistrationLease, + geo: Arc, +} +impl ProviderRuntime { + pub(super) fn new( + cache: ProviderCache, + prepared: &PreparedCore, + parts: &BuiltRuntimeParts, + dialer: Dialer, + ) -> Arc { + let (requests, receiver) = mpsc::channel(16); + let mut configured = prepared.config.clone(); + configured.rules = (0..prepared.rules.len()) + .filter_map(|i| prepared.rules.rule(i).cloned()) + .collect(); + let requirements = GeoRequirements::for_config(&configured).expect("prepared requirements"); + Arc::new(Self { + current: ArcSwap::from_pointee(Generation::from_parts( + prepared.config.clone(), + parts, + requirements, + )), + retired: Mutex::new(Vec::new()), + publication: Mutex::new(()), + states: RwLock::new( + cache + .entries + .iter() + .map(|entry| entry.state.clone()) + .collect(), + ), + cache: tokio::sync::Mutex::new(cache), + requests, + receiver: tokio::sync::Mutex::new(Some(receiver)), + cancellation: CancellationToken::new(), + dialer, + limits: prepared.limits, + manager: prepared.geodata_manager.clone(), + lease: prepared.geodata_registration.updater_lease(), + geo: prepared.geodata_registration.matcher(), + }) + } + pub(super) fn begin_shutdown(&self) { + self.cancellation.cancel(); + self.current.load().graph.begin_shutdown(); + for generation in self.retired.lock().unwrap().iter() { + generation.graph.begin_shutdown(); + } + } + pub(super) async fn shutdown(&self) { + self.begin_shutdown(); + let current = self.current.load_full(); + current.graph.shutdown().await; + let retired = std::mem::take(&mut *self.retired.lock().unwrap()); + for generation in retired { + generation.graph.shutdown().await; + } + } + async fn reap(&self) { + let expired = { + let mut retired = self.retired.lock().unwrap(); + let mut expired = Vec::new(); + let mut i = 0; + while i < retired.len() { + if Arc::strong_count(&retired[i]) == 1 { + expired.push(retired.swap_remove(i)); + } else { + i += 1; + } + } + expired + }; + let changed = !expired.is_empty(); + for generation in expired { + generation.graph.begin_shutdown(); + generation.graph.shutdown().await; + } + if changed && !self.cancellation.is_cancelled() { + let current = self.current.load_full(); + if let Ok(requirements) = self.combined_requirements(¤t.requirements) { + let manager = self.manager.clone(); + let lease = self.lease.clone(); + let cancellation = self.cancellation.clone(); + let _ = tokio::task::spawn_blocking(move || { + manager.replace_requirements(&lease, requirements, &cancellation) + }) + .await; + } + } + } + fn combined_requirements( + &self, + next: &GeoRequirements, + ) -> Result { + let mut requirements = next.clone(); + requirements + .extend(&self.current.load().requirements) + .map_err(|_| "invalid resource requirements")?; + for old in self.retired.lock().unwrap().iter() { + requirements + .extend(&old.requirements) + .map_err(|_| "invalid resource requirements")?; + } + Ok(requirements) + } + pub(super) async fn run(self: Arc, parent: CancellationToken) -> io::Result<()> { + let mut receiver = self + .receiver + .lock() + .await + .take() + .ok_or_else(|| io::Error::other("provider worker already running"))?; + let mut tick = tokio::time::interval(Duration::from_secs(1)); + loop { + tokio::select! { + biased; + ()=parent.cancelled()=>{self.begin_shutdown();return Ok(());} + ()=self.cancellation.cancelled()=>return Ok(()), + request=receiver.recv()=>if let Some(request)=request { + let index=self.cache.lock().await.entries.iter().position(|entry|entry.state.rule==request.rules && entry.state.name==request.name); + let result=match index {Some(index)=>self.update(index).await,None=>Err("unknown provider")}; + let _=request.reply.send(result); + }, + _=tick.tick()=>{ + let due=self.cache.lock().await.next_due(); + if let Some(index)=due { let _=self.update(index).await; } + self.reap().await; + } + } + } + } + async fn update(self: &Arc, index: usize) -> Result<(), &'static str> { + let mut cache = self.cache.lock().await; + let proxy = cache.entries[index].spec.proxy.clone(); + let generation = self.current.load_full(); + let dispatcher = match self.download_dispatcher(generation.clone(), &proxy) { + Ok(dispatcher) => dispatcher, + Err(error) => { + cache.reject(index); + self.record_states(&cache); + return Err(error); + } + }; + cache.entries[index].state.updating = true; + self.record_states(&cache); + let user_agent = generation.config.global_ua.clone(); + let etag = generation.config.etag_support; + let candidate = cache + .fetch( + index, + dispatcher, + user_agent, + etag, + self.cancellation.clone(), + ) + .await; + self.record_states(&cache); + let Some(candidate) = candidate.map_err(|_| "resource fetch failed")? else { + return Ok(()); + }; + let mut contents = cache.contents.clone(); + contents.insert(cache.entries[index].key.clone(), candidate.content.clone()); + let template = cache.template.clone(); + let limits = self.limits; + let dialer = self.dialer.clone(); + let geo = self.geo.clone(); + let previous_dns = generation.dns.clone(); + let prepared = tokio::task::spawn_blocking( + move || -> io::Result<(Config, BuiltRuntimeParts, GeoRequirements)> { + let mut config = template.materialize(&contents).map_err(io::Error::other)?; + let requirements = + GeoRequirements::for_config(&config).map_err(io::Error::other)?; + let rules = + RuleSet::compile(config.take_runtime_rules()).map_err(io::Error::other)?; + let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; + let parts = GraphBuilder { + config: config.clone(), + endpoints, + limits, + rules, + geo_matcher: geo, + previous_dns, + } + .build(dialer)?; + Ok((config, parts, requirements)) + }, + ) + .await + .map_err(|_| "provider preparation failed")?; + let (config, parts, requirements) = match prepared { + Ok(value) => value, + Err(_) => { + cache.reject(index); + self.record_states(&cache); + return Err("provider candidate rejected"); + } + }; + let next = Arc::new(Generation::from_parts(config, &parts, requirements.clone())); + let requirements = self.combined_requirements(&requirements)?; + let manager = self.manager.clone(); + let lease = self.lease.clone(); + let cancellation = self.cancellation.clone(); + let updated = tokio::task::spawn_blocking(move || { + manager.replace_requirements(&lease, requirements, &cancellation) + }) + .await; + if !matches!(updated, Ok(Ok(()))) || self.cancellation.is_cancelled() { + next.graph.begin_shutdown(); + next.graph.shutdown().await; + return Err("provider update cancelled"); + } + let result: io::Result<()> = { + let _publication = self.publication.lock().unwrap(); + if self.cancellation.is_cancelled() { + Err(io::ErrorKind::Interrupted.into()) + } else { + cache + .commit(index, candidate, &self.cancellation) + .map(|()| { + for state in self.current.load().groups.list_states() { + let _ = next.groups.select(&state.name, &state.now); + } + let old = self.current.swap(next.clone()); + self.retired.lock().unwrap().push(old); + }) + } + }; + if result.is_err() { + next.graph.begin_shutdown(); + next.graph.shutdown().await; + cache.reject(index); + } + self.record_states(&cache); + result.map_err(|_| "provider cache commit failed") + } + fn record_states(&self, cache: &ProviderCache) { + *self.states.write().unwrap() = cache + .entries + .iter() + .map(|entry| entry.state.clone()) + .collect(); + } + fn download_dispatcher( + &self, + generation: Arc, + name: &str, + ) -> Result, &'static str> { + let dispatcher = if name.is_empty() { + generation.dispatcher.clone() + } else { + let action = match name { + "DIRECT" | "COMPATIBLE" => RuleAction::Direct, + "REJECT" => RuleAction::Reject, + _ => { + if let Some(index) = generation + .config + .proxies + .iter() + .position(|proxy| proxy.tag == name) + { + RuleAction::Route(RouteTargetId::Proxy(ProxyId::new(index).unwrap())) + } else if let Some(index) = generation + .config + .proxy_groups + .iter() + .position(|group| group.name == name) + { + RuleAction::Route(RouteTargetId::Group( + crate::config::ProxyGroupId::new(index).unwrap(), + )) + } else { + return Err("unknown provider download proxy"); + } + } + }; + let rules = RuleSet::compile(vec![RuleSpec { + kind: RuleKind::Match, + action, + no_resolve: false, + }]) + .map_err(|_| "invalid resource route")?; + Arc::new(RoutingDispatcher::new_with_ipv6( + generation.targets.clone(), + generation.direct.clone(), + generation.dns.clone(), + generation.config.ipv6, + rules, + self.geo.clone(), + )) as Arc + }; + Ok(Arc::new(PinnedDispatcher { + generation, + dispatcher, + })) + } +} +#[async_trait] +impl RuntimeResources for ProviderRuntime { + fn groups(&self) -> Arc { + self.current.load().groups.clone() + } + fn select(&self, group: &str, member: &str) -> Result<(), ProxyGroupError> { + let _publication = self.publication.lock().unwrap(); + self.current.load().groups.select(group, member) + } + fn providers(&self, rules: bool) -> serde_json::Value { + let providers = self + .states + .read() + .unwrap() + .iter() + .filter(|state| state.rule == rules) + .map(|state| { + let mut value = serde_json::to_value(state).expect("provider status"); + value["type"] = serde_json::json!(if rules { "Rule" } else { "Proxy" }); + (state.name.clone(), value) + }) + .collect::>(); + serde_json::json!({"providers":providers}) + } + fn dns(&self) -> Option { + let generation = self.current.load_full(); + generation.dns.clone().map(|dns| DnsSnapshot { + dns, + _generation: Some(generation), + }) + } + async fn refresh(&self, rules: bool, name: &str) -> Result<(), &'static str> { + if self.cancellation.is_cancelled() { + return Err("runtime stopped"); + } + let (reply, response) = oneshot::channel(); + tokio::select! {biased; ()=self.cancellation.cancelled()=>Err("runtime stopped"),result=async {self.requests.send(Refresh{rules,name:name.to_owned(),reply}).await.map_err(|_|"runtime stopped")?;response.await.map_err(|_|"runtime stopped")?}=>result} + } +} +#[async_trait] +impl Dispatcher for ProviderRuntime { + async fn connect_tcp(&self, session: StreamSession) -> Result { + if self.cancellation.is_cancelled() { + return Err(DispatchError::NotAllowed); + } + let generation = self.current.load_full(); + let io = generation.dispatcher.connect_tcp(session).await?; + Ok(Box::new(GenerationStream { + io, + _generation: generation, + })) + } + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + if self.cancellation.is_cancelled() { + return Err(DispatchError::NotAllowed); + } + let generation = self.current.load_full(); + let io = generation.dispatcher.open_datagram(session).await?; + Ok(Box::new(GenerationDatagram { + io, + _generation: generation, + })) + } +} +struct PinnedDispatcher { + generation: Arc, + dispatcher: Arc, +} +#[async_trait] +impl Dispatcher for PinnedDispatcher { + async fn connect_tcp(&self, session: StreamSession) -> Result { + let io = self.dispatcher.connect_tcp(session).await?; + Ok(Box::new(GenerationStream { + io, + _generation: self.generation.clone(), + })) + } + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + let io = self.dispatcher.open_datagram(session).await?; + Ok(Box::new(GenerationDatagram { + io, + _generation: self.generation.clone(), + })) + } +} +struct GenerationStream { + io: BoxStream, + _generation: Arc, +} +impl AsyncRead for GenerationStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.io).poll_read(cx, buf) + } +} +impl AsyncWrite for GenerationStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.io).poll_write(cx, buf) + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.io).poll_shutdown(cx) + } +} +struct GenerationDatagram { + io: Box, + _generation: Arc, +} +#[async_trait] +impl DatagramTransport for GenerationDatagram { + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { + self.io.payload_budget(peer) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + self.io.send(datagram).await + } + async fn send_with_flow_id( + &mut self, + datagram: Datagram, + id: &[u8], + ) -> Result<(), DispatchError> { + self.io.send_with_flow_id(datagram, id).await + } + async fn receive(&mut self) -> Result { + self.io.receive().await + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.io.close().await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dialer::SystemResolver; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + #[tokio::test] + async fn file_refresh_publishes_valid_members_and_preserves_old_sessions() { + let root = tempfile::tempdir().unwrap(); + let file = root.path().join("nodes.yaml"); + let payload = |name: &str| { + format!("proxies: [{{name: {name}, type: socks5, server: 192.0.2.1, port: 1080}}]\n") + }; + std::fs::write(&file, payload("old")).unwrap(); + let config = Config::parse_yaml( + br#" +mixed-port: 1080 +proxy-providers: + remote: {type: file, path: nodes.yaml} +proxy-groups: + - {name: choice, type: select, use: [remote]} +rules: ['MATCH,choice'] +"#, + ) + .unwrap(); + let manager = + GeoDataManager::open(&root.path().join("geodata"), Duration::from_secs(86400)).unwrap(); + let prepared = PreparedCore::prepare_config( + config, + manager, + &SystemResolver, + ResourceLimits::default(), + ) + .await + .unwrap(); + let parts = prepared.build_dispatcher(Dialer::default()).unwrap(); + let live = parts.resources.clone().unwrap(); + let generation = live.current.load_full(); + let weak = Arc::downgrade(&generation); + let (io, mut peer) = tokio::io::duplex(32); + let mut old_session = GenerationStream { + io: Box::new(io), + _generation: generation, + }; + assert_eq!(live.groups().state("choice").unwrap().now, "old"); + std::fs::write(&file, payload("new-node")).unwrap(); + live.update(0).await.unwrap(); + assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); + peer.write_all(b"active").await.unwrap(); + let mut buffer = [0; 6]; + old_session.read_exact(&mut buffer).await.unwrap(); + assert_eq!(&buffer, b"active"); + live.reap().await; + assert!(weak.upgrade().is_some()); + std::fs::write( + &file, + b"proxies: [{name: bad, type: socks5, server: missing}]\n", + ) + .unwrap(); + assert!(live.update(0).await.is_err()); + assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); + assert!(live.providers(false)["providers"]["remote"]["error"].is_string()); + drop(old_session); + live.reap().await; + assert!(weak.upgrade().is_none()); + let worker = tokio::spawn(live.clone().run(CancellationToken::new())); + live.begin_shutdown(); + worker.await.unwrap().unwrap(); + live.shutdown().await; + assert!(live.refresh(false, "remote").await.is_err()); + } +} diff --git a/src/runtime_group_tests.rs b/src/runtime_group_tests.rs index ac66b13..390fb83 100644 --- a/src/runtime_group_tests.rs +++ b/src/runtime_group_tests.rs @@ -48,116 +48,37 @@ rules: ['MATCH,child'] } #[tokio::test] -async fn prepare_includes_unselected_direct_candidates_and_excludes_proxy_only_hosts() { - for (members, expected) in [ - ("root, DIRECT", vec!["child.invalid", "root.invalid"]), - ("root, REJECT", vec!["root.invalid"]), - ] { - let resolver = RecordingResolver::default(); - let prepared = PreparedCore::prepare( - grouped_config(members).as_bytes(), - &resolver, - ResourceLimits::default(), - ) - .await - .unwrap(); - let mut actual = resolver - .hosts - .lock() - .unwrap() - .iter() - .map(|(host, _)| host.clone()) - .collect::>(); - actual.sort(); - assert_eq!(actual, expected); - for endpoints in &prepared.endpoints { - if let Some(endpoint) = &endpoints.upload { - assert!(endpoint.addresses.iter().all(SocketAddr::is_ipv4)); - } - } - assert_eq!( - prepared.endpoints[0].upload.is_some(), - members.contains("DIRECT") - ); - } -} - -#[tokio::test] -async fn candidate_dns_failure_rolls_back_geodata_registration_and_loopback_domains_fail_closed() { +async fn group_preparation_is_lazy_and_releases_registration_on_drop() { let directory = tempfile::tempdir().unwrap(); let manager = GeoDataManager::open(directory.path(), Duration::from_secs(86400)).unwrap(); let resolver = RecordingResolver { fail_host: Some("child.invalid"), ..Default::default() }; - let config = Config::parse_yaml(grouped_config("root, DIRECT").as_bytes()).unwrap(); - let error = PreparedCore::prepare_config( - config, - manager.clone(), - &resolver, - ResourceLimits::default(), - ) - .await - .unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::NotFound); - // The failed prepare must release its exclusive GeoData registration. - let config = Config::parse_yaml(grouped_config("root, REJECT").as_bytes()).unwrap(); - drop( - PreparedCore::prepare_config(config, manager, &resolver, ResourceLimits::default()) - .await - .unwrap(), - ); - - let yaml = grouped_config("root, DIRECT") - .replace("child.invalid", "localhost") - .replace("root.invalid", "127.0.0.1"); - let error = PreparedCore::prepare(yaml.as_bytes(), &SystemResolver, ResourceLimits::default()) - .await - .unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::PermissionDenied); -} - -#[tokio::test] -async fn cancelling_group_prepare_releases_its_geodata_registration() { - struct PendingResolver(AtomicUsize); - #[async_trait] - impl Resolver for PendingResolver { - async fn resolve(&self, _host: &str, _port: u16) -> io::Result { - self.0.fetch_add(1, Ordering::Relaxed); - std::future::pending().await - } - } - let directory = tempfile::tempdir().unwrap(); - let manager = GeoDataManager::open(directory.path(), Duration::from_secs(86400)).unwrap(); - let resolver = PendingResolver(AtomicUsize::new(0)); - let yaml = grouped_config("root, DIRECT"); - let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); - let mut preparing = Box::pin(PreparedCore::prepare_config( - config, - manager.clone(), - &resolver, - ResourceLimits::default(), - )); - assert!(futures_util::poll!(&mut preparing).is_pending()); - assert!(resolver.0.load(Ordering::Relaxed) > 0); - drop(preparing); - // Cancellation during candidate DNS must not retain the exclusive registration. - let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); - drop( - PreparedCore::prepare_config( + for members in ["root, DIRECT", "root, REJECT"] { + let config = Config::parse_yaml(grouped_config(members).as_bytes()).unwrap(); + let prepared = PreparedCore::prepare_config( config, - manager, - &RecordingResolver::default(), + manager.clone(), + &resolver, ResourceLimits::default(), ) .await - .unwrap(), - ); + .unwrap(); + assert!(resolver.hosts.lock().unwrap().is_empty()); + assert!( + prepared + .endpoints + .iter() + .all(|entry| entry.upload.is_none()) + ); + drop(prepared); + } } #[cfg(all(feature = "outbound-vless", feature = "outbound-socks5"))] #[tokio::test] -async fn possible_direct_vless_prepares_both_independent_legs() { +async fn possible_direct_vless_defers_both_independent_legs() { let yaml = grouped_config("root, DIRECT").replace( "{name: child, type: socks5, server: child.invalid, port: 1080, udp: true, dialer-proxy: outer}", r#"name: child @@ -184,16 +105,9 @@ async fn possible_direct_vless_prepares_both_independent_legs() { .await .unwrap(); let child = ProxyId::new(0).unwrap(); - assert_eq!(prepared.endpoint(child).is_some(), direct); - assert_eq!(prepared.download_endpoint(child).is_some(), direct); - if direct { - let download = prepared.download_endpoint(child).unwrap(); - assert_eq!(download.logical_host, "download.invalid"); - assert_eq!(download.port, 8443); - assert_eq!(resolver.hosts.lock().unwrap().len(), 3); - } else { - assert_eq!(resolver.hosts.lock().unwrap().len(), 1); - } + assert!(prepared.endpoint(child).is_none()); + assert!(prepared.download_endpoint(child).is_none()); + assert!(resolver.hosts.lock().unwrap().is_empty()); let graph = prepared.build_proxy_graph(Dialer::default()).unwrap(); assert_eq!(graph.len(), 2); } diff --git a/src/runtime_lifecycle_tests.rs b/src/runtime_lifecycle_tests.rs index eb41d63..406f881 100644 --- a/src/runtime_lifecycle_tests.rs +++ b/src/runtime_lifecycle_tests.rs @@ -106,6 +106,7 @@ rules: ['MATCH,path'] geodata_updater, prepared.geodata_registration, proxy_graph, + None, ) .await .unwrap(); diff --git a/src/tun_runtime.rs b/src/tun_runtime.rs index 0c99959..c5fc106 100644 --- a/src/tun_runtime.rs +++ b/src/tun_runtime.rs @@ -90,6 +90,7 @@ pub(crate) struct TunRuntime { config: TunConfig, dispatcher: Arc, dns: Option>, + resources: Option>, ipv6: bool, fake_icmp_echo: bool, sniffer: Option>, @@ -152,6 +153,7 @@ impl TunRuntime { config: config.clone(), dispatcher, dns: dns.filter(|dns| dns.is_enabled()), + resources: None, ipv6, fake_icmp_echo, sniffer, @@ -159,6 +161,14 @@ impl TunRuntime { }) } + pub(crate) fn with_resources( + mut self, + resources: Option>, + ) -> Self { + self.resources = resources; + self + } + pub(crate) async fn run(self, cancellation: CancellationToken) -> io::Result<()> { let config = tun_netstack_config(self.limits, self.fake_icmp_echo); let resource_stats = RuntimeResourceStats::new("tun_runtime"); @@ -182,6 +192,7 @@ impl TunRuntime { UdpIngressContext { dispatcher: self.dispatcher.clone(), dns: self.dns, + resources: self.resources, sniffer: self.sniffer.clone(), limits: self.limits, config: self.config.clone(), @@ -1001,6 +1012,7 @@ fn try_queue_association_input( struct UdpIngressContext { dispatcher: Arc, dns: Option>, + resources: Option>, sniffer: Option>, limits: ResourceLimits, config: TunConfig, @@ -1054,8 +1066,23 @@ impl UdpIngress { return; } if self.context.config.hijacks_dns(datagram.destination) - && let Some(dns) = &self.context.dns + && let Some(snapshot) = self + .context + .resources + .as_ref() + .and_then(|resources| resources.dns()) + .or_else(|| { + self.context + .dns + .clone() + .map(|dns| crate::resource::control::DnsSnapshot { + dns, + _generation: None, + }) + }) + && snapshot.dns.is_enabled() { + let dns = snapshot.dns.clone(); if let Err(error) = classify_query(datagram.payload) { tracing::debug!(%error, "dropping invalid TUN DNS datagram"); return; @@ -1066,8 +1093,8 @@ impl UdpIngress { datagram.destination, Bytes::copy_from_slice(datagram.payload), ); - self.dns_tasks.spawn(observation::task(run_tun_dns_query( - dns.clone(), + self.dns_tasks.spawn(observation::task(run_tun_dns_snapshot( + snapshot, permit, request, self.dns_responses.clone(), @@ -1217,6 +1244,28 @@ impl Deref for QueuedUdpResponse { } } +async fn run_tun_dns_snapshot( + snapshot: crate::resource::control::DnsSnapshot, + permit: DnsQueryPermit, + request: UdpDatagram, + responses: mpsc::Sender, + tun_mtu: usize, + resource_stats: RuntimeResourceStats, + cancellation: CancellationToken, +) { + let _generation = snapshot._generation; + run_tun_dns_query( + snapshot.dns, + permit, + request, + responses, + tun_mtu, + resource_stats, + cancellation, + ) + .await; +} + async fn run_tun_dns_query( dns: Arc, permit: DnsQueryPermit, diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index 21e74e5..4203458 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -1676,6 +1676,7 @@ async fn default_udp_response_queue_absorbs_bounded_bursts_without_blocking_dns( let cancellation = CancellationToken::new(); let (mut ingress, mut ordinary, mut dns) = UdpIngress::new( UdpIngressContext { + resources: None, config: test_tun_config(), dispatcher: Arc::new(MockDispatcher::default()), dns: None, @@ -3253,6 +3254,7 @@ async fn full_tcp_ingress_drops_only_that_packet_and_keeps_udp_and_dns_responsiv let cancellation = CancellationToken::new(); let (udp, mut ordinary_rx, mut dns_rx) = UdpIngress::new( UdpIngressContext { + resources: None, config: test_tun_config(), dispatcher: dispatcher.clone(), dns: Some(test_runtime_dns(dns_dispatcher.clone())), @@ -3630,6 +3632,7 @@ async fn udp_ingress_stop_releases_associations_without_cancelling_caller() { let probe = observation::ResourceProbe::default(); let (mut udp, ordinary, dns) = UdpIngress::new( UdpIngressContext { + resources: None, config: test_tun_config(), dispatcher, dns: None, @@ -3903,6 +3906,7 @@ async fn configured_udp_idle_timeout_controls_real_association_cleanup() { let cancellation = CancellationToken::new(); let (mut ingress, _ordinary, _dns) = UdpIngress::new( UdpIngressContext { + resources: None, dispatcher: Arc::new(MockDispatcher::default()), dns: None, sniffer: None, @@ -3938,6 +3942,7 @@ async fn configured_udp_dns_targets_match_exact_endpoints_and_empty_disables_hij let target = "192.0.2.53:5353".parse().unwrap(); let (mut ingress, _ordinary, _dns) = UdpIngress::new( UdpIngressContext { + resources: None, dispatcher, dns: Some(dns), sniffer: None, diff --git a/tests/README.md b/tests/README.md index aa46d0e..602bfa5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -66,3 +66,5 @@ container-benchmark compare --source vole=/path/to/Vole 所有服务端遵守 [容器隔离规则](../docs/testing-isolation.md)。用例和测量参数由 benchmark 维护,结果记录在对应运行或 PR 中;[验收边界](../docs/acceptance.md) 区分编译、互通与设备验证。 + +`runtime::providers::tests::` 使用临时文件与内存流验证候选发布、坏更新保留、旧会话资源和停止清理;`config::` 同时覆盖 provider、规则集合和 MRS 格式。 From 5370d14f4877954c3fc6f488b998ba98036ae383 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 18:25:15 +0800 Subject: [PATCH 006/445] Add automatic groups and persistent selection Share bounded HTTP health probes between provider checks, automatic groups and node-only measurement. Add lazy checks, delay tolerance, ordered recovery and runtime-owned selected-member caching. Validated configuration and memory probe/group/persistence regressions, provider generation regressions, all-target compilation and all-feature/minimal CLI clippy. No network-server or device tests were run. --- .github/workflows/test.yml | 2 +- AGENTS.md | 2 +- ...t-proxy-groups-as-session-route-targets.md | 4 +- ...roxy-group-selection-through-controller.md | 4 +- docs/config.yaml | 40 +- docs/controller-api.md | 18 +- src/config/groups.rs | 86 +++++ src/config/mod.rs | 67 +++- src/config/providers.rs | 26 +- src/controller.rs | 33 +- src/dns/runtime.rs | 4 + src/invoke/measure_delay.rs | 290 ++------------ src/outbound/anytls/client.rs | 2 +- src/outbound/connector.rs | 51 +-- src/outbound/socks5.rs | 6 +- src/resource/control.rs | 1 + src/resource/mod.rs | 2 + src/resource/probe.rs | 354 ++++++++++++++++++ src/resource/profile.rs | 60 +++ src/routing/dispatcher.rs | 4 + src/routing/mod.rs | 3 + src/routing/proxy_group.rs | 178 +++++++-- src/routing/selection.rs | 77 ++++ src/runtime.rs | 46 ++- src/runtime/health.rs | 286 ++++++++++++++ src/runtime/providers.rs | 76 +++- tests/README.md | 2 + 27 files changed, 1346 insertions(+), 378 deletions(-) create mode 100644 src/config/groups.rs create mode 100644 src/resource/probe.rs create mode 100644 src/resource/profile.rs create mode 100644 src/routing/selection.rs create mode 100644 src/runtime/health.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a23dbf..226f3c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -191,7 +191,7 @@ jobs: transport::sing_mux::smux_driver::tests:: transport::sing_mux::tests:: platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: platform::windows_wintun_io::tests:: tun_runtime::tests:: - invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: + invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: resource::probe::tests:: invoke::measure_delay::tests:: ) for filter in "${filters[@]}"; do run_test 1 --lib "$filter"; done run_test 1 --bin vole cli::tests:: diff --git a/AGENTS.md b/AGENTS.md index 666c939..4fd366f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Read the relevant document completely before changing that area: - `src/tun_runtime.rs` connects platform raw-IP I/O to `vole-netstack` and dispatches TCP, UDP, DNS, ICMP, and sniffing. - `src/platform/` contains platform adapters. Keep framing internal, propagate configured MTU, and preserve original borrowed-fd flags and ownership. Windows backend selection occurs at compilation; keep Windows callback semantics here instead of simulating a Unix fd. - `src/dialer.rs` is the shared physical TCP/UDP socket seam. Fix socket protection or Windows `(source IP, interface index)` binding once here rather than in each outbound. -- Static and provider-backed `select` groups share Running Session selection between routing and `dialer-proxy`. Provider candidates validate the complete graph before publication; existing sessions retain their old resource generation until release. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; resolve DIRECT server endpoints on demand through the proxy DNS role. `measureDelay` remains node-only. +- Static and provider-backed select, url-test and fallback groups share Running Session selection between routing and `dialer-proxy`. Provider candidates validate the complete graph before publication; existing sessions retain their old resource generation until release. Keep protocol configuration immutable, validate the complete node/member DAG, and retain only declared dependencies to avoid Arc cycles. Resolve upstreams at the `OutboundConnector` seam using one setup deadline and per-group selection snapshot; resolve DIRECT server endpoints on demand through the proxy DNS role. `measureDelay` remains node-only. - `crates/vole-netstack` is platform-independent raw-IP state and must not depend on WinRT, JNI, Swift, or host UI frameworks. - Core runtime lifecycle does not infer App, extension, service, or daemon roles and does not implement cross-process state or IPC. The Windows-only host Invoke is the explicit package integration seam for profile/status/Session Snapshot/StartupTask operations and an optional bounded `sessionBackend`; its Session Host owns backend process liveness without interpreting arguments, files, ports, or protocols. Provider runtime state remains process-local. diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index e490c52..b367c2c 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -8,10 +8,12 @@ status: accepted `mode` 支持 rule、global、direct;rule 模式允许空规则和任意位置的 MATCH,未命中直连。global 使用 GLOBAL 组;未显式声明时自动生成,成员顺序为 DIRECT、REJECT、全部节点、全部声明组,默认 DIRECT。该组与普通 select 共用运行时选择和 Controller 接口。 -组选择只属于当前 Vole 运行会话,由调用方在下一次配置中重新提供。新建底层 transport 时读取当前选择;同一次建链对每个上游组只读一次,SOCKS5 UDP 的控制连接和 relay 路径共享快照与绝对期限。不同组没有原子快照保证。在途握手、既有 TCP / UDP、DNS cache / singleflight / TCP pool 和旧 AnyTLS 会话不迁移;复用旧会话的新业务流仍保持原路径。被选成员失败时原样返回,不隐式 failover。 +组当前选择属于运行会话;profile.store-selected 开启时由 Core 保存 Controller 的显式选择,下次启动恢复有效缓存成员。新建底层 transport 时读取当前选择;同一次建链对每个上游组只读一次,SOCKS5 UDP 的控制连接和 relay 路径共享快照与绝对期限。不同组没有原子快照保证。在途握手、既有 TCP / UDP、DNS cache / singleflight / TCP pool 和旧 AnyTLS 会话不迁移;复用旧会话的新业务流仍保持原路径。被选成员失败时原样返回,不隐式 failover。 路由组保留 `Dispatcher` seam;组上游在 `OutboundConnector` seam 解析,共享同一个原子选择索引,但不重新执行业务规则,不丢弃 effective peer、UDP 预算或建链期限。协议配置保持不可变,组连接器只持有声明的依赖;依赖优先构造、逆序停止与释放,避免全局节点注册表形成 Arc 环或深图递归析构。 上游组的 DIRECT 连接当前节点服务器,不是业务目标;REJECT 直接使建链失败。节点的主端点与独立下载端点在实际建链时通过运行时节点 resolver 解析;准备阶段不预解析潜在首跳。未选成员的 DNS 故障不阻塞启动。仅经代理访问的节点保持逻辑域名。DNS 关闭时使用系统解析,开启时使用配置的节点 nameserver/policy,不因失败切换系统解析;所有路径共享建链期限、递归依赖检查和停止取消。独立 measureDelay 的节点准备仍使用受控引导 resolver。 Provider 成员通过完整资源候选生成。候选重新校验名称及节点/组依赖图后发布,新连接使用当前资源,既有 TCP/UDP 与在途 DNS 保留仍使用的旧资源;不再使用的资源按原停止屏障释放。失败候选不替换可用配置或 HTTP 缓存。动态组移除当前成员时回到有效默认项或首成员,空组按 empty-fallback 处理。下载使用已发布资源,不依赖候选发布完成。 + +url-test 与 fallback 复用同一组选择和依赖图。共享 HTTP(S) 健康探测分别执行延迟容差和按序恢复,检查遵循 lazy、超时、并发上限和停止取消。探测结果不声明 UDP 能力,也不迁移既有连接。 diff --git a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md index 92b384f..6d354bc 100644 --- a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md +++ b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md @@ -8,4 +8,6 @@ Vole 首版只通过 Controller 暴露代理组的实时选择,不新增 Invok Windows WinRT VPN 的实际运行时位于独立 Session Host,桌面 Wintun、Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。 -配置同时包含代理组和 Controller 时,必须为整个 Controller 配置 Bearer secret。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;调用方在成功切换后自行持久化下一次启动所需的 `default-selected`,持久化失败不回滚已经成功的运行时选择。 +配置同时包含代理组和 Controller 时,必须为整个 Controller 配置 Bearer secret。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;Core 按 `profile.store-selected`(默认 true)保存 Controller 的显式选择;下次启动优先恢复仍存在的缓存成员,否则使用配置默认或首成员。持久化失败不回滚已经成功的运行时选择。 + +Controller 同时展示动态 provider、自动组和节点健康状态;组选择与探测任务均位于实际运行会话。独立 measureDelay 保持 node-only Invoke 接口,复用内部 HTTP/TLS 探测能力。 diff --git a/docs/config.yaml b/docs/config.yaml index c42c8b5..7dcfcb2 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -20,6 +20,14 @@ ipv6: true mode: rule log-level: info +# unified-delay 默认 false;true 时复用探测连接再次发送 HEAD,成功时记录第二次耗时。 +# profile.store-selected 默认 true,将 Controller 的显式选择保存到 dataDir/selected.json。 +# 启动时缓存中的有效选择优先于 default-selected;成员已删除时使用配置默认/首成员。 +# 文件损坏时使用配置;写入失败保留已生效选择,并在 Controller 状态中报告。 +unified-delay: false +profile: + store-selected: true + # 资源下载使用 global-ua(缺省 Vole/ 加 Core 版本);etag-support 缺省 true。 # GeoData 字段独立可选;URL 缺省 MetaCubeX meta-rules-dat latest DAT。 # URL 以域名为主机、无 userinfo/fragment,生产构建使用 HTTPS。 @@ -401,7 +409,7 @@ proxies: # name-cert-verify: verified.example.com # 可选独立证书验证名 # client-fingerprint: chrome # 仅有插件时允许;共享七值,缺省/none/空串关闭命名模板 -# select 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, +# select/url-test/fallback 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, # 也可分别使用 include-all-proxies / include-all-providers。后者启用时忽略 use。 # filter 筛选动态成员;exclude-filter / exclude-type 同时排除显式成员。 # 正则支持 Rust regex 子集,区分大小写;反引号分隔的多表达式按匹配顺序排列。 @@ -412,14 +420,18 @@ proxies: # 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 # 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 # -# default-selected 省略时选中第一项;显式值必须是当前组的直接成员。重复名称按 -# 第一项命中。没有 Controller 时,该初始选择在本次 session 内固定;有 Controller -# 时可实时切换。选择只存于 Vole 当前 session 内存,Vole 不写回 YAML;宿主若需 -# 跨 session 保留,必须持久化选择并在下次配置中注入 default-selected。 -# -# 切换只影响之后新建的物理 TCP 连接、UDP association/transport 和 DNS transport; -# 既有 TCP 连接、UDP association、DNS cache/singleflight、DNS TCP pool 和其中的 -# transport 不迁移、不清空。选中成员失败时原样失败,代理组不自动换成员。 +# select 的 default-selected 省略时选中第一项;有效缓存选择优先。名称精确匹配。 +# url-test 按 URL 探测延迟选择,tolerance(毫秒,默认 0)抑制小幅切换;fallback +# 按声明顺序选择首个健康成员,恢复后可回到前面的成员。全部失败时返回首成员。 +# 三种组均接受 url/interval/timeout/lazy/expected-status/max-failed-times。 +# url 缺省使用首个 provider 的健康 URL,否则 https://www.gstatic.com/generate_204。 +# interval 单位秒,自动组缺省/0 为 300;select 缺省 0,不主动周期探测。 +# timeout 单位毫秒,缺省/0 为 5000;lazy 默认 true,无近期使用时跳过周期探测。 +# expected-status 省略/空/* 接受所有最终 HTTP 状态,可用 200/204 或 200-299。 +# max-failed-times 缺省/0 为 5,建链失败达到阈值后触发探测,不重试同一次业务连接。 +# HTTP 探测结果不代表 UDP 可用性,业务 UDP 仍检查节点能力与 disable-udp。 +# Controller 可手动固定自动组成员,成员不可用时恢复自动选择。 +# 自动或手动切换只影响新连接;既有连接、认证会话及其中复用的流仍保留原路径。 # 组作为 dialer-proxy 时,与路由共用当前选择,但不重新执行业务规则;DIRECT 连接 # 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。单次建链对每个组 # 只读一次选择,SOCKS5 UDP 控制流与 relay 路径共用该快照和期限;不同组没有原子 @@ -450,6 +462,9 @@ proxy-groups: # override 支持 udp、udp-over-tcp、up/down、dialer-proxy、skip-cert-verify、 # additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]; # 协议专属覆盖只用于支持该字段的节点。未实现的覆盖字段严格拒绝。 +# proxy providers 的 health-check 接受 enable/url/interval/timeout/lazy/expected-status; +# enable 默认 false,开启后 interval 缺省/0 为 300 秒。空 URL 不发起探测。 +# 相同节点/URL 的检查共享结果,每批最多 10 个并发,超时与停止均取消 I/O。 # 更新先解析并校验整个依赖图,成功才替换;失败保留旧配置与 HTTP 缓存。 # 新连接使用新资源,旧 TCP/UDP 会话保留原资源;停止等待下载、解析和资源释放。 # rule providers 支持 domain/ipcidr/classical,format 为 yaml/text/mrs;MRS 仅 domain/ipcidr。 @@ -460,6 +475,13 @@ proxy-providers: local-nodes: type: inline payload: [] + health-check: + enable: false + url: https://www.gstatic.com/generate_204 + interval: 300 + timeout: 5000 + lazy: true + expected-status: "204" rule-providers: local-domains: type: inline diff --git a/docs/controller-api.md b/docs/controller-api.md index 6b89ea0..18f0df3 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -1,6 +1,6 @@ # 运行时 Controller API -Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态与动态 `select` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 +Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 ## 配置与生命周期 @@ -15,7 +15,7 @@ secret: "vole-runtime-secret" - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 - `validateConfig` 只校验字段;`prepare` 不监听端口;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 -Controller 与公共运行时 session 同生共灭。代理组选择只保存在 Vole 当前 session 的内存中;Vole 不写回配置。宿主如需跨 session 保留选择,必须自行持久化,并在下次 `configYaml` 中提供对应的 `default-selected`。 +Controller 与公共运行时 session 同生共灭。`profile.store-selected` 默认 true,Core 将显式选择保存到 dataDir/selected.json,在下次启动时恢复有效成员;不写回 YAML。关闭该选项后只保留会话内选择。写入失败不回滚已生效的选择,`GET /proxies` 的 `selectionError` 给出脱敏原因。 ## Bearer 鉴权 @@ -83,11 +83,17 @@ GET /group/{name} GET /proxies/{name} ``` -- `name` 是组定义名,`type` 固定为 `Selector`; +- `name` 是组定义名,`type` 分别为 `Selector`、`URLTest` 或 `Fallback`; - `all` 是配置中的直接成员,严格保留顺序和重复项; - `now` 是当前选中的直接成员名。若该成员是嵌套组,`now` 仍返回组名,不展开为最终叶节点; - 名称和值大小写敏感并精确匹配,不做大小写折叠或 Unicode normalization。 +`GET /proxies` 返回以名称为键的 `proxies` 对象,包含组状态及节点的 `alive/history`。 +`GET /proxies/{name}` 也可查询节点。history 当前保留最近一次探测,delay 单位毫秒, +time 为 Unix 秒;未探测节点初始 alive 为 true、history 为空。HTTP 健康状态不声明 UDP 可用性。 + +自动组的手动选择在该成员健康时保持固定,失败后恢复自动选择。url-test 使用 tolerance 抑制小幅切换,fallback 按成员顺序选择与恢复。 + ## `PUT /proxies/{name}` 切换组的当前直接成员: @@ -126,8 +132,8 @@ Content-Length: 26 - 新建的物理 TCP 连接使用切换后的当前叶节点;既有 TCP 连接保持原路径; - 新建的 UDP association/物理 transport 使用切换后的当前叶节点;既有 association 不迁移; - 新建的 DNS transport 使用切换后的当前叶节点;DNS cache、singleflight 状态和已在池中的 TCP transport 不清空、不迁移; -- 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML 或下一次 session 的默认值; -- 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。失败按原样返回,不自动选择其他成员。 +- 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML;可按 profile 配置保存选择; +- 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。select 的失败按原样返回;自动组根据探测结果选择后续连接的成员。 `dialer-proxy` 与规则、DNS 共享同一组选择,但经共享连接器直接建链,不重新执行业务规则。上游组的 `DIRECT` 连接当前节点按需解析的服务器,`REJECT` 使建链失败。单次建链对同一组只读取一次选择,SOCKS5 UDP 的控制连接和 relay 路径共享该快照;不同组之间不提供原子快照。已经读取旧选择的在途握手继续原路径。复用旧 AnyTLS 会话的新业务流也保留原路径,只有真正新建底层 transport 才读取新选择。 @@ -164,7 +170,7 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 | `413 Payload Too Large` | PUT 正文超过 1 KiB | | `415 Unsupported Media Type` | PUT 不是严格 `application/json` | -当前不提供 health check、自动 failover、`url-test`/`fallback`/`load-balance`、delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的 provider 路由、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 +当前不提供 `load-balance`、Controller 主动 delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; diff --git a/src/config/groups.rs b/src/config/groups.rs new file mode 100644 index 0000000..838cec8 --- /dev/null +++ b/src/config/groups.rs @@ -0,0 +1,86 @@ +use super::*; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum GroupKind { + #[default] + Select, + UrlTest, + Fallback, +} +impl GroupKind { + pub fn controller_type(self) -> &'static str { + match self { + Self::Select => "Selector", + Self::UrlTest => "URLTest", + Self::Fallback => "Fallback", + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] +pub struct ProfileConfig { + pub store_selected: bool, +} +impl Default for ProfileConfig { + fn default() -> Self { + Self { + store_selected: true, + } + } +} +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] +pub struct HealthCheckConfig { + pub enable: bool, + pub url: String, + pub interval: u64, + pub timeout: u64, + pub lazy: bool, + pub expected_status: String, +} +impl Default for HealthCheckConfig { + fn default() -> Self { + Self { + enable: false, + url: String::new(), + interval: 0, + timeout: 5000, + lazy: true, + expected_status: String::new(), + } + } +} +impl HealthCheckConfig { + pub(crate) fn validate(&self) -> Result<()> { + if !self.url.is_empty() { + crate::resource::probe::Target::parse(&self.url).map_err(|_| { + VoleError::InvalidConfig("health check requires an HTTP(S) URL".into()) + })?; + } + status_ranges(&self.expected_status)?; + Ok(()) + } +} +pub(crate) fn status_ranges(value: &str) -> Result> { + let value = value.trim(); + if value.is_empty() || value == "*" { + return Ok(Vec::new()); + } + value + .split('/') + .map(|part| { + let (first, last) = part.split_once('-').unwrap_or((part, part)); + let start = first + .parse::() + .map_err(|_| VoleError::InvalidConfig("invalid expected-status".into()))?; + let end = last + .parse::() + .map_err(|_| VoleError::InvalidConfig("invalid expected-status".into()))?; + if start > end { + return invalid("invalid expected-status range"); + } + Ok((start, end)) + }) + .collect() +} diff --git a/src/config/mod.rs b/src/config/mod.rs index b61789f..4db2e0a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,7 @@ //! Strict parsing for the current Vole YAML configuration. +mod groups; +pub(crate) use groups::status_ranges; +pub use groups::{GroupKind, HealthCheckConfig, ProfileConfig}; use std::{ collections::{HashMap, VecDeque}, @@ -73,6 +76,8 @@ pub struct Config { pub log_level: LogLevel, pub global_ua: String, pub etag_support: bool, + pub unified_delay: bool, + pub profile: ProfileConfig, pub geodata_update: Option, pub external_controller: Option, pub inbounds: Vec, @@ -81,6 +86,7 @@ pub struct Config { pub sniffer: SnifferConfig, pub dns: DnsConfig, pub rules: Vec, + pub provider_members: HashMap>, pub providers: Option>, } @@ -449,6 +455,10 @@ pub struct ProxyGroupMemberConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SelectProxyGroupConfig { + pub kind: GroupKind, + pub health: HealthCheckConfig, + pub tolerance: u16, + pub max_failed_times: u32, pub name: String, pub members: Vec, pub initial_member: usize, @@ -861,6 +871,10 @@ impl std::fmt::Debug for HttpInboundConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawVoleConfig { + #[serde(rename = "unified-delay", default)] + unified_delay: bool, + #[serde(default)] + profile: ProfileConfig, #[serde(default)] mode: Mode, #[serde(rename = "log-level", default)] @@ -930,6 +944,21 @@ struct RawVoleConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawProxyGroup { + #[serde(default)] + url: String, + #[serde(default)] + interval: u64, + #[serde(default)] + timeout: u64, + #[serde(default = "default_true")] + lazy: bool, + #[serde(rename = "expected-status", default)] + expected_status: String, + #[serde(default)] + tolerance: u16, + #[serde(rename = "max-failed-times", default)] + max_failed_times: u32, + #[serde(rename = "disable-udp", default)] disable_udp: bool, #[serde(default)] @@ -938,7 +967,7 @@ struct RawProxyGroup { icon: String, name: String, #[serde(rename = "type")] - kind: String, + kind: GroupKind, proxies: Vec, #[serde( rename = "default-selected", @@ -1703,7 +1732,14 @@ impl RawVoleConfig { hidden: false, icon: String::new(), name: "GLOBAL".into(), - kind: "select".into(), + kind: GroupKind::Select, + url: String::new(), + interval: 0, + timeout: 0, + lazy: true, + expected_status: String::new(), + tolerance: 0, + max_failed_times: 0, proxies: vec!["DIRECT".into(), "REJECT".into()], default_selected: None, }); @@ -1770,6 +1806,8 @@ impl RawVoleConfig { log_level: self.log_level, global_ua: self.global_ua, etag_support: self.etag_support, + unified_delay: self.unified_delay, + profile: self.profile, geodata_update, external_controller, inbounds, @@ -1779,6 +1817,7 @@ impl RawVoleConfig { dns, rules, providers: None, + provider_members: HashMap::new(), }) } } @@ -2127,12 +2166,6 @@ fn normalize_proxy_group_declarations( ) -> Result<(Vec, HashMap)> { let mut group_ids = HashMap::with_capacity(raw_groups.len()); for (index, group) in raw_groups.iter().enumerate() { - if group.kind != "select" { - return invalid(format!( - "proxy group `{}` type must be exactly `select`", - group.name - )); - } validate_route_target_definition_name(&group.name, "proxy group name")?; if group.proxies.is_empty() { return invalid(format!( @@ -2209,7 +2242,18 @@ fn normalize_proxy_groups( })?, None => 0, }; + let health = HealthCheckConfig { + enable: true, + url: if group.url.is_empty() { "https://www.gstatic.com/generate_204".into() } else { group.url }, + interval: if group.interval == 0 && group.kind != GroupKind::Select { 300 } else { group.interval }, + timeout: if group.timeout == 0 { 5000 } else { group.timeout }, + lazy: group.lazy, + expected_status: group.expected_status, + }; + health.validate()?; Ok(SelectProxyGroupConfig { + kind: group.kind, health, tolerance: group.tolerance, + max_failed_times: if group.max_failed_times == 0 { 5 } else { group.max_failed_times }, name: group.name, members, initial_member, @@ -5327,9 +5371,6 @@ rules: "mixed-port: 1080\nauthentication:\n - measure:secret\nrules:\n - MATCH,主线路"; for groups in [ r#" - name: 主线路 - type: url-test - proxies: [proxy]"#, - r#" - name: 主线路 type: select proxies: []"#, r#" - name: 主线路 @@ -5345,10 +5386,6 @@ rules: r#" - name: 主线路 type: select proxies: [proxy] - url: https://example.com"#, - r#" - name: 主线路 - type: select - proxies: [proxy] default-selected: null"#, ] { let yaml = with_proxy_groups(groups, fields); diff --git a/src/config/providers.rs b/src/config/providers.rs index 30f4ba9..8e0da65 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -49,6 +49,8 @@ pub struct ProviderSpec { pub size_limit: u64, #[serde(default)] pub payload: Vec, + #[serde(default)] + pub health_check: HealthCheckConfig, pub behavior: Option, #[serde(default)] pub format: RuleFormat, @@ -108,6 +110,10 @@ impl ProviderSpec { } _ => {} } + self.health_check.validate()?; + if is_rule && self.health_check != HealthCheckConfig::default() { + return invalid("rule providers do not support health-check"); + } Filter::new(&self.filter, &self.exclude_filter, &self.exclude_type)?; for key in self.overrides.keys() { if ![ @@ -207,9 +213,6 @@ impl ProviderTemplate { rules, }) } - pub fn has_resources(&self) -> bool { - !self.proxies.is_empty() || !self.rules.is_empty() - } pub(crate) fn key(is_rule: bool, name: &str) -> String { format!("{}/{name}", if is_rule { "rules" } else { "proxies" }) } @@ -313,6 +316,18 @@ impl ProviderTemplate { if all_providers { used = self.proxies.iter().map(|(name, _)| name.clone()).collect(); } + if map + .get("url") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + && let Some(url) = used + .iter() + .filter_map(|name| self.proxies.iter().find(|(n, _)| n == name)) + .map(|(_, spec)| &spec.health_check.url) + .find(|url| !url.is_empty()) + { + map.insert("url".into(), url.clone().into()); + } let dynamic = !used.is_empty() || all_proxies || all_providers; let mut names = declared; if all_proxies { @@ -398,9 +413,8 @@ impl ProviderTemplate { } } } - if self.has_resources() { - config.providers = Some(self.clone()); - } + config.provider_members = providers; + config.providers = Some(self.clone()); Ok(config) } } diff --git a/src/controller.rs b/src/controller.rs index 9a94847..3517a20 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -231,6 +231,22 @@ async fn handle_connection( .as_ref() .map_or(proxy_groups, |resources| resources.groups()); + if request.target == "/proxies" { + if request.method != "GET" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; + } + let mut health = resources.as_ref().map_or_else( + || serde_json::json!({"proxies":{}}), + |resources| resources.health(), + ); + for state in proxy_groups.list_states() { + let name = state.name.clone(); + health["proxies"][name] = + serde_json::to_value(GroupResponse::from(state)).expect("group status"); + } + return write_json(&mut stream, 200, &health, &[]).await; + } + if request.target == "/group" { if request.method != "GET" { return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; @@ -268,7 +284,16 @@ async fn handle_connection( return match request.method.as_str() { "GET" => match proxy_groups.state(&name) { Ok(state) => write_json(&mut stream, 200, &GroupResponse::from(state), &[]).await, - Err(_) => write_error(&mut stream, 404, "Resource not found", &[]).await, + Err(_) => { + let health = resources.as_ref().map(|resources| resources.health()); + match health + .as_ref() + .and_then(|state| state["proxies"].get(&name)) + { + Some(node) => write_json(&mut stream, 200, node, &[]).await, + None => write_error(&mut stream, 404, "Resource not found", &[]).await, + } + } }, "PUT" => { handle_select( @@ -492,7 +517,7 @@ impl From for GroupResponse { fn from(state: ProxyGroupState) -> Self { Self { name: state.name, - kind: "Selector", + kind: state.kind.controller_type(), all: state.all, now: state.now, hidden: state.hidden, @@ -599,6 +624,10 @@ mod tests { let dispatcher: Arc = Arc::new(UnusedDispatcher); ProxyGroups::new( &[SelectProxyGroupConfig { + kind: crate::config::GroupKind::Select, + health: crate::config::HealthCheckConfig::default(), + tolerance: 0, + max_failed_times: 5, disable_udp: false, hidden: false, icon: String::new(), diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index dfc0266..841317c 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -3050,6 +3050,10 @@ dns: let proxy_dispatchers: Vec> = vec![old.clone(), new.clone()]; let groups = ProxyGroups::new( &[SelectProxyGroupConfig { + kind: crate::config::GroupKind::Select, + health: crate::config::HealthCheckConfig::default(), + tolerance: 0, + max_failed_times: 5, disable_udp: false, hidden: false, icon: String::new(), diff --git a/src/invoke/measure_delay.rs b/src/invoke/measure_delay.rs index f8db44f..b8544c5 100644 --- a/src/invoke/measure_delay.rs +++ b/src/invoke/measure_delay.rs @@ -2,35 +2,38 @@ use std::{ future::Future, - io, - net::{IpAddr, Ipv4Addr, SocketAddr}, panic::AssertUnwindSafe, sync::{ Arc, atomic::{AtomicBool, Ordering}, }, - time::{Duration, Instant}, + time::Duration, }; use futures_util::{FutureExt, StreamExt, stream}; -use rustls::{ClientConfig, RootCertStore, client::Resumption, pki_types::ServerName}; use serde::{Deserialize, Serialize}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio_rustls::TlsConnector; -use url::{Host, Url}; use crate::{ ResourceLimits, config::MeasureConfig, dialer::{Dialer, SystemResolver}, - dispatch::{BoxStream, DispatchError, Dispatcher}, - inbound::DEFAULT_HEADER_LIMIT, - outbound::{OutboundDiagnostic, capture_outbound_diagnostic}, + dispatch::Dispatcher, runtime::PreparedMeasurement, - session::{Destination, InboundKind, StreamSession}, }; use super::{InvokeFailure, registry}; +use crate::resource::probe::Target; +#[cfg(test)] +use crate::resource::probe::{parse_status, probe_once, read_response_status}; +#[cfg(test)] +use crate::{ + dispatch::BoxStream, + session::{Destination, InboundKind}, +}; +#[cfg(test)] +use std::io; +#[cfg(test)] +use tokio::io::{AsyncReadExt, AsyncWriteExt}; const MIN_TIMEOUT_SECONDS: u32 = 1; const MAX_TIMEOUT_SECONDS: u32 = 30; @@ -57,27 +60,6 @@ pub(super) struct MeasureDelayResult { error: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TargetScheme { - Http, - Https, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum TargetHost { - Domain(String), - Ip(IpAddr), -} - -#[derive(Debug, Clone)] -struct Target { - scheme: TargetScheme, - destination: Destination, - authority: String, - origin_form: String, - tls_server_name: String, -} - struct MeasureRequest { config_yamls: Vec, timeout: Duration, @@ -141,7 +123,7 @@ impl MeasureRequest { Ok(Self { config_yamls: payload.config_yamls, timeout: Duration::from_secs(u64::from(payload.timeout)), - target: Target::parse(&payload.url)?, + target: Target::parse(&payload.url).map_err(InvokeFailure::from)?, }) } } @@ -168,75 +150,6 @@ impl MeasureDelayResult { } } -impl Target { - fn parse(raw: &str) -> Result { - let url = Url::parse(raw) - .map_err(|_| InvokeFailure::invalid_request("url is not a valid absolute URL"))?; - let scheme = match url.scheme() { - "http" => TargetScheme::Http, - "https" => TargetScheme::Https, - _ => { - return Err(InvokeFailure::invalid_request( - "url scheme must be http or https", - )); - } - }; - if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { - return Err(InvokeFailure::invalid_request( - "url must not contain authentication or a fragment", - )); - } - let host = match url.host() { - Some(Host::Domain(domain)) => { - if domain.len() > u8::MAX as usize { - return Err(InvokeFailure::invalid_request("url host is too long")); - } - TargetHost::Domain(domain.to_owned()) - } - Some(Host::Ipv4(address)) => TargetHost::Ip(IpAddr::V4(address)), - Some(Host::Ipv6(address)) => TargetHost::Ip(IpAddr::V6(address)), - None => return Err(InvokeFailure::invalid_request("url must contain a host")), - }; - let port = url - .port_or_known_default() - .ok_or_else(|| InvokeFailure::invalid_request("url must contain a port"))?; - let destination = match &host { - TargetHost::Domain(domain) => { - Destination::domain(domain.clone(), port).map_err(InvokeFailure::invalid_request)? - } - TargetHost::Ip(address) => Destination::Ip(SocketAddr::new(*address, port)), - }; - let authority = authority(&host, port); - let mut origin_form = url.path().to_owned(); - if origin_form.is_empty() { - origin_form.push('/'); - } - if let Some(query) = url.query() { - origin_form.push('?'); - origin_form.push_str(query); - } - let tls_server_name = match &host { - TargetHost::Domain(domain) => domain.clone(), - TargetHost::Ip(address) => address.to_string(), - }; - Ok(Self { - scheme, - destination, - authority, - origin_form, - tls_server_name, - }) - } -} - -fn authority(host: &TargetHost, port: u16) -> String { - match host { - TargetHost::Domain(domain) => format!("{domain}:{port}"), - TargetHost::Ip(IpAddr::V4(address)) => format!("{address}:{port}"), - TargetHost::Ip(IpAddr::V6(address)) => format!("[{address}]:{port}"), - } -} - async fn run_batch(request: MeasureRequest) -> Vec { let MeasureRequest { config_yamls, @@ -294,169 +207,16 @@ async fn run_probe( dispatcher: Arc, timeout: Duration, ) -> Result { - let connector = (target.scheme == TargetScheme::Https) - .then(build_tls_connector) - .transpose() - .map_err(InvokeFailure::from)?; - let started = Instant::now(); - match tokio::time::timeout(timeout, probe_once(target, dispatcher, connector.as_ref())).await { - Ok(Ok(())) => u64::try_from(started.elapsed().as_millis()) - .map_err(|_| InvokeFailure::internal("measureDelay duration overflow")), - Ok(Err(error)) => Err(InvokeFailure::new(format!( - "measureDelay probe failed: {error}" - ))), - Err(_) => Err(InvokeFailure::new(format!( - "measureDelay timed out after {} seconds", - timeout.as_secs() - ))), - } -} - -fn build_tls_connector() -> io::Result { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - let roots: RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); - let mut config = ClientConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(io::Error::other)? - .with_root_certificates(Arc::new(roots)) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - config.resumption = Resumption::disabled(); - Ok(TlsConnector::from(Arc::new(config))) -} - -async fn probe_once( - target: &Target, - dispatcher: Arc, - tls_connector: Option<&TlsConnector>, -) -> io::Result<()> { - let session = StreamSession { - inbound: InboundKind::InternalMeasure, - source: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), - destination: target.destination.clone(), - sniffed_domain: None, - }; - let (stream, diagnostic) = capture_outbound_diagnostic(dispatcher.connect_tcp(session)).await; - let stream = stream.map_err(|error| outbound_connect_error(error, diagnostic.as_ref()))?; - let stream = if target.scheme == TargetScheme::Https { - let connector = tls_connector - .ok_or_else(|| io::Error::other("HTTPS probe is missing its TLS connector"))?; - connect_target_tls(connector, stream, &target.tls_server_name).await? - } else { - stream - }; - send_head_and_read_response(stream, &target.origin_form, &target.authority).await -} - -fn outbound_connect_error( - error: DispatchError, - diagnostic: Option<&OutboundDiagnostic>, -) -> io::Error { - let diagnostic = diagnostic.map_or_else(String::new, |diagnostic| { - format!( - " (Vole outbound stage={} kind={} error={:?})", - diagnostic.stage(), - diagnostic.kind(), - diagnostic.message() - ) - }); - io::Error::other(format!("outbound connect failed: {error}{diagnostic}")) -} - -async fn connect_target_tls( - connector: &TlsConnector, - stream: BoxStream, - server_name: &str, -) -> io::Result { - let server_name = ServerName::try_from(server_name.to_owned()) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - connector - .connect(server_name, stream) - .await - .map(|stream| Box::new(stream) as BoxStream) - .map_err(io::Error::other) -} - -async fn send_head_and_read_response( - mut stream: BoxStream, - request_target: &str, - authority: &str, -) -> io::Result<()> { - stream - .write_all( - format!( - "HEAD {request_target} HTTP/1.1\r\nHost: {authority}\r\nAccept: */*\r\nUser-Agent: Vole/{}\r\nConnection: close\r\n\r\n", - env!("CARGO_PKG_VERSION") - ) - .as_bytes(), - ) - .await?; - stream.flush().await?; - read_response_status(&mut stream).await.map(drop) -} - -async fn read_response_status(reader: &mut R) -> io::Result -where - R: tokio::io::AsyncRead + Unpin + ?Sized, -{ - let mut input = Vec::with_capacity(1024); - let mut total_read = 0_usize; - loop { - if let Some(end) = input.windows(4).position(|window| window == b"\r\n\r\n") { - let status = parse_status(&input[..end])?; - if (100..200).contains(&status) && status != 101 { - input.drain(..end + 4); - continue; - } - return Ok(status); - } - if total_read >= DEFAULT_HEADER_LIMIT { - return Err(io::Error::new( - io::ErrorKind::FileTooLarge, - "HTTP response heads exceed their aggregate limit", - )); - } - let mut chunk = [0_u8; 1024]; - let remaining = DEFAULT_HEADER_LIMIT - total_read; - let max_read = remaining.min(chunk.len()); - let length = reader.read(&mut chunk[..max_read]).await?; - if length == 0 { - return Err(io::Error::new( - io::ErrorKind::UnexpectedEof, - "HTTP connection closed before the response head", - )); - } - total_read += length; - input.extend_from_slice(&chunk[..length]); - } -} - -fn parse_status(head: &[u8]) -> io::Result { - let line_end = head - .windows(2) - .position(|window| window == b"\r\n") - .unwrap_or(head.len()); - let line = &head[..line_end]; - let mut fields = line.splitn(3, |byte| *byte == b' '); - let version = fields.next().unwrap_or_default(); - let status = fields.next().unwrap_or_default(); - if (version != b"HTTP/1.0" && version != b"HTTP/1.1") - || status.len() != 3 - || !status.iter().all(u8::is_ascii_digit) - { - return Err(invalid_data("invalid HTTP response status line")); - } - let status = u16::from(status[0] - b'0') * 100 - + u16::from(status[1] - b'0') * 10 - + u16::from(status[2] - b'0'); - if !(100..=599).contains(&status) { - return Err(invalid_data("HTTP response status is out of range")); - } - Ok(status) -} - -fn invalid_data(message: &'static str) -> io::Error { - io::Error::new(io::ErrorKind::InvalidData, message) + crate::resource::probe::probe( + target, + dispatcher, + timeout, + false, + &[], + &tokio_util::sync::CancellationToken::new(), + ) + .await + .map_err(InvokeFailure::from) } fn truncate_utf8(value: &str, maximum: usize) -> String { diff --git a/src/outbound/anytls/client.rs b/src/outbound/anytls/client.rs index b722aed..8e5df73 100644 --- a/src/outbound/anytls/client.rs +++ b/src/outbound/anytls/client.rs @@ -645,7 +645,7 @@ mod tests { } let first = DuplexDialer::new(ServerMode::V2); let second = DuplexDialer::new(ServerMode::V2); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(crate::routing::GroupSelection::new(0)); let group = Arc::new(SelectUpstream::new( selection.clone(), vec![ diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index ff82362..96c1ac0 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -1,13 +1,13 @@ +use crate::routing::GroupSelection; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::{ collections::HashMap, fmt::{self, Write as _}, future::Future, io, net::IpAddr, - sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }, + sync::{Arc, Mutex, atomic::Ordering}, time::Duration, }; @@ -212,7 +212,7 @@ pub struct EstablishContext { selections: Arc>, } -type SelectionSnapshot = HashMap, usize)>; +type SelectionSnapshot = HashMap, usize)>; impl EstablishContext { #[must_use] @@ -265,7 +265,8 @@ impl EstablishContext { /// One read per group for the entire setup, including both legs of a /// SOCKS5 UDP association. Retaining the atomic prevents address reuse; /// unrelated groups are not an atomic cross-group snapshot. - fn selected_member(&self, selection: &Arc) -> usize { + fn selected_member(&self, selection: &Arc) -> usize { + selection.touch(); let key = Arc::as_ptr(selection) as usize; let mut selections = self .selections @@ -277,6 +278,12 @@ impl EstablishContext { .1 } + fn report_group_outcome(&self, success: bool) { + for (selection, _) in self.selections.lock().unwrap().values() { + selection.outcome(success); + } + } + pub async fn run(&self, operation: &'static str, future: F) -> Result where F: Future>, @@ -438,7 +445,7 @@ pub trait OutboundConnector: Send + Sync { /// never a registry of all nodes, so a validated DAG cannot form an Arc cycle. /// The atomic is shared with the route/Controller view of the same group. pub struct SelectUpstream { - selection: Arc, + selection: Arc, members: Box<[SelectUpstreamMember]>, disable_udp: bool, } @@ -451,7 +458,7 @@ pub(crate) enum SelectUpstreamMember { } impl SelectUpstream { - pub(crate) fn new(selection: Arc, members: Vec) -> Self { + pub(crate) fn new(selection: Arc, members: Vec) -> Self { Self { selection, members: members.into_boxed_slice(), @@ -755,10 +762,12 @@ impl ConnectorDispatcher { #[async_trait] impl Dispatcher for ConnectorDispatcher { async fn connect_tcp(&self, session: StreamSession) -> Result { - self.inner - .connect_stream(session, &self.context()) - .await - .map(|connected| connected.io) + let context = self.context(); + let result = self.inner.connect_stream(session, &context).await; + if !matches!(result, Err(DispatchError::NotAllowed)) { + context.report_group_outcome(result.is_ok()); + } + result.map(|connected| connected.io) } async fn open_datagram( @@ -794,7 +803,7 @@ mod tests { "HYSTERIA2-UNIT", "authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline", ); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(GroupSelection::new(0)); let path = selected_path( selection.clone(), vec![ @@ -928,7 +937,7 @@ mod tests { } fn selected_path( - selection: Arc, + selection: Arc, members: Vec, ) -> UpstreamPath { UpstreamPath::Group { @@ -948,7 +957,7 @@ mod tests { marker: 2, ..Default::default() }); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(GroupSelection::new(0)); let nested = Arc::new(SelectUpstream::new( selection.clone(), vec![ @@ -956,7 +965,7 @@ mod tests { SelectUpstreamMember::Proxy(second.clone()), ], )); - let outer_selection = Arc::new(AtomicUsize::new(0)); + let outer_selection = Arc::new(GroupSelection::new(0)); let path = selected_path( outer_selection.clone(), vec![ @@ -1016,7 +1025,7 @@ mod tests { marker: 2, ..Default::default() }); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(GroupSelection::new(0)); let path = selected_path( selection.clone(), vec![ @@ -1065,7 +1074,7 @@ mod tests { gate: Some(Arc::new(Semaphore::new(0))), ..Default::default() }); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(GroupSelection::new(0)); let connector_weak = Arc::downgrade(&connector); let selection_weak = Arc::downgrade(&selection); let path = selected_path(selection, vec![SelectUpstreamMember::Proxy(connector)]); @@ -1085,7 +1094,7 @@ mod tests { async fn upstream_direct_connects_prepared_node_server_and_reject_never_falls_back() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let address = listener.local_addr().unwrap(); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(GroupSelection::new(0)); let path = UpstreamPath::Group { group: Arc::new(SelectUpstream::new( selection.clone(), @@ -1153,7 +1162,7 @@ mod tests { let protector = Arc::new(RejectProtector(AtomicUsize::new(0))); let path = UpstreamPath::Group { group: Arc::new(SelectUpstream::new( - Arc::new(AtomicUsize::new(0)), + Arc::new(GroupSelection::new(0)), vec![SelectUpstreamMember::Direct], )), endpoint: Some(ResolvedEndpoint { @@ -1190,7 +1199,7 @@ mod tests { reject_udp: true, ..Default::default() }); - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(GroupSelection::new(0)); let path = selected_path( selection.clone(), vec![ diff --git a/src/outbound/socks5.rs b/src/outbound/socks5.rs index e284e96..979a983 100644 --- a/src/outbound/socks5.rs +++ b/src/outbound/socks5.rs @@ -505,7 +505,7 @@ mod tests { close_control: bool, fragment_response: bool, opened_limit: Arc, - switch_after_handshake: Option>, + switch_after_handshake: Option>, } impl MockUpstream { @@ -851,7 +851,7 @@ mod tests { async fn group_switch_during_udp_handshake_keeps_control_and_relay_on_one_upstream() { use crate::outbound::{SelectUpstream, SelectUpstreamMember}; - let selection = Arc::new(AtomicUsize::new(0)); + let selection = Arc::new(crate::routing::GroupSelection::new(0)); let mut first = MockUpstream::new(COMMAND_UDP_ASSOCIATE); first.switch_after_handshake = Some(selection.clone()); let first_budget = first.opened_limit.clone(); @@ -866,7 +866,7 @@ mod tests { ], )); let group = Arc::new(SelectUpstream::new( - Arc::new(AtomicUsize::new(0)), + Arc::new(crate::routing::GroupSelection::new(0)), vec![SelectUpstreamMember::Group(child)], )); let outbound = Socks5Outbound::new_with_path( diff --git a/src/resource/control.rs b/src/resource/control.rs index 9c40ba3..f1bbc3a 100644 --- a/src/resource/control.rs +++ b/src/resource/control.rs @@ -14,6 +14,7 @@ pub(crate) struct DnsSnapshot { pub(crate) trait RuntimeResources: Send + Sync { fn groups(&self) -> Arc; fn select(&self, group: &str, member: &str) -> Result<(), ProxyGroupError>; + fn health(&self) -> serde_json::Value; fn providers(&self, rules: bool) -> serde_json::Value; fn dns(&self) -> Option; async fn refresh(&self, rules: bool, name: &str) -> Result<(), &'static str>; diff --git a/src/resource/mod.rs b/src/resource/mod.rs index 5d6be0f..84cbbf7 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -2,3 +2,5 @@ pub(crate) mod cache; pub(crate) mod control; pub(crate) mod http; +pub(crate) mod probe; +pub(crate) mod profile; diff --git a/src/resource/probe.rs b/src/resource/probe.rs new file mode 100644 index 0000000..2cff801 --- /dev/null +++ b/src/resource/probe.rs @@ -0,0 +1,354 @@ +//! Shared, bounded HTTP(S) health probes through the runtime dispatcher. +use crate::{ + dispatch::{BoxStream, DispatchError, Dispatcher}, + inbound::DEFAULT_HEADER_LIMIT, + outbound::{OutboundDiagnostic, capture_outbound_diagnostic}, + session::{Destination, InboundKind, StreamSession}, +}; +use rustls::{ClientConfig, RootCertStore, client::Resumption, pki_types::ServerName}; +use std::{ + io, + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_rustls::TlsConnector; +use tokio_util::sync::CancellationToken; +use url::{Host, Url}; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TargetScheme { + Http, + Https, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TargetHost { + Domain(String), + Ip(IpAddr), +} + +#[derive(Debug, Clone)] +pub(crate) struct Target { + scheme: TargetScheme, + pub(crate) destination: Destination, + pub(crate) authority: String, + pub(crate) origin_form: String, + tls_server_name: String, +} + +impl Target { + pub(crate) fn parse(raw: &str) -> io::Result { + let url = Url::parse(raw).map_err(|_| invalid_input("url is not a valid absolute URL"))?; + let scheme = match url.scheme() { + "http" => TargetScheme::Http, + "https" => TargetScheme::Https, + _ => { + return Err(invalid_input("url scheme must be http or https")); + } + }; + if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + return Err(invalid_input( + "url must not contain authentication or a fragment", + )); + } + let host = match url.host() { + Some(Host::Domain(domain)) => { + if domain.len() > u8::MAX as usize { + return Err(invalid_input("url host is too long")); + } + TargetHost::Domain(domain.to_owned()) + } + Some(Host::Ipv4(address)) => TargetHost::Ip(IpAddr::V4(address)), + Some(Host::Ipv6(address)) => TargetHost::Ip(IpAddr::V6(address)), + None => return Err(invalid_input("url must contain a host")), + }; + let port = url + .port_or_known_default() + .ok_or_else(|| invalid_input("url must contain a port"))?; + let destination = match &host { + TargetHost::Domain(domain) => { + Destination::domain(domain.clone(), port).map_err(invalid_input)? + } + TargetHost::Ip(address) => Destination::Ip(SocketAddr::new(*address, port)), + }; + let authority = authority(&host, port); + let mut origin_form = url.path().to_owned(); + if origin_form.is_empty() { + origin_form.push('/'); + } + if let Some(query) = url.query() { + origin_form.push('?'); + origin_form.push_str(query); + } + let tls_server_name = match &host { + TargetHost::Domain(domain) => domain.clone(), + TargetHost::Ip(address) => address.to_string(), + }; + Ok(Self { + scheme, + destination, + authority, + origin_form, + tls_server_name, + }) + } +} + +fn authority(host: &TargetHost, port: u16) -> String { + match host { + TargetHost::Domain(domain) => format!("{domain}:{port}"), + TargetHost::Ip(IpAddr::V4(address)) => format!("{address}:{port}"), + TargetHost::Ip(IpAddr::V6(address)) => format!("[{address}]:{port}"), + } +} + +fn build_tls_connector() -> io::Result { + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let roots: RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); + let mut config = ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(io::Error::other)? + .with_root_certificates(Arc::new(roots)) + .with_no_client_auth(); + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + config.resumption = Resumption::disabled(); + Ok(TlsConnector::from(Arc::new(config))) +} + +async fn connect( + target: &Target, + dispatcher: Arc, + tls_connector: Option<&TlsConnector>, +) -> io::Result { + let session = StreamSession { + inbound: InboundKind::InternalMeasure, + source: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), + destination: target.destination.clone(), + sniffed_domain: None, + }; + let (stream, diagnostic) = capture_outbound_diagnostic(dispatcher.connect_tcp(session)).await; + let stream = stream.map_err(|error| outbound_connect_error(error, diagnostic.as_ref()))?; + let stream = if target.scheme == TargetScheme::Https { + let connector = tls_connector + .ok_or_else(|| io::Error::other("HTTPS probe is missing its TLS connector"))?; + connect_target_tls(connector, stream, &target.tls_server_name).await? + } else { + stream + }; + Ok(stream) +} + +fn outbound_connect_error( + error: DispatchError, + diagnostic: Option<&OutboundDiagnostic>, +) -> io::Error { + let diagnostic = diagnostic.map_or_else(String::new, |diagnostic| { + format!( + " (Vole outbound stage={} kind={} error={:?})", + diagnostic.stage(), + diagnostic.kind(), + diagnostic.message() + ) + }); + io::Error::other(format!("outbound connect failed: {error}{diagnostic}")) +} + +async fn connect_target_tls( + connector: &TlsConnector, + stream: BoxStream, + server_name: &str, +) -> io::Result { + let server_name = ServerName::try_from(server_name.to_owned()) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; + connector + .connect(server_name, stream) + .await + .map(|stream| Box::new(stream) as BoxStream) + .map_err(io::Error::other) +} + +async fn head(stream: &mut BoxStream, target: &Target, keep_alive: bool) -> io::Result { + let connection = if keep_alive { "keep-alive" } else { "close" }; + stream.write_all(format!("HEAD {} HTTP/1.1\r\nHost: {}\r\nAccept: */*\r\nUser-Agent: Vole/{}\r\nConnection: {connection}\r\n\r\n",target.origin_form,target.authority,env!("CARGO_PKG_VERSION")).as_bytes()).await?; + stream.flush().await?; + read_response_status(stream).await +} +#[cfg(test)] +pub(crate) async fn probe_once( + target: &Target, + dispatcher: Arc, + tls: Option<&TlsConnector>, +) -> io::Result<()> { + let mut stream = connect(target, dispatcher, tls).await?; + head(&mut stream, target, false).await.map(drop) +} +pub(crate) async fn probe( + target: &Target, + dispatcher: Arc, + timeout: Duration, + unified: bool, + expected: &[(u16, u16)], + cancel: &CancellationToken, +) -> io::Result { + let operation = async { + let tls = (target.scheme == TargetScheme::Https) + .then(build_tls_connector) + .transpose()?; + let mut started = Instant::now(); + let mut stream = connect(target, dispatcher, tls.as_ref()).await?; + let mut status = head(&mut stream, target, unified).await?; + if unified { + let second = Instant::now(); + if let Ok(next) = head(&mut stream, target, false).await { + started = second; + status = next; + } + } + if !expected.is_empty() + && !expected + .iter() + .any(|(start, end)| (*start..=*end).contains(&status)) + { + return Err(io::Error::other("unexpected health check status")); + } + Ok(started.elapsed().as_millis().min(u64::MAX as u128) as u64) + }; + tokio::select! {biased; + ()=cancel.cancelled()=>Err(io::ErrorKind::Interrupted.into()), + result=tokio::time::timeout(timeout,operation)=>result.map_err(|_| io::Error::new(io::ErrorKind::TimedOut,"health check timed out"))? + } +} +fn invalid_input(message: impl std::fmt::Display) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message.to_string()) +} + +pub(crate) async fn read_response_status(reader: &mut R) -> io::Result +where + R: tokio::io::AsyncRead + Unpin + ?Sized, +{ + let mut input = Vec::with_capacity(1024); + let mut total_read = 0_usize; + loop { + if let Some(end) = input.windows(4).position(|window| window == b"\r\n\r\n") { + let status = parse_status(&input[..end])?; + if (100..200).contains(&status) && status != 101 { + input.drain(..end + 4); + continue; + } + return Ok(status); + } + if total_read >= DEFAULT_HEADER_LIMIT { + return Err(io::Error::new( + io::ErrorKind::FileTooLarge, + "HTTP response heads exceed their aggregate limit", + )); + } + let mut chunk = [0_u8; 1024]; + let remaining = DEFAULT_HEADER_LIMIT - total_read; + let max_read = remaining.min(chunk.len()); + let length = reader.read(&mut chunk[..max_read]).await?; + if length == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "HTTP connection closed before the response head", + )); + } + total_read += length; + input.extend_from_slice(&chunk[..length]); + } +} + +pub(crate) fn parse_status(head: &[u8]) -> io::Result { + let line_end = head + .windows(2) + .position(|window| window == b"\r\n") + .unwrap_or(head.len()); + let line = &head[..line_end]; + let mut fields = line.splitn(3, |byte| *byte == b' '); + let version = fields.next().unwrap_or_default(); + let status = fields.next().unwrap_or_default(); + if (version != b"HTTP/1.0" && version != b"HTTP/1.1") + || status.len() != 3 + || !status.iter().all(u8::is_ascii_digit) + { + return Err(invalid_data("invalid HTTP response status line")); + } + let status = u16::from(status[0] - b'0') * 100 + + u16::from(status[1] - b'0') * 10 + + u16::from(status[2] - b'0'); + if !(100..=599).contains(&status) { + return Err(invalid_data("HTTP response status is out of range")); + } + Ok(status) +} + +fn invalid_data(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{dispatch::DatagramTransport, session::DatagramSession}; + use std::sync::Mutex; + struct Memory(Mutex>); + #[async_trait::async_trait] + impl Dispatcher for Memory { + async fn connect_tcp(&self, _: StreamSession) -> Result { + Ok(self.0.lock().unwrap().take().unwrap()) + } + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } + } + #[tokio::test] + async fn unified_probe_reuses_stream_checks_status_and_cancels() { + let (client, mut server) = tokio::io::duplex(4096); + let dispatcher = Arc::new(Memory(Mutex::new(Some(Box::new(client))))); + let peer = tokio::spawn(async move { + for _ in 0..2 { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(server.read_u8().await.unwrap()); + } + assert!(head.starts_with(b"HEAD / HTTP/1.1")); + server + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + } + }); + let cancel = CancellationToken::new(); + let target = Target::parse("http://example.test/").unwrap(); + probe( + &target, + dispatcher.clone(), + Duration::from_secs(1), + true, + &[(204, 204)], + &cancel, + ) + .await + .unwrap(); + peer.await.unwrap(); + cancel.cancel(); + assert_eq!( + probe( + &target, + dispatcher, + Duration::from_secs(1), + false, + &[], + &cancel + ) + .await + .unwrap_err() + .kind(), + io::ErrorKind::Interrupted + ); + } +} diff --git a/src/resource/profile.rs b/src/resource/profile.rs new file mode 100644 index 0000000..c935176 --- /dev/null +++ b/src/resource/profile.rs @@ -0,0 +1,60 @@ +//! Runtime-owned selection cache. The source YAML is never rewritten. +use std::{ + collections::BTreeMap, + fs, + io::{self, Read}, + path::{Path, PathBuf}, + sync::Mutex, +}; +#[derive(Debug)] +pub(crate) struct SelectionStore { + path: Option, + values: Mutex>, +} +impl SelectionStore { + pub fn open(root: &Path, enabled: bool) -> Self { + let path = enabled.then(|| root.join("selected.json")); + let values = path + .as_ref() + .and_then(|path| { + let file = fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take(8 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .ok()?; + if bytes.len() > 8 * 1024 * 1024 { + return None; + } + serde_json::from_slice(&bytes).ok() + }) + .unwrap_or_default(); + Self { + path, + values: Mutex::new(values), + } + } + pub fn restore(&self, groups: &crate::routing::ProxyGroups) { + for (name, member) in self.values.lock().unwrap().iter() { + let _ = groups.select(name, member); + } + } + pub fn save(&self, name: &str, member: &str) -> io::Result<()> { + let Some(path) = &self.path else { + return Ok(()); + }; + let mut values = self.values.lock().unwrap(); + values.insert(name.into(), member.into()); + let temporary = path.with_extension(format!("{}.tmp", std::process::id())); + let result = (|| { + fs::write( + &temporary, + serde_json::to_vec(&*values).map_err(io::Error::other)?, + )?; + fs::rename(&temporary, path) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result + } +} diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 323aa1d..939d9c8 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -1120,6 +1120,10 @@ mod tests { members: Vec<(&str, ProxyGroupMemberTarget)>, ) -> SelectProxyGroupConfig { SelectProxyGroupConfig { + kind: crate::config::GroupKind::Select, + health: crate::config::HealthCheckConfig::default(), + tolerance: 0, + max_failed_times: 5, disable_udp: false, hidden: false, icon: String::new(), diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 9202ba6..21f123d 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -16,6 +16,9 @@ use crate::{ session::Destination, }; +mod selection; +pub(crate) use selection::GroupSelection; + mod dispatcher; mod proxy_group; diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index 5c325b0..e03ca40 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -1,10 +1,10 @@ +use super::GroupSelection; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; use std::{ collections::HashMap, fmt, - sync::{ - Arc, - atomic::{AtomicUsize, Ordering}, - }, + sync::{Arc, atomic::Ordering}, }; use async_trait::async_trait; @@ -17,6 +17,7 @@ use crate::{ #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProxyGroupState { + pub kind: crate::config::GroupKind, pub(crate) name: String, pub(crate) all: Vec, pub(crate) now: String, @@ -42,9 +43,10 @@ impl fmt::Display for ProxyGroupError { impl std::error::Error for ProxyGroupError {} struct SelectProxyGroup { + config: SelectProxyGroupConfig, name: String, members: Box<[ProxyGroupMember]>, - selected: Arc, + selected: Arc, disable_udp: bool, hidden: bool, icon: String, @@ -90,7 +92,7 @@ impl ProxyGroups { ) -> Result, DispatchError> { let selections = configs .iter() - .map(|config| Arc::new(AtomicUsize::new(config.initial_member))) + .map(|config| Arc::new(GroupSelection::new(config.initial_member))) .collect::>(); Self::with_selections(configs, proxies, direct, &selections) } @@ -99,7 +101,7 @@ impl ProxyGroups { configs: &[SelectProxyGroupConfig], proxies: Vec>, direct: Arc, - selections: &[Arc], + selections: &[Arc], ) -> Result, DispatchError> { if selections.len() != configs.len() { return Err(DispatchError::Other( @@ -154,20 +156,24 @@ impl ProxyGroups { groups: configs .iter() .zip(selections) - .map(|(config, selection)| SelectProxyGroup { - name: config.name.clone(), - members: config - .members - .iter() - .map(|member| ProxyGroupMember { - name: member.name.clone(), - target: member.target, - }) - .collect(), - selected: selection.clone(), - disable_udp: config.disable_udp, - hidden: config.hidden, - icon: config.icon.clone(), + .map(|(config, selection)| { + selection.configure(config.max_failed_times, config.health.timeout); + SelectProxyGroup { + config: config.clone(), + name: config.name.clone(), + members: config + .members + .iter() + .map(|member| ProxyGroupMember { + name: member.name.clone(), + target: member.target, + }) + .collect(), + selected: selection.clone(), + disable_udp: config.disable_udp, + hidden: config.hidden, + icon: config.icon.clone(), + } }) .collect(), ids_by_name, @@ -193,6 +199,14 @@ impl ProxyGroups { } pub(crate) fn select(&self, group: &str, member: &str) -> Result<(), ProxyGroupError> { + self.inherit_selection(group, member, true) + } + pub(crate) fn inherit_selection( + &self, + group: &str, + member: &str, + forced: bool, + ) -> Result<(), ProxyGroupError> { let id = self .ids_by_name .get(group) @@ -204,7 +218,11 @@ impl ProxyGroups { .iter() .position(|candidate| candidate.name == member) .ok_or(ProxyGroupError::UnknownMember)?; - group.selected.store(selected, Ordering::Release); + if forced { + group.selected.force(selected); + } else { + group.selected.store(selected, Ordering::Release); + } Ok(()) } @@ -227,6 +245,7 @@ impl ProxyGroups { if udp && group.disable_udp { return Err(DispatchError::NotAllowed); } + group.selected.touch(); let selected = group.selected.load(Ordering::Acquire); let member = group.members.get(selected).ok_or_else(|| { DispatchError::Other(format!( @@ -268,6 +287,46 @@ impl ProxyGroups { )) } + pub(crate) fn selection(&self, index: usize) -> Arc { + self.groups[index].selected.clone() + } + pub(crate) fn apply_health(&self, index: usize, samples: &[Option]) { + let group = &self.groups[index]; + if group.config.kind == crate::config::GroupKind::Select + || samples.len() != group.members.len() + { + return; + } + if let Some(forced) = group.selected.forced() { + if samples[forced].is_some() { + group.selected.store(forced, Ordering::Release); + return; + } + group.selected.clear_forced(); + } + let old = group.selected.load(Ordering::Acquire); + let next = if group.config.kind == crate::config::GroupKind::Fallback { + samples.iter().position(Option::is_some).unwrap_or(0) + } else { + let fastest = samples + .iter() + .enumerate() + .filter_map(|(i, d)| d.map(|d| (i, d))) + .min_by_key(|(_, d)| *d) + .map(|(i, _)| i) + .unwrap_or(0); + if samples[old] + .zip(samples[fastest]) + .is_some_and(|(a, b)| a <= b.saturating_add(u64::from(group.config.tolerance))) + { + old + } else { + fastest + } + }; + group.selected.store(next, Ordering::Release); + } + pub(super) fn dispatcher(self: &Arc, id: ProxyGroupId) -> Arc { Arc::new(SelectProxyGroupDispatcher { groups: self.clone(), @@ -280,6 +339,7 @@ impl SelectProxyGroup { fn state(&self) -> ProxyGroupState { let selected = self.selected.load(Ordering::Acquire); ProxyGroupState { + kind: self.config.kind, name: self.name.clone(), all: self .members @@ -301,11 +361,14 @@ struct SelectProxyGroupDispatcher { #[async_trait] impl Dispatcher for SelectProxyGroupDispatcher { async fn connect_tcp(&self, session: StreamSession) -> Result { - self.groups - .resolve_leaf(self.id)? - .dispatcher - .connect_tcp(session) - .await + let leaf = self.groups.resolve_leaf(self.id)?; + let result = leaf.dispatcher.connect_tcp(session).await; + if !leaf.direct && !matches!(result, Err(DispatchError::NotAllowed)) { + self.groups.groups[self.id.index()] + .selected + .outcome(result.is_ok()); + } + result } async fn open_datagram( @@ -351,11 +414,7 @@ impl DatagramTransport for RejectDatagramTransport { #[cfg(test)] mod tests { - use std::{ - net::SocketAddr, - sync::atomic::{AtomicUsize, Ordering}, - time::Duration, - }; + use std::{net::SocketAddr, sync::atomic::Ordering, time::Duration}; use bytes::Bytes; use tokio::sync::Semaphore; @@ -471,6 +530,10 @@ mod tests { initial_member: usize, ) -> SelectProxyGroupConfig { SelectProxyGroupConfig { + kind: crate::config::GroupKind::Select, + health: crate::config::HealthCheckConfig::default(), + tolerance: 0, + max_failed_times: 5, disable_udp: false, hidden: false, icon: String::new(), @@ -501,6 +564,55 @@ mod tests { } } + #[tokio::test(start_paused = true)] + async fn automatic_groups_apply_tolerance_recover_and_restore_selection() { + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: + - {name: a, type: socks5, server: 192.0.2.1, port: 1080} + - {name: b, type: socks5, server: 192.0.2.2, port: 1080} +proxy-groups: + - {name: fastest, type: url-test, proxies: [a,b], tolerance: 20, max-failed-times: 2} + - {name: backup, type: fallback, proxies: [a,b]} + - {name: manual, type: select, proxies: [a,b], default-selected: a} +"#, + ) + .unwrap(); + let node = Arc::new(FailingDispatcher); + let groups = ProxyGroups::new( + &config.proxy_groups, + vec![node.clone(), node.clone()], + node.clone(), + ) + .unwrap(); + groups.apply_health(0, &[Some(100), Some(90)]); + assert_eq!(groups.state("fastest").unwrap().now, "a"); + groups.apply_health(0, &[Some(100), Some(60)]); + assert_eq!(groups.state("fastest").unwrap().now, "b"); + groups.apply_health(1, &[None, Some(60)]); + assert_eq!(groups.state("backup").unwrap().now, "b"); + groups.apply_health(1, &[Some(100), Some(60)]); + assert_eq!(groups.state("backup").unwrap().now, "a"); + groups.select("fastest", "a").unwrap(); + groups.apply_health(0, &[None, Some(60)]); + assert_eq!(groups.state("fastest").unwrap().now, "b"); + let selection = groups.selection(0); + selection.outcome(false); + selection.outcome(false); + assert!(selection.take_urgent()); + tokio::time::advance(std::time::Duration::from_secs(301)).await; + assert!(!selection.active(std::time::Duration::from_secs(300))); + let root = tempfile::tempdir().unwrap(); + let store = crate::resource::profile::SelectionStore::open(root.path(), true); + groups.select("manual", "b").unwrap(); + store.save("manual", "b").unwrap(); + let restarted = + ProxyGroups::new(&config.proxy_groups, vec![node.clone(), node.clone()], node).unwrap(); + crate::resource::profile::SelectionStore::open(root.path(), true).restore(&restarted); + assert_eq!(restarted.state("manual").unwrap().now, "b"); + } + #[tokio::test] async fn nested_selection_preserves_duplicates_and_snapshots_new_transports() { let first = Arc::new(RecordingDispatcher::default()); @@ -558,6 +670,7 @@ mod tests { groups.list_states(), vec![ ProxyGroupState { + kind: crate::config::GroupKind::Select, name: "child".to_owned(), all: vec!["first".to_owned(), "first".to_owned(), "second".to_owned()], now: "second".to_owned(), @@ -565,6 +678,7 @@ mod tests { icon: String::new(), }, ProxyGroupState { + kind: crate::config::GroupKind::Select, name: "parent".to_owned(), all: vec!["child".to_owned(), "DIRECT".to_owned(), "REJECT".to_owned()], now: "child".to_owned(), diff --git a/src/routing/selection.rs b/src/routing/selection.rs new file mode 100644 index 0000000..3a5b04c --- /dev/null +++ b/src/routing/selection.rs @@ -0,0 +1,77 @@ +//! Selection and probe activity shared by route and upstream groups. +use std::sync::{ + Mutex, + atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}, +}; +use std::time::Duration; +use tokio::time::Instant; +#[derive(Debug)] +pub(crate) struct GroupSelection { + index: AtomicUsize, + forced: Mutex>, + touched: Mutex, + failures: Mutex<(Instant, u32)>, + urgent: AtomicBool, + maximum: AtomicU32, + window_ms: AtomicU64, +} +impl GroupSelection { + pub fn new(index: usize) -> Self { + let now = Instant::now(); + Self { + index: AtomicUsize::new(index), + forced: Mutex::new(None), + touched: Mutex::new(now), + failures: Mutex::new((now, 0)), + urgent: AtomicBool::new(false), + maximum: AtomicU32::new(5), + window_ms: AtomicU64::new(5000), + } + } + pub fn load(&self, order: Ordering) -> usize { + self.index.load(order) + } + pub fn store(&self, index: usize, order: Ordering) { + self.index.store(index, order); + } + pub fn touch(&self) { + *self.touched.lock().unwrap() = Instant::now(); + } + pub fn active(&self, interval: Duration) -> bool { + self.touched.lock().unwrap().elapsed() < interval + } + pub fn configure(&self, maximum: u32, window: u64) { + self.maximum.store(maximum, Ordering::Relaxed); + self.window_ms.store(window, Ordering::Relaxed); + } + pub fn force(&self, index: usize) { + *self.forced.lock().unwrap() = Some(index); + self.store(index, Ordering::Release); + } + pub fn forced(&self) -> Option { + *self.forced.lock().unwrap() + } + pub fn clear_forced(&self) { + *self.forced.lock().unwrap() = None; + } + pub fn take_urgent(&self) -> bool { + self.urgent.swap(false, Ordering::Relaxed) + } + pub fn outcome(&self, success: bool) { + let mut failures = self.failures.lock().unwrap(); + if success { + failures.1 = 0; + return; + } + if failures.0.elapsed() > Duration::from_millis(self.window_ms.load(Ordering::Relaxed)) + || failures.1 == 0 + { + *failures = (Instant::now(), 0); + } + failures.1 = failures.1.saturating_add(1); + if failures.1 >= self.maximum.load(Ordering::Relaxed) { + self.urgent.store(true, Ordering::Relaxed); + failures.1 = 0; + } + } +} diff --git a/src/runtime.rs b/src/runtime.rs index 43a038e..79482ee 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -12,13 +12,9 @@ use tracing::instrument::WithSubscriber; #[path = "runtime_lifecycle_tests.rs"] mod lifecycle_tests; -use std::{ - future::Future, - io, - net::SocketAddr, - sync::{Arc, atomic::AtomicUsize}, - time::Duration, -}; +#[cfg(test)] +use std::sync::atomic::AtomicUsize; +use std::{future::Future, io, net::SocketAddr, sync::Arc, time::Duration}; use futures_util::future::{join_all, select_all}; use tokio::task::JoinHandle; @@ -96,6 +92,7 @@ use crate::security::StandardTlsClient; ))] use crate::security::{SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}; +mod health; mod providers; use providers::ProviderRuntime; @@ -177,6 +174,7 @@ struct BuiltRuntimeParts { proxy_groups: Arc, proxy_graph: Arc, resources: Option>, + health: Arc, resource_targets: RouteTargetDispatchers, direct: Arc, } @@ -186,7 +184,7 @@ struct BuiltProxyGraph { resolution: ResolutionContext, nodes: Vec>, lifecycle_order: Vec, - selections: Vec>, + selections: Vec>, } enum BuiltRouteTarget { @@ -256,8 +254,11 @@ impl PreparedCore { let config = Config::parse_yaml(yaml) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let directory = tempfile::tempdir()?; - let manager = GeoDataManager::open(directory.path(), Duration::from_secs(24 * 60 * 60)) - .map_err(io::Error::other)?; + let manager = GeoDataManager::open( + &directory.path().join("geodata"), + Duration::from_secs(24 * 60 * 60), + ) + .map_err(io::Error::other)?; Self::prepare_config(config, manager, resolver, limits).await } @@ -621,6 +622,20 @@ impl GraphBuilder { &handshake_stats, &proxy_graph.resolution, )?; + let activities = proxy_dispatchers + .iter() + .map(|_| health::Activity::new()) + .collect::>(); + let proxy_dispatchers = proxy_dispatchers + .into_iter() + .zip(&activities) + .map(|(dispatcher, activity)| { + Arc::new(health::ActiveDispatcher { + dispatcher, + activity: activity.clone(), + }) as Arc + }) + .collect::>(); let direct_raw: Arc = Arc::new(DirectOutbound::new(dialer)); let direct = observe_handshakes_with_stats(direct_raw, handshake_stats.clone()); let proxy_groups = ProxyGroups::with_selections( @@ -657,6 +672,14 @@ impl GraphBuilder { .with_mapping_from(self.previous_dns.as_deref()), )); proxy_graph.resolution.bind_runtime(dns.as_ref())?; + let health = health::HealthRuntime::new( + &self.config, + proxy_groups.clone(), + route_targets.clone(), + direct.clone(), + activities, + dns.clone(), + ); let router: Arc = Arc::new( RoutingDispatcher::new_with_ipv6( route_targets.clone(), @@ -676,6 +699,7 @@ impl GraphBuilder { proxy_groups, proxy_graph: Arc::new(proxy_graph), resources: None, + health, resource_targets: route_targets, direct, }) @@ -826,7 +850,7 @@ fn build_proxy_graph( lifecycle_order: Vec::with_capacity(order.len()), selections: groups .iter() - .map(|group| Arc::new(AtomicUsize::new(group.initial_member))) + .map(|group| Arc::new(crate::routing::GroupSelection::new(group.initial_member))) .collect(), }; let mut nodes: Vec>> = vec![None; proxies.len()]; diff --git a/src/runtime/health.rs b/src/runtime/health.rs new file mode 100644 index 0000000..f1b73f5 --- /dev/null +++ b/src/runtime/health.rs @@ -0,0 +1,286 @@ +//! Shared probe scheduling and automatic group selection for one resource generation. +use super::*; +use crate::{ + config::{HealthCheckConfig, status_ranges}, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + resource::probe::{self, Target}, + session::{DatagramSession, StreamSession}, +}; +use futures_util::{StreamExt, stream}; +use std::{collections::BTreeMap, sync::Mutex}; +use tokio::time::Instant; + +#[derive(Debug)] +pub(super) struct Activity(Mutex); +impl Activity { + pub fn new() -> Arc { + Arc::new(Self(Mutex::new(Instant::now()))) + } + fn touch(&self) { + *self.0.lock().unwrap() = Instant::now(); + } + fn active(&self, interval: Duration) -> bool { + self.0.lock().unwrap().elapsed() < interval + } +} +pub(super) struct ActiveDispatcher { + pub dispatcher: Arc, + pub activity: Arc, +} +#[async_trait::async_trait] +impl Dispatcher for ActiveDispatcher { + async fn connect_tcp(&self, session: StreamSession) -> Result { + if session.inbound != crate::session::InboundKind::InternalMeasure { + self.activity.touch(); + } + self.dispatcher.connect_tcp(session).await + } + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + self.activity.touch(); + self.dispatcher.open_datagram(session).await + } +} +#[derive(Debug, Clone, serde::Serialize)] +pub(super) struct Sample { + pub alive: bool, + pub delay: u64, + pub time: u64, +} +struct Job { + spec: HealthCheckConfig, + members: Vec, + group: Option, + last: Option, +} +pub(super) struct HealthRuntime { + jobs: Mutex>, + samples: Mutex>, + groups: Arc, + targets: RouteTargetDispatchers, + direct: Arc, + names: BTreeMap, + activity: BTreeMap>, + unified: bool, + dns: Option>, + ipv6: bool, +} +impl HealthRuntime { + pub fn new( + config: &Config, + groups: Arc, + targets: RouteTargetDispatchers, + direct: Arc, + activity: Vec>, + dns: Option>, + ) -> Arc { + let mut jobs = Vec::new(); + for (i, group) in config.proxy_groups.iter().enumerate() { + jobs.push(Job { + spec: group.health.clone(), + members: group + .members + .iter() + .map(|member| member.name.clone()) + .collect(), + group: Some(i), + last: None, + }); + } + if let Some(template) = &config.providers { + for (name, spec) in &template.proxies { + let mut health = spec.health_check.clone(); + if health.enable && health.interval == 0 { + health.interval = 300; + } + jobs.push(Job { + spec: health, + members: config + .provider_members + .get(name) + .cloned() + .unwrap_or_default(), + group: None, + last: None, + }); + } + } + let mut names = BTreeMap::new(); + for (i, node) in config.proxies.iter().enumerate() { + names.insert( + node.tag.clone(), + RouteTargetId::Proxy(crate::config::ProxyId::new(i).unwrap()), + ); + } + for (i, group) in config.proxy_groups.iter().enumerate() { + names.insert( + group.name.clone(), + RouteTargetId::Group(crate::config::ProxyGroupId::new(i).unwrap()), + ); + } + let activity = config + .proxies + .iter() + .map(|proxy| proxy.tag.clone()) + .zip(activity) + .collect(); + Arc::new(Self { + jobs: Mutex::new(jobs), + samples: Mutex::new(BTreeMap::new()), + groups, + targets, + direct, + names, + activity, + unified: config.unified_delay, + dns, + ipv6: config.ipv6, + }) + } + pub fn states(&self) -> serde_json::Value { + let samples = self.samples.lock().unwrap(); + let mut nodes = self + .names + .iter() + .filter(|(_, target)| matches!(target, RouteTargetId::Proxy(_))) + .map(|(name, _)| { + ( + name.clone(), + serde_json::json!({"name":name,"alive":true,"history":[]}), + ) + }) + .collect::>(); + for ((name, _), sample) in samples.iter() { + nodes.insert(name.clone(),serde_json::json!({"name":name,"alive":sample.alive,"history":[{"time":sample.time,"delay":sample.delay}]})); + } + serde_json::json!(nodes) + } + pub async fn tick(&self, cancel: &CancellationToken) { + let mut work = BTreeMap::new(); + { + let mut jobs = self.jobs.lock().unwrap(); + for job in jobs.iter_mut() { + let urgent = job + .group + .is_some_and(|i| self.groups.selection(i).take_urgent()); + if !job.spec.enable + || job.spec.url.is_empty() + || (job.spec.interval == 0 && !urgent) + { + continue; + } + let interval = Duration::from_secs(job.spec.interval); + if !urgent && job.last.is_some_and(|last| last.elapsed() < interval) { + continue; + } + let active = job + .group + .map(|i| self.groups.selection(i).active(interval)) + .unwrap_or_else(|| { + job.members.iter().any(|name| { + self.activity + .get(name) + .is_some_and(|activity| activity.active(interval)) + }) || self + .groups + .list_states() + .iter() + .enumerate() + .any(|(i, group)| { + self.groups.selection(i).active(interval) + && group.all.iter().any(|name| job.members.contains(name)) + }) + }); + if !urgent && job.last.is_some() && job.spec.lazy && !active { + job.last = Some(Instant::now()); + continue; + } + job.last = Some(Instant::now()); + for member in &job.members { + work.entry((member.clone(), job.spec.url.clone())) + .or_insert_with(|| job.spec.clone()); + } + } + } + let jobs = work.into_iter().map(|((name, url), spec)| async move { + let action = match name.as_str() { + "DIRECT" | "COMPATIBLE" => Some(crate::config::RuleAction::Direct), + "REJECT" => Some(crate::config::RuleAction::Reject), + _ => self + .names + .get(&name) + .copied() + .map(crate::config::RuleAction::Route), + }; + let dispatcher = action.map(|action| { + let rules = RuleSet::compile(vec![crate::config::RuleSpec { + kind: crate::config::RuleKind::Match, + action, + no_resolve: false, + }]) + .expect("health route"); + Arc::new(RoutingDispatcher::new_with_ipv6( + self.targets.clone(), + self.direct.clone(), + self.dns.clone(), + self.ipv6, + rules, + Arc::new(crate::routing::EmptyGeoMatcher), + )) as Arc + }); + let result = async { + let target = Target::parse(&url)?; + let expected = status_ranges(&spec.expected_status).map_err(io::Error::other)?; + probe::probe( + &target, + dispatcher.ok_or_else(|| io::Error::other("unavailable health target"))?, + Duration::from_millis(if spec.timeout == 0 { + 5000 + } else { + spec.timeout + }), + self.unified, + &expected, + cancel, + ) + .await + } + .await; + (name, url, result) + }); + let mut results = stream::iter(jobs).buffer_unordered(10); + while let Some((name, url, result)) = results.next().await { + if cancel.is_cancelled() { + return; + } + self.samples.lock().unwrap().insert( + (name, url), + Sample { + alive: result.is_ok(), + delay: result.unwrap_or(0), + time: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }, + ); + } + let samples = self.samples.lock().unwrap(); + for job in self.jobs.lock().unwrap().iter() { + if let Some(index) = job.group { + let values = job + .members + .iter() + .map(|name| { + samples + .get(&(name.clone(), job.spec.url.clone())) + .map_or(Some(u16::MAX.into()), |s| s.alive.then_some(s.delay)) + }) + .collect::>(); + self.groups.apply_health(index, &values); + } + } + } +} diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 298a16e..b5f2cf7 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -25,6 +25,7 @@ use tokio::{ }; struct Generation { + health: Arc, requirements: GeoRequirements, config: Config, dispatcher: Arc, @@ -41,6 +42,7 @@ impl Generation { requirements: GeoRequirements, ) -> Self { Self { + health: parts.health.clone(), requirements, config, dispatcher: parts.dispatcher.clone(), @@ -58,6 +60,8 @@ struct Refresh { reply: oneshot::Sender>, } pub(super) struct ProviderRuntime { + store: crate::resource::profile::SelectionStore, + persistence_error: RwLock>, current: ArcSwap, retired: Mutex>>, publication: Mutex<()>, @@ -85,7 +89,19 @@ impl ProviderRuntime { .filter_map(|i| prepared.rules.rule(i).cloned()) .collect(); let requirements = GeoRequirements::for_config(&configured).expect("prepared requirements"); + let root = prepared + .geodata_manager + .store_dir() + .parent() + .expect("dataDir"); + let store = crate::resource::profile::SelectionStore::open( + root, + prepared.config.profile.store_selected, + ); + store.restore(&parts.proxy_groups); Arc::new(Self { + store, + persistence_error: RwLock::new(None), current: ArcSwap::from_pointee(Generation::from_parts( prepared.config.clone(), parts, @@ -175,6 +191,21 @@ impl ProviderRuntime { Ok(requirements) } pub(super) async fn run(self: Arc, parent: CancellationToken) -> io::Result<()> { + let health = async { + let mut tick = tokio::time::interval(Duration::from_secs(1)); + loop { + tokio::select! {biased; + ()=parent.cancelled()=>{self.begin_shutdown();break;}, + ()=self.cancellation.cancelled()=>break, + _=tick.tick()=>{let current=self.current.load_full(); current.health.tick(&self.cancellation).await;} + } + } + }; + let worker = self.provider_loop(parent.clone()); + let (result, ()) = tokio::join!(worker, health); + result + } + async fn provider_loop(self: &Arc, parent: CancellationToken) -> io::Result<()> { let mut receiver = self .receiver .lock() @@ -288,8 +319,24 @@ impl ProviderRuntime { cache .commit(index, candidate, &self.cancellation) .map(|()| { - for state in self.current.load().groups.list_states() { - let _ = next.groups.select(&state.name, &state.now); + for (index, state) in self + .current + .load() + .groups + .list_states() + .into_iter() + .enumerate() + { + let forced = self + .current + .load() + .groups + .selection(index) + .forced() + .is_some(); + let _ = next + .groups + .inherit_selection(&state.name, &state.now, forced); } let old = self.current.swap(next.clone()); self.retired.lock().unwrap().push(old); @@ -372,7 +419,16 @@ impl RuntimeResources for ProviderRuntime { } fn select(&self, group: &str, member: &str) -> Result<(), ProxyGroupError> { let _publication = self.publication.lock().unwrap(); - self.current.load().groups.select(group, member) + self.current.load().groups.select(group, member)?; + *self.persistence_error.write().unwrap() = self + .store + .save(group, member) + .err() + .map(|_| "selection cache write failed"); + Ok(()) + } + fn health(&self) -> serde_json::Value { + serde_json::json!({"proxies":self.current.load().health.states(),"selectionError":*self.persistence_error.read().unwrap()}) } fn providers(&self, rules: bool) -> serde_json::Value { let providers = self @@ -384,6 +440,20 @@ impl RuntimeResources for ProviderRuntime { .map(|state| { let mut value = serde_json::to_value(state).expect("provider status"); value["type"] = serde_json::json!(if rules { "Rule" } else { "Proxy" }); + if !rules { + let current = self.current.load_full(); + let health = current.health.states(); + value["proxies"] = serde_json::json!( + current + .config + .provider_members + .get(&state.name) + .into_iter() + .flatten() + .filter_map(|name| health.get(name)) + .collect::>() + ); + } (state.name.clone(), value) }) .collect::>(); diff --git a/tests/README.md b/tests/README.md index 602bfa5..296a264 100644 --- a/tests/README.md +++ b/tests/README.md @@ -68,3 +68,5 @@ container-benchmark compare --source vole=/path/to/Vole 维护,结果记录在对应运行或 PR 中;[验收边界](../docs/acceptance.md) 区分编译、互通与设备验证。 `runtime::providers::tests::` 使用临时文件与内存流验证候选发布、坏更新保留、旧会话资源和停止清理;`config::` 同时覆盖 provider、规则集合和 MRS 格式。 + +`routing::proxy_group::tests::` 覆盖自动选择、容差、恢复与选择缓存;`resource::probe::tests::` 和 `invoke::measure_delay::tests::` 使用内存流验证共享健康探测、HTTP 状态和取消。 From c083253bf8a2d4ca7d057949293b252617f6147f Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 18:37:21 +0800 Subject: [PATCH 007/445] feat: add mixed access controls and HTTP proxy outbound Support multiple users, source CIDR access and authentication bypass, explicit bind addresses, and per-node IP version selection. Route HTTP CONNECT through shared TLS, dialer chains and setup deadlines. Validation: config and mixed memory regressions, HTTP CONNECT/prebuffer/deadline cases, all-target compilation, all-feature clippy, HTTP-only compilation and fmt/diff checks. --- .github/workflows/test.yml | 4 +- Cargo.toml | 3 + docs/config.yaml | 11 +- docs/inbounds.md | 8 +- docs/outbounds.md | 13 +- src/config/credentials.rs | 91 +++++++++++--- src/config/http_proxy.rs | 185 +++++++++++++++++++++++++++ src/config/ip_version.rs | 71 +++++++++++ src/config/mod.rs | 144 ++++++++++++++++++---- src/config/providers.rs | 1 + src/dialer.rs | 30 +++-- src/dns/resolution.rs | 13 +- src/inbound/http/server.rs | 3 + src/inbound/http/tests.rs | 3 + src/inbound/listen.rs | 16 ++- src/inbound/mixed.rs | 13 +- src/inbound/mixed/tests.rs | 70 +++++++++++ src/inbound/socks5/server.rs | 5 + src/inbound/socks5/tests.rs | 3 + src/lib.rs | 2 + src/outbound/connector.rs | 44 ++++++- src/outbound/http.rs | 233 +++++++++++++++++++++++++++++++++++ src/outbound/mod.rs | 5 +- src/runtime.rs | 51 +++++++- tests/README.md | 1 + 25 files changed, 951 insertions(+), 72 deletions(-) create mode 100644 src/config/http_proxy.rs create mode 100644 src/config/ip_version.rs create mode 100644 src/outbound/http.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 226f3c3..29e8ed7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -184,7 +184,7 @@ jobs: # Keep this explicit allowlist: unfiltered legacy tests may open host listeners. filters=( geodata:: routing:: security:: outbound::vless::encryption config:: - inbound::mixed::tests:: inbound::socks5::association::tests:: + outbound::http::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: inbound::http::parser::tests:: inbound::http::framing::tests:: outbound::hysteria2:: outbound::shadowsocks::tests::official_tcp_ outbound::anytls:: outbound::uot:: outbound::tuic:: transport::h2_write::tests:: @@ -240,7 +240,7 @@ jobs: cargo check --locked --no-default-features --features "outbound-$protocol" --lib run_test 1 --features "outbound-$protocol" --lib runtime::tests::integration_feature_admission_is_explicit_without_opening_sockets -- --exact done - for feature in inbound-http inbound-socks5 stream-transport quic-transport shadow-tls-v3 tun invoke ffi cli windows-wintun windows-uwp; do + for feature in outbound-http inbound-http inbound-socks5 stream-transport quic-transport shadow-tls-v3 tun invoke ffi cli windows-wintun windows-uwp; do cargo check --locked --no-default-features --features "$feature" --lib done cargo check --locked --no-default-features --features cli --bin vole diff --git a/Cargo.toml b/Cargo.toml index 7d0c91f..2bb962c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ required-features = ["ffi", "windows-uwp"] default = [ "inbound-http", "inbound-socks5", + "outbound-http", "outbound-anytls", "outbound-socks5", "outbound-shadowsocks", @@ -46,6 +47,7 @@ inbound-http = [] inbound-socks5 = [] outbound-anytls = ["dep:md-5", "tls-fingerprint"] outbound-socks5 = [] +outbound-http = [] outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:zeroize", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn", "dep:yamux", "tokio-util/compat"] outbound-trojan = ["stream-transport", "tls-fingerprint"] @@ -71,6 +73,7 @@ tun = [ "inbound-http", "inbound-socks5", "outbound-anytls", + "outbound-http", "outbound-socks5", "outbound-shadowsocks", "outbound-trojan", diff --git a/docs/config.yaml b/docs/config.yaml index 7dcfcb2..69e5726 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -46,7 +46,7 @@ geo-update-interval: 24 # 构建必须同时包含 inbound-http 和 inbound-socks5;缺少任一 feature 则拒绝启用入口。 # allow-lan 默认 false,绑定回环;true 时绑定通配地址,与 authentication 独立。 # ipv6: true 时同时绑定同端口 IPv6(v6-only);false 时只监听 IPv4。 -# authentication 省略或 [] 表示任何绑定地址均免认证;非空时必须恰好包含一项 user:password, +# authentication 省略或 [] 表示任何绑定地址均免认证;非空支持多个 user:password, # 按首个冒号分隔,用户名 / 密码各 1–255 UTF-8 字节,HTTP/SOCKS5 共用。 # mixed-port 关闭时可以保留合法的 allow-lan 和 authentication,供之后启用入口使用。 # HTTP 逐请求认证、选路,支持流式正文 / 分块、Keep-Alive、CONNECT 与 Upgrade。 @@ -54,6 +54,10 @@ geo-update-interval: 24 # UDP 必须属于有效的 TCP 控制连接,源 IP/端口校验和资源上限见 inbounds.md#socks5。 mixed-port: 1080 allow-lan: false +bind-address: "*" # allow-lan=true 时可指定单个 IP +lan-allowed-ips: [0.0.0.0/0, "::/0"] +lan-disallowed-ips: [] # 拒绝优先 +skip-auth-prefixes: [] # 仅跳过认证,仍执行来源访问控制 authentication: - "probe-user:probe-password" @@ -168,6 +172,11 @@ sniffer: # # 下方 vless-edge 通过 upstream-select 当前选择的 socks-hop 建链: # 客户端 -> socks-hop -> vless-edge -> 目标。 +# 所有节点支持 ip-version: dual/ipv4/ipv6/ipv4-prefer/ipv6-prefer;默认 dual。 +# HTTP(S) CONNECT 示例:type: http,必填 name/server/port,tls 默认 false。 +# username/password 均非空才认证;headers 为字符串映射;仅 TCP,不支持 udp: true。 +# tls: true 时支持 sni/skip-cert-verify/name-cert-verify/fingerprint, +# 以及成对的内联 certificate/private-key。支持 dialer-proxy;详见 outbounds.md。 proxies: # TUIC v5:固定 QUIC/TLS1.3;必填 UUID 和原样 password(可空,最多 65535 UTF-8 字节)。 # 不接受 v4、token、0-RTT、client-fingerprint、mTLS、ECH 或其他 TLS 后端选项。 diff --git a/docs/inbounds.md b/docs/inbounds.md index 8c127e5..1b660dc 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -1,6 +1,6 @@ # HTTP 与 SOCKS5 混合入站 -HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则、DNS、静态 select 组及出站图。 +HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则、DNS、动态策略组及出站图。 字段全集见[配置协议](config.yaml),Controller 另见[接口](controller-api.md)。 ## 监听与认证 @@ -15,9 +15,13 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 - `allow-lan: false` 绑定 IPv4 回环,true 绑定通配地址。`ipv6: true` 增加独立 IPv6-only 回环/通配 socket。仅系统明确不支持地址族时可省略 IPv6,其余绑定错误 整体回滚。取得 Controller 和全部业务 socket 后才启动接收任务。 +- `bind-address` 默认为 `*`;`allow-lan: true` 时可指定单个 IPv4/IPv6 地址,false 仍绑定回环。 + `lan-allowed-ips` 默认允许全部 IPv4/IPv6,`lan-disallowed-ips` 的拒绝规则优先。 + 两者作用于 TCP 来源;SOCKS5 UDP 必须属于已获准的 TCP 关联。 + `skip-auth-prefixes` 只跳过匹配来源的认证,不跳过来源访问控制。 - `allow-lan` 与 `authentication` 独立;通配绑定不强制认证。 `authentication` 省略或空列表时,HTTP/SOCKS5 在任何绑定地址均免认证; - 非空必须恰好一项 `user:password`, + 非空接受多个 `user:password`,相同用户名以最后一项为准, 按第一个冒号拆分,两项各 1–255 UTF-8 字节,不 trim。null 不视为省略。 配置凭据后 HTTP/SOCKS5 均校验凭据;混合入口关闭时允许保留合法的共享和认证配置。 - Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 仍独立回环监听, diff --git a/docs/outbounds.md b/docs/outbounds.md index ce4fce2..a4e3bf8 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -7,8 +7,11 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 - 物理 TCP/UDP 只能由共享 Dialer 创建,保留 Android protect、Windows 物理绑定和受控解析。 传输包装器只消费 IO,不另建 socket 或系统 resolver。 -- `dialer-proxy` 可引用节点或静态 select 组;与业务路由共享选择,完整 DAG 在配置期验证。 +- `dialer-proxy` 可引用节点或策略组;与业务路由共享选择,完整 DAG 在配置期验证。 一次建链使用组快照和同一绝对期限;已有连接不随切组迁移,无自动 failover 或 DIRECT 回落。 +- 节点 `ip-version` 为 `dual`(默认)、`ipv4`、`ipv6`、`ipv4-prefer` 或 `ipv6-prefer`。 + 限制或排序该节点的物理服务器地址与本地解析结果,仍受全局 IPv6 开关约束。 + 不要求远端代理按相同地址族解析业务域名。 - gRPC/legacy H2 首次读取延迟响应头时先检查原建链期限,即使响应已就绪也不能绕过 超时;响应头已确认后的业务读取不再受该期限限制。 - HTTP、SOCKS5、TUN、DNS 选路及 node-only `measureDelay` 使用同一连接器。 @@ -19,6 +22,14 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 - 未知字段、null、错型、非法枚举或不适用的传输选项在网络 IO 前拒绝。 凭据、UUID、业务目标和完整请求不进入 Debug/错误;资源与 Stop 见[公共策略](runtime-resource-policy.md)。 +## HTTP / HTTPS CONNECT + +`type: http` 提供 TCP CONNECT;`tls: true` 在代理链路使用共享 TLS 与证书策略。 +支持 `username/password`、`headers`、`sni`、`skip-cert-verify`、`name-cert-verify`、 +`fingerprint` 及成对的内联 `certificate/private-key`。用户名和密码均非空时发送 Basic 认证。 +HTTP CONNECT 不提供 UDP,`udp: true` 在校验时拒绝。握手沿用原期限,失败关闭, +成功后保留响应头后的业务字节。启用此协议需要 `outbound-http` feature。 + ## SOCKS5 仅 SOCKS5 CONNECT / UDP ASSOCIATE;用户名密码成对配置,不支持 SOCKS4。 diff --git a/src/config/credentials.rs b/src/config/credentials.rs index db4dac0..dc6b97a 100644 --- a/src/config/credentials.rs +++ b/src/config/credentials.rs @@ -1,12 +1,11 @@ -use std::io; +use std::{io, net::IpAddr}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -/// One client-proxy credential shared by the HTTP and SOCKS5 frontends. +/// Client-proxy credentials shared by the HTTP and SOCKS5 frontends. #[derive(Clone, PartialEq, Eq)] pub struct ProxyCredentials { - username: String, - password: String, + entries: Vec<(String, String)>, } impl std::fmt::Debug for ProxyCredentials { @@ -35,33 +34,50 @@ impl ProxyCredentials { "authentication user must not contain `:`", )); } - Ok(Self { username, password }) + Ok(Self { + entries: vec![(username, password)], + }) } #[must_use] pub fn matches(&self, username: &[u8], password: &[u8]) -> bool { - // Always compare both fixed-size credential slots; never expose the - // matching prefix or short-circuit after a successful username check. - let mut difference = username.len() ^ self.username.len(); - difference |= password.len() ^ self.password.len(); - for index in 0..255 { - difference |= usize::from( - username.get(index).copied().unwrap_or(0) - ^ self.username.as_bytes().get(index).copied().unwrap_or(0), - ); - difference |= usize::from( - password.get(index).copied().unwrap_or(0) - ^ self.password.as_bytes().get(index).copied().unwrap_or(0), - ); + let mut matched = false; + for (expected_user, expected_password) in &self.entries { + let mut difference = username.len() ^ expected_user.len(); + difference |= password.len() ^ expected_password.len(); + for index in 0..255 { + difference |= usize::from( + username.get(index).copied().unwrap_or(0) + ^ expected_user.as_bytes().get(index).copied().unwrap_or(0), + ); + difference |= usize::from( + password.get(index).copied().unwrap_or(0) + ^ expected_password + .as_bytes() + .get(index) + .copied() + .unwrap_or(0), + ); + } + matched |= difference == 0; + } + matched + } + pub(crate) fn multiple(values: Vec) -> Self { + let mut entries = std::collections::BTreeMap::new(); + for (username, password) in values.into_iter().flat_map(|value| value.entries) { + entries.insert(username, password); + } + Self { + entries: entries.into_iter().collect(), } - difference == 0 } #[must_use] pub fn authorization_header_value(&self) -> String { format!( "Basic {}", - STANDARD.encode(format!("{}:{}", self.username, self.password)) + STANDARD.encode(format!("{}:{}", self.entries[0].0, self.entries[0].1)) ) } @@ -93,6 +109,41 @@ impl ProxyCredentials { /// A fixed client-oriented listener policy, not an arbitrary address list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProxyAccess { + pub bind_address: Option, pub allow_lan: bool, pub ipv6: bool, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyAccessRules { + pub allowed: Vec, + pub denied: Vec, + pub skip_auth: Vec, +} +impl Default for ProxyAccessRules { + fn default() -> Self { + Self { + allowed: vec![ + super::IpCidr { + network: "0.0.0.0".parse().unwrap(), + prefix_len: 0, + }, + super::IpCidr { + network: "::".parse().unwrap(), + prefix_len: 0, + }, + ], + denied: Vec::new(), + skip_auth: Vec::new(), + } + } +} +impl ProxyAccessRules { + pub fn permits(&self, address: IpAddr) -> bool { + self.allowed.iter().any(|cidr| cidr.contains(address)) + && !self.denied.iter().any(|cidr| cidr.contains(address)) + } + pub fn skips_auth(&self, address: IpAddr) -> bool { + self.skip_auth.iter().any(|cidr| cidr.contains(address)) + } +} diff --git a/src/config/http_proxy.rs b/src/config/http_proxy.rs new file mode 100644 index 0000000..6753bc4 --- /dev/null +++ b/src/config/http_proxy.rs @@ -0,0 +1,185 @@ +use super::*; +use std::collections::BTreeMap; +#[derive(Clone, PartialEq, Eq)] +pub struct HttpOutboundConfig { + pub address: String, + pub port: u16, + pub tls: Option, + pub auth: Option, + pub headers: BTreeMap, +} +impl std::fmt::Debug for HttpOutboundConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HttpOutboundConfig") + .field("tls", &self.tls.is_some()) + .finish_non_exhaustive() + } +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub(super) struct RawHttp { + name: String, + server: String, + port: u16, + #[serde(default)] + username: String, + #[serde(default)] + password: String, + #[serde(default)] + tls: bool, + #[serde(default)] + udp: bool, + #[serde(default)] + sni: Option, + #[serde(default)] + skip_cert_verify: bool, + #[serde(default)] + name_cert_verify: Option, + #[serde(default)] + fingerprint: Option, + #[serde(default)] + certificate: Option, + #[serde(default)] + private_key: Option, + #[serde(default)] + headers: BTreeMap, + #[serde(default)] + dialer_proxy: Option, +} +impl std::fmt::Debug for RawHttp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawHttp").finish_non_exhaustive() + } +} +impl RawHttp { + pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { + if !cfg!(feature = "outbound-http") { + return invalid("HTTP outbound support is disabled at build time"); + } + if self.udp { + return invalid("HTTP CONNECT outbound does not support UDP"); + } + validate_host(&self.server, "HTTP server")?; + validate_port(self.port, "HTTP")?; + let auth = if !self.username.is_empty() && !self.password.is_empty() { + Some( + ProxyCredentials::new(self.username, self.password).map_err(|_| { + VoleError::InvalidConfig("invalid HTTP proxy credentials".into()) + })?, + ) + } else { + None + }; + let mut header_bytes = 0usize; + for (name, value) in &self.headers { + header_bytes = header_bytes + .saturating_add(name.len()) + .saturating_add(value.len()); + if http::header::HeaderName::from_bytes(name.as_bytes()).is_err() + || http::HeaderValue::from_str(value).is_err() + || header_bytes > 32 * 1024 + { + return invalid("invalid HTTP proxy headers"); + } + } + if self.headers.keys().any(|key| { + ["content-length", "transfer-encoding"] + .iter() + .any(|reserved| key.eq_ignore_ascii_case(reserved)) + }) { + return invalid("HTTP CONNECT does not carry a request body"); + } + let tls = if self.tls { + let name = self.sni.unwrap_or_else(|| self.server.clone()); + validate_host(&name, "HTTP sni")?; + if let Some(verify) = &self.name_cert_verify { + validate_host(verify, "HTTP verification name")?; + } + let identity = match (self.certificate, self.private_key) { + (Some(certificate), Some(private_key)) => { + crate::security::TlsClientIdentity::from_pem(&certificate, &private_key) + .map_err(|_| { + VoleError::InvalidConfig("invalid HTTP client identity".into()) + })?; + Some(TlsIdentityPem { + certificate, + private_key, + }) + } + (None, None) => None, + _ => return invalid("HTTP certificate and private-key must be paired"), + }; + Some(TlsConfig { + server_name: name, + alpn: vec![b"http/1.1".to_vec()], + tls13_only: false, + required_alpn: None, + ech: None, + client_fingerprint: None, + identity, + certificate: TlsCertificatePolicy { + skip_cert_verify: self.skip_cert_verify, + verification_name: self.name_cert_verify, + fingerprint: self + .fingerprint + .as_deref() + .map(vless::parse_pin) + .transpose()?, + }, + }) + } else { + if self.sni.is_some() + || self.skip_cert_verify + || self.name_cert_verify.is_some() + || self.fingerprint.is_some() + || self.certificate.is_some() + || self.private_key.is_some() + { + return invalid("HTTP TLS options require tls: true"); + } + None + }; + Ok(( + self.name, + self.dialer_proxy, + false, + ProxyProtocol::Http(HttpOutboundConfig { + address: self.server, + port: self.port, + tls, + auth, + headers: self.headers, + }), + )) + } +} + +#[cfg(all(test, feature = "outbound-http"))] +mod tests { + use super::*; + #[test] + fn http_tls_and_common_node_options_normalize() { + let config = Config::parse_yaml( + br#"mixed-port: 1080 +proxies: + - name: web + type: http + server: proxy.example.com + port: 443 + tls: true + username: u + password: p + ip-version: ipv4-prefer + headers: {Host: custom.example.com} +"#, + ) + .unwrap(); + assert_eq!(config.proxies[0].ip_version, IpVersion::Ipv4Prefer); + let ProxyProtocol::Http(http) = &config.proxies[0].protocol else { + panic!() + }; + assert_eq!(http.tls.as_ref().unwrap().server_name, "proxy.example.com"); + assert!(http.auth.as_ref().unwrap().matches(b"u", b"p")); + assert!(!config.proxies[0].udp); + } +} diff --git a/src/config/ip_version.rs b/src/config/ip_version.rs new file mode 100644 index 0000000..d852c59 --- /dev/null +++ b/src/config/ip_version.rs @@ -0,0 +1,71 @@ +use super::*; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(try_from = "String")] +pub enum IpVersion { + #[default] + Dual, + Ipv4, + Ipv6, + Ipv4Prefer, + Ipv6Prefer, +} +impl TryFrom for IpVersion { + type Error = &'static str; + fn try_from(value: String) -> std::result::Result { + match value.to_ascii_lowercase().as_str() { + "dual" => Ok(Self::Dual), + "ipv4" => Ok(Self::Ipv4), + "ipv6" => Ok(Self::Ipv6), + "ipv4-prefer" => Ok(Self::Ipv4Prefer), + "ipv6-prefer" => Ok(Self::Ipv6Prefer), + _ => Err("invalid ip-version"), + } + } +} +impl IpVersion { + pub fn permits(self, address: IpAddr) -> bool { + match self { + Self::Ipv4 => address.is_ipv4(), + Self::Ipv6 => address.is_ipv6(), + _ => true, + } + } + pub(crate) fn order(self, addresses: &mut Vec) { + addresses.retain(|address| self.permits(address.ip())); + match self { + Self::Ipv4Prefer => addresses.sort_by_key(|address| address.is_ipv6()), + Self::Ipv6Prefer => addresses.sort_by_key(|address| address.is_ipv4()), + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn node_policy_orders_and_filters_physical_candidates() { + let addresses: Vec = + ["[2001:db8::1]:443", "192.0.2.1:443", "[2001:db8::2]:443"] + .into_iter() + .map(|value| value.parse().unwrap()) + .collect(); + let preferred = crate::dialer::Dialer::default() + .with_ip_version(IpVersion::Ipv4Prefer) + .ordered_addresses(&addresses); + assert_eq!(preferred, vec![addresses[1], addresses[0], addresses[2]]); + assert_eq!( + crate::dialer::Dialer::default() + .with_ip_version(IpVersion::Ipv4) + .ordered_addresses(&addresses), + vec![addresses[1]] + ); + assert!( + crate::dialer::Dialer::default() + .with_ipv6(false) + .with_ip_version(IpVersion::Ipv6) + .ordered_addresses(&addresses) + .is_empty() + ); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 4db2e0a..e4980ea 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,9 @@ //! Strict parsing for the current Vole YAML configuration. +mod http_proxy; +mod ip_version; +pub use http_proxy::HttpOutboundConfig; +pub use ip_version::IpVersion; + mod groups; pub(crate) use groups::status_ranges; pub use groups::{GroupKind, HealthCheckConfig, ProfileConfig}; @@ -30,7 +35,7 @@ pub use rules::DomainRegex; use rules::parse_rule; mod credentials; -pub use credentials::{ProxyAccess, ProxyCredentials}; +pub use credentials::{ProxyAccess, ProxyAccessRules, ProxyCredentials}; mod ech; pub use ech::StaticEchConfig; mod hysteria2; @@ -473,6 +478,24 @@ pub struct IpCidr { pub prefix_len: u8, } +impl IpCidr { + pub fn contains(&self, ip: IpAddr) -> bool { + match (self.network, ip) { + (IpAddr::V4(a), IpAddr::V4(b)) => { + self.prefix_len == 0 + || u32::from(a) >> (32 - self.prefix_len) + == u32::from(b) >> (32 - self.prefix_len) + } + (IpAddr::V6(a), IpAddr::V6(b)) => { + self.prefix_len == 0 + || u128::from(a) >> (128 - self.prefix_len) + == u128::from(b) >> (128 - self.prefix_len) + } + _ => false, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PortRange { pub start: u16, @@ -487,6 +510,7 @@ pub enum Network { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProxyConfig { + pub ip_version: IpVersion, pub tag: String, pub dialer_proxy: Option, pub udp: bool, @@ -495,6 +519,7 @@ pub struct ProxyConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProxyProtocol { + Http(HttpOutboundConfig), Tuic(TuicOutboundConfig), Hysteria2(Hysteria2OutboundConfig), Vmess(VmessOutboundConfig), @@ -509,6 +534,7 @@ impl ProxyConfig { #[must_use] pub fn address(&self) -> &str { match &self.protocol { + ProxyProtocol::Http(config) => &config.address, ProxyProtocol::Tuic(config) => &config.address, ProxyProtocol::Hysteria2(config) => &config.address, ProxyProtocol::Vmess(config) => &config.address, @@ -523,6 +549,7 @@ impl ProxyConfig { #[must_use] pub const fn port(&self) -> u16 { match &self.protocol { + ProxyProtocol::Http(config) => config.port, ProxyProtocol::Tuic(config) => config.port, ProxyProtocol::Hysteria2(config) => config.port, ProxyProtocol::Vmess(config) => config.port, @@ -834,6 +861,7 @@ pub struct TunInboundConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MixedInboundConfig { + pub access_rules: std::sync::Arc, pub tag: String, pub port: u16, pub access: ProxyAccess, @@ -871,6 +899,15 @@ impl std::fmt::Debug for HttpInboundConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawVoleConfig { + #[serde(rename = "bind-address", default)] + bind_address: Option, + #[serde(rename = "lan-allowed-ips", default)] + lan_allowed_ips: Option>, + #[serde(rename = "lan-disallowed-ips", default)] + lan_disallowed_ips: Vec, + #[serde(rename = "skip-auth-prefixes", default)] + skip_auth_prefixes: Vec, + #[serde(rename = "unified-delay", default)] unified_delay: bool, #[serde(default)] @@ -1209,12 +1246,40 @@ where deserializer.deserialize_map(OrderedPoliciesVisitor) } +#[derive(Debug)] +struct RawOutbound { + ip_version: IpVersion, + protocol: RawProtocol, +} +impl<'de> Deserialize<'de> for RawOutbound { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + let mut value = serde_yaml_ng::Value::deserialize(deserializer)?; + let ip_version = value + .as_mapping_mut() + .ok_or_else(|| serde::de::Error::custom("proxy requires a mapping"))? + .remove("ip-version") + .map(serde_yaml_ng::from_value) + .transpose() + .map_err(serde::de::Error::custom)? + .unwrap_or_default(); + let protocol = serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?; + Ok(Self { + ip_version, + protocol, + }) + } +} + #[derive(Debug, Deserialize)] #[serde(tag = "type", deny_unknown_fields)] // This enum exists only while deserializing a configuration. Keeping the // protocol fields inline avoids extra heap allocations in the startup path. #[allow(clippy::large_enum_variant)] -enum RawOutbound { +enum RawProtocol { + #[serde(rename = "http")] + Http(http_proxy::RawHttp), #[serde(rename = "tuic")] Tuic(#[serde(deserialize_with = "tuic::deserialize")] tuic::RawTuic), #[serde(rename = "hysteria2")] @@ -1578,6 +1643,7 @@ struct RawRealitySettings { #[derive(Debug)] struct PendingProxyConfig { + ip_version: IpVersion, tag: String, dialer_proxy: Option, udp: bool, @@ -1710,6 +1776,27 @@ impl RawVoleConfig { )?; let mixed_port = self.mixed_port.filter(|port| *port != 0); + let bind_address = match self.bind_address.as_deref() { + None | Some("" | "*") => None, + Some(value) => Some(value.trim_matches(['[', ']']).parse::().map_err( + |_| VoleError::InvalidConfig("bind-address must be an IP address or *".into()), + )?), + }; + let cidrs = |values: Vec| { + values + .into_iter() + .map(|value| parse_ip_cidr(&value, value.contains(':'))) + .collect::>>() + }; + let access_rules = std::sync::Arc::new(ProxyAccessRules { + allowed: self + .lan_allowed_ips + .map(cidrs) + .transpose()? + .unwrap_or_else(|| ProxyAccessRules::default().allowed), + denied: cidrs(self.lan_disallowed_ips)?, + skip_auth: cidrs(self.skip_auth_prefixes)?, + }); let authentication = normalize_proxy_authentication(self.authentication)?; let tun = self @@ -1782,9 +1869,11 @@ impl RawVoleConfig { let mut inbounds = Vec::with_capacity(2); if let Some(port) = mixed_port { inbounds.push(InboundConfig::Mixed(MixedInboundConfig { + access_rules, tag: "mixed-in".to_owned(), port, access: ProxyAccess { + bind_address, allow_lan: self.allow_lan, ipv6: self.ipv6, }, @@ -2082,17 +2171,17 @@ fn normalize_proxy_authentication( match authentication { None => Ok(None), Some(entries) if entries.is_empty() => Ok(None), - Some(entries) => { - let [credential] = entries.as_slice() else { - return invalid("authentication must contain exactly one user:password entry"); - }; - let (username, password) = credential.split_once(':').ok_or_else(|| { - VoleError::InvalidConfig("authentication entry must use user:password".to_owned()) - })?; - ProxyCredentials::new(username, password) - .map(Some) - .map_err(|error| VoleError::InvalidConfig(error.to_string())) - } + Some(entries) => entries + .into_iter() + .map(|credential| { + let (username, password) = credential.split_once(':').ok_or_else(|| { + VoleError::InvalidConfig("authentication entry must use user:password".into()) + })?; + ProxyCredentials::new(username, password) + .map_err(|error| VoleError::InvalidConfig(error.to_string())) + }) + .collect::>>() + .map(|values| Some(ProxyCredentials::multiple(values))), } } @@ -2152,6 +2241,7 @@ fn normalize_proxy_graph_with_groups( .into_iter() .zip(dialer_proxies) .map(|(proxy, dialer_proxy)| ProxyConfig { + ip_version: proxy.ip_version, tag: proxy.tag, dialer_proxy, udp: proxy.udp, @@ -2328,12 +2418,13 @@ pub(crate) fn proxy_graph_order( impl RawOutbound { fn normalize(self) -> Result { - let (tag, dialer_proxy, udp, protocol) = match self { - Self::Hysteria2(raw) => raw.normalize()?, - Self::Tuic(raw) => raw.normalize()?, - Self::Vmess(raw) => raw.normalize()?, - Self::Trojan(raw) => raw.normalize()?, - Self::Shadowsocks { + let (tag, dialer_proxy, udp, protocol) = match self.protocol { + RawProtocol::Http(raw) => raw.normalize()?, + RawProtocol::Hysteria2(raw) => raw.normalize()?, + RawProtocol::Tuic(raw) => raw.normalize()?, + RawProtocol::Vmess(raw) => raw.normalize()?, + RawProtocol::Trojan(raw) => raw.normalize()?, + RawProtocol::Shadowsocks { name, server, port, @@ -2361,8 +2452,8 @@ impl RawOutbound { config.udp_over_tcp = udp_over_tcp; (name, dialer_proxy, udp, ProxyProtocol::Shadowsocks(config)) } - Self::Vless(raw) => raw.normalize()?, - Self::Socks5 { + RawProtocol::Vless(raw) => raw.normalize()?, + RawProtocol::Socks5 { name, server, port, @@ -2377,7 +2468,7 @@ impl RawOutbound { udp, ProxyProtocol::Socks5(normalize_socks5(server, port, tls, username, password)?), ), - Self::AnyTls { + RawProtocol::AnyTls { name, server, port, @@ -2409,6 +2500,7 @@ impl RawOutbound { validate_route_target_name(dialer_proxy, "dialer-proxy")?; } Ok(PendingProxyConfig { + ip_version: self.ip_version, tag, dialer_proxy, udp, @@ -4292,10 +4384,6 @@ authentication: assert!(Config::parse_yaml(max.as_bytes()).is_ok()); for (fields, expected) in [ - ( - "mixed-port: 1080\nauthentication:\n - one:secret\n - two:secret", - "authentication must contain exactly one", - ), ( "mixed-port: 1080\nauthentication:\n - missing-separator", "authentication entry must use user:password", @@ -4424,6 +4512,7 @@ authentication: assert_eq!( mixed.access, ProxyAccess { + bind_address: None, allow_lan: true, ipv6: false } @@ -4461,9 +4550,11 @@ authentication: #[test] fn mixed_inbound_debug_redacts_credentials() { let inbound = MixedInboundConfig { + access_rules: Default::default(), tag: "mixed-in".to_owned(), port: 1080, access: ProxyAccess { + bind_address: None, allow_lan: false, ipv6: true, }, @@ -4800,6 +4891,7 @@ rules: [ InboundConfig::Mixed(MixedInboundConfig { access: ProxyAccess { + bind_address: None, allow_lan: false, ipv6: true }, diff --git a/src/config/providers.rs b/src/config/providers.rs index 8e0da65..bc1e232 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -117,6 +117,7 @@ impl ProviderSpec { Filter::new(&self.filter, &self.exclude_filter, &self.exclude_type)?; for key in self.overrides.keys() { if ![ + "ip-version", "udp", "udp-over-tcp", "up", diff --git a/src/dialer.rs b/src/dialer.rs index 64f7ef0..19d3afc 100644 --- a/src/dialer.rs +++ b/src/dialer.rs @@ -365,6 +365,7 @@ struct InterfaceBinding { #[derive(Clone)] pub struct Dialer { + ip_version: crate::config::IpVersion, initialization: Arc, protector: Option>, source_binding: Option, @@ -392,6 +393,7 @@ impl std::fmt::Debug for Dialer { impl Default for Dialer { fn default() -> Self { Self { + ip_version: crate::config::IpVersion::Dual, initialization: Arc::new(SocketInitialization::default()), protector: None, source_binding: None, @@ -404,6 +406,23 @@ impl Default for Dialer { } impl Dialer { + pub(crate) fn with_ip_version(mut self, version: crate::config::IpVersion) -> Self { + self.ip_version = version; + self + } + pub(crate) fn ip_version(&self) -> crate::config::IpVersion { + self.ip_version + } + pub(crate) fn ordered_addresses(&self, addresses: &[SocketAddr]) -> Vec { + let mut addresses = addresses + .iter() + .copied() + .filter(|address| self.ipv6 || address.is_ipv4()) + .collect(); + self.ip_version.order(&mut addresses); + addresses + } + /// Start a new owner for a Running Session or private measurement. Policy /// is retained, but stopping one graph must not close another session. pub(crate) fn with_fresh_initialization(mut self) -> Self { @@ -470,11 +489,8 @@ impl Dialer { pub async fn connect(&self, endpoint: &ResolvedEndpoint) -> io::Result { let mut last_error = None; - for address in &endpoint.addresses { - if !self.ipv6 && address.is_ipv6() { - continue; - } - match self.connect_one(*address).await { + for address in self.ordered_addresses(&endpoint.addresses) { + match self.connect_one(address).await { Ok(stream) => return Ok(stream), Err(error) => last_error = Some(error), } @@ -667,10 +683,10 @@ impl Dialer { } fn require_permitted(&self, address: SocketAddr) -> io::Result<()> { - if !self.ipv6 && address.is_ipv6() { + if (!self.ipv6 && address.is_ipv6()) || !self.ip_version.permits(address.ip()) { return Err(io::Error::new( io::ErrorKind::PermissionDenied, - "IPv6 is disabled", + "address family is disabled", )); } Ok(()) diff --git a/src/dns/resolution.rs b/src/dns/resolution.rs index 97bc858..a47a85b 100644 --- a/src/dns/resolution.rs +++ b/src/dns/resolution.rs @@ -30,6 +30,7 @@ struct State { /// Default contexts permit literal IPs but have no domain resolver. #[derive(Clone, Default)] pub struct ResolutionContext { + ip_version: crate::config::IpVersion, inner: Option>, } @@ -56,8 +57,14 @@ pub(crate) fn inherited_deadline(deadline: Instant) -> Instant { } impl ResolutionContext { + pub(crate) fn with_ip_version(mut self, version: crate::config::IpVersion) -> Self { + self.ip_version = version; + self + } + pub fn runtime(ipv6: bool) -> Self { Self { + ip_version: crate::config::IpVersion::Dual, inner: Some(Arc::new(State { source: OnceLock::new(), ipv6, @@ -143,7 +150,8 @@ impl ResolutionContext { return Err(DispatchError::NotAllowed); } if let Destination::Ip(address) = target { - if address.port() == 0 + if !self.ip_version.permits(address.ip()) + || address.port() == 0 || (address.is_ipv6() && self.inner.as_ref().is_some_and(|inner| !inner.ipv6)) { return Err(DispatchError::HostUnreachable); @@ -206,10 +214,11 @@ impl ResolutionContext { resolved.addresses } }; - let addresses: Vec<_> = addresses + let mut addresses: Vec<_> = addresses .into_iter() .filter(|address| inner.ipv6 || address.is_ipv4()) .collect(); + self.ip_version.order(&mut addresses); if addresses.is_empty() { Err(DispatchError::HostUnreachable) } else { diff --git a/src/inbound/http/server.rs b/src/inbound/http/server.rs index c414d03..27b0152 100644 --- a/src/inbound/http/server.rs +++ b/src/inbound/http/server.rs @@ -62,6 +62,7 @@ impl HttpServerConfig { let mut config = Self::proxy( listen.port(), ProxyAccess { + bind_address: None, allow_lan: false, ipv6: false, }, @@ -114,6 +115,7 @@ impl HttpServer { bind_proxy_tcp( config.listen.port(), ProxyAccess { + bind_address: None, allow_lan: config.listen.ip().is_unspecified(), ipv6: true, }, @@ -880,6 +882,7 @@ mod tests { let config = HttpServerConfig::proxy( 0, ProxyAccess { + bind_address: None, allow_lan: shared, ipv6: false, }, diff --git a/src/inbound/http/tests.rs b/src/inbound/http/tests.rs index d96b266..1a93c65 100644 --- a/src/inbound/http/tests.rs +++ b/src/inbound/http/tests.rs @@ -74,6 +74,7 @@ impl Fixture { let mut config = HttpServerConfig::proxy( 0, ProxyAccess { + bind_address: None, allow_lan: false, ipv6: false, }, @@ -187,6 +188,7 @@ async fn local_no_auth_and_shared_auth_work_on_both_loopback_families() { *config = HttpServerConfig::proxy( 0, ProxyAccess { + bind_address: None, allow_lan: shared, ipv6: true, }, @@ -341,6 +343,7 @@ async fn keep_alive_requests_reenter_the_real_rule_dispatcher() { let config = HttpServerConfig::proxy( 0, ProxyAccess { + bind_address: None, allow_lan: false, ipv6: false, }, diff --git a/src/inbound/listen.rs b/src/inbound/listen.rs index 0e27812..b6c9914 100644 --- a/src/inbound/listen.rs +++ b/src/inbound/listen.rs @@ -31,6 +31,11 @@ pub(crate) fn bind_tcp(address: SocketAddr) -> io::Result { } pub(crate) fn bind_proxy_tcp(port: u16, access: ProxyAccess) -> io::Result> { + if access.allow_lan + && let Some(address) = access.bind_address + { + return Ok(vec![bind_tcp(SocketAddr::new(address, port))?]); + } let ipv4 = if access.allow_lan { Ipv4Addr::UNSPECIFIED } else { @@ -109,7 +114,15 @@ mod tests { async fn policy_binds_loopback_or_wildcard_and_respects_ipv6() { for allow_lan in [false, true] { for ipv6 in [false, true] { - let listeners = bind_proxy_tcp(0, ProxyAccess { allow_lan, ipv6 }).unwrap(); + let listeners = bind_proxy_tcp( + 0, + ProxyAccess { + bind_address: None, + allow_lan, + ipv6, + }, + ) + .unwrap(); let port = listeners[0].local_addr().unwrap().port(); if !ipv6 { assert_eq!(listeners.len(), 1); @@ -138,6 +151,7 @@ mod tests { let error = bind_proxy_tcp( port, ProxyAccess { + bind_address: None, allow_lan: false, ipv6: true, }, diff --git a/src/inbound/mixed.rs b/src/inbound/mixed.rs index 2d923f5..05d791f 100644 --- a/src/inbound/mixed.rs +++ b/src/inbound/mixed.rs @@ -23,6 +23,7 @@ use super::{ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); pub struct MixedServer { + access_rules: Arc, listeners: Vec, sockets: Vec>, http: HttpServerConfig, @@ -50,6 +51,7 @@ impl MixedServer { dispatcher.clone(), ); Ok(Self { + access_rules: config.access_rules, listeners, sockets, http, @@ -106,6 +108,7 @@ impl MixedServer { Err(error) => break Err(error), }; let connection = MixedConnection { + access_rules:self.access_rules.clone(), peer, relay, udp: Some(self.sockets[index].clone()), @@ -129,6 +132,7 @@ impl MixedServer { } struct MixedConnection { + access_rules: Arc, peer: SocketAddr, relay: SocketAddr, udp: Option>, @@ -152,10 +156,17 @@ impl MixedConnection { } } - async fn serve_inner(self, stream: S) -> io::Result<()> + async fn serve_inner(mut self, stream: S) -> io::Result<()> where S: AsyncRead + AsyncWrite + Unpin, { + if !self.access_rules.permits(self.peer.ip()) { + return Err(io::ErrorKind::PermissionDenied.into()); + } + if self.access_rules.skips_auth(self.peer.ip()) { + self.http.auth = None; + self.socks = self.socks.without_auth(); + } // Keep the probed byte in the reader. Both real protocol handlers // consume this same reader, so fragmented and pipelined bytes survive. let mut stream = BufReader::with_capacity(1, stream); diff --git a/src/inbound/mixed/tests.rs b/src/inbound/mixed/tests.rs index 3189fc8..5fc59f4 100644 --- a/src/inbound/mixed/tests.rs +++ b/src/inbound/mixed/tests.rs @@ -39,6 +39,7 @@ impl Dispatcher for Peers { fn connection(peers: Arc, auth: Option) -> MixedConnection { let access = ProxyAccess { + bind_address: None, allow_lan: true, ipv6: false, }; @@ -53,6 +54,7 @@ fn connection(peers: Arc, auth: Option) -> MixedConnect peers.clone(), ); MixedConnection { + access_rules: Default::default(), peer: "192.0.2.1:20000".parse().unwrap(), relay: "192.0.2.2:1080".parse().unwrap(), udp: None, @@ -252,3 +254,71 @@ async fn memory_mixed_eof_and_cancellation_release_pending_protocol_reads() { assert!(peers.sessions.lock().unwrap().is_empty()); } } + +#[tokio::test] +async fn memory_mixed_acl_multiple_users_and_auth_bypass() { + for (skip, denied) in [(false, false), (true, false), (true, true)] { + let yaml = format!( + r#"mixed-port: 1080 +allow-lan: true +bind-address: 192.0.2.2 +authentication: [first:old, second:pass, first:new] +lan-allowed-ips: [192.0.2.0/24] +lan-disallowed-ips: [{}] +skip-auth-prefixes: [{}] +"#, + if denied { + "192.0.2.1/32" + } else { + "192.0.2.9/32" + }, + if skip { + "192.0.2.0/24" + } else { + "198.51.100.0/24" + } + ); + let config = crate::config::Config::parse_yaml(yaml.as_bytes()).unwrap(); + let crate::config::InboundConfig::Mixed(mixed) = &config.inbounds[0] else { + panic!() + }; + assert_eq!( + mixed.access.bind_address, + Some("192.0.2.2".parse().unwrap()) + ); + assert!(mixed.auth.as_ref().unwrap().matches(b"first", b"new")); + assert!(!mixed.auth.as_ref().unwrap().matches(b"first", b"old")); + let peers = Arc::new(Peers::default()); + let (upstream, mut remote) = tokio::io::duplex(1024); + peers.streams.lock().unwrap().push_back(upstream); + let mut connection = connection(peers.clone(), mixed.auth.clone()); + connection.access_rules = mixed.access_rules.clone(); + let cancellation = connection.cancellation.clone(); + let (mut client, inbound) = tokio::io::duplex(1024); + let request = format!( + "CONNECT 192.0.2.3:80 HTTP/1.1\r\nHost: 192.0.2.3:80\r\n{}\r\n", + if skip { + String::new() + } else { + format!( + "Proxy-Authorization: {}\r\n", + ProxyCredentials::new("second", "pass") + .unwrap() + .authorization_header_value() + ) + } + ); + client.write_all(request.as_bytes()).await.unwrap(); + let task = tokio::spawn(connection.serve(inbound)); + if denied { + assert!(task.await.unwrap().is_err()); + assert!(peers.sessions.lock().unwrap().is_empty()); + } else { + exact(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await; + client.write_all(b"body").await.unwrap(); + exact(&mut remote, b"body").await; + cancellation.cancel(); + task.await.unwrap().unwrap(); + } + } +} diff --git a/src/inbound/socks5/server.rs b/src/inbound/socks5/server.rs index 86c340c..fa1fc6a 100644 --- a/src/inbound/socks5/server.rs +++ b/src/inbound/socks5/server.rs @@ -135,6 +135,11 @@ pub(crate) struct Socks5Handler { pub(crate) const UDP_CLEANUP_INTERVAL: Duration = CLEANUP_INTERVAL; impl Socks5Handler { + pub(crate) fn without_auth(mut self) -> Self { + self.config.auth = None; + self + } + pub(crate) fn new(config: Socks5InboundConfig, dispatcher: Arc) -> Self { Self { config, diff --git a/src/inbound/socks5/tests.rs b/src/inbound/socks5/tests.rs index d7680c0..b54dbf4 100644 --- a/src/inbound/socks5/tests.rs +++ b/src/inbound/socks5/tests.rs @@ -129,6 +129,7 @@ impl Fixture { tag: "socks".into(), port: 0, access: ProxyAccess { + bind_address: None, allow_lan: lan, ipv6, }, @@ -137,6 +138,7 @@ impl Fixture { #[cfg(feature = "inbound-http")] let server = MixedServer::bind( MixedInboundConfig { + access_rules: Default::default(), tag: config.tag, port: config.port, access: config.access, @@ -475,6 +477,7 @@ async fn dual_stack_udp_and_tcp_share_port_and_failed_udp_bind_releases_tcp() { tag: "test".into(), port: address.port(), access: ProxyAccess { + bind_address: None, allow_lan: false, ipv6: false, }, diff --git a/src/lib.rs b/src/lib.rs index 4425ef3..51af6dd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,7 @@ pub mod resources; pub mod routing; #[cfg(all( any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-socks5", feature = "outbound-shadowsocks", @@ -51,6 +52,7 @@ pub mod security; pub mod session; #[cfg(any( feature = "inbound-socks5", + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-shadowsocks", feature = "outbound-socks5", diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index 96c1ac0..951bd97 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -608,6 +608,8 @@ impl UpstreamPath { ResolvedUpstream::LazyDirect(dialer) => { let endpoint = context .resolution + .clone() + .with_ip_version(dialer.ip_version()) .resolve_proxy(server, context.deadline) .await?; let stream = context @@ -655,17 +657,19 @@ impl UpstreamPath { context: &EstablishContext, ) -> Result { match self.resolve(context)? { - ResolvedUpstream::Direct(endpoint, _) => { + ResolvedUpstream::Direct(endpoint, dialer) => { validate_prepared_endpoint(server, endpoint)?; - endpoint - .addresses + dialer + .ordered_addresses(&endpoint.addresses) .first() .copied() .map(Destination::Ip) .ok_or(DispatchError::HostUnreachable) } - ResolvedUpstream::LazyDirect(_) => context + ResolvedUpstream::LazyDirect(dialer) => context .resolution + .clone() + .with_ip_version(dialer.ip_version()) .resolve_proxy(server, context.deadline) .await? .addresses @@ -678,6 +682,38 @@ impl UpstreamPath { } } +pub(crate) struct IpVersionConnector { + pub inner: Arc, + pub version: crate::config::IpVersion, +} +#[async_trait] +impl OutboundConnector for IpVersionConnector { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let mut context = context.clone(); + context.resolution = context.resolution.with_ip_version(self.version); + self.inner.connect_stream(session, &context).await + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + let mut context = context.clone(); + context.resolution = context.resolution.with_ip_version(self.version); + self.inner.open_datagram(request, &context).await + } + fn begin_shutdown(&self) { + self.inner.begin_shutdown(); + } + async fn shutdown(&self) { + self.inner.shutdown().await; + } +} + /// Creates the logical server destination retained by proxy connectors. pub fn server_destination(address: &str, port: u16) -> io::Result { if let Ok(address) = address.parse::() { diff --git a/src/outbound/http.rs b/src/outbound/http.rs new file mode 100644 index 0000000..d0a1633 --- /dev/null +++ b/src/outbound/http.rs @@ -0,0 +1,233 @@ +//! HTTP/1.1 CONNECT over a shared plain or TLS upstream stream. +use crate::{ + config::HttpOutboundConfig, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + server_destination, + }, + security::{SecurityContext, StandardTlsClient, TlsClientIdentity, TlsClientOptions}, + session::{Destination, StreamSession}, +}; +use async_trait::async_trait; +use std::{collections::BTreeMap, io}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +pub struct HttpOutbound { + server: Destination, + upstream: UpstreamPath, + tls: Option, + headers: BTreeMap, + authorization: Option, +} +impl HttpOutbound { + pub(crate) fn with_shared_security( + config: &HttpOutboundConfig, + upstream: UpstreamPath, + security: &SecurityContext, + sessions: usize, + buffer_limit: usize, + ) -> io::Result { + let tls = config + .tls + .as_ref() + .map(|config| { + StandardTlsClient::with_options( + security, + &config.server_name, + TlsClientOptions { + alpn: config.alpn.clone(), + certificate: config.certificate.clone(), + identity: config + .identity + .as_ref() + .map(|identity| { + TlsClientIdentity::from_pem( + &identity.certificate, + &identity.private_key, + ) + }) + .transpose()?, + ..Default::default() + }, + sessions, + buffer_limit, + ) + }) + .transpose()?; + Ok(Self { + server: server_destination(&config.address, config.port)?, + upstream, + tls, + headers: config.headers.clone(), + authorization: config + .auth + .as_ref() + .map(|auth| auth.authorization_header_value()), + }) + } +} +async fn handshake( + mut stream: BoxStream, + target: &Destination, + headers: &BTreeMap, + authorization: Option<&str>, +) -> io::Result { + let authority = match target { + Destination::Ip(address) => address.to_string(), + Destination::Domain { host, port } => format!("{host}:{port}"), + }; + let mut fields = BTreeMap::from([ + ("host".to_owned(), authority.clone()), + ("proxy-connection".into(), "Keep-Alive".into()), + ( + "user-agent".into(), + concat!("Vole/", env!("CARGO_PKG_VERSION")).into(), + ), + ]); + for (name, value) in headers { + fields.insert(name.to_ascii_lowercase(), value.clone()); + } + if let Some(value) = authorization { + fields.insert("proxy-authorization".into(), value.into()); + } + let mut request = format!("CONNECT {authority} HTTP/1.1\r\n"); + for (name, value) in fields { + request.push_str(&format!("{name}: {value}\r\n")); + } + request.push_str("\r\n"); + stream.write_all(request.as_bytes()).await?; + stream.flush().await?; + let mut total = 0; + loop { + let mut head = Vec::new(); + // Exact reads preserve application bytes coalesced with the response. + while !head.ends_with(b"\r\n\r\n") { + if total >= 32 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "HTTP CONNECT response too large", + )); + } + head.push(stream.read_u8().await?); + total += 1; + } + let status = crate::resource::probe::parse_status(&head)?; + if (100..200).contains(&status) && status != 101 { + continue; + } + if status != 200 { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "HTTP CONNECT was rejected", + )); + } + return Ok(stream); + } +} +#[async_trait] +impl OutboundConnector for HttpOutbound { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + if tokio::time::Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + let target = session.destination.clone(); + let raw = self + .upstream + .connect_server(session, &self.server, context) + .await?; + let io = match &self.tls { + Some(tls) => { + context + .run_io("HTTP proxy TLS", tls.connect(raw.io)) + .await? + } + None => raw.io, + }; + let io = context + .run_io( + "HTTP CONNECT", + handshake(io, &target, &self.headers, self.authorization.as_deref()), + ) + .await?; + Ok(ConnectedStream { + io, + effective_peer: target, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test(start_paused = true)] + async fn stalled_connect_obeys_deadline_and_releases_stream() { + let (client, mut peer) = tokio::io::duplex(4096); + let context = EstablishContext::with_timeout(std::time::Duration::from_secs(1)); + let result = context + .run_io( + "HTTP CONNECT", + handshake( + Box::new(client), + &Destination::Ip("192.0.2.1:443".parse().unwrap()), + &BTreeMap::new(), + None, + ), + ) + .await; + assert!(matches!(result, Err(DispatchError::TimedOut))); + let mut request = Vec::new(); + peer.read_to_end(&mut request).await.unwrap(); + assert!(request.ends_with(b"\r\n\r\n")); + } + #[tokio::test] + async fn connect_auth_and_prebuffer_are_preserved() { + for status in [200, 407] { + let (client, mut peer) = tokio::io::duplex(4096); + let remote = tokio::spawn(async move { + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push(peer.read_u8().await.unwrap()); + } + let text = String::from_utf8(request).unwrap(); + assert!(text.starts_with("CONNECT example.com:443 HTTP/1.1\r\n")); + assert!(text.contains("proxy-authorization: Basic dTpw\r\n")); + assert!(text.contains("x-proxy: custom\r\n")); + peer.write_all(format!("HTTP/1.1 {status} Status\r\n\r\nfirst-byte").as_bytes()) + .await + .unwrap(); + }); + let result = handshake( + Box::new(client), + &Destination::Domain { + host: "example.com".into(), + port: 443, + }, + &BTreeMap::from([("X-Proxy".into(), "custom".into())]), + Some("Basic dTpw"), + ) + .await; + if status == 200 { + let mut bytes = [0; 10]; + result.unwrap().read_exact(&mut bytes).await.unwrap(); + assert_eq!(&bytes, b"first-byte"); + } else { + assert_eq!( + result.err().unwrap().kind(), + io::ErrorKind::PermissionDenied + ); + } + remote.await.unwrap(); + } + } +} diff --git a/src/outbound/mod.rs b/src/outbound/mod.rs index 0379cd9..ac01d4b 100644 --- a/src/outbound/mod.rs +++ b/src/outbound/mod.rs @@ -1,6 +1,8 @@ //! Composable proxy connectors and the built-in DIRECT dispatcher. pub mod address; +#[cfg(feature = "outbound-http")] +pub mod http; #[cfg(feature = "outbound-trojan")] pub mod trojan; #[cfg(feature = "outbound-vmess")] @@ -30,7 +32,8 @@ pub use connector::{ EstablishContext, OutboundConnector, SelectUpstream, UpstreamPath, server_destination, }; pub(crate) use connector::{ - MAX_OUTBOUND_DIAGNOSTIC_MESSAGE_BYTES, OutboundDiagnostic, capture_outbound_diagnostic, + IpVersionConnector, MAX_OUTBOUND_DIAGNOSTIC_MESSAGE_BYTES, OutboundDiagnostic, + capture_outbound_diagnostic, }; pub use direct::DirectOutbound; #[cfg(feature = "outbound-socks5")] diff --git a/src/runtime.rs b/src/runtime.rs index 79482ee..f2bcea5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -82,6 +82,7 @@ use crate::outbound::{VlessOutbound, VlessResourceLimits}; #[cfg(feature = "outbound-anytls")] use crate::security::StandardTlsClient; #[cfg(any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -810,6 +811,7 @@ fn build_proxy_graph( let order = proxy_graph_order(proxies, groups) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; #[cfg(any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -820,6 +822,7 @@ fn build_proxy_graph( ))] let (security_client_count, standard_tls_count) = security_counts(proxies); #[cfg(any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -830,6 +833,7 @@ fn build_proxy_graph( ))] let security_context = (security_client_count != 0).then(SecurityContext::new); #[cfg(any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -894,14 +898,35 @@ fn build_proxy_graph( RouteTargetId::Proxy(id) => id.index(), }; let proxy = &proxies[index]; + let node_dialer = dialer.clone().with_ip_version(proxy.ip_version); let upstream = build_upstream_path( proxy.dialer_proxy, endpoints[index].upload.clone(), &nodes, &group_nodes, - &dialer, + &node_dialer, )?; let connector: Arc = match &proxy.protocol { + ProxyProtocol::Http(config) => { + #[cfg(feature = "outbound-http")] + { + Arc::new(crate::outbound::http::HttpOutbound::with_shared_security( + config, + upstream, + security_context.as_ref().expect("HTTP security context"), + resumption_sessions, + limits.tls_buffer_limit, + )?) + } + #[cfg(not(feature = "outbound-http"))] + { + let _ = (config, upstream); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "HTTP outbound support is disabled", + )); + } + } ProxyProtocol::Tuic(config) => { #[cfg(feature = "outbound-tuic")] { @@ -1017,7 +1042,7 @@ fn build_proxy_graph( endpoints[index].download.clone(), &nodes, &group_nodes, - &dialer, + &node_dialer, ) }) .transpose()?; @@ -1090,6 +1115,10 @@ fn build_proxy_graph( } }; + let connector: Arc = Arc::new(crate::outbound::IpVersionConnector { + inner: connector, + version: proxy.ip_version, + }); graph .lifecycle_order .push(BuiltRouteTarget::Proxy(connector.clone())); @@ -1170,7 +1199,8 @@ async fn prepare_proxy_endpoints( let upload_port = proxy.port(); let download = match &proxy.protocol { ProxyProtocol::Vless(config) => config.download(), - ProxyProtocol::Socks5(_) + ProxyProtocol::Http(_) + | ProxyProtocol::Socks5(_) | ProxyProtocol::Trojan(_) | ProxyProtocol::Vmess(_) | ProxyProtocol::AnyTls(_) @@ -1236,6 +1266,7 @@ fn restrict_endpoint_addresses( } #[cfg(any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -1276,6 +1307,10 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { client_count + usize::from(config.shadow_tls.is_some()), standard_count, ), + ProxyProtocol::Http(config) => ( + client_count + 1, + standard_count + usize::from(config.tls.is_some()), + ), ProxyProtocol::Socks5(_) => (client_count, standard_count), ProxyProtocol::Tuic(_) => (client_count + 1, standard_count), }, @@ -1283,6 +1318,7 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { } #[cfg(any( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -1949,7 +1985,11 @@ rules: } #[test] - #[cfg(any(feature = "outbound-anytls", feature = "outbound-vless"))] + #[cfg(any( + feature = "outbound-http", + feature = "outbound-anytls", + feature = "outbound-vless" + ))] fn security_budget_counts_every_vless_transport_leg() { let yaml = config_with_download(" server: download.test\n port: 8443"); let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); @@ -2308,6 +2348,7 @@ rules: } #[cfg(all( + feature = "outbound-http", feature = "outbound-anytls", feature = "outbound-socks5", feature = "outbound-vless" @@ -2334,6 +2375,7 @@ rules: fn socks5(tag: &str) -> ProxyConfig { ProxyConfig { + ip_version: crate::config::IpVersion::Dual, tag: tag.to_owned(), dialer_proxy: None, udp: false, @@ -2348,6 +2390,7 @@ rules: fn anytls(tag: &str) -> ProxyConfig { ProxyConfig { + ip_version: crate::config::IpVersion::Dual, tag: tag.to_owned(), dialer_proxy: None, udp: true, diff --git a/tests/README.md b/tests/README.md index 296a264..49af322 100644 --- a/tests/README.md +++ b/tests/README.md @@ -14,6 +14,7 @@ cargo test --locked --no-default-features --features cli --lib invoke::foregroun cargo test --locked --lib config::tests:: cargo test --locked --lib geodata::tests:: cargo test --locked --all-features --lib resource::http::tests:: +cargo test --locked --all-features --lib outbound::http::tests:: cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 3f7fd966938b5a82cd997a5ee20aa09769c6bcaa Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 18:47:40 +0800 Subject: [PATCH 008/445] feat: manage native Linux TUN routes and system DNS Apply auto-route and auto-detect-interface through shared socket and platform seams. Record owned per-link DNS, routes and rules for startup rollback and synchronous stop; leave borrowed descriptors and other platform networking host-owned. Include package capability and noninteractive resolve1 authorization hooks. Validation: native GNU/Linux ARM64 CLI compilation and two memory network-adapter tests in isolated Ubuntu; Linux/macOS TUN config regressions, macOS all-target compilation and all-feature clippy. Full network flow and DNS acceptance remains for the final compatibility run. --- .github/workflows/test.yml | 2 +- AGENTS.md | 2 +- docs/cli.md | 3 +- docs/config.yaml | 7 +- docs/invoke-api.md | 5 +- docs/tun-platform.md | 41 +++- packaging/linux/50-vole-resolved.rules | 12 + packaging/linux/post-install | 7 + src/config/mod.rs | 22 +- src/dialer.rs | 21 ++ src/invoke/mod.rs | 22 +- src/platform/linux_network/mod.rs | 128 ++++++++++ src/platform/linux_network/native.rs | 257 ++++++++++++++++++++ src/platform/linux_network/netlink.rs | 316 +++++++++++++++++++++++++ src/platform/mod.rs | 3 + src/platform/tun_rs_io.rs | 38 +++ src/runtime.rs | 32 +++ tests/README.md | 1 + 18 files changed, 901 insertions(+), 18 deletions(-) create mode 100644 packaging/linux/50-vole-resolved.rules create mode 100755 packaging/linux/post-install create mode 100644 src/platform/linux_network/mod.rs create mode 100644 src/platform/linux_network/native.rs create mode 100644 src/platform/linux_network/netlink.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29e8ed7..c807b9d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -189,7 +189,7 @@ jobs: outbound::hysteria2:: outbound::shadowsocks::tests::official_tcp_ outbound::anytls:: outbound::uot:: outbound::tuic:: transport::h2_write::tests:: transport::sing_mux::smux_driver::tests:: transport::sing_mux::tests:: - platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: + platform::linux_network::tests:: platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: platform::windows_wintun_io::tests:: tun_runtime::tests:: invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: resource::probe::tests:: invoke::measure_delay::tests:: ) diff --git a/AGENTS.md b/AGENTS.md index 4fd366f..dc396cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ Vole is a standalone Rust proxy core with native libraries and an optional foreground CLI. The native library contract is the JSON Invoke API, with no API or configuration revision fields. The `invoke` feature owns the shared request handler; `cli` and `ffi` are its command-line and native transports. The CLI parses flags, calls Invoke, renders responses, and sets exit status without requiring C ABI or Windows package entry points. Runtime configuration uses the strict schema documented in `docs/config.yaml`; native callers pass it inline as `configYaml` / `configYamls`; the explicit Invoke `foreground` operation owns bounded file/stdin reads, environment/path defaults, signals, and cleanup for CLI use. YAML does not contain `default-proxy`. Business proxy listeners use only `mixed-port`: shared HTTP/SOCKS5 TCP and SOCKS5 UDP require both inbound features. Top-level `port`, `socks-port`, `udp`, and `listeners` are rejected; proxy-node `port` and `udp` retain their outbound semantics. `allow-lan` and `authentication` are independent; omitted or empty authentication leaves both loopback and wildcard bindings unauthenticated. Public lifecycle state is runtime-local and single-instance. -iOS, tvOS 17+ (ARM64 device/simulator), macOS, Android and Linux use config-owned `tun.file-descriptor` through the Unix `tun-rs` adapter; positive descriptors remain host-owned and Vole closes only its duplicate. With descriptor 0, Linux and macOS can create/open native TUN devices from `tun.device`. Linux validates a real single-queue raw-IP TUN and its configured MTU; the host owns addresses, DNS, namespace/routing isolation, and physical egress. Apple packetFlow does not publicly guarantee raw-fd access; Linux or simulator results do not sign off a physical Network Extension. Windows has mutually exclusive `windows-uwp` (`windows-rs` / `Windows.Networking.Vpn`) and `windows-wintun` builds sharing the same core. Validate Wintun devices and packaged UWP integration separately. +iOS, tvOS 17+ (ARM64 device/simulator), macOS, Android and Linux use config-owned `tun.file-descriptor` through the Unix `tun-rs` adapter; positive descriptors remain host-owned and Vole closes only its duplicate. With descriptor 0, Linux and macOS can create/open native TUN devices from `tun.device`. Linux validates a real single-queue raw-IP TUN and its configured MTU; the host owns borrowed-fd addresses, DNS, namespace/routing isolation, and physical egress. Core-owned Linux TUN supports auto-detect-interface, auto-route and per-link systemd-resolved setup with rollback; package installation supplies capabilities and noninteractive DNS authorization. Apple packetFlow does not publicly guarantee raw-fd access; Linux or simulator results do not sign off a physical Network Extension. Windows has mutually exclusive `windows-uwp` (`windows-rs` / `Windows.Networking.Vpn`) and `windows-wintun` builds sharing the same core. Validate Wintun devices and packaged UWP integration separately. # Sources of Truth diff --git a/docs/cli.md b/docs/cli.md index 7463339..2b771cb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -39,7 +39,8 @@ Unix 用户目录取 `HOME`,Windows 取 `USERPROFILE`,缺失时使用工作 TUN 完全由 YAML 配置。Unix 的 `tun.file-descriptor > 0` 借用宿主 fd,内核只关闭自己的副本; 省略或为 0 时 Linux/macOS 可根据 `tun.device` 创建或打开原生 TUN。 Windows CLI 固定使用 Wintun,DLL 由宿主提供;Windows FFI 编译时可选择 Wintun 或 UWP。 -接口地址、DNS、系统路由及物理出口隔离由宿主管理,CLI 直接把配置交给内核。 +Linux 自建 TUN 的自动路由与 DNS 接管由 Core 执行,CLI 直接把配置交给内核。 +其他平台和借用 fd 的接口地址、DNS、系统路由及物理出口隔离仍由宿主管理。 平台接入见 [TUN](tun-platform.md) 和 [Windows VPN](windows-vpn.md)。 构建命令、tag 发布矩阵和归档内容统一见 [编译与发布](../scripts/README.md)。 diff --git a/docs/config.yaml b/docs/config.yaml index 69e5726..6765a3b 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -70,8 +70,9 @@ authentication: external-controller: 127.0.0.1:9090 secret: "vole-runtime-secret" -# TUN 使用 Mihomo 同名字段;未实现的 auto-route、auto-redirect、 -# auto-detect-interface、地址/路由、stack、offload 等字段仍严格拒绝。 +# TUN 使用 Mihomo 同名字段;auto-route/auto-detect-interface 仅供 Linux 自建 TUN。 +# Linux 缺省均为 true,其他平台缺省 false;借用 fd 的网络配置始终由宿主负责。 +# auto-redirect、自定义地址/路由、stack、offload 等字段仍严格拒绝。 # file-descriptor 缺省或 0 时由 core 打开本机平台 TUN;正数为当前进程已有 fd。 # Unix 借用 fd 必须已 nonblocking,核心仅复制并关闭副本;帧格式由平台固定处理。 # Linux / macOS 支持原生打开;Android / iOS / tvOS 仍要求宿主提供 fd。 @@ -88,6 +89,8 @@ secret: "vole-runtime-secret" # udp-timeout 单位秒,缺省或 0 为 300;控制源关联及各目的流的空闲失效。 # 配置必须启用 mixed-port 或 TUN。 tun: + # auto-route: true # Linux:自动路由;DNS 启用且劫持网关时接管 systemd-resolved + # auto-detect-interface: true # Linux:出站自动绑定物理接口 enable: true device: "" file-descriptor: 0 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 1ac815d..4d3f8c5 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -223,8 +223,9 @@ Apple 使用 utun 包头,Android 与 Linux 使用 raw-IP。Linux 要求真实 PI/VNET header,并验证实际 MTU 与 `tun.mtu` 相同;跨网络命名空间的校验需要宿主提供权限。 `tun.file-descriptor` 省略或为 0 时,Linux 与 macOS 根据 `tun.device` 创建或打开原生 -TUN;移动平台仍需要宿主描述符。接口地址、DNS、路由和物理出口隔离由宿主配置,Vole -不自动管理系统路由。`tun.mtu` 省略或为 0 使用 9000;完整六字段契约及借用 fd 边界见 +TUN;移动平台仍需要宿主描述符。Linux 自建 TUN 支持 `auto-route`、 +`auto-detect-interface` 与 systemd-resolved 接管;其他平台和借用 fd 的系统网络由宿主配置。 +`tun.mtu` 省略或为 0 使用 9000;字段契约及借用 fd 边界见 [配置参考](config.yaml)与 [TUN 平台层](tun-platform.md)。 Windows 编译时选择互斥的 `windows-wintun` 或 `windows-uwp`: diff --git a/docs/tun-platform.md b/docs/tun-platform.md index 48e8206..0b58bbf 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -11,12 +11,13 @@ TunRuntime -> TunRsIo -> 宿主持有的 TUN fd 副本 - iOS/tvOS/macOS:宿主提供 utun 文件描述符,适配器处理四字节 packet-information 头。 - Android:`VpnService` 提供 raw-IP 文件描述符。 - Linux:真实单队列 TUN,使用 `IFF_TUN | IFF_NO_PI`、无 VNET header;接口实际 MTU - 必须与 `tun.mtu` 一致。宿主负责地址、网络命名空间和路由,Vole 不自动接管系统网络。 + 必须与 `tun.mtu` 一致。借用 fd 的地址、网络命名空间和路由由宿主负责。 `tun.file-descriptor` 缺省或 0 时,Linux / macOS 由 core 打开本机 TUN,Android / iOS / tvOS 仍需宿主提供 fd。`tun.device` 控制原生打开的接口名:空值在 Linux 使用 `Vole`, -macOS 自动选择 utun。core 只设置自有接口的 MTU 和自有 fd 的 nonblocking,不配置地址、 -DNS 或路由;Stop 关闭自有句柄,不承诺删除宿主已有接口。创建失败没有备用数据面。 +macOS 自动选择 utun。macOS 只设置自有接口的 MTU 和 nonblocking,网络配置仍由宿主负责。 +Linux 自建接口的自动网络配置见下节;关闭自动路由时只设置 MTU/nonblocking。 +Stop 关闭自有句柄,不承诺删除宿主已有接口。创建失败没有备用数据面。 正数 file-descriptor 选择下述借用路径;fd 数字必须对当前进程有效,CLI 不增加专用 fd 参数。 宿主始终拥有原始文件描述符。Vole 启动时: @@ -115,6 +116,36 @@ Linux 目前不启用 GRO/GSO:`tun-rs` 的 Linux offload 示例通过 builder 设备调用 `recv_multiple` 不会自动合并读取或启用 offload。Vole 保留宿主 MTU/格式与 fd 标志,原生 builder 只打开 raw-IP TUN,不暗中启用 offload 或改变借用接口。 +## Linux 自建 TUN 的系统网络 + +`tun.file-descriptor: 0` 的 Linux 路径支持 `auto-route` 和 `auto-detect-interface`, +两者缺省 true。其他平台缺省 false;借用 fd 路径不修改宿主网络,无论这两个字段的值。 + +- `auto-detect-interface` 从主路由表检测每个地址族的默认物理出口,最多缓存一秒。 + 新建 TCP/UDP socket 绑定该接口,所有协议、DNS、资源下载共用 Dialer;没有出口时失败。 + 已有 socket 不迁移,下一次建链重新检测。回环目标保持本机访问。 +- `auto-route` 独占创建配置命名的 raw-IP TUN,已有同名接口失败。IPv4 使用 + `198.18.0.1/30`,启用 IPv6 时使用 `fdfe:dcba:9876::1/126`。 + 内部路由表 2022、规则优先级 9000/9001 必须空闲;保留主表的非默认路由, + 本机未标记流量经 TUN,转发流量不在本功能范围。Core socket 使用内部 mark 绕过捕获, + 不依靠 source IP 避免递归。配置接口与路由直接通过内核接口完成,不调用 ip 子进程。 +- 自动路由启用,且 `dns.enable: true`、`dns-hijack` 匹配 `198.18.0.2:53` 时, + 通过 systemd-resolved 给新接口设置 DNS `198.18.0.2`、路由域 `~.` 和 default-route。 + 系统须使用 systemd-resolved;调用 `/usr/bin/resolvectl --no-ask-password`, + 每次最多五秒并收回子进程。未启用 DNS 或关闭匹配劫持时保留系统 DNS 行为。 + 不覆盖 `/etc/resolv.conf` 或其他接口设置,也不新增 DNS 配置字段。 +- 启动失败逆序撤销已应用的操作;正常 Stop 先停止业务,再恢复 DNS、规则与路由, + 最后释放自建接口。清理期间额外持有接口 fd,避免接口索引被提前复用。 + 已被外部移除的自有路由视为完成,其他清理错误作为停止错误返回。 + +安装包负责权限:`packaging/linux/post-install` 为 `/usr/bin/vole` 设置 +`CAP_NET_ADMIN`,并把执行权限限制给 `vole` 组。安装器为获授权用户添加组成员关系, +将 `50-vole-resolved.rules` 安装到 `/usr/share/polkit-1/rules.d/`,并依赖 systemd-resolved、 +polkit 与提供 setcap 的包。deb postinst / rpm %post 每次更新二进制后执行该脚本。 +运行时不弹出提权请求,不使用 sudo,也不把 capability 传给 resolvectl 子进程; +后者使用已安装的 [resolve1 授权](https://github.com/systemd/systemd/blob/main/src/resolve/org.freedesktop.resolve1.policy)。 +CLI 的进程启动、后台管理及用户授权仍由调用方负责。 + ## Windows Wintun 桌面 Wintun 使用宿主提供的动态库与原始 IP 适配器,通过同一 `TunIo` 边界进入 @@ -196,8 +227,8 @@ netstack 端点及纯 UDP codec,独立 crate 的通用 UDP endpoint 不在生 ## 物理出口 -- Linux:宿主负责网络命名空间或等效路由隔离,确保被捕获的客户端流量进入 TUN, - Vole 出站绕过 TUN;不能把仅绑定 source IP 当作绕路保证。没有自动路由配置或降级路径。 +- Linux:自建接口使用上述自动出口与内部路由隔离;借用 fd 继续由宿主负责命名空间 + 或等效路由隔离。仅绑定 source IP 不能作为绕路保证。 - Android:每个出站 TCP/UDP socket 在 connect 前调用宿主 protect;失败则当前连接失败关闭。 - Windows WinRT:Provider 为当前会话选择不可变的物理网络绑定;每个地址族只从非 link-local 地址中选择一个源 IP 和接口索引交给 Session Host,同时独立保留物理适配器全部去重的 on-link prefixes(包括 link-local)用于 VPN 路由。普通出站 socket 必须同时绑定源地址和 WinSock 接口索引。 - Windows WinRT 只有配置中显式使用 `127.0.0.0/8` 范围内的 IPv4 字面量或 `::1` 的本地出站可以跳过物理绑定;物理代理服务器的域名解析到任何回环地址都会失败关闭。 diff --git a/packaging/linux/50-vole-resolved.rules b/packaging/linux/50-vole-resolved.rules new file mode 100644 index 0000000..c23f640 --- /dev/null +++ b/packaging/linux/50-vole-resolved.rules @@ -0,0 +1,12 @@ +// Installed authorization for the group allowed to execute the VPN binary. +polkit.addRule(function(action, subject) { + var actions = [ + "org.freedesktop.resolve1.set-dns-servers", + "org.freedesktop.resolve1.set-domains", + "org.freedesktop.resolve1.set-default-route", + "org.freedesktop.resolve1.revert" + ]; + if (subject.isInGroup("vole") && actions.indexOf(action.id) !== -1) { + return polkit.Result.YES; + } +}); diff --git a/packaging/linux/post-install b/packaging/linux/post-install new file mode 100755 index 0000000..4439aa9 --- /dev/null +++ b/packaging/linux/post-install @@ -0,0 +1,7 @@ +#!/bin/sh +# Run from deb postinst or rpm %post after placing the root-owned binary/rule. +set -eu +getent group vole >/dev/null || groupadd --system vole +chown root:vole /usr/bin/vole +chmod 0750 /usr/bin/vole +setcap cap_net_admin=ep /usr/bin/vole diff --git a/src/config/mod.rs b/src/config/mod.rs index e4980ea..901c218 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -172,6 +172,8 @@ pub struct GeoDataUrls { #[derive(Debug, Clone, PartialEq, Eq)] pub struct TunConfig { + pub auto_route: bool, + pub auto_detect_interface: bool, pub enable: bool, pub mtu: u16, pub file_descriptor: i32, @@ -183,6 +185,8 @@ pub struct TunConfig { impl Default for TunConfig { fn default() -> Self { Self { + auto_route: cfg!(target_os = "linux"), + auto_detect_interface: cfg!(target_os = "linux"), enable: false, mtu: default_mtu(), file_descriptor: 0, @@ -1043,6 +1047,10 @@ impl Default for RawGeoDataUrls { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawTunConfig { + #[serde(rename = "auto-route", default)] + auto_route: Option, + #[serde(rename = "auto-detect-interface", default)] + auto_detect_interface: Option, #[serde(default)] enable: bool, #[serde(default)] @@ -1098,6 +1106,10 @@ impl RawTunConfig { self.udp_timeout }); Ok(TunConfig { + auto_route: self.auto_route.unwrap_or(cfg!(target_os = "linux")), + auto_detect_interface: self + .auto_detect_interface + .unwrap_or(cfg!(target_os = "linux")), enable: self.enable, mtu, file_descriptor: self.file_descriptor, @@ -5022,6 +5034,14 @@ authentication: ); } + #[test] + fn linux_network_options_keep_borrowed_descriptor_ownership() { + let config = Config::parse_yaml(b"tun:\n enable: true\n file-descriptor: 9\n auto-route: false\n auto-detect-interface: false\n").unwrap(); + assert_eq!(config.tun.file_descriptor, 9); + assert!(!config.tun.auto_route); + assert!(!config.tun.auto_detect_interface); + } + #[test] fn tun_config_uses_mihomo_defaults_and_zero_sentinels() { for fields in ["", " mtu: 0\n udp-timeout: 0"] { @@ -5072,9 +5092,7 @@ authentication: "dns-hijack: [name.example:53]", "dns-hijack: null", "framing: rawIp", - "auto-route: false", "auto-redirect: false", - "auto-detect-interface: false", "stack: mips", "inet4-address: [198.18.0.1/30]", "inet6-address: ['fd00::1/126']", diff --git a/src/dialer.rs b/src/dialer.rs index 19d3afc..761a494 100644 --- a/src/dialer.rs +++ b/src/dialer.rs @@ -365,6 +365,8 @@ struct InterfaceBinding { #[derive(Clone)] pub struct Dialer { + #[cfg(all(target_os = "linux", feature = "tun"))] + linux_policy: Option>, ip_version: crate::config::IpVersion, initialization: Arc, protector: Option>, @@ -393,6 +395,8 @@ impl std::fmt::Debug for Dialer { impl Default for Dialer { fn default() -> Self { Self { + #[cfg(all(target_os = "linux", feature = "tun"))] + linux_policy: None, ip_version: crate::config::IpVersion::Dual, initialization: Arc::new(SocketInitialization::default()), protector: None, @@ -406,6 +410,15 @@ impl Default for Dialer { } impl Dialer { + #[cfg(all(target_os = "linux", feature = "tun"))] + pub(crate) fn with_linux_policy( + mut self, + policy: Arc, + ) -> Self { + self.linux_policy = Some(policy); + self + } + pub(crate) fn with_ip_version(mut self, version: crate::config::IpVersion) -> Self { self.ip_version = version; self @@ -538,6 +551,10 @@ impl Dialer { socket.set_nonblocking(true)?; check()?; dialer.protect_socket(&socket)?; + #[cfg(all(target_os = "linux", feature = "tun"))] + if let Some(policy) = &dialer.linux_policy { + policy.apply(&socket, destination)?; + } check()?; Ok((std::net::UdpSocket::from(socket), guard)) }) @@ -646,6 +663,10 @@ impl Dialer { let guard = track(ResourceKind::Socket); check()?; dialer.protect_socket(&socket)?; + #[cfg(all(target_os = "linux", feature = "tun"))] + if let Some(policy) = &dialer.linux_policy { + policy.apply(&socket, address)?; + } check()?; #[cfg(all(windows, feature = "windows-uwp"))] dialer.apply_interface_binding(socket.as_raw_socket(), address)?; diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index 61eb9fe..64b1427 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -1312,10 +1312,24 @@ fn run_engine_inner( Err(error) => Err(io::Error::other(error)), }, None if has_tun => { - let config = prepared.tun_config().expect("validated TUN configuration"); - match TunIo::open(&config.device, config.mtu) { - Ok(tun) => prepared.start_tun(tun, dialer).await, - Err(error) => Err(io::Error::other(error)), + #[cfg(target_os = "linux")] + { + prepared.start_native_tun(dialer).await + } + #[cfg(not(target_os = "linux"))] + { + let config = prepared.tun_config().expect("validated TUN configuration"); + if config.auto_route || config.auto_detect_interface { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "automatic TUN networking is only available on Linux", + )) + } else { + match TunIo::open(&config.device, config.mtu) { + Ok(tun) => prepared.start_tun(tun, dialer).await, + Err(error) => Err(io::Error::other(error)), + } + } } } None => prepared.start_local(dialer).await, diff --git a/src/platform/linux_network/mod.rs b/src/platform/linux_network/mod.rs new file mode 100644 index 0000000..984e39f --- /dev/null +++ b/src/platform/linux_network/mod.rs @@ -0,0 +1,128 @@ +//! Transactional system network changes for one core-owned Linux TUN. +use std::io; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Change { + Route { ipv6: bool, index: u32 }, + Rule { ipv6: bool, preserve_local: bool }, + DnsServers, + DnsDomains, + DnsDefaultRoute, +} + +pub(crate) trait Backend { + fn apply(&mut self, change: &Change) -> io::Result<()>; + fn remove(&mut self, change: &Change) -> io::Result<()>; +} + +pub(crate) struct Lease { + backend: B, + applied: Vec, +} +impl Lease { + fn new(backend: B) -> Self { + Self { + backend, + applied: Vec::new(), + } + } + fn apply(&mut self, change: Change) -> io::Result<()> { + self.backend.apply(&change)?; + self.applied.push(change); + Ok(()) + } + pub(crate) fn restore(&mut self) -> io::Result<()> { + let mut error = None; + while let Some(change) = self.applied.pop() { + if let Err(failure) = self.backend.remove(&change) { + error.get_or_insert(failure); + } + } + error.map_or(Ok(()), Err) + } +} +impl Drop for Lease { + fn drop(&mut self) { + if self.restore().is_err() { + tracing::error!("Linux network cleanup failed"); + } + } +} + +#[cfg(target_os = "linux")] +mod native; +#[cfg(target_os = "linux")] +mod netlink; +#[cfg(target_os = "linux")] +pub(crate) use native::{LinuxBackend, SocketPolicy, open}; +#[cfg(target_os = "linux")] +pub(crate) type NetworkLease = Lease; + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + struct Mock { + events: Arc>>, + fail: Option, + } + impl Backend for Mock { + fn apply(&mut self, change: &Change) -> io::Result<()> { + if self.fail.as_ref() == Some(change) { + return Err(io::Error::other("fixture")); + } + self.events.lock().unwrap().push((true, change.clone())); + Ok(()) + } + fn remove(&mut self, change: &Change) -> io::Result<()> { + self.events.lock().unwrap().push((false, change.clone())); + Ok(()) + } + } + #[test] + fn failed_start_and_normal_stop_restore_only_completed_changes_in_reverse() { + let changes = [ + Change::Route { + ipv6: false, + index: 7, + }, + Change::Rule { + ipv6: false, + preserve_local: true, + }, + Change::Rule { + ipv6: false, + preserve_local: false, + }, + Change::DnsServers, + Change::DnsDomains, + Change::DnsDefaultRoute, + ]; + for fail_at in 0..=changes.len() { + let events = Arc::new(Mutex::new(Vec::new())); + { + let mut lease = Lease::new(Mock { + events: events.clone(), + fail: changes.get(fail_at).cloned(), + }); + for change in &changes { + if lease.apply(change.clone()).is_err() { + break; + } + } + if fail_at == changes.len() { + lease.restore().unwrap(); + lease.restore().unwrap(); + } + } + let events = events.lock().unwrap(); + let expected: Vec<_> = changes[..fail_at] + .iter() + .cloned() + .map(|c| (true, c)) + .chain(changes[..fail_at].iter().rev().cloned().map(|c| (false, c))) + .collect(); + assert_eq!(*events, expected); + } + } +} diff --git a/src/platform/linux_network/native.rs b/src/platform/linux_network/native.rs new file mode 100644 index 0000000..4bf7ef4 --- /dev/null +++ b/src/platform/linux_network/native.rs @@ -0,0 +1,257 @@ +use super::{Backend, Change, Lease, NetworkLease, netlink}; +use crate::{config::TunConfig, dialer::Dialer, platform::TunIo}; +use std::{ + ffi::CString, + io, + net::SocketAddr, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, + process::{Command, Stdio}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +struct Interfaces { + refreshed: Instant, + ipv4: Option, + ipv6: Option, +} +pub(crate) struct SocketPolicy { + detect: bool, + mark: bool, + interfaces: Mutex>, +} +impl SocketPolicy { + fn new(detect: bool, mark: bool) -> Self { + Self { + detect, + mark, + interfaces: Mutex::new(None), + } + } + pub(crate) fn apply(&self, socket: &impl AsRawFd, destination: SocketAddr) -> io::Result<()> { + if self.mark { + let value = netlink::MARK; + // SAFETY: socket is live and the option points to a u32 mark. + if unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_MARK, + (&raw const value).cast(), + std::mem::size_of_val(&value) as libc::socklen_t, + ) + } < 0 + { + return Err(io::Error::last_os_error()); + } + } + if !self.detect || destination.ip().is_loopback() { + return Ok(()); + } + let mut state = self + .interfaces + .lock() + .map_err(|_| io::Error::other("Linux egress state unavailable"))?; + if state + .as_ref() + .is_none_or(|state| state.refreshed.elapsed() >= Duration::from_secs(1)) + { + *state = Some(Interfaces { + ipv4: netlink::default_interface(false)?, + ipv6: netlink::default_interface(true)?, + refreshed: Instant::now(), + }); + } + let state = state.as_ref().unwrap(); + let interface = if destination.is_ipv6() { + &state.ipv6 + } else { + &state.ipv4 + }; + let name = CString::new( + interface + .as_ref() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NetworkUnreachable, + "no physical default interface for address family", + ) + })? + .as_str(), + ) + .map_err(|_| io::Error::other("invalid Linux interface"))?; + // SAFETY: name is NUL terminated and socket is still owned by its initializer. + if unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + name.as_ptr().cast(), + name.as_bytes_with_nul().len() as libc::socklen_t, + ) + } < 0 + { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + +pub(crate) struct LinuxBackend { + name: String, + // Keep the newly created interface alive until DNS and routing cleanup ends. + _tun_owner: OwnedFd, + dns_reverted: bool, +} +impl LinuxBackend { + fn resolved(&self, operation: &str, values: &[&str]) -> io::Result<()> { + let mut child = Command::new("/usr/bin/resolvectl") + .arg("--no-ask-password") + .arg(operation) + .arg(&self.name) + .args(values) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|_| { + io::Error::new( + io::ErrorKind::NotFound, + "Linux DNS takeover requires systemd-resolved and resolvectl", + ) + })?; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(status)) => { + return if status.success() { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Linux per-link DNS setup failed; check installed resolve1 authorization", + )) + }; + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)) + } + result => { + let _ = child.kill(); + let _ = child.wait(); + return Err(result.err().unwrap_or_else(|| { + io::Error::new(io::ErrorKind::TimedOut, "Linux DNS configuration timed out") + })); + } + } + } + } +} +impl Backend for LinuxBackend { + fn apply(&mut self, change: &Change) -> io::Result<()> { + match change { + Change::DnsServers => self.resolved("dns", &["198.18.0.2"]), + Change::DnsDomains => self.resolved("domain", &["~."]), + Change::DnsDefaultRoute => self.resolved("default-route", &["yes"]), + other => netlink::change(other, true), + } + } + fn remove(&mut self, change: &Change) -> io::Result<()> { + match change { + Change::DnsServers | Change::DnsDomains | Change::DnsDefaultRoute => { + if self.dns_reverted { + return Ok(()); + } + self.resolved("revert", &[])?; + self.dns_reverted = true; + Ok(()) + } + other => match netlink::change(other, false) { + Err(error) + if matches!( + error.raw_os_error(), + Some(libc::ENOENT | libc::ESRCH | libc::ENODEV) + ) => + { + Ok(()) + } + result => result, + }, + } + } +} + +pub(crate) fn open( + config: &TunConfig, + dns_enabled: bool, + ipv6: bool, + dialer: Dialer, +) -> io::Result<(TunIo, Dialer, Option)> { + if !config.auto_route && !config.auto_detect_interface { + return Ok(( + TunIo::open(&config.device, config.mtu).map_err(io::Error::other)?, + dialer, + None, + )); + } + let dialer = dialer.with_linux_policy(Arc::new(SocketPolicy::new( + config.auto_detect_interface, + config.auto_route, + ))); + if !config.auto_route { + return Ok(( + TunIo::open(&config.device, config.mtu).map_err(io::Error::other)?, + dialer, + None, + )); + } + for family in [false, true].into_iter().take(if ipv6 { 2 } else { 1 }) { + netlink::ensure_available(family)?; + } + let tun = TunIo::open_exclusive(&config.device, config.mtu).map_err(io::Error::other)?; + let device = tun.linux_device(); + let name = device.name()?; + let c_name = + CString::new(name.as_str()).map_err(|_| io::Error::other("invalid Linux TUN name"))?; + // SAFETY: name is a valid terminated device name. + let index = unsafe { libc::if_nametoindex(c_name.as_ptr()) }; + if index == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: duplicate preserves ownership while cleanup runs after the reader exits. + let owner = unsafe { libc::fcntl(device.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 0) }; + if owner < 0 { + return Err(io::Error::last_os_error()); + } + let owner = unsafe { OwnedFd::from_raw_fd(owner) }; + let mut lease = Lease::new(LinuxBackend { + name, + _tun_owner: owner, + dns_reverted: false, + }); + device.set_network_address("198.18.0.1", 30, None::<&str>)?; + if ipv6 { + device.add_address_v6("fdfe:dcba:9876::1", 126)?; + } + device.enabled(true)?; + for family in [false, true].into_iter().take(if ipv6 { 2 } else { 1 }) { + lease.apply(Change::Route { + ipv6: family, + index, + })?; + lease.apply(Change::Rule { + ipv6: family, + preserve_local: true, + })?; + lease.apply(Change::Rule { + ipv6: family, + preserve_local: false, + })?; + } + if dns_enabled && config.hijacks_dns("198.18.0.2:53".parse().unwrap()) { + lease.apply(Change::DnsServers)?; + lease.apply(Change::DnsDomains)?; + lease.apply(Change::DnsDefaultRoute)?; + } + Ok((tun, dialer, Some(lease))) +} diff --git a/src/platform/linux_network/netlink.rs b/src/platform/linux_network/netlink.rs new file mode 100644 index 0000000..0f10dd8 --- /dev/null +++ b/src/platform/linux_network/netlink.rs @@ -0,0 +1,316 @@ +//! Minimal bounded rtnetlink requests. Constants and layouts follow Linux UAPI. +use super::Change; +use std::{ + ffi::CStr, + io, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, +}; +pub(super) const TABLE: u32 = 2022; +pub(super) const PRIORITY: u32 = 9000; +pub(super) const MARK: u32 = 0x564f4c45; +const REQUEST: u16 = 1; +const ACK: u16 = 4; +const CREATE_EXCL: u16 = 0x600; +const DUMP: u16 = 0x300; +const GET_ROUTE: u16 = 26; +const NEW_ROUTE: u16 = 24; +const DEL_ROUTE: u16 = 25; +const GET_RULE: u16 = 34; +const NEW_RULE: u16 = 32; +const DEL_RULE: u16 = 33; + +fn invalid() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid Linux route response") +} +fn u32_at(bytes: &[u8]) -> io::Result { + Ok(u32::from_ne_bytes(bytes.try_into().map_err(|_| invalid())?)) +} +fn attr(out: &mut Vec, kind: u16, value: &[u8]) { + out.extend_from_slice(&((4 + value.len()) as u16).to_ne_bytes()); + out.extend_from_slice(&kind.to_ne_bytes()); + out.extend_from_slice(value); + out.resize((out.len() + 3) & !3, 0); +} +fn attribute(mut bytes: &[u8], kind: u16) -> io::Result> { + let mut found = None; + while !bytes.is_empty() { + if bytes.len() < 4 { + return Err(invalid()); + } + let length = u16::from_ne_bytes([bytes[0], bytes[1]]) as usize; + let aligned = (length + 3) & !3; + if length < 4 || aligned > bytes.len() { + return Err(invalid()); + } + if u16::from_ne_bytes([bytes[2], bytes[3]]) & 0x3fff == kind { + if found.is_some() { + return Err(invalid()); + } + found = Some(&bytes[4..length]); + } + bytes = &bytes[aligned..]; + } + Ok(found) +} +fn property(bytes: &[u8], kind: u16, default: u32) -> io::Result { + attribute(bytes, kind)?.map_or(Ok(default), u32_at) +} + +fn exchange(kind: u16, flags: u16, payload: &[u8]) -> io::Result>> { + // SAFETY: successful socket returns a fresh owned descriptor. + let raw = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_RAW | libc::SOCK_CLOEXEC, + libc::NETLINK_ROUTE, + ) + }; + if raw < 0 { + return Err(io::Error::last_os_error()); + } + let fd = unsafe { OwnedFd::from_raw_fd(raw) }; + let limit = libc::timeval { + tv_sec: 1, + tv_usec: 0, + }; + for option in [libc::SO_SNDTIMEO, libc::SO_RCVTIMEO] { + // SAFETY: limit is a valid timeval and fd remains owned. + if unsafe { + libc::setsockopt( + fd.as_raw_fd(), + libc::SOL_SOCKET, + option, + (&raw const limit).cast(), + std::mem::size_of_val(&limit) as libc::socklen_t, + ) + } < 0 + { + return Err(io::Error::last_os_error()); + } + } + let mut request = vec![0; 16]; + request[4..6].copy_from_slice(&kind.to_ne_bytes()); + request[6..8].copy_from_slice(&(flags | REQUEST).to_ne_bytes()); + request[8..12].copy_from_slice(&1u32.to_ne_bytes()); + request.extend_from_slice(payload); + let length = request.len() as u32; + request[..4].copy_from_slice(&length.to_ne_bytes()); + // SAFETY: kernel pid/groups zero is the rtnetlink destination. + let mut address: libc::sockaddr_nl = unsafe { std::mem::zeroed() }; + address.nl_family = libc::AF_NETLINK as u16; + let sent = unsafe { + libc::sendto( + fd.as_raw_fd(), + request.as_ptr().cast(), + request.len(), + 0, + (&raw const address).cast(), + std::mem::size_of_val(&address) as libc::socklen_t, + ) + }; + if sent < 0 { + return Err(io::Error::last_os_error()); + } + if sent as usize != request.len() { + return Err(invalid()); + } + let mut messages = Vec::new(); + let mut total = 0usize; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + if std::time::Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Linux route query timed out", + )); + } + let mut buffer = [0u8; 65536]; + let mut size = std::mem::size_of_val(&address) as libc::socklen_t; + // SAFETY: buffers are writable for the lengths passed; MSG_TRUNC detects oversized datagrams. + let received = unsafe { + libc::recvfrom( + fd.as_raw_fd(), + buffer.as_mut_ptr().cast(), + buffer.len(), + libc::MSG_TRUNC, + (&raw mut address).cast(), + &mut size, + ) + }; + if received < 0 { + return Err(io::Error::last_os_error()); + } + if received == 0 + || received as usize > buffer.len() + || address.nl_pid != 0 + || address.nl_groups != 0 + || size as usize != std::mem::size_of_val(&address) + { + return Err(invalid()); + } + total += received as usize; + if total > 4 * 1024 * 1024 { + return Err(invalid()); + } + let mut bytes = &buffer[..received as usize]; + while !bytes.is_empty() { + if bytes.len() < 16 { + return Err(invalid()); + } + let len = u32_at(&bytes[..4])? as usize; + let aligned = (len + 3) & !3; + if len < 16 || aligned > bytes.len() || u32_at(&bytes[8..12])? != 1 { + return Err(invalid()); + } + let kind = u16::from_ne_bytes([bytes[4], bytes[5]]); + let flags = u16::from_ne_bytes([bytes[6], bytes[7]]); + let payload = &bytes[16..len]; + if flags & 0x10 != 0 { + return Err(io::Error::new( + io::ErrorKind::Interrupted, + "Linux route dump changed", + )); + } + match kind { + 2 => { + let code = i32::from_ne_bytes( + payload + .get(..4) + .ok_or_else(invalid)? + .try_into() + .map_err(|_| invalid())?, + ); + if code < 0 && code != i32::MIN { + return Err(io::Error::from_raw_os_error(-code)); + } + if code != 0 { + return Err(invalid()); + } + return Ok(messages); + } + 3 => { + if payload.len() >= 4 && u32_at(&payload[..4])? != 0 { + return Err(invalid()); + } + return Ok(messages); + } + _ => messages.push(payload.to_vec()), + } + bytes = &bytes[aligned..]; + } + } +} +fn dump(kind: u16, ipv6: bool) -> io::Result>> { + let mut header = [0; 12]; + header[0] = if ipv6 { 10 } else { 2 }; + exchange(kind, DUMP, &header) +} + +pub(super) fn default_interface(ipv6: bool) -> io::Result> { + let mut selected: Option<(u32, u32)> = None; + for route in dump(GET_ROUTE, ipv6)? { + if route.len() < 12 { + return Err(invalid()); + } + if route[1] != 0 || route[7] != 1 || property(&route[12..], 15, u32::from(route[4]))? != 254 + { + continue; + } + let index = property(&route[12..], 4, 0)?; + let metric = property(&route[12..], 6, 0)?; + if index > 0 && selected.is_none_or(|(previous, _)| metric < previous) { + selected = Some((metric, index)); + } + } + selected + .map(|(_, index)| { + let mut name = [0 as libc::c_char; libc::IF_NAMESIZE]; + // SAFETY: name has IF_NAMESIZE writable bytes; successful output is NUL terminated. + if unsafe { libc::if_indextoname(index, name.as_mut_ptr()) }.is_null() { + return Err(io::Error::last_os_error()); + } + Ok(unsafe { CStr::from_ptr(name.as_ptr()) } + .to_string_lossy() + .into_owned()) + }) + .transpose() +} +pub(super) fn ensure_available(ipv6: bool) -> io::Result<()> { + for route in dump(GET_ROUTE, ipv6)? { + if route.len() < 12 { + return Err(invalid()); + } + if property(&route[12..], 15, u32::from(route[4]))? == TABLE { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "Linux TUN routing table is occupied", + )); + } + } + for rule in dump(GET_RULE, ipv6)? { + if rule.len() < 12 { + return Err(invalid()); + } + let priority = property(&rule[12..], 6, 0)?; + if (PRIORITY..=PRIORITY + 1).contains(&priority) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "Linux TUN rule priority is occupied", + )); + } + } + Ok(()) +} +pub(super) fn change(change: &Change, add: bool) -> io::Result<()> { + let mut payload = vec![0; 12]; + let kind = match change { + Change::Route { ipv6, index } => { + payload[0] = if *ipv6 { 10 } else { 2 }; + payload[5] = 4; + payload[7] = 1; // static unicast + attr(&mut payload, 15, &TABLE.to_ne_bytes()); + attr(&mut payload, 4, &index.to_ne_bytes()); + if add { NEW_ROUTE } else { DEL_ROUTE } + } + Change::Rule { + ipv6, + preserve_local, + } => { + payload[0] = if *ipv6 { 10 } else { 2 }; + payload[7] = 1; // FR_ACT_TO_TBL + if *preserve_local { + payload[4] = 254; + attr(&mut payload, 14, &0u32.to_ne_bytes()); // suppress default route only + } else { + attr(&mut payload, 3, b"lo\0"); // locally generated traffic only + attr(&mut payload, 10, &0u32.to_ne_bytes()); + attr(&mut payload, 16, &u32::MAX.to_ne_bytes()); + attr(&mut payload, 15, &TABLE.to_ne_bytes()); + } + attr( + &mut payload, + 6, + &(PRIORITY + u32::from(!preserve_local)).to_ne_bytes(), + ); + if add { NEW_RULE } else { DEL_RULE } + } + _ => return Err(invalid()), + }; + exchange(kind, ACK | if add { CREATE_EXCL } else { 0 }, &payload).map(|_| ()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn route_attributes_preserve_native_endian_and_padding() { + let mut bytes = Vec::new(); + attr(&mut bytes, 15, &TABLE.to_ne_bytes()); + attr(&mut bytes, 3, b"lo\0"); + assert_eq!(property(&bytes, 15, 0).unwrap(), TABLE); + assert_eq!(attribute(&bytes, 3).unwrap(), Some(b"lo\0".as_slice())); + for len in [1, 3, 7, 9] { + assert!(attribute(&bytes[..len], 15).is_err()); + } + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index cf3de2a..1c5dafd 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,9 @@ any(target_os = "ios", target_os = "tvos", target_os = "macos") ))] pub(crate) mod apple_logging; +#[cfg(all(feature = "tun", any(target_os = "linux", test)))] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +pub(crate) mod linux_network; #[cfg(all(target_os = "linux", feature = "tun"))] mod linux_tun; #[cfg(all( diff --git a/src/platform/tun_rs_io.rs b/src/platform/tun_rs_io.rs index 76f9416..a410ac9 100644 --- a/src/platform/tun_rs_io.rs +++ b/src/platform/tun_rs_io.rs @@ -58,6 +58,44 @@ impl TunRsIo { }) } + #[cfg(target_os = "linux")] + pub(crate) fn linux_device(&self) -> &tun_rs::SyncDevice { + self.device.get_ref() + } + + #[cfg(target_os = "linux")] + pub(crate) fn open_exclusive(name: &str, mtu: u16) -> Result { + use std::{ffi::CString, os::fd::AsRawFd}; + let name = CString::new(if name.is_empty() { "Vole" } else { name }) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid TUN name"))?; + if name.as_bytes().len() >= libc::IFNAMSIZ { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "TUN name is too long").into()); + } + let fd = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open("/dev/net/tun")?; + // SAFETY: ifreq starts zeroed; copied name includes its terminator. + let mut request: libc::ifreq = unsafe { std::mem::zeroed() }; + for (slot, byte) in request.ifr_name.iter_mut().zip(name.as_bytes_with_nul()) { + *slot = *byte as libc::c_char; + } + request.ifr_ifru.ifru_flags = (libc::IFF_TUN | libc::IFF_NO_PI | 0x8000) as libc::c_short; // IFF_TUN_EXCL + // SAFETY: fd points to /dev/net/tun and request is a complete initialized ifreq. + if unsafe { libc::ioctl(fd.as_raw_fd(), libc::TUNSETIFF, &request) } < 0 { + return Err(io::Error::last_os_error().into()); + } + // SAFETY: ownership moves to SyncDevice only after the exclusive ioctl succeeded. + let device = unsafe { tun_rs::SyncDevice::from_fd(fd.into_raw_fd()) }?; + device.set_mtu(mtu)?; + device.set_nonblocking(true)?; + Ok(Self { + device: AsyncFd::new(device)?, + framing: TunFraming::RawIp, + mtu: usize::from(mtu), + }) + } + /// Open a core-owned local TUN without configuring addresses or routes. /// Owned descriptors may be made nonblocking; borrowed ones are never changed. pub fn open(name: &str, mtu: u16) -> Result { diff --git a/src/runtime.rs b/src/runtime.rs index f2bcea5..4ce0138 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -535,6 +535,28 @@ impl PreparedCore { .await } + #[cfg(all(feature = "tun", target_os = "linux"))] + pub(crate) async fn start_native_tun(self, dialer: Dialer) -> io::Result { + let (tun, dialer, mut network) = crate::platform::linux_network::open( + &self.config.tun, + self.config.dns.enable, + self.config.ipv6, + dialer, + )?; + match self.start_tun(tun, dialer).await { + Ok(mut running) => { + running.system_network = network; + Ok(running) + } + Err(error) => { + if let Some(network) = &mut network { + network.restore()?; + } + Err(error) + } + } + } + #[cfg(all( feature = "tun", any( @@ -1364,6 +1386,8 @@ fn prepare_routing( } pub(crate) struct RunningCore { + #[cfg(all(feature = "tun", target_os = "linux"))] + system_network: Option, cancellation: CancellationToken, tasks: Vec>>, traffic_stats: Option>, @@ -1482,6 +1506,8 @@ impl RunningCore { )); } Ok(Self { + #[cfg(all(feature = "tun", target_os = "linux"))] + system_network: None, resources, cancellation, tasks, @@ -1522,6 +1548,12 @@ impl RunningCore { if let Some(resources) = &self.resources { resources.shutdown().await; } + #[cfg(all(feature = "tun", target_os = "linux"))] + let result = if let Some(mut network) = self.system_network.take() { + result.and(network.restore()) + } else { + result + }; result } } diff --git a/tests/README.md b/tests/README.md index 49af322..380fe64 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,6 +15,7 @@ cargo test --locked --lib config::tests:: cargo test --locked --lib geodata::tests:: cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: +cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From c5f3fea303d6b44482dfb9225f25e15674ddb7c3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:02:48 +0800 Subject: [PATCH 009/445] feat(dns): add fake-IP pools and persistent ingress mappings Support filtered synthetic A/AAAA answers, restart-safe disk mappings, domain recovery and distinct UDP reply paths. Update native Linux DNS gateway derivation from the configured pool.\n\nValidation: three memory regressions, 91 config cases, all-target compilation, all-feature clippy, TLS dependency audit and isolated Linux CLI compilation. --- Cargo.lock | 10 + Cargo.toml | 1 + docs/config.yaml | 12 +- docs/tun-icmp-dns.md | 30 ++- docs/tun-platform.md | 6 +- src/config/dns.rs | 5 +- src/config/fake_ip.rs | 138 +++++++++++ src/config/groups.rs | 2 + src/config/mod.rs | 13 + src/config/providers.rs | 42 +++- src/dns/fake_ip.rs | 351 +++++++++++++++++++++++++++ src/dns/mod.rs | 1 + src/dns/runtime.rs | 151 ++++++++++++ src/geodata/mod.rs | 16 +- src/platform/linux_network/native.rs | 29 ++- src/routing/dispatcher.rs | 251 ++++++++++++++++++- src/runtime.rs | 17 +- src/runtime/providers.rs | 6 + tests/README.md | 1 + 19 files changed, 1048 insertions(+), 34 deletions(-) create mode 100644 src/config/fake_ip.rs create mode 100644 src/dns/fake_ip.rs diff --git a/Cargo.lock b/Cargo.lock index de3d454..66c81f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1792,6 +1792,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redb" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb338a6c67830a61bed824b78c2bb034ab1ff401a20616bd7957524f4fdc2f22" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2662,6 +2671,7 @@ dependencies = [ "quinn-proto", "rand", "rcgen", + "redb", "regex", "regex-syntax", "ring", diff --git a/Cargo.toml b/Cargo.toml index 2bb962c..8ce61e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -113,6 +113,7 @@ quinn = { version = "=0.11.12", default-features = false, features = ["runtime-t quinn-proto = { version = "=0.11.18", default-features = false, features = ["rustls-ring"], optional = true } rand = "0.10" ring = { version = "0.17.14", optional = true } +redb = "=4.3.0" regex = "1.13" regex-syntax = { version = "0.8", default-features = false, features = ["std", "unicode-case"] } rustls = { version = "=0.23.45", default-features = false, features = ["ring", "std", "tls12"] } diff --git a/docs/config.yaml b/docs/config.yaml index 6765a3b..666514c 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -27,6 +27,7 @@ log-level: info unified-delay: false profile: store-selected: true + store-fake-ip: false # true:dataDir/fake-ip.redb 持久化双向映射与分配位置 # 资源下载使用 global-ua(缺省 Vole/ 加 Core 版本);etag-support 缺省 true。 # GeoData 字段独立可选;URL 缺省 MetaCubeX meta-rules-dat latest DAT。 @@ -531,7 +532,16 @@ hosts: dns: enable: true ipv6: true - enhanced-mode: redir-host + enhanced-mode: redir-host # 或 fake-ip;内部节点/直连解析始终取得真实地址 + fake-ip-range: 198.18.0.1/16 + fake-ip-range6: "" # 设置 IPv6 CIDR 且启用 IPv6 后分配 AAAA + fake-ip-ttl: 1 # 秒,最小 1;不决定映射寿命 + fake-ip-filter-mode: blacklist # blacklist / whitelist / rule + fake-ip-filter: [dns.msftnsci.com, www.msftnsci.com, www.msftconnecttest.com] + # blacklist:命中返回真实地址;whitelist:只有命中才分配 fake-IP。 + # 普通过滤项支持 Clash 域名通配、geosite: 和 rule-set:。 + # rule:使用 DOMAIN-SUFFIX,example.com,real-ip 等域名规则;动作 real-ip/fake-ip。 + # rule 未命中分配 fake-IP;两种地址池均保留网络地址后的前三个地址及广播地址。 use-hosts: true use-system-hosts: true respect-rules: false diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index c962861..660d59a 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -50,7 +50,29 @@ UDP 读取器只做有界 wire 分类和任务提交,不同步等待上游。 SVCB、HTTPS、TXT、PTR、MX、SRV、NS、SOA、DNSSEC 和未知 16 位 qtype 都可按原始查询转发。A/AAAA 响应中的可达 CNAME 链继续执行严格地址和 TTL 校验。 -TUN 域名提示存储只在启用 TUN 且业务规则包含域名类规则时创建。嗅探域名优先于 DNS 提示,两者都不改写实际目标。 +redir-host 模式的 TUN 域名提示存储只在启用 TUN 且业务规则包含域名类规则时创建。嗅探域名优先于 DNS 提示,两者都不改写实际目标。 + +## Fake-IP + +`enhanced-mode: fake-ip` 在 TUN DNS 回答 A/AAAA 时分配合成地址;hosts 优先。 +TCP 和 UDP 的 TUN / mixed 入口恢复域名后选路,节点、DIRECT 与必须 IP 的出站通过内部解析器取得真实地址。 +SVCB/HTTPS 在选择 fake-IP 时返回空回答;过滤到真实地址的查询仍转发。 + +IPv4 池默认 `198.18.0.1/16`,IPv6 池默认关闭。每个池从网络地址 +4 开始分配, +排除广播地址并循环复用;没有配置相应地址池时返回 NODATA。 +`fake-ip-ttl` 最小为一秒,只控制 DNS 回答 TTL。内存模式每个地址族最多保留 1,000 项, +按使用情况淘汰。未知或已淘汰的合成地址不能作为真实目标拨号。 + +`fake-ip-filter-mode` 支持 blacklist、whitelist 和 rule。前两种使用域名通配、GeoSite 或 +域名规则集合;rule 按声明顺序匹配域名规则并使用 `real-ip` / `fake-ip` 动作,未匹配时分配。 +默认 blacklist 只包含三个 Microsoft 连通性探测域名,详见配置示例。 + +`profile.store-fake-ip: true` 将双向映射和分配位置写入 `dataDir/fake-ip.redb`, +写入完成后才返回地址。磁盘存储的缓存有界,映射不受内存模式的 1,000 项限制。 +相同地址池在重启后保留系统 DNS 缓存中的旧地址;修改池范围时清除对应地址族映射。 +资源更新共享当前地址池,过滤器可独立更新。关闭持久化后重启不保证旧映射有效。 +活动 UDP 流保留首包选择和真实目标,并使用独立传输区分解析到相同真实端点的不同名称; +回复恢复原合成地址,空闲和停止时释放。 ## Nameserver 与出口 @@ -73,7 +95,7 @@ dns: - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 - 上游域名由 `default-nameserver` 引导解析,后者只允许 IP 上游。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。显式 system/DHCP nameserver 尚未实现。 - 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 -- Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或静态 `select` 组名。 +- Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或支持的代理组名。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 - 规则结果为 `REJECT` 时,当前尝试失败并继续当前组下一项。 @@ -91,7 +113,7 @@ dns: `code`、`code@attr@attr` 或前置 `!` 的反选,属性为 key 存在性 AND。与业务规则共享分类和记录, `@!cn` 是字面属性 key,不是属性排除; - GeoSite 项隔开域名区块,区块与 GeoSite 项按声明顺序匹配;重复 GeoSite selector 也按此顺序处理; -- value 接受单个 nameserver 字符串或非空数组;`rule-set:` 依赖尚未支持的 rule-providers; +- value 接受单个 nameserver 字符串或非空数组;`rule-set:` 引用 domain/classical 集合,classical 仅使用域名条件; - 命中后只在所选列表内并发查询,列表耗尽不查询其他 policy 或主组; - GeoSite 资产或分类不可用时该 policy 不命中,不能因反选变成全匹配; - 存在分类但属性筛选为空时仍可用,正选不命中、反选匹配合法非空名称。 @@ -204,7 +226,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; - DoQ、DoH HTTP/2/3、显式 system/DHCP nameserver; -- 代理组 provider/health check/自动 failover、DNS fallback group、fake IP、动态 hosts: lan; +- DNS fallback group、动态 hosts: lan; - 用户可配置的缓存容量、超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/docs/tun-platform.md b/docs/tun-platform.md index 0b58bbf..90c1b7b 100644 --- a/docs/tun-platform.md +++ b/docs/tun-platform.md @@ -125,12 +125,12 @@ MTU/格式与 fd 标志,原生 builder 只打开 raw-IP TUN,不暗中启用 新建 TCP/UDP socket 绑定该接口,所有协议、DNS、资源下载共用 Dialer;没有出口时失败。 已有 socket 不迁移,下一次建链重新检测。回环目标保持本机访问。 - `auto-route` 独占创建配置命名的 raw-IP TUN,已有同名接口失败。IPv4 使用 - `198.18.0.1/30`,启用 IPv6 时使用 `fdfe:dcba:9876::1/126`。 + `dns.fake-ip-range` 的原始 IPv4 地址(默认 `198.18.0.1`),前缀固定 /30;启用 IPv6 时使用 `fdfe:dcba:9876::1/126`。 内部路由表 2022、规则优先级 9000/9001 必须空闲;保留主表的非默认路由, 本机未标记流量经 TUN,转发流量不在本功能范围。Core socket 使用内部 mark 绕过捕获, 不依靠 source IP 避免递归。配置接口与路由直接通过内核接口完成,不调用 ip 子进程。 -- 自动路由启用,且 `dns.enable: true`、`dns-hijack` 匹配 `198.18.0.2:53` 时, - 通过 systemd-resolved 给新接口设置 DNS `198.18.0.2`、路由域 `~.` 和 default-route。 +- 自动路由启用,且 `dns.enable: true`、`dns-hijack` 匹配接口 IPv4 下一地址的 53 端口时, + 通过 systemd-resolved 给新接口设置 DNS(默认 `198.18.0.2`)、路由域 `~.` 和 default-route。 系统须使用 systemd-resolved;调用 `/usr/bin/resolvectl --no-ask-password`, 每次最多五秒并收回子进程。未启用 DNS 或关闭匹配劫持时保留系统 DNS 行为。 不覆盖 `/etc/resolv.conf` 或其他接口设置,也不新增 DNS 配置字段。 diff --git a/src/config/dns.rs b/src/config/dns.rs index e46ebb2..161ab19 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -30,9 +30,7 @@ impl DnsConfig { impl RawDnsConfig { pub(super) fn normalize(self, targets: &RouteTargetsByName) -> Result { - if self.enhanced_mode != "redir-host" { - return invalid("dns.enhanced-mode currently supports redir-host"); - } + let fake_ip = self.fake_ip()?; if self.enable && self.nameserver.is_empty() { return invalid("enabled DNS requires nameserver"); } @@ -65,6 +63,7 @@ impl RawDnsConfig { return invalid("default-nameserver requires literal IP addresses"); } Ok(DnsConfig { + fake_ip, enable: self.enable, ipv6: self.ipv6, nameservers: parse(self.nameserver, main_route)?, diff --git a/src/config/fake_ip.rs b/src/config/fake_ip.rs new file mode 100644 index 0000000..af474d9 --- /dev/null +++ b/src/config/fake_ip.rs @@ -0,0 +1,138 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum FakeIpFilterMode { + #[default] + Blacklist, + Whitelist, + Rule, +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FakeIpConfig { + pub enabled: bool, + pub range: Option, + pub range6: Option, + pub ttl: u32, + pub mode: FakeIpFilterMode, + pub filter: Vec, + pub rules: Vec, +} +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FakeIpRange { + pub cidr: IpCidr, + pub interface_address: IpAddr, +} +impl FakeIpRange { + pub fn parse(value: &str, ipv6: bool) -> Result> { + if value.is_empty() { + return Ok(None); + } + let cidr = parse_ip_cidr(value, ipv6)?; + if cidr.prefix_len > if ipv6 { 125 } else { 29 } { + return invalid("fake-IP range must leave at least three allocatable addresses"); + } + let interface_address = value + .split('/') + .next() + .unwrap() + .parse() + .map_err(|_| VoleError::InvalidConfig("invalid fake-IP prefix".into()))?; + Ok(Some(Self { + cidr, + interface_address, + })) + } + pub(crate) fn bounds(self) -> (u128, u128) { + let (base, width) = match self.cidr.network { + IpAddr::V4(ip) => (u128::from(u32::from(ip)), 32), + IpAddr::V6(ip) => (u128::from(ip), 128), + }; + let host_bits = width - self.cidr.prefix_len; + let mask = if host_bits == 128 { + u128::MAX + } else { + (1u128 << host_bits) - 1 + }; + (base, base | mask) + } + pub(crate) fn address(self, value: u128) -> IpAddr { + if self.cidr.network.is_ipv4() { + Ipv4Addr::from(value as u32).into() + } else { + Ipv6Addr::from(value).into() + } + } +} +pub(super) fn default_fake_range() -> String { + "198.18.0.1/16".into() +} +pub(super) fn default_fake_ttl() -> i64 { + 1 +} +pub(super) fn default_fake_filter() -> Vec { + [ + "dns.msftnsci.com", + "www.msftnsci.com", + "www.msftconnecttest.com", + ] + .map(str::to_owned) + .to_vec() +} +impl RawDnsConfig { + pub(super) fn fake_ip(&self) -> Result { + let enabled = match self.enhanced_mode.as_str() { + "redir-host" => false, + "fake-ip" => true, + _ => return invalid("dns.enhanced-mode must be redir-host or fake-ip"), + }; + let range = FakeIpRange::parse(&self.fake_ip_range, false)?; + let range6 = FakeIpRange::parse(&self.fake_ip_range6, true)?; + if enabled && range.is_none() && range6.is_none() { + return invalid("fake-ip requires at least one range"); + } + let mut filter = Vec::new(); + let mut rules = Vec::new(); + if self.fake_ip_filter_mode == FakeIpFilterMode::Rule { + for value in &self.fake_ip_filter { + let (predicate, action) = value.rsplit_once(',').ok_or_else(|| { + VoleError::InvalidConfig("fake-IP rule requires an action".into()) + })?; + let action = match action.trim().to_ascii_lowercase().as_str() { + "real-ip" => "DIRECT", + "fake-ip" => "REJECT", + _ => return invalid("fake-IP rule action must be real-ip or fake-ip"), + }; + let rule = + parse_rule(&format!("{predicate},{action}"), &RouteTargetsByName::new())?; + if !matches!( + rule.kind, + RuleKind::Domain(_) + | RuleKind::DomainSuffix(_) + | RuleKind::DomainKeyword(_) + | RuleKind::DomainWildcard(_) + | RuleKind::DomainRegex(_) + | RuleKind::GeoSite(_) + | RuleKind::RuleSet { .. } + | RuleKind::Match + ) { + return invalid("fake-IP filter rules must match domains"); + } + rules.push(rule); + } + } else { + for value in &self.fake_ip_filter { + filter.push(normalize_dns_policy_selector(value)?); + } + } + Ok(FakeIpConfig { + enabled, + range, + range6, + ttl: self.fake_ip_ttl.clamp(1, u32::MAX.into()) as u32, + mode: self.fake_ip_filter_mode, + filter, + rules, + }) + } +} diff --git a/src/config/groups.rs b/src/config/groups.rs index 838cec8..d02750b 100644 --- a/src/config/groups.rs +++ b/src/config/groups.rs @@ -20,12 +20,14 @@ impl GroupKind { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields, rename_all = "kebab-case")] pub struct ProfileConfig { + pub store_fake_ip: bool, pub store_selected: bool, } impl Default for ProfileConfig { fn default() -> Self { Self { store_selected: true, + store_fake_ip: false, } } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 901c218..9581c7e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,6 @@ //! Strict parsing for the current Vole YAML configuration. +mod fake_ip; +pub use fake_ip::{FakeIpConfig, FakeIpFilterMode, FakeIpRange}; mod http_proxy; mod ip_version; pub use http_proxy::HttpOutboundConfig; @@ -245,6 +247,7 @@ impl SnifferConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DnsConfig { + pub fake_ip: FakeIpConfig, pub enable: bool, pub ipv6: bool, pub nameservers: Vec, @@ -1168,6 +1171,16 @@ enum RawSnifferPort { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawDnsConfig { + #[serde(rename = "fake-ip-range", default = "fake_ip::default_fake_range")] + fake_ip_range: String, + #[serde(rename = "fake-ip-range6", default)] + fake_ip_range6: String, + #[serde(rename = "fake-ip-filter", default = "fake_ip::default_fake_filter")] + fake_ip_filter: Vec, + #[serde(rename = "fake-ip-filter-mode", default)] + fake_ip_filter_mode: FakeIpFilterMode, + #[serde(rename = "fake-ip-ttl", default = "fake_ip::default_fake_ttl")] + fake_ip_ttl: i64, #[serde(default)] enable: bool, #[serde(default)] diff --git a/src/config/providers.rs b/src/config/providers.rs index bc1e232..ed73c47 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -396,29 +396,49 @@ impl ProviderTemplate { sets.insert(name.clone(), rules.clone()); } bind_rules(&mut config.rules, &sets)?; + bind_rules(&mut config.dns.fake_ip.rules, &sets)?; + for rule in &config.dns.fake_ip.rules { + if let RuleKind::RuleSet { + content: Some(set), .. + } = &rule.kind + && set.behavior() == RuleBehavior::Ipcidr + { + return invalid("fake-IP filter requires a domain or classical rule provider"); + } + } + for selector in &mut config.dns.fake_ip.filter { + bind_domain_selector(selector, &sets)?; + } for policy in config .dns .nameserver_policies .iter_mut() .chain(&mut config.dns.proxy_policies) { - if let DnsPolicySelector::RuleSets { names, sets: bound } = &mut policy.selector { - for name in names.iter() { - let set = sets.get(name).ok_or_else(|| { - VoleError::InvalidConfig(format!("unknown rule provider `{name}`")) - })?; - if set.behavior() == RuleBehavior::Ipcidr { - return invalid("DNS policy requires a domain or classical rule provider"); - } - bound.push(set.clone()); - } - } + bind_domain_selector(&mut policy.selector, &sets)?; } config.provider_members = providers; config.providers = Some(self.clone()); Ok(config) } } +fn bind_domain_selector( + selector: &mut DnsPolicySelector, + sets: &HashMap>, +) -> Result<()> { + if let DnsPolicySelector::RuleSets { names, sets: bound } = selector { + for name in names.iter() { + let set = sets.get(name).ok_or_else(|| { + VoleError::InvalidConfig(format!("unknown rule provider `{name}`")) + })?; + if set.behavior() == RuleBehavior::Ipcidr { + return invalid("domain selector requires a domain or classical rule provider"); + } + bound.push(set.clone()); + } + } + Ok(()) +} fn bind_rules(rules: &mut [RuleSpec], sets: &HashMap>) -> Result<()> { for rule in rules { match &mut rule.kind { diff --git a/src/dns/fake_ip.rs b/src/dns/fake_ip.rs new file mode 100644 index 0000000..2a8fb5f --- /dev/null +++ b/src/dns/fake_ip.rs @@ -0,0 +1,351 @@ +//! Separate synthetic address pools; answer TTL does not expire the reverse map. +use crate::config::Network; +use crate::config::{ + DnsPolicySelector, FakeIpConfig, FakeIpFilterMode, FakeIpRange, RuleAction, RuleKind, +}; +use crate::routing::{GeoMatcher, RoutingContext}; +use crate::session::Destination; +use redb::{Database, ReadableDatabase, ReadableTable, TableDefinition}; +use std::{ + collections::HashMap, + io, + net::IpAddr, + path::Path, + sync::{Arc, Mutex}, +}; + +const MEMORY_ENTRIES: usize = 1000; +const RANGES: TableDefinition<&str, &str> = TableDefinition::new("ranges"); +const OFFSETS: TableDefinition<&str, u128> = TableDefinition::new("offsets"); +fn storage_error(_: impl std::fmt::Display) -> io::Error { + io::Error::other("fake-IP storage is unavailable") +} + +pub(crate) struct DomainFilter { + index: super::policy::DomainIndex, + selectors: Vec, +} +impl DomainFilter { + pub(crate) fn new(selectors: &[DnsPolicySelector]) -> Self { + let mut index = super::policy::DomainIndex::new(); + for selector in selectors { + if let DnsPolicySelector::Domains(patterns) = selector { + for pattern in patterns { + index.insert(pattern, 0); + } + } + } + Self { + index, + selectors: selectors.to_vec(), + } + } + pub(crate) fn matches(&self, host: &str, geo: &dyn GeoMatcher) -> bool { + self.index.find(0, host).is_some() + || self.selectors.iter().any(|selector| match selector { + DnsPolicySelector::GeoSite(codes) => codes + .iter() + .any(|code| geo.geosite_available(code) && geo.matches_geosite(code, host)), + DnsPolicySelector::RuleSets { sets, .. } => { + sets.iter().any(|set| set.matches_domain(host, geo)) + } + _ => false, + }) + } +} +pub(crate) struct Filter { + config: FakeIpConfig, + domains: DomainFilter, +} +impl Filter { + pub(crate) fn new(config: &FakeIpConfig) -> Self { + Self { + config: config.clone(), + domains: DomainFilter::new(&config.filter), + } + } + pub(crate) fn skips(&self, host: &str, geo: &dyn GeoMatcher) -> bool { + if self.config.mode == FakeIpFilterMode::Rule { + let destination = Destination::Domain { + host: host.to_owned(), + port: 53, + }; + let Ok(context) = RoutingContext::new(Network::Tcp, &destination) else { + return true; + }; + return self + .config + .rules + .iter() + .find(|rule| { + if let RuleKind::RuleSet { + content: Some(set), .. + } = &rule.kind + { + return set.matches_domain(host, geo); + } + crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) + }) + .is_some_and(|rule| rule.action == RuleAction::Direct); + } + let matched = self.domains.matches(host, geo); + if self.config.mode == FakeIpFilterMode::Whitelist { + !matched + } else { + matched + } + } +} + +pub(crate) struct FakePools { + v4: Option>, + v6: Option>, +} +impl FakePools { + pub(crate) fn open(config: &FakeIpConfig, ipv6: bool, path: Option<&Path>) -> io::Result { + let database = path + .map(|path| { + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + } + Database::builder() + .set_cache_size(2 * 1024 * 1024) + .create_file(file) + .map(Arc::new) + .map_err(storage_error) + }) + .transpose()?; + Ok(Self { + v4: config + .range + .map(|range| Pool::new(range, database.clone())) + .transpose()? + .map(Mutex::new), + v6: config + .range6 + .filter(|_| ipv6) + .map(|range| Pool::new(range, database.clone())) + .transpose()? + .map(Mutex::new), + }) + } + pub(crate) fn lookup(&self, host: &str, ipv6: bool) -> io::Result> { + let pool = if ipv6 { &self.v6 } else { &self.v4 }; + pool.as_ref() + .map(|pool| pool.lock().map_err(storage_error)?.lookup(host)) + .transpose() + } + pub(crate) fn recover(&self, address: IpAddr) -> io::Result> { + let pool = if address.is_ipv6() { + &self.v6 + } else { + &self.v4 + }; + let Some(pool) = pool else { + return Ok(None); + }; + let mut pool = pool.lock().map_err(storage_error)?; + let (base, last) = pool.range.bounds(); + let value = ip_value(address); + if !pool.range.cidr.contains(address) || value == base + 1 || value == last { + return Ok(None); + } + pool.reverse(value)?.map(Some).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "fake-IP mapping is no longer available", + ) + }) + } +} +fn ip_value(address: IpAddr) -> u128 { + match address { + IpAddr::V4(ip) => u128::from(u32::from(ip)), + IpAddr::V6(ip) => u128::from(ip), + } +} +struct Pool { + range: FakeIpRange, + next: u128, + database: Option>, + family: &'static str, + hosts: TableDefinition<'static, &'static str, u128>, + ips: TableDefinition<'static, u128, &'static str>, + memory: HashMap, + reverse: HashMap, + clock: u64, +} +impl Pool { + fn new(range: FakeIpRange, database: Option>) -> io::Result { + let ipv6 = range.cidr.network.is_ipv6(); + let family = if ipv6 { "6" } else { "4" }; + let hosts = TableDefinition::new(if ipv6 { "host6" } else { "host4" }); + let ips = TableDefinition::new(if ipv6 { "ip6" } else { "ip4" }); + let mut next = range.bounds().0 + 3; + if let Some(db) = &database { + let tx = db.begin_write().map_err(storage_error)?; + { + let mut ranges = tx.open_table(RANGES).map_err(storage_error)?; + let key = format!("{}/{}", range.cidr.network, range.cidr.prefix_len); + let same = ranges + .get(family) + .map_err(storage_error)? + .is_some_and(|v| v.value() == key); + let mut offsets = tx.open_table(OFFSETS).map_err(storage_error)?; + if !same { + tx.delete_table(hosts).map_err(storage_error)?; + tx.delete_table(ips).map_err(storage_error)?; + ranges.insert(family, key.as_str()).map_err(storage_error)?; + offsets.insert(family, next).map_err(storage_error)?; + } else if let Some(value) = offsets.get(family).map_err(storage_error)? { + next = value.value(); + } + tx.open_table(hosts).map_err(storage_error)?; + tx.open_table(ips).map_err(storage_error)?; + } + tx.commit().map_err(storage_error)?; + } + let (base, last) = range.bounds(); + if next < base + 3 || next >= last { + return Err(storage_error("invalid offset")); + } + Ok(Self { + range, + next, + database, + family, + hosts, + ips, + memory: HashMap::new(), + reverse: HashMap::new(), + clock: 0, + }) + } + fn lookup(&mut self, host: &str) -> io::Result { + self.clock = self.clock.wrapping_add(1); + let previous = if let Some(db) = &self.database { + let tx = db.begin_read().map_err(storage_error)?; + let hosts = tx.open_table(self.hosts).map_err(storage_error)?; + hosts.get(host).map_err(storage_error)?.map(|v| v.value()) + } else { + self.memory.get_mut(host).map(|entry| { + entry.1 = self.clock; + entry.0 + }) + }; + if let Some(value) = previous { + return Ok(self.range.address(value)); + } + let (base, last) = self.range.bounds(); + let mut next = self.next + 1; + if next >= last { + next = base + 4; + } + if let Some(db) = &self.database { + let tx = db.begin_write().map_err(storage_error)?; + { + let mut hosts = tx.open_table(self.hosts).map_err(storage_error)?; + let mut ips = tx.open_table(self.ips).map_err(storage_error)?; + let previous = ips + .remove(next) + .map_err(storage_error)? + .map(|v| v.value().to_owned()); + if let Some(previous) = previous { + hosts.remove(previous.as_str()).map_err(storage_error)?; + } + hosts.insert(host, next).map_err(storage_error)?; + ips.insert(next, host).map_err(storage_error)?; + tx.open_table(OFFSETS) + .map_err(storage_error)? + .insert(self.family, next) + .map_err(storage_error)?; + } + tx.commit().map_err(storage_error)?; + } else { + if let Some(previous) = self.reverse.remove(&next) { + self.memory.remove(&previous); + } + if self.memory.len() >= MEMORY_ENTRIES + && let Some(old) = self + .memory + .iter() + .min_by_key(|(_, (_, clock))| clock) + .map(|(host, _)| host.clone()) + { + let (address, _) = self.memory.remove(&old).unwrap(); + self.reverse.remove(&address); + } + self.memory.insert(host.to_owned(), (next, self.clock)); + self.reverse.insert(next, host.to_owned()); + } + self.next = next; + Ok(self.range.address(next)) + } + fn reverse(&mut self, ip: u128) -> io::Result> { + self.clock = self.clock.wrapping_add(1); + if let Some(db) = &self.database { + let tx = db.begin_read().map_err(storage_error)?; + let ips = tx.open_table(self.ips).map_err(storage_error)?; + ips.get(ip) + .map_err(storage_error) + .map(|v| v.map(|v| v.value().to_owned())) + } else { + let host = self.reverse.get(&ip).cloned(); + if let Some(entry) = host.as_ref().and_then(|host| self.memory.get_mut(host)) { + entry.1 = self.clock; + } + Ok(host) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn fake_ip_persistence_restarts_and_reuses_addresses_transactionally() { + let mut config = crate::config::DnsConfig::default().fake_ip; + config.range = FakeIpRange::parse("198.18.0.1/29", false).unwrap(); + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("fake-ip.redb"); + let first; + { + let pools = FakePools::open(&config, false, Some(&path)).unwrap(); + first = pools.lookup("one.example", false).unwrap().unwrap(); + assert_eq!(first, "198.18.0.4".parse::().unwrap()); + pools.lookup("two.example", false).unwrap(); + } + { + let pools = FakePools::open(&config, false, Some(&path)).unwrap(); + assert_eq!( + pools.recover(first).unwrap().as_deref(), + Some("one.example") + ); + assert_eq!(pools.lookup("one.example", false).unwrap(), Some(first)); + assert_eq!( + pools.lookup("three.example", false).unwrap(), + Some("198.18.0.6".parse().unwrap()) + ); + assert_eq!(pools.lookup("four.example", false).unwrap(), Some(first)); + assert_eq!( + pools.recover(first).unwrap().as_deref(), + Some("four.example") + ); + } + config.range = FakeIpRange::parse("198.19.0.1/29", false).unwrap(); + let pools = FakePools::open(&config, false, Some(&path)).unwrap(); + assert_eq!(pools.recover(first).unwrap(), None); + assert_eq!( + pools.lookup("one.example", false).unwrap(), + Some("198.19.0.4".parse().unwrap()) + ); + } +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 75a66e5..bb9f5bf 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -5,6 +5,7 @@ //! redir-host table is bounded by the constants below, while concurrent query //! activity is observed rather than rejected by a fixed local admission cap. +pub(crate) mod fake_ip; pub(crate) mod hosts; mod https; pub(crate) mod policy; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 841317c..4848d22 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -92,6 +92,9 @@ pub struct RuntimeDns { ipv6: bool, nameservers: Box<[DnsNameserver]>, hosts: super::hosts::Hosts, + fake_config: crate::config::FakeIpConfig, + fake_filter: super::fake_ip::Filter, + fake_pools: Option>, bootstrap: Option>, proxy_resolver: Option>, direct_resolver: Option>, @@ -803,9 +806,47 @@ impl RuntimeDns { pub(crate) fn with_mapping_from(mut self, previous: Option<&Self>) -> Self { if let Some(previous) = previous { self.hints = previous.hints.clone(); + if self.fake_config.enabled == previous.fake_config.enabled + && self.fake_config.range == previous.fake_config.range + && self.fake_config.range6 == previous.fake_config.range6 + && self.ipv6 == previous.ipv6 + { + self.fake_pools = previous.fake_pools.clone(); + } } self } + pub(crate) fn with_fake_ip_store(mut self, root: Option<&std::path::Path>) -> io::Result { + if self.enabled + && self.fake_config.enabled + && let Some(root) = root + { + self.fake_pools = Some(Arc::new(super::fake_ip::FakePools::open( + &self.fake_config, + self.ipv6, + Some(&root.join("fake-ip.redb")), + )?)); + } + Ok(self) + } + + pub(crate) fn recover_fake_ip(&self, address: IpAddr) -> io::Result> { + self.fake_pools + .as_ref() + .map_or(Ok(None), |pool| pool.recover(address)) + } + + fn use_fake_ip(&self, host: &str) -> bool { + if self.fake_pools.is_none() { + return false; + } + let snapshot = self.geo_matcher.routing_snapshot(); + !self.fake_filter.skips( + host, + snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()), + ) + } + /// Creates a resolver whose upstream traffic uses one preselected /// dispatcher and no GeoData matcher. Production runtimes use /// [`Self::new_routed`]. @@ -918,6 +959,12 @@ impl RuntimeDns { )); let mut root = Self::assemble_leaf(config, egress.clone(), geo.clone(), cache, hints); root.bootstrap = Some(bootstrap.clone()); + if config.enable && config.fake_ip.enabled { + root.fake_pools = Some(Arc::new( + super::fake_ip::FakePools::open(&config.fake_ip, config.ipv6, None) + .expect("validated fake-IP ranges"), + )); + } let role = |nameservers: &[DnsNameserver], policies: &[DnsNameserverPolicy]| { let mut role_config = config.clone(); role_config.nameservers = nameservers.to_vec(); @@ -959,6 +1006,9 @@ impl RuntimeDns { config.use_hosts, config.use_system_hosts, ), + fake_config: config.fake_ip.clone(), + fake_filter: super::fake_ip::Filter::new(&config.fake_ip), + fake_pools: None, bootstrap: None, proxy_resolver: None, direct_resolver: None, @@ -1232,6 +1282,13 @@ impl RuntimeDns { .await } DnsQueryKind::Opaque => { + if matches!(failure_mode, FailureMode::WireServfail) + && matches!(classified.question.query_type, 64 | 65) + && self.use_fake_ip(&classified.question.name) + { + return super::synthesize_empty_response(classified, 0) + .map_err(RuntimeDnsError::InvalidResponse); + } self.exchange_opaque(query, classified, deadline, failure_mode) .await } @@ -1255,6 +1312,26 @@ impl RuntimeDns { } } + fn synthetic_answer(&self, query: &DnsMessage, host: &str) -> Result, RuntimeDnsError> { + match self + .fake_pools + .as_ref() + .expect("enabled fake-IP pool") + .lookup(host, query.question.query_type == QueryType::Aaaa) + { + Ok(address) => { + let mut wire = synthesize_response( + query, + &CacheValue::Positive(address.into_iter().collect()), + self.fake_config.ttl, + )?; + wire[2] |= 0x04; + Ok(wire) + } + Err(_) => synthesize_response(query, &CacheValue::Negative { rcode: 2 }, 0), + } + } + async fn exchange_address( &self, query: &[u8], @@ -1276,6 +1353,9 @@ impl RuntimeDns { })? { HostValue::Addresses(addresses) => Some(addresses), HostValue::Alias(alias) if alias != parsed_query.question.name => { + if matches!(failure_mode, FailureMode::WireServfail) && self.use_fake_ip(&alias) { + return self.synthetic_answer(&parsed_query, &alias); + } Some(Box::pin(self.resolve(&alias)).await?) } _ => None, @@ -1291,6 +1371,12 @@ impl RuntimeDns { return synthesize_response(&parsed_query, &CacheValue::Positive(addresses), 600); } + if matches!(failure_mode, FailureMode::WireServfail) + && self.use_fake_ip(&parsed_query.question.name) + { + return self.synthetic_answer(&parsed_query, &parsed_query.question.name); + } + loop { let key = SingleflightKey::from(classified); let role = { @@ -4861,4 +4947,69 @@ dns: drop(response); assert_eq!(resolver.resource_stats.snapshot().dns_current, 0); } + #[tokio::test] + async fn fake_ip_answers_recover_names_and_keep_internal_resolution_real() { + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +hosts: + alias.example: synthetic.example +dns: + enable: true + enhanced-mode: fake-ip + fake-ip-filter: [real.example] + nameserver: [192.0.2.1] + use-system-hosts: false +"#, + ) + .unwrap(); + let real: IpAddr = "203.0.113.7".parse().unwrap(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Response(ResponseSpec::answer(real)), + MockReply::Response(ResponseSpec::answer(real)), + ], + vec![], + )); + let resolver = RuntimeDns::new(&config.dns, dispatcher.clone()); + for name in ["synthetic.example", "alias.example"] { + let wire = resolver + .exchange(&build_query(10, name, QueryType::A).unwrap()) + .await + .unwrap(); + let answer = parse_response(&wire).unwrap(); + assert!(answer.authoritative); + let address = matching_answer_set(&answer).unwrap().addresses[0]; + assert_eq!(address, "198.18.0.4".parse::().unwrap()); + assert_eq!( + resolver.recover_fake_ip(address).unwrap().as_deref(), + Some("synthetic.example") + ); + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); + assert_eq!(resolver.resolve("synthetic.example").await.unwrap(), [real]); + let wire = resolver + .exchange(&build_query(11, "real.example", QueryType::A).unwrap()) + .await + .unwrap(); + assert_eq!( + matching_answer_set(&parse_response(&wire).unwrap()) + .unwrap() + .addresses, + [real] + ); + let updated = RuntimeDns::new(&config.dns, dispatcher).with_mapping_from(Some(&resolver)); + assert_eq!( + updated + .recover_fake_ip("198.18.0.4".parse().unwrap()) + .unwrap() + .as_deref(), + Some("synthetic.example") + ); + assert!( + updated + .recover_fake_ip("198.18.0.9".parse().unwrap()) + .is_err() + ); + } } diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 4f4c4b5..c5cd9b6 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -449,7 +449,7 @@ pub struct GeoRequirements { impl GeoRequirements { pub(crate) fn for_config(config: &crate::config::Config) -> Result { - let policies: Vec<_> = config + let mut policies: Vec<_> = config .dns .nameserver_policies .iter() @@ -459,7 +459,19 @@ impl GeoRequirements { nameservers: Box::new([]), }) .collect(); - Self::collect(&config.rules, &policies) + if config.dns.enable && config.dns.fake_ip.enabled { + policies.extend(config.dns.fake_ip.filter.iter().cloned().map(|selector| { + DnsNameserverPolicy { + selector, + nameservers: Box::new([]), + } + })); + } + let mut result = Self::collect(&config.rules, &policies)?; + if config.dns.enable && config.dns.fake_ip.enabled { + result.extend(&Self::collect(&config.dns.fake_ip.rules, &[])?)?; + } + Ok(result) } pub(crate) fn extend(&mut self, other: &Self) -> Result<(), GeoDataError> { diff --git a/src/platform/linux_network/native.rs b/src/platform/linux_network/native.rs index 4bf7ef4..61335da 100644 --- a/src/platform/linux_network/native.rs +++ b/src/platform/linux_network/native.rs @@ -1,5 +1,9 @@ use super::{Backend, Change, Lease, NetworkLease, netlink}; -use crate::{config::TunConfig, dialer::Dialer, platform::TunIo}; +use crate::{ + config::{DnsConfig, TunConfig}, + dialer::Dialer, + platform::TunIo, +}; use std::{ ffi::CString, io, @@ -102,6 +106,7 @@ pub(crate) struct LinuxBackend { // Keep the newly created interface alive until DNS and routing cleanup ends. _tun_owner: OwnedFd, dns_reverted: bool, + dns_server: String, } impl LinuxBackend { fn resolved(&self, operation: &str, values: &[&str]) -> io::Result<()> { @@ -150,7 +155,7 @@ impl LinuxBackend { impl Backend for LinuxBackend { fn apply(&mut self, change: &Change) -> io::Result<()> { match change { - Change::DnsServers => self.resolved("dns", &["198.18.0.2"]), + Change::DnsServers => self.resolved("dns", &[&self.dns_server]), Change::DnsDomains => self.resolved("domain", &["~."]), Change::DnsDefaultRoute => self.resolved("default-route", &["yes"]), other => netlink::change(other, true), @@ -183,7 +188,7 @@ impl Backend for LinuxBackend { pub(crate) fn open( config: &TunConfig, - dns_enabled: bool, + dns: &DnsConfig, ipv6: bool, dialer: Dialer, ) -> io::Result<(TunIo, Dialer, Option)> { @@ -224,12 +229,26 @@ pub(crate) fn open( return Err(io::Error::last_os_error()); } let owner = unsafe { OwnedFd::from_raw_fd(owner) }; + let address = dns + .fake_ip + .range + .map(|range| range.interface_address) + .unwrap_or("198.18.0.1".parse().unwrap()); + let std::net::IpAddr::V4(address) = address else { + unreachable!("validated IPv4 range") + }; + let dns_address = std::net::Ipv4Addr::from( + u32::from(address) + .checked_add(1) + .ok_or_else(|| io::Error::other("TUN DNS address is outside IPv4"))?, + ); let mut lease = Lease::new(LinuxBackend { name, _tun_owner: owner, dns_reverted: false, + dns_server: dns_address.to_string(), }); - device.set_network_address("198.18.0.1", 30, None::<&str>)?; + device.set_network_address(address, 30, None::)?; if ipv6 { device.add_address_v6("fdfe:dcba:9876::1", 126)?; } @@ -248,7 +267,7 @@ pub(crate) fn open( preserve_local: false, })?; } - if dns_enabled && config.hijacks_dns("198.18.0.2:53".parse().unwrap()) { + if dns.enable && config.hijacks_dns(SocketAddr::new(dns_address.into(), 53)) { lease.apply(Change::DnsServers)?; lease.apply(Change::DnsDomains)?; lease.apply(Change::DnsDefaultRoute)?; diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 939d9c8..39fa2f3 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -40,6 +40,9 @@ trait RoutingDns: Send + Sync { fn is_enabled(&self) -> bool { true } + fn recover_fake_ip(&self, _address: IpAddr) -> Result, DispatchError> { + Ok(None) + } async fn resolve_direct(&self, host: &str) -> Result, DispatchError> { self.resolve(host).await } @@ -50,6 +53,10 @@ trait RoutingDns: Send + Sync { #[async_trait] impl RoutingDns for RuntimeDns { + fn recover_fake_ip(&self, address: IpAddr) -> Result, DispatchError> { + RuntimeDns::recover_fake_ip(self, address).map_err(|_| DispatchError::HostUnreachable) + } + fn is_enabled(&self) -> bool { RuntimeDns::is_enabled(self) } @@ -283,6 +290,24 @@ impl RoutingDispatcher { sniffed_domain: Option<&str>, ) -> Result { self.ensure_destination_allowed(destination)?; + let recovered = if matches!( + inbound, + InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 + ) { + match (destination, &self.dns) { + (Destination::Ip(address), Some(dns)) => { + dns.recover_fake_ip(address.ip())? + .map(|host| Destination::Domain { + host, + port: address.port(), + }) + } + _ => None, + } + } else { + None + }; + let destination = recovered.as_ref().unwrap_or(destination); let accepts_domain_hint = inbound == InboundKind::Tun && self.rules.uses_domain_routing(); let sniffed_domain = accepts_domain_hint.then_some(sniffed_domain).flatten(); let dns_domain_hint = match (accepts_domain_hint, destination, &self.dns, sniffed_domain) { @@ -560,6 +585,7 @@ impl Dispatcher for RoutingDispatcher { router: self.clone_parts(), session, route_transports: HashMap::new(), + fake_flows: HashMap::new(), direct_transport: None, flows: DatagramRoutes::new(self.tun_config.udp_timeout), logged_route_actions: HashSet::new(), @@ -586,12 +612,22 @@ struct RoutedDatagramTransport { router: RoutingDispatcher, session: DatagramSession, route_transports: HashMap, + fake_flows: HashMap, direct_transport: Option>, flows: DatagramRoutes, logged_route_actions: HashSet, receive_wakeup: Arc, } +// Each synthetic target owns an association: two names may resolve to the same +// real endpoint, and replies must still retain their original synthetic address. +struct FakeDatagramFlow { + transport: Box, + remote: SocketAddr, + last_used: Instant, + flow_id: Option>, +} + struct CachedRouteTransport { transport: Box, direct: bool, @@ -676,6 +712,89 @@ impl From for LoggedRouteAction { } impl RoutedDatagramTransport { + async fn expire_fake_flows(&mut self) { + let now = Instant::now(); + let expired: Vec<_> = self + .fake_flows + .iter() + .filter_map(|(key, flow)| { + (now.duration_since(flow.last_used) >= self.router.tun_config.udp_timeout) + .then_some(*key) + }) + .collect(); + for key in expired { + if let Some(mut flow) = self.fake_flows.remove(&key) { + let _ = flow.transport.close().await; + } + } + } + + async fn send_fake( + &mut self, + original: SocketAddr, + mut datagram: Datagram, + ) -> Result<(), DispatchError> { + if !self.fake_flows.contains_key(&original) { + let mut decision = self + .router + .route( + Network::Udp, + self.session.inbound, + self.session.source, + &datagram.remote, + datagram.sniffed_domain.as_deref(), + ) + .await?; + let (dispatcher, direct) = match decision.action { + RuleAction::Reject => return Ok(()), + RuleAction::Direct => (self.router.direct.clone(), true), + RuleAction::Route(id) => { + let target = self.router.route_targets.resolve(id)?; + (target.dispatcher, target.direct) + } + }; + if direct { + self.router + .resolve_direct_destination(&mut decision) + .await?; + } + let remote = match decision.destination { + Destination::Ip(address) => address, + Destination::Domain { host, port } => { + let dns = self + .router + .dns + .as_ref() + .ok_or(DispatchError::HostUnreachable)?; + let addresses = self.router.resolve_addresses(dns, &host).await; + SocketAddr::new( + preferred_address(&addresses).ok_or(DispatchError::HostUnreachable)?, + port, + ) + } + }; + let transport = dispatcher.open_datagram(self.session.clone()).await?; + self.fake_flows.insert( + original, + FakeDatagramFlow { + transport, + remote, + last_used: Instant::now(), + flow_id: None, + }, + ); + self.receive_wakeup.notify_one(); + } + let flow = self + .fake_flows + .get_mut(&original) + .expect("fake-IP flow inserted"); + datagram.remote = Destination::Ip(flow.remote); + datagram.sniffed_domain = None; + flow.last_used = Instant::now(); + flow.transport.send(datagram).await + } + fn should_log_route_action(&mut self, action: RuleAction) -> bool { self.logged_route_actions.insert(action.into()) } @@ -744,11 +863,14 @@ impl RoutedDatagramTransport { // Only send/close can change these caches, and they also require // &mut self. The sole backend cannot change during this receive; // cancellation releases the borrow before another route is opened. - if self.route_transports.is_empty() { + if self.fake_flows.is_empty() && self.route_transports.is_empty() { if let Some(direct) = self.direct_transport.as_mut() { return direct.receive().await; } - } else if self.route_transports.len() == 1 && self.direct_transport.is_none() { + } else if self.fake_flows.is_empty() + && self.route_transports.len() == 1 + && self.direct_transport.is_none() + { return self .route_transports .values_mut() @@ -758,7 +880,9 @@ impl RoutedDatagramTransport { .receive() .await; } - let mut receives = self + let mut receives: Vec< + futures_util::future::BoxFuture<'_, Result>, + > = self .route_transports .values_mut() .map(|cached| cached.transport.receive()) @@ -766,6 +890,19 @@ impl RoutedDatagramTransport { if let Some(direct) = self.direct_transport.as_mut() { receives.push(direct.receive()); } + for (original, flow) in &mut self.fake_flows { + receives.push(Box::pin(async move { + loop { + let mut response = flow.transport.receive().await?; + if response.remote != Destination::Ip(flow.remote) { + continue; + } + response.remote = Destination::Ip(*original); + flow.last_used = Instant::now(); + return Ok(response); + } + })); + } if receives.is_empty() { self.receive_wakeup.notified().await; continue; @@ -781,6 +918,21 @@ impl RoutedDatagramTransport { #[async_trait] impl DatagramTransport for RoutedDatagramTransport { async fn send(&mut self, mut datagram: Datagram) -> Result<(), DispatchError> { + self.expire_fake_flows().await; + if matches!(self.session.inbound, InboundKind::Tun | InboundKind::Socks5) + && let Destination::Ip(original) = datagram.remote + && (self.fake_flows.contains_key(&original) + || self + .router + .dns + .as_ref() + .map(|dns| dns.recover_fake_ip(original.ip())) + .transpose()? + .flatten() + .is_some()) + { + return self.send_fake(original, datagram).await; + } let now = Instant::now(); if self.session.inbound == InboundKind::Tun { self.flows.expire(now); @@ -858,7 +1010,21 @@ impl DatagramTransport for RoutedDatagramTransport { self.flows.entries.remove(&destination); } } + if let Some(destination) = flow + && self + .fake_flows + .get(&destination) + .is_some_and(|flow| flow.flow_id.as_deref() != Some(flow_id)) + && let Some(mut old) = self.fake_flows.remove(&destination) + { + old.transport.close().await?; + } self.send(datagram).await?; + if let Some(destination) = flow + && let Some(flow) = self.fake_flows.get_mut(&destination) + { + flow.flow_id = Some(flow_id.into()); + } if let Some(destination) = flow && let Some(route) = self.flows.entries.get_mut(&destination) && route.flow_id.as_deref() != Some(flow_id) @@ -869,6 +1035,7 @@ impl DatagramTransport for RoutedDatagramTransport { } async fn receive(&mut self) -> Result { + self.expire_fake_flows().await; let datagram = self.receive_inner().await?; if self.session.inbound == InboundKind::Tun { let now = Instant::now(); @@ -882,6 +1049,14 @@ impl DatagramTransport for RoutedDatagramTransport { async fn close(&mut self) -> Result<(), DispatchError> { let mut first_error = None; + for flow in self.fake_flows.values_mut() { + if let Err(error) = flow.transport.close().await + && first_error.is_none() + { + first_error = Some(error); + } + } + self.fake_flows.clear(); for route in self.route_transports.values_mut() { if let Err(error) = route.transport.close().await && first_error.is_none() @@ -2902,4 +3077,74 @@ mod tests { Destination::Ip(SocketAddr::new(selected, 443)) ); } + #[tokio::test] + async fn fake_ip_routing_preserves_tcp_names_and_distinct_udp_reply_addresses() { + struct FakeDns; + #[async_trait] + impl RoutingDns for FakeDns { + fn recover_fake_ip(&self, address: IpAddr) -> Result, DispatchError> { + Ok(match address.to_string().as_str() { + "198.18.0.4" => Some("one.example".into()), + "198.18.0.5" => Some("two.example".into()), + _ => None, + }) + } + async fn resolve(&self, _: &str) -> Result, DispatchError> { + Ok(vec!["203.0.113.10".parse().unwrap()]) + } + async fn exchange(&self, _: &[u8]) -> Result { + Err(DispatchError::HostUnreachable) + } + async fn domain_hint(&self, _: IpAddr) -> Option { + None + } + } + let proxy = Arc::new(RecordingDispatcher::default()); + let direct = Arc::new(RecordingDispatcher::default()); + let router = dispatcher( + proxy.clone(), + direct, + Some(Arc::new(FakeDns)), + vec![rule(RuleKind::Match, proxy_action(0))], + ); + let first: SocketAddr = "198.18.0.4:443".parse().unwrap(); + let second: SocketAddr = "198.18.0.5:443".parse().unwrap(); + let _stream = router + .connect_tcp(stream(first, InboundKind::Tun)) + .await + .unwrap(); + assert_eq!( + proxy.tcp_sessions.lock().unwrap()[0].destination, + Destination::Domain { + host: "one.example".into(), + port: 443 + } + ); + let mut udp = router + .open_datagram(DatagramSession::new( + InboundKind::Tun, + "192.0.2.2:9000".parse().unwrap(), + )) + .await + .unwrap(); + for (address, payload) in [(first, "one"), (second, "two")] { + udp.send(Datagram { + remote: Destination::Ip(address), + payload: Bytes::from(payload), + sniffed_domain: None, + }) + .await + .unwrap(); + } + let mut received = Vec::new(); + for _ in 0..2 { + let packet = udp.receive().await.unwrap(); + received.push((packet.remote, packet.payload)); + } + assert!(received.contains(&(Destination::Ip(first), Bytes::from("one")))); + assert!(received.contains(&(Destination::Ip(second), Bytes::from("two")))); + assert_eq!(proxy.udp_opens.load(Ordering::Relaxed), 2); + assert!(proxy.datagrams.lock().unwrap().iter().all(|packet| packet.remote == Destination::Ip("203.0.113.10:443".parse().unwrap()))); + udp.close().await.unwrap(); + } } diff --git a/src/runtime.rs b/src/runtime.rs index 4ce0138..f6800c0 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -403,6 +403,11 @@ impl PreparedCore { rules: self.rules.clone(), geo_matcher: self.geodata_registration.matcher(), previous_dns: None, + data_dir: self + .geodata_manager + .store_dir() + .parent() + .map(std::path::Path::to_path_buf), } .build(dialer.clone())?; if let Some(cache) = self.provider_cache.lock().unwrap().take() { @@ -539,7 +544,7 @@ impl PreparedCore { pub(crate) async fn start_native_tun(self, dialer: Dialer) -> io::Result { let (tun, dialer, mut network) = crate::platform::linux_network::open( &self.config.tun, - self.config.dns.enable, + &self.config.dns, self.config.ipv6, dialer, )?; @@ -632,6 +637,7 @@ struct GraphBuilder { rules: RuleSet, geo_matcher: Arc, previous_dns: Option>, + data_dir: Option, } impl GraphBuilder { fn build(&self, dialer: Dialer) -> io::Result { @@ -692,7 +698,14 @@ impl GraphBuilder { self.limits.dns_address_cache_entries, redir_host_entries, ) - .with_mapping_from(self.previous_dns.as_deref()), + .with_mapping_from(self.previous_dns.as_deref()) + .with_fake_ip_store( + if self.config.profile.store_fake_ip && self.previous_dns.is_none() { + self.data_dir.as_deref() + } else { + None + }, + )?, )); proxy_graph.resolution.bind_runtime(dns.as_ref())?; let health = health::HealthRuntime::new( diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index b5f2cf7..e76c7bf 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -267,6 +267,11 @@ impl ProviderRuntime { let dialer = self.dialer.clone(); let geo = self.geo.clone(); let previous_dns = generation.dns.clone(); + let data_dir = self + .manager + .store_dir() + .parent() + .map(std::path::Path::to_path_buf); let prepared = tokio::task::spawn_blocking( move || -> io::Result<(Config, BuiltRuntimeParts, GeoRequirements)> { let mut config = template.materialize(&contents).map_err(io::Error::other)?; @@ -282,6 +287,7 @@ impl ProviderRuntime { rules, geo_matcher: geo, previous_dns, + data_dir, } .build(dialer)?; Ok((config, parts, requirements)) diff --git a/tests/README.md b/tests/README.md index 380fe64..03b56b1 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,7 @@ cargo test --locked --lib geodata::tests:: cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: +cargo test --locked --all-features --lib fake_ip_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 761489b2d66df8b9822767cf99657234a4068b22 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:07:09 +0800 Subject: [PATCH 010/445] feat(dns): add filtered fallback resolution and query scheduling Apply policy precedence, domain and shared GeoData filters, and primary answer checks. Own speculative queries within the current DNS flight and cancel them when no longer needed. Validation: three memory regressions, all-target compilation and all-feature clippy. --- docs/config.yaml | 8 +++ docs/tun-icmp-dns.md | 20 +++++- src/config/dns.rs | 4 ++ src/config/fallback.rs | 66 ++++++++++++++++++ src/config/mod.rs | 9 +++ src/dns/fallback.rs | 133 +++++++++++++++++++++++++++++++++++ src/dns/mod.rs | 1 + src/dns/runtime.rs | 155 +++++++++++++++++++++++++++++++++++------ src/geodata/mod.rs | 15 ++++ tests/README.md | 2 + 10 files changed, 389 insertions(+), 24 deletions(-) create mode 100644 src/config/fallback.rs create mode 100644 src/dns/fallback.rs diff --git a/docs/config.yaml b/docs/config.yaml index 666514c..07c033b 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -532,6 +532,14 @@ hosts: dns: enable: true ipv6: true + fallback: [] # A/AAAA 的备用解析组;不是 nameserver 内部的故障转移 + fallback-lazy-query: false # false:与主组同时查询;true:确定需要时才查询 + fallback-filter: + geoip: true # 非本地地址且不属于 geoip-code 时使用 fallback + geoip-code: CN + ipcidr: [] # 任意返回地址命中时使用 fallback + domain: [] # Clash 域名通配,匹配后只查询 fallback + geosite: [] # 共用 GeoData,匹配后只查询 fallback enhanced-mode: redir-host # 或 fake-ip;内部节点/直连解析始终取得真实地址 fake-ip-range: 198.18.0.1/16 fake-ip-range6: "" # 设置 IPv6 CIDR 且启用 IPv6 后分配 AAAA diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 660d59a..13c40ff 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -127,6 +127,22 @@ Policy 只决定当前 DNS exchange,不改写后续业务动作。分类、属 Regex selector 记录与业务规则共用 `regex::bytes::Regex`;其搜索缓存由正则库管理, 可能分配 scratch 或发生缓存竞争,不承诺 DNS policy 正则搜索零分配。 +## Fallback + +`fallback` 只作用于主 resolver 的 A/AAAA。先应用 nameserver-policy;命中的 policy +独立查询自己的上游,不再进入 fallback。否则,`fallback-filter.domain` 的 Clash 通配或 +`geosite` 命中时直接查询 fallback,不启动主组。 + +其余查询先取主组结果。主组失败、没有地址,或任一地址命中 `ipcidr` / GeoIP 条件时 +使用 fallback 的结果。`geoip` 默认 true、`geoip-code` 默认 CN:非本地且不属于该分类的 +地址触发 fallback;私有、回环、链路本地、多播和未指定地址不触发 GeoIP 条件, +但仍可被显式 `ipcidr` 匹配。GeoData 与业务规则共用快照;缺失分类不当作反选全匹配。 + +`fallback-lazy-query` 默认 false,两组并发查询,主组结果可接受时立即返回并取消剩余尝试; +true 只在确定需要时发起 fallback。fallback 返回后不再次套用过滤条件。 +选定的最终结果进入主 resolver 缓存,fake-IP 回答和 hosts 不触发上游查询。 +bootstrap、单独配置的节点/直连 resolver,以及非地址 qtype 不使用这组 fallback。 + ## Wire 校验 - DNS message 最大 4,096 字节。 @@ -176,7 +192,7 @@ A/AAAA 缓存最多 256 项,从空容量按需增长。TUN 域名提示存储 - UDP 尝试只打开一个传输;首次发送后 1 秒无合法响应时,在同一传输和 ID 上最多重发一次。 - 前两次 peer/ID/question 不匹配只丢当前响应,第三次使当前尝试失败。 - 当前策略最多同时发起 4 个上游尝试;优先返回有效响应并取消其余尝试。超时、I/O、格式错误、SERVFAIL、REFUSED 或 REJECT 不作为成功结果。 -- NXDOMAIN 和 NODATA 是终态。 +- 未启用 fallback 或命中 nameserver-policy 时,NXDOMAIN 和 NODATA 是终态。 - 验证来源、ID 和 question 后,UDP TC 在原期限内改用同一 endpoint 的 TCP。TCP TC 仍是失败响应。 - 全部上游失败时生成 SERVFAIL;非法 UDP 查询只丢当前数据报。 @@ -226,7 +242,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; - DoQ、DoH HTTP/2/3、显式 system/DHCP nameserver; -- DNS fallback group、动态 hosts: lan; +- 动态 hosts: lan; - 用户可配置的缓存容量、超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 161ab19..5fead33 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -62,7 +62,11 @@ impl RawDnsConfig { { return invalid("default-nameserver requires literal IP addresses"); } + let fallback = self + .fallback_filter + .normalize(parse(self.fallback, main_route)?, self.fallback_lazy_query)?; Ok(DnsConfig { + fallback, fake_ip, enable: self.enable, ipv6: self.ipv6, diff --git a/src/config/fallback.rs b/src/config/fallback.rs new file mode 100644 index 0000000..67ca942 --- /dev/null +++ b/src/config/fallback.rs @@ -0,0 +1,66 @@ +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DnsFallbackConfig { + pub nameservers: Vec, + pub geoip: Option, + pub ipcidr: Vec, + pub domains: Vec, + pub lazy: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(default, deny_unknown_fields, rename_all = "kebab-case")] +pub(super) struct RawFallbackFilter { + geoip: bool, + geoip_code: String, + ipcidr: Vec, + domain: Vec, + geosite: Vec, +} +impl Default for RawFallbackFilter { + fn default() -> Self { + Self { + geoip: true, + geoip_code: "CN".into(), + ipcidr: Vec::new(), + domain: Vec::new(), + geosite: Vec::new(), + } + } +} +impl RawFallbackFilter { + pub(super) fn normalize( + self, + nameservers: Vec, + lazy: bool, + ) -> Result { + let mut domains = Vec::new(); + for pattern in self.domain { + let selector = normalize_dns_policy_selector(&pattern)?; + if !matches!(selector, DnsPolicySelector::Domains(_)) { + return invalid("fallback-filter.domain requires domain patterns"); + } + domains.push(selector); + } + for code in self.geosite { + domains.push(DnsPolicySelector::GeoSite( + vec![normalize_geo_code(&code, "fallback-filter.geosite")?].into_boxed_slice(), + )); + } + Ok(DnsFallbackConfig { + nameservers, + geoip: self + .geoip + .then(|| normalize_geo_code(&self.geoip_code, "fallback-filter.geoip-code")) + .transpose()?, + ipcidr: self + .ipcidr + .iter() + .map(|cidr| parse_ip_cidr(cidr, cidr.contains(':'))) + .collect::>()?, + domains, + lazy, + }) + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 9581c7e..f03cf7b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,8 @@ //! Strict parsing for the current Vole YAML configuration. mod fake_ip; +mod fallback; pub use fake_ip::{FakeIpConfig, FakeIpFilterMode, FakeIpRange}; +pub use fallback::DnsFallbackConfig; mod http_proxy; mod ip_version; pub use http_proxy::HttpOutboundConfig; @@ -248,6 +250,7 @@ impl SnifferConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DnsConfig { pub fake_ip: FakeIpConfig, + pub fallback: DnsFallbackConfig, pub enable: bool, pub ipv6: bool, pub nameservers: Vec, @@ -1171,6 +1174,12 @@ enum RawSnifferPort { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawDnsConfig { + #[serde(default)] + fallback: Vec, + #[serde(rename = "fallback-filter", default)] + fallback_filter: fallback::RawFallbackFilter, + #[serde(rename = "fallback-lazy-query", default)] + fallback_lazy_query: bool, #[serde(rename = "fake-ip-range", default = "fake_ip::default_fake_range")] fake_ip_range: String, #[serde(rename = "fake-ip-range6", default)] diff --git a/src/dns/fallback.rs b/src/dns/fallback.rs new file mode 100644 index 0000000..f85d30f --- /dev/null +++ b/src/dns/fallback.rs @@ -0,0 +1,133 @@ +use crate::{config::DnsFallbackConfig, routing::GeoMatcher}; +use std::{future::Future, net::IpAddr}; + +pub(crate) struct Filter { + config: DnsFallbackConfig, + domains: super::fake_ip::DomainFilter, +} +impl Filter { + pub(crate) fn new(config: &DnsFallbackConfig) -> Self { + Self { + config: config.clone(), + domains: super::fake_ip::DomainFilter::new(&config.domains), + } + } + pub(crate) fn domain(&self, host: &str, geo: &dyn GeoMatcher) -> bool { + self.domains.matches(host, geo) + } + pub(crate) fn address(&self, address: IpAddr, geo: &dyn GeoMatcher) -> bool { + if self.config.ipcidr.iter().any(|cidr| cidr.contains(address)) { + return true; + } + let Some(code) = &self.config.geoip else { + return false; + }; + let local = address.is_unspecified() + || address.is_loopback() + || address.is_multicast() + || match address { + IpAddr::V4(ip) => ip.is_private() || ip.is_link_local(), + IpAddr::V6(ip) => ip.is_unique_local() || ip.is_unicast_link_local(), + }; + !local + && (code == "lan" || (geo.geoip_available(code) && !geo.matches_geoip(code, address))) + } +} + +// Both futures remain owned by the requesting DNS flight. Returning the primary +// drops the speculative fallback; cancellation drops both without detached work. +pub(super) async fn choose( + primary: impl Future, + fallback: impl Future, + lazy: bool, + acceptable: impl FnOnce(&T) -> bool, +) -> T { + tokio::pin!(fallback); + let mut ready = None; + let first = if lazy { + primary.await + } else { + tokio::pin!(primary); + tokio::select! { + biased; + answer = &mut fallback => { ready = Some(answer); primary.await } + answer = &mut primary => answer, + } + }; + if acceptable(&first) { + first + } else if let Some(answer) = ready { + answer + } else { + fallback.await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + #[tokio::test] + async fn fallback_query_scheduling_cancels_speculation_and_defers_lazy_work() { + struct Guard(Arc); + impl Drop for Guard { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + for lazy in [true, false] { + let started = Arc::new(AtomicBool::new(false)); + let dropped = Arc::new(AtomicBool::new(false)); + let fallback = { + let started = started.clone(); + let dropped = dropped.clone(); + async move { + started.store(true, Ordering::SeqCst); + let _guard = Guard(dropped); + std::future::pending::().await + } + }; + assert_eq!(choose(async { 1 }, fallback, lazy, |v| *v == 1).await, 1); + assert_eq!(started.load(Ordering::SeqCst), !lazy); + assert_eq!(dropped.load(Ordering::SeqCst), !lazy); + } + assert_eq!(choose(async { 0 }, async { 2 }, true, |v| *v == 1).await, 2); + } + #[test] + fn fallback_geoip_preserves_local_addresses_and_uses_shared_matcher() { + struct Country; + impl GeoMatcher for Country { + fn matches_geosite(&self, _: &str, _: &str) -> bool { + false + } + fn geoip_available(&self, code: &str) -> bool { + code == "cn" + } + fn matches_geoip(&self, _: &str, address: IpAddr) -> bool { + address == "203.0.113.1".parse::().unwrap() + } + } + let filter = Filter::new(&DnsFallbackConfig { + geoip: Some("cn".into()), + ..Default::default() + }); + for address in [ + "10.1.2.3", + "127.0.0.1", + "169.254.0.2", + "::1", + "fc00::1", + "203.0.113.1", + ] { + assert!(!filter.address(address.parse().unwrap(), &Country)); + } + assert!(filter.address("203.0.113.2".parse().unwrap(), &Country)); + assert!(!filter.address( + "203.0.113.2".parse().unwrap(), + &crate::routing::EmptyGeoMatcher + )); + } +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index bb9f5bf..3696979 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -6,6 +6,7 @@ //! activity is observed rather than rejected by a fixed local admission cap. pub(crate) mod fake_ip; +mod fallback; pub(crate) mod hosts; mod https; pub(crate) mod policy; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 4848d22..aff6b2a 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -92,6 +92,8 @@ pub struct RuntimeDns { ipv6: bool, nameservers: Box<[DnsNameserver]>, hosts: super::hosts::Hosts, + fallback: crate::config::DnsFallbackConfig, + fallback_filter: super::fallback::Filter, fake_config: crate::config::FakeIpConfig, fake_filter: super::fake_ip::Filter, fake_pools: Option>, @@ -948,6 +950,7 @@ impl RuntimeDns { ) -> Self { let mut bootstrap_config = config.clone(); bootstrap_config.enable = true; + bootstrap_config.fallback = crate::config::DnsFallbackConfig::default(); bootstrap_config.nameservers = config.default_nameservers.clone(); bootstrap_config.nameserver_policies.clear(); let bootstrap = Arc::new(Self::assemble_leaf( @@ -968,6 +971,7 @@ impl RuntimeDns { let role = |nameservers: &[DnsNameserver], policies: &[DnsNameserverPolicy]| { let mut role_config = config.clone(); role_config.nameservers = nameservers.to_vec(); + role_config.fallback = crate::config::DnsFallbackConfig::default(); role_config.nameserver_policies = policies.to_vec(); let mut resolver = Self::assemble_leaf(&role_config, egress.clone(), geo.clone(), cache, 0); @@ -1006,6 +1010,8 @@ impl RuntimeDns { config.use_hosts, config.use_system_hosts, ), + fallback: config.fallback.clone(), + fallback_filter: super::fallback::Filter::new(&config.fallback), fake_config: config.fake_ip.clone(), fake_filter: super::fake_ip::Filter::new(&config.fake_ip), fake_pools: None, @@ -1031,25 +1037,40 @@ impl RuntimeDns { } } + fn policy_for(&self, query: &ClassifiedDnsQuery) -> Option<&[DnsNameserver]> { + let snapshot = self.geo_matcher.routing_snapshot(); + let geo = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); + self.policy_index + .select(&query.question.name, &self.nameserver_policies, geo) + .map(|index| self.nameserver_policies[index].nameservers.as_ref()) + } + fn nameservers_for(&self, query: &ClassifiedDnsQuery) -> &[DnsNameserver] { - if self.nameserver_policies.is_empty() { - return &self.nameservers; - } + self.policy_for(query).unwrap_or(&self.nameservers) + } + + fn fallback_domain(&self, host: &str) -> bool { let snapshot = self.geo_matcher.routing_snapshot(); - let geo_matcher = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); - if let Some(policy_index) = - self.policy_index - .select(&query.question.name, &self.nameserver_policies, geo_matcher) - { - tracing::debug!( - policy_index, - query_type = query.question.query_type, - event = "runtime_dns_nameserver_policy_match", - "runtime DNS selected a nameserver policy" - ); - return &self.nameserver_policies[policy_index].nameservers; - } - &self.nameservers + self.fallback_filter.domain( + host, + snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()), + ) + } + + fn accepts_primary(&self, result: &Result<(Vec, DnsMessage), RuntimeDnsError>) -> bool { + let Ok((_, response)) = result else { + return false; + }; + let Ok(answers) = matching_answer_set(response) else { + return false; + }; + let snapshot = self.geo_matcher.routing_snapshot(); + let geo = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); + !answers.addresses.is_empty() + && answers + .addresses + .iter() + .all(|ip| !self.fallback_filter.address(*ip, geo)) } fn egress_for( @@ -1430,15 +1451,44 @@ impl RuntimeDns { let upstream_query = rewrite_query_id(query, self.next_upstream_id()); let upstream_parsed_query = parse_query(&upstream_query).map_err(RuntimeDnsError::InvalidQuery)?; - let nameservers = self.nameservers_for(classified); - let (mut response, parsed_response) = self - .exchange_upstreams( - nameservers, + let (mut response, parsed_response) = if let Some(policy) = self.policy_for(classified) { + self.exchange_upstreams(policy, &upstream_query, &upstream_parsed_query, deadline) + .await? + } else if self.fallback.nameservers.is_empty() { + self.exchange_upstreams( + &self.nameservers, &upstream_query, &upstream_parsed_query, deadline, ) - .await?; + .await? + } else if self.fallback_domain(&classified.question.name) { + self.exchange_upstreams( + &self.fallback.nameservers, + &upstream_query, + &upstream_parsed_query, + deadline, + ) + .await? + } else { + super::fallback::choose( + self.exchange_upstreams( + &self.nameservers, + &upstream_query, + &upstream_parsed_query, + deadline, + ), + self.exchange_upstreams( + &self.fallback.nameservers, + &upstream_query, + &upstream_parsed_query, + deadline, + ), + self.fallback.lazy, + |answer| self.accepts_primary(answer), + ) + .await? + }; self.cache_response(&parsed_response).await?; restore_response_id(&mut response, 0); Ok(response) @@ -5012,4 +5062,65 @@ dns: .is_err() ); } + #[tokio::test] + async fn dns_fallback_obeys_policy_domains_and_answer_filters() { + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +dns: + enable: true + use-system-hosts: false + nameserver: [192.0.2.1] + fallback: [192.0.2.2] + fallback-lazy-query: true + fallback-filter: + geoip: false + ipcidr: [203.0.113.0/24] + domain: ['+.fallback.example'] + nameserver-policy: + policy.fallback.example: 192.0.2.3 +"#, + ) + .unwrap(); + let filtered: IpAddr = "203.0.113.10".parse().unwrap(); + let accepted: IpAddr = "192.0.2.30".parse().unwrap(); + for (host, answers, endpoints, expected) in [ + ( + "ordinary.example", + vec![filtered, accepted], + vec![1, 2], + accepted, + ), + ("only.fallback.example", vec![accepted], vec![2], accepted), + ("policy.fallback.example", vec![filtered], vec![3], filtered), + ("accepted.example", vec![accepted], vec![1], accepted), + ] { + let dispatcher = Arc::new(MockDispatcher::with_replies( + answers + .into_iter() + .map(|ip| MockReply::Response(ResponseSpec::answer(ip))) + .collect(), + vec![], + )); + let resolver = RuntimeDns::new(&config.dns, dispatcher.clone()); + assert_eq!(resolver.resolve(host).await.unwrap(), [expected]); + assert_eq!( + *dispatcher.udp_destinations.lock().unwrap(), + endpoints + .into_iter() + .map(|last| Destination::Ip(SocketAddr::from(([192, 0, 2, last], 53)))) + .collect::>() + ); + } + // Empty successful answers also trigger fallback. + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Response(ResponseSpec::rcode(RCODE_NXDOMAIN)), + MockReply::Response(ResponseSpec::answer(accepted)), + ], + vec![], + )); + let resolver = RuntimeDns::new(&config.dns, dispatcher); + assert_eq!(resolver.resolve("empty.example").await.unwrap(), [accepted]); + } } diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index c5cd9b6..034e88f 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -467,7 +467,22 @@ impl GeoRequirements { } })); } + if config.dns.enable && !config.dns.fallback.nameservers.is_empty() { + policies.extend(config.dns.fallback.domains.iter().cloned().map(|selector| { + DnsNameserverPolicy { + selector, + nameservers: Box::new([]), + } + })); + } let mut result = Self::collect(&config.rules, &policies)?; + if config.dns.enable + && !config.dns.fallback.nameservers.is_empty() + && let Some(code) = &config.dns.fallback.geoip + && code != "lan" + { + result.ips.insert(GeoDataKind::GeoIp, code)?; + } if config.dns.enable && config.dns.fake_ip.enabled { result.extend(&Self::collect(&config.dns.fake_ip.rules, &[])?)?; } diff --git a/tests/README.md b/tests/README.md index 03b56b1..b69a49b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,6 +17,8 @@ cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --locked --all-features --lib fake_ip_ +cargo test --locked --all-features --lib dns::fallback::tests:: +cargo test --locked --all-features --lib dns::runtime::tests::dns_fallback_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 72059a806705d8696f3108e1be685447d110ff1c Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:21:40 +0800 Subject: [PATCH 011/445] feat(sniffer): apply shared trigger and destination override policies Share bounded TCP prefetch across overlapping HTTP and TLS ports, apply domain/address exclusions and mapping triggers, and preserve UDP reply targets when QUIC overrides routing destinations. Validation: 44 sniffer regressions plus UDP override regression; config cases with the obsolete rejection case corrected; all-target compilation, all-feature clippy and isolated Linux CLI compilation. --- docs/config.yaml | 15 +- docs/geodata.md | 4 +- docs/inbounds.md | 6 +- docs/tun-icmp-dns.md | 20 ++- src/config/mod.rs | 196 +++++++++++++------------- src/config/providers.rs | 26 ++++ src/config/rule_provider.rs | 12 ++ src/config/sniffer.rs | 37 +++++ src/dispatch.rs | 24 ++++ src/geodata/mod.rs | 18 +++ src/inbound/http/server.rs | 33 ++++- src/inbound/socks5/server.rs | 25 +++- src/lib.rs | 3 +- src/routing/dispatcher.rs | 177 ++++++++++++++++-------- src/routing/mod.rs | 6 + src/runtime.rs | 77 ++--------- src/runtime/providers.rs | 23 +++ src/sniffing.rs | 261 +++++++++++++++++++++++++++++++++++ src/tcp_sniffer.rs | 73 +++++----- src/tun_runtime.rs | 99 ++++++------- src/tun_runtime/tests.rs | 4 + tests/README.md | 2 + 22 files changed, 818 insertions(+), 323 deletions(-) create mode 100644 src/config/sniffer.rs create mode 100644 src/sniffing.rs diff --git a/docs/config.yaml b/docs/config.yaml index 07c033b..35e880d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -100,15 +100,22 @@ tun: - any:53 udp-timeout: 300 -# 可选的 TUN TCP/UDP 嗅探器。省略或 enable: false 表示关闭。启用时至少配置 -# HTTP、TLS、QUIC 之一;协议值为空或省略 ports 时分别使用 80/443/443。每种协议 -# 最多 64 个单端口或升序闭区间;HTTP 与 TLS 的 TCP 端口不能重叠,QUIC 使用 -# UDP,可与二者使用相同端口。嗅探结果只参与选路,不改写实际目标。 +# TCP 嗅探用于 TUN 和 mixed;QUIC 多包嗅探用于 TUN UDP。 +# 省略或 enable: false 关闭;未列出协议时不嗅探。协议 ports 省略/为空表示全部端口。 +# HTTP/TLS 重叠端口共享有界预读,按 TLS、HTTP 顺序识别;每协议最多 64 个端口/区间。 sniffer: enable: true + force-dns-mapping: true + parse-pure-ip: true + force-domain: [] # Clash 通配、geosite:、rule-set:;可强制嗅探已知域名/fake-IP + skip-domain: [] # 对识别出的域名生效 + skip-src-address: [] # CIDR、geoip:、rule-set: + skip-dst-address: [] + override-destination: true # false 只提供选路域名;true 同时替换实际目标 sniff: HTTP: ports: [80, 8080-8380] + override-destination: false # 协议项覆盖全局值 TLS: ports: [443, 8443] QUIC: diff --git a/docs/geodata.md b/docs/geodata.md index 438c5d3..c7d30f8 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -100,10 +100,10 @@ geo-update-interval: 24 TUN 的 IP 目标采用以下选路提示优先级: ```text -嗅探域名 > TUN DNS 提示 > 无域名 +嗅探域名 > 原始/恢复的域名 > DNS 提示 > 无域名 ``` -目标本来是域名时使用目标域名,不受提示覆盖。嗅探和 DNS 提示只参与选路,不改写 +DNS 提示只在目标缺少域名时使用;嗅探域名优先参与域名规则。启用嗅探的目标覆盖时替换 实际出站目标。没有域名时,`GEOSITE` 不匹配,也不触发 DNS。 运行时目标、嗅探和 DNS 提示的域名规范化(不是资产加载条件): diff --git a/docs/inbounds.md b/docs/inbounds.md index 1b660dc..458aef4 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -55,7 +55,8 @@ HTTP/1.0 和 HTTP/1.1 使用绝对 `http://host[:port]/path?query` URI。每个 ### 隧道 -CONNECT 在上游成功后才返回 200,保留头后的预读数据。Upgrade 只接受无正文请求; +CONNECT 通常在上游成功后返回 200,保留头后的预读数据。匹配 TCP 嗅探策略时先返回 200, +再读取有界业务前缀并按嗅探结果建链;此后建链失败直接关闭隧道。Upgrade 只接受无正文请求; 合法 101、Connection/Upgrade 一致且选中请求协议时才切隧道,保留双方预读和 TCP 半关闭。非 101 按普通响应处理,非法切换返回 502。 @@ -72,7 +73,8 @@ trailer 8 KiB/100 字段,每个最终响应前最多 16 个临时响应。正 ## SOCKS5 仅支持 RFC 1928/1929 的 SOCKS5 CONNECT、UDP ASSOCIATE 和用户名密码协商。 -不降级绕过已配置认证。精确读取握手并保留 CONNECT 后业务字节;上游成功才回复成功。 +不降级绕过已配置认证。精确读取握手并保留 CONNECT 后业务字节;通常在上游成功后回复成功。匹配 TCP 嗅探策略 +时先确认 CONNECT,取得业务前缀后建链,失败关闭已确认的连接。 目标保留 IPv4、IPv6 或域名交给 Dispatcher,入站不提前解析。TCP 每方向复制 4 KiB, 保留半关闭。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 13c40ff..6f34428 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -50,7 +50,25 @@ UDP 读取器只做有界 wire 分类和任务提交,不同步等待上游。 SVCB、HTTPS、TXT、PTR、MX、SRV、NS、SOA、DNSSEC 和未知 16 位 qtype 都可按原始查询转发。A/AAAA 响应中的可达 CNAME 链继续执行严格地址和 TTL 校验。 -redir-host 模式的 TUN 域名提示存储只在启用 TUN 且业务规则包含域名类规则时创建。嗅探域名优先于 DNS 提示,两者都不改写实际目标。 +redir-host 域名提示在 DNS 开启且规则需要域名或已启用嗅探时创建,供业务入口共用。DNS 提示本身不改写目标;嗅探是否覆盖目标由 override-destination 控制。 + +## 嗅探 + +HTTP/TLS 用于 TUN 与 mixed TCP;QUIC v1/v2 多包识别用于 TUN UDP。 +`parse-pure-ip` 控制无域名目标,`force-dns-mapping` 控制 redir-host 提示,均默认 true。 +fake-IP 恢复后的域名与显式域名只在 `force-domain` 匹配时强制嗅探。 +`skip-src-address` / `skip-dst-address` 在读取前排除地址;`skip-domain` 排除识别结果。 +域名项支持通配、GeoSite 和 domain/classical 规则集合;地址项支持 CIDR、GeoIP 与 +ipcidr/classical 集合,classical 只使用对应的域名或目标 IP 条件。 + +全局 `override-destination` 默认 true,协议级值优先。false 只将识别域名提供给规则, +保留实际目标;true 替换为识别域名,DIRECT 或必须 IP 的传输再解析。 +UDP 覆盖保留原始目标供回包使用,活动流不随后续查询变更。 + +HTTP/TLS 端口可重叠。每连接共享最多 64 KiB 预读,首字节最多一秒,随后解析最多一秒; +优先识别 TLS,再识别 HTTP。失败、超时或跳过均保留已读数据供业务转发。 +HTTP 识别 HTTP/1.x Host;TLS/QUIC 识别 ClientHello,检测到 ECH 时不使用外层 SNI。 +不嗅探劫持的 DNS。没有列出任何协议时不读取业务数据。 ## Fake-IP diff --git a/src/config/mod.rs b/src/config/mod.rs index f03cf7b..dd863ab 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,7 @@ //! Strict parsing for the current Vole YAML configuration. mod fake_ip; mod fallback; +mod sniffer; pub use fake_ip::{FakeIpConfig, FakeIpFilterMode, FakeIpRange}; pub use fallback::DnsFallbackConfig; mod http_proxy; @@ -215,6 +216,15 @@ impl TunConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnifferConfig { pub enable: bool, + pub force_dns_mapping: bool, + pub parse_pure_ip: bool, + pub force_domain: Vec, + pub skip_domain: Vec, + pub skip_src_address: Vec, + pub skip_dst_address: Vec, + pub http_override: bool, + pub tls_override: bool, + pub quic_override: bool, pub http_ports: Box<[PortRange]>, pub tls_ports: Box<[PortRange]>, pub quic_ports: Box<[PortRange]>, @@ -225,6 +235,15 @@ impl SnifferConfig { pub fn disabled() -> Self { Self { enable: false, + force_dns_mapping: true, + parse_pure_ip: true, + force_domain: Vec::new(), + skip_domain: Vec::new(), + skip_src_address: Vec::new(), + skip_dst_address: Vec::new(), + http_override: true, + tls_override: true, + quic_override: true, http_ports: Box::new([]), tls_ports: Box::new([]), quic_ports: Box::new([]), @@ -1129,37 +1148,57 @@ impl RawTunConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawSnifferConfig { + #[serde(default)] enable: bool, + #[serde(rename = "force-dns-mapping", default = "default_true")] + force_dns_mapping: bool, + #[serde(rename = "parse-pure-ip", default = "default_true")] + parse_pure_ip: bool, + #[serde(rename = "force-domain", default)] + force_domain: Vec, + #[serde(rename = "skip-domain", default)] + skip_domain: Vec, + #[serde(rename = "skip-src-address", default)] + skip_src_address: Vec, + #[serde(rename = "skip-dst-address", default)] + skip_dst_address: Vec, + #[serde(rename = "override-destination", default = "default_true")] + override_destination: bool, #[serde(default, deserialize_with = "deserialize_present_option")] sniff: Option, } -#[derive(Debug, Default, Deserialize)] -#[serde(deny_unknown_fields)] +#[derive(Debug, Default)] struct RawSniffConfig { - #[serde( - rename = "HTTP", - default, - deserialize_with = "deserialize_present_option" - )] http: Option>, - #[serde( - rename = "TLS", - default, - deserialize_with = "deserialize_present_option" - )] tls: Option>, - #[serde( - rename = "QUIC", - default, - deserialize_with = "deserialize_present_option" - )] quic: Option>, } +impl<'de> Deserialize<'de> for RawSniffConfig { + fn deserialize>(deserializer: D) -> std::result::Result { + let entries = serde_yaml_ng::Mapping::deserialize(deserializer)?; + let mut result = Self::default(); + for (name, value) in entries { + let name = name.as_str().ok_or_else(|| { + serde::de::Error::custom("sniffer protocol name must be a string") + })?; + let field = match name.to_ascii_uppercase().as_str() { + "HTTP" => &mut result.http, + "TLS" => &mut result.tls, + "QUIC" => &mut result.quic, + _ => return Err(serde::de::Error::custom("unsupported sniffer protocol")), + }; + *field = Some(serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?); + } + Ok(result) + } +} #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] struct RawSniffProtocolConfig { + #[serde(rename = "override-destination", default)] + override_destination: Option, #[serde(default, deserialize_with = "deserialize_present_option")] ports: Option>, } @@ -2038,35 +2077,35 @@ fn normalize_geox_url(raw: String, field: &str) -> Result { impl RawSnifferConfig { fn normalize(self) -> Result { let sniff = self.sniff.unwrap_or_default(); - let http = normalize_sniffer_protocol(sniff.http, 80, "HTTP")?; - let tls = normalize_sniffer_protocol(sniff.tls, 443, "TLS")?; - let quic = normalize_sniffer_protocol(sniff.quic, 443, "QUIC")?; - - if self.enable && http.is_none() && tls.is_none() && quic.is_none() { - return invalid( - "sniffer.enable: true requires at least one HTTP, TLS, or QUIC sniffer", - ); - } - - let http_ports = http.unwrap_or_default(); - let tls_ports = tls.unwrap_or_default(); - let quic_ports = quic.unwrap_or_default(); - if port_ranges_overlap(&http_ports, &tls_ports) { - return invalid("sniffer HTTP and TLS port ranges must not overlap"); - } - + let override_for = |raw: &Option>| { + raw.as_ref() + .and_then(Option::as_ref) + .and_then(|raw| raw.override_destination) + .unwrap_or(self.override_destination) + }; + let http_override = override_for(&sniff.http); + let tls_override = override_for(&sniff.tls); + let quic_override = override_for(&sniff.quic); Ok(SnifferConfig { enable: self.enable, - http_ports, - tls_ports, - quic_ports, + force_dns_mapping: self.force_dns_mapping, + parse_pure_ip: self.parse_pure_ip, + force_domain: sniffer::domains(self.force_domain)?, + skip_domain: sniffer::domains(self.skip_domain)?, + skip_src_address: sniffer::addresses(self.skip_src_address)?, + skip_dst_address: sniffer::addresses(self.skip_dst_address)?, + http_override, + tls_override, + quic_override, + http_ports: normalize_sniffer_protocol(sniff.http, "HTTP")?.unwrap_or_default(), + tls_ports: normalize_sniffer_protocol(sniff.tls, "TLS")?.unwrap_or_default(), + quic_ports: normalize_sniffer_protocol(sniff.quic, "QUIC")?.unwrap_or_default(), }) } } fn normalize_sniffer_protocol( raw: Option>, - default_port: u16, protocol: &str, ) -> Result>> { let Some(raw) = raw else { @@ -2075,12 +2114,18 @@ fn normalize_sniffer_protocol( let raw = raw.unwrap_or_default(); let ranges = match raw.ports { None => vec![PortRange { - start: default_port, - end: default_port, + start: 0, + end: u16::MAX, }], Some(items) => { if items.is_empty() { - return invalid(format!("sniffer.sniff.{protocol}.ports must not be empty")); + return Ok(Some( + vec![PortRange { + start: 0, + end: u16::MAX, + }] + .into_boxed_slice(), + )); } if items.len() > MAX_SNIFFER_PORT_ITEMS { return invalid(format!( @@ -2146,14 +2191,11 @@ fn parse_sniffer_port_string(value: &str, protocol: &str) -> Result { } fn parse_sniffer_port_number(value: u64, protocol: &str) -> Result { - u16::try_from(value) - .ok() - .filter(|port| *port != 0) - .ok_or_else(|| { - VoleError::InvalidConfig(format!( - "sniffer.sniff.{protocol}.ports values must be between 1 and 65535" - )) - }) + u16::try_from(value).ok().ok_or_else(|| { + VoleError::InvalidConfig(format!( + "sniffer.sniff.{protocol}.ports values must be between 1 and 65535" + )) + }) } fn merge_port_ranges(mut ranges: Vec) -> Vec { @@ -2171,20 +2213,6 @@ fn merge_port_ranges(mut ranges: Vec) -> Vec { merged } -fn port_ranges_overlap(left: &[PortRange], right: &[PortRange]) -> bool { - let (mut left_index, mut right_index) = (0, 0); - while let (Some(left), Some(right)) = (left.get(left_index), right.get(right_index)) { - if left.end < right.start { - left_index += 1; - } else if right.end < left.start { - right_index += 1; - } else { - return true; - } - } - false -} - fn port_ranges_contain(ranges: &[PortRange], port: u16) -> bool { ranges .binary_search_by(|range| { @@ -3809,7 +3837,10 @@ geo-update-interval: 24"#] assert!(config.sniffer.enable); assert_eq!( config.sniffer.http_ports.as_ref(), - &[PortRange { start: 80, end: 80 }] + &[PortRange { + start: 0, + end: u16::MAX + }] ); assert_eq!( config.sniffer.tls_ports.as_ref(), @@ -3825,18 +3856,18 @@ geo-update-interval: 24"#] ] ); assert!(config.sniffer.matches_http_port(80)); - assert!(!config.sniffer.matches_http_port(8080)); + assert!(config.sniffer.matches_http_port(8080)); assert!(config.sniffer.matches_tls_port(8445)); assert!(!config.sniffer.matches_tls_port(8448)); assert_eq!( config.sniffer.quic_ports.as_ref(), &[PortRange { - start: 443, - end: 443 + start: 0, + end: u16::MAX }] ); assert!(config.sniffer.matches_quic_port(443)); - assert!(!config.sniffer.matches_quic_port(8443)); + assert!(config.sniffer.matches_quic_port(8443)); let http_only = Config::parse_yaml(with_sniffer(" enable: true\n sniff:\n HTTP: {}").as_bytes()) @@ -3902,21 +3933,13 @@ geo-update-interval: 24"#] } #[test] - fn rejects_invalid_sniffer_shapes_ports_and_overlap() { + fn rejects_invalid_sniffer_shapes_and_ports() { let too_many_ports = (1..=MAX_SNIFFER_PORT_ITEMS + 1) .map(|port| port.to_string()) .collect::>() .join(", "); for yaml in [ - with_sniffer(" enable: true"), - with_sniffer(" enable: true\n sniff: {}"), - with_sniffer(" sniff:\n HTTP: {}"), with_sniffer(" enable: \"true\"\n sniff:\n HTTP: {}"), - CURRENT_TLS.replacen("tun:\n", "sniffer:\ntun:\n", 1), - with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: []"), - with_sniffer(" enable: true\n sniff:\n QUIC:\n ports: []"), - with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: [0]"), - with_sniffer(" enable: true\n sniff:\n QUIC:\n ports: [0]"), with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: [65536]"), with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: [\"\"]"), with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: [\"+80\"]"), @@ -3926,9 +3949,6 @@ geo-update-interval: 24"#] with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: [\"443-\"]"), with_sniffer(" enable: true\n sniff:\n HTTP:\n ports: [\"444-443\"]"), with_sniffer(" enable: true\n sniff:\n QUIC:\n ports: [\"444-443\"]"), - with_sniffer( - " enable: true\n sniff:\n HTTP:\n ports: [\"80-90\"]\n TLS:\n ports: [90]", - ), with_sniffer(&format!( " enable: true\n sniff:\n HTTP:\n ports: [{too_many_ports}]" )), @@ -4223,22 +4243,6 @@ geo-update-interval: 24"#] ), CURRENT_TLS.replace("tun:\n", "tun:\n typo: true\n"), with_sniffer(" enable: true\n typo: true\n sniff:\n HTTP: {}"), - with_sniffer(" enable: true\n override-destination: false\n sniff:\n HTTP: {}"), - with_sniffer(" enable: true\n force-dns-mapping: true\n sniff:\n HTTP: {}"), - with_sniffer(" enable: true\n parse-pure-ip: true\n sniff:\n HTTP: {}"), - with_sniffer(" enable: true\n force-domain: [example.com]\n sniff:\n HTTP: {}"), - with_sniffer( - " enable: true\n skip-src-address: [192.0.2.1/32]\n sniff:\n HTTP: {}", - ), - with_sniffer( - " enable: true\n skip-dst-address: [192.0.2.1/32]\n sniff:\n HTTP: {}", - ), - with_sniffer(" enable: true\n skip-domain: [example.com]\n sniff:\n HTTP: {}"), - with_sniffer(" enable: true\n sniffing: [http]"), - with_sniffer(" enable: true\n port-whitelist: [80]"), - with_sniffer(" enable: true\n sniff:\n http: {}"), - with_sniffer(" enable: true\n sniff:\n quic: {}"), - with_sniffer(" enable: true\n sniff:\n HTTP:\n override-destination: false"), CURRENT_TLS.replace(" - name: proxy\n", " - typo: true\n name: proxy\n"), CURRENT_TLS.replace(" server: ", " vnext: []\n server: "), CURRENT_TLS.replace(" network: xhttp", " typo: true\n network: xhttp"), diff --git a/src/config/providers.rs b/src/config/providers.rs index ed73c47..4b6ffbe 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -396,6 +396,32 @@ impl ProviderTemplate { sets.insert(name.clone(), rules.clone()); } bind_rules(&mut config.rules, &sets)?; + bind_rules(&mut config.sniffer.skip_src_address, &sets)?; + bind_rules(&mut config.sniffer.skip_dst_address, &sets)?; + for rule in config + .sniffer + .skip_src_address + .iter() + .chain(&config.sniffer.skip_dst_address) + { + if let RuleKind::RuleSet { + content: Some(set), .. + } = &rule.kind + && set.behavior() == RuleBehavior::Domain + { + return invalid( + "sniffer address filter requires an ipcidr or classical rule provider", + ); + } + } + for selector in config + .sniffer + .force_domain + .iter_mut() + .chain(&mut config.sniffer.skip_domain) + { + bind_domain_selector(selector, &sets)?; + } bind_rules(&mut config.dns.fake_ip.rules, &sets)?; for rule in &config.dns.fake_ip.rules { if let RuleKind::RuleSet { diff --git a/src/config/rule_provider.rs b/src/config/rule_provider.rs index 99c6660..72b0a32 100644 --- a/src/config/rule_provider.rs +++ b/src/config/rule_provider.rs @@ -188,6 +188,18 @@ impl RuleCollection { } } } + pub(crate) fn matches_ip(&self, address: std::net::IpAddr, geo: &dyn GeoMatcher) -> bool { + let destination = crate::session::Destination::Ip(std::net::SocketAddr::new(address, 1)); + let context = RoutingContext::new(Network::Tcp, &destination).expect("literal address"); + if self.behavior == RuleBehavior::Classical { + self.classical.iter().any(|rule| { + matches!(rule.kind, RuleKind::IpCidr(_) | RuleKind::GeoIp(_)) + && crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) + }) + } else { + self.matches(&context, geo, true).unwrap_or(false) + } + } pub(crate) fn matches_domain(&self, domain: &str, geo: &dyn GeoMatcher) -> bool { let Ok(destination) = crate::session::Destination::domain(domain, 53) else { return false; diff --git a/src/config/sniffer.rs b/src/config/sniffer.rs new file mode 100644 index 0000000..ffa1bac --- /dev/null +++ b/src/config/sniffer.rs @@ -0,0 +1,37 @@ +use super::*; + +pub(super) fn addresses(values: Vec) -> Result> { + let mut rules = Vec::new(); + for value in values { + let lower = value.to_ascii_lowercase(); + if let Some(codes) = lower.strip_prefix("geoip:") { + for code in codes.split(',') { + rules.push(parse_rule( + &format!("GEOIP,{code},DIRECT,no-resolve"), + &RouteTargetsByName::new(), + )?); + } + } else if lower.starts_with("rule-set:") { + for name in value[9..].split(',') { + rules.push(parse_rule( + &format!("RULE-SET,{name},DIRECT,no-resolve"), + &RouteTargetsByName::new(), + )?); + } + } else { + let cidr = parse_ip_cidr(&value, value.contains(':'))?; + rules.push(RuleSpec { + kind: RuleKind::IpCidr(cidr), + action: RuleAction::Direct, + no_resolve: true, + }); + } + } + Ok(rules) +} +pub(super) fn domains(values: Vec) -> Result> { + values + .iter() + .map(|pattern| normalize_dns_policy_selector(pattern)) + .collect() +} diff --git a/src/dispatch.rs b/src/dispatch.rs index a376943..430d6d2 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -110,6 +110,14 @@ pub trait DatagramTransport: Send { #[async_trait] pub trait Dispatcher: Send + Sync { + async fn sniff_plan( + &self, + _source: std::net::SocketAddr, + _destination: &crate::session::Destination, + ) -> Option { + None + } + async fn connect_tcp(&self, session: StreamSession) -> Result; async fn open_datagram( @@ -138,6 +146,14 @@ impl SessionObservedDispatcher { #[async_trait] impl Dispatcher for SessionObservedDispatcher { + async fn sniff_plan( + &self, + source: std::net::SocketAddr, + destination: &crate::session::Destination, + ) -> Option { + self.inner.sniff_plan(source, destination).await + } + async fn connect_tcp(&self, session: StreamSession) -> Result { let stream = self.inner.connect_tcp(session).await?; Ok(Box::new(ObservedStream { @@ -178,6 +194,14 @@ impl HandshakeObservedDispatcher { #[async_trait] impl Dispatcher for HandshakeObservedDispatcher { + async fn sniff_plan( + &self, + source: std::net::SocketAddr, + destination: &crate::session::Destination, + ) -> Option { + self.inner.sniff_plan(source, destination).await + } + async fn connect_tcp(&self, session: StreamSession) -> Result { let _activity = self.resource_stats.begin(ResourceActivity::Handshake); self.inner.connect_tcp(session).await diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 034e88f..7496a4d 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -475,7 +475,25 @@ impl GeoRequirements { } })); } + if config.sniffer.enable { + policies.extend( + config + .sniffer + .force_domain + .iter() + .chain(&config.sniffer.skip_domain) + .cloned() + .map(|selector| DnsNameserverPolicy { + selector, + nameservers: Box::new([]), + }), + ); + } let mut result = Self::collect(&config.rules, &policies)?; + if config.sniffer.enable { + result.extend(&Self::collect(&config.sniffer.skip_src_address, &[])?)?; + result.extend(&Self::collect(&config.sniffer.skip_dst_address, &[])?)?; + } if config.dns.enable && !config.dns.fallback.nameservers.is_empty() && let Some(code) = &config.dns.fallback.geoip diff --git a/src/inbound/http/server.rs b/src/inbound/http/server.rs index 27b0152..9a88e6e 100644 --- a/src/inbound/http/server.rs +++ b/src/inbound/http/server.rs @@ -270,12 +270,43 @@ where return Err(error); } }; - let session = StreamSession { + let mut session = StreamSession { inbound: InboundKind::Http, source: peer, destination, sniffed_domain: None, }; + let sniff = dispatcher + .sniff_plan(peer, &session.destination) + .await + .filter(|plan| plan.tcp_enabled()); + if let (Some(forward), Some(sniff)) = (&plan, &sniff) { + let _prefix = sniff + .sniff(&mut forward.head.as_slice(), &mut session) + .await; + } + if plan.is_none() + && let Some(sniff) = sniff + { + write_timed( + writer, + b"HTTP/1.1 200 Connection Established\r\n\r\n", + config.header_timeout, + ) + .await?; + let prefix = sniff.sniff(reader, &mut session).await; + let mut outbound = timeout(config.connect_timeout, dispatcher.connect_tcp(session)) + .await + .map_err(|_| { + io::Error::new(io::ErrorKind::TimedOut, "HTTP outbound connect timed out") + })? + .map_err(dispatch_to_io)?; + if !prefix.is_empty() { + write_timed(&mut outbound, &prefix, config.connect_timeout).await?; + } + let (mut remote_read, mut remote_write) = tokio::io::split(outbound); + return relay(reader, writer, &mut remote_read, &mut remote_write).await; + } let outbound = connect(dispatcher, session, config, writer, diagnostic).await?; if let Some(plan) = plan { if !forward_request(reader, writer, outbound, &request, plan, config).await? { diff --git a/src/inbound/socks5/server.rs b/src/inbound/socks5/server.rs index fa1fc6a..86aef15 100644 --- a/src/inbound/socks5/server.rs +++ b/src/inbound/socks5/server.rs @@ -1,7 +1,7 @@ use std::{io, net::SocketAddr, sync::Arc, time::Duration}; use tokio::{ - io::{AsyncRead, AsyncReadExt, AsyncWrite, copy_bidirectional_with_sizes}, + io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, copy_bidirectional_with_sizes}, net::{TcpListener, TcpStream, UdpSocket}, sync::mpsc, task::JoinSet, @@ -199,18 +199,34 @@ impl Socks5Handler { return Ok(()); } if command == COMMAND_CONNECT { - let session = StreamSession { + let mut session = StreamSession { inbound: InboundKind::Socks5, source: peer, destination, sniffed_domain: None, }; + let sniff = dispatcher + .sniff_plan(peer, &session.destination) + .await + .filter(|plan| plan.tcp_enabled()); + let acknowledged = sniff.is_some(); + let prefix = if let Some(plan) = sniff { + timed(handshake::reply(&mut stream, 0, None)).await?; + plan.sniff(&mut stream, &mut session).await + } else { + Vec::new() + }; let connected = timeout(IO_TIMEOUT, dispatcher.connect_tcp(session)) .await .unwrap_or(Err(DispatchError::TimedOut)); match connected { Ok(mut upstream) => { - timed(handshake::reply(&mut stream, 0, None)).await?; + if !acknowledged { + timed(handshake::reply(&mut stream, 0, None)).await?; + } + if !prefix.is_empty() { + timed(upstream.write_all(&prefix)).await?; + } copy_bidirectional_with_sizes( &mut stream, &mut upstream, @@ -220,6 +236,9 @@ impl Socks5Handler { .await?; } Err(error) => { + if acknowledged { + return Err(io::Error::other(error)); + } timed(handshake::reply( &mut stream, handshake::status(&error), diff --git a/src/lib.rs b/src/lib.rs index 51af6dd..51ebcd7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -50,6 +50,8 @@ pub mod routing; mod runtime; pub mod security; pub mod session; +#[doc(hidden)] +pub mod sniffing; #[cfg(any( feature = "inbound-socks5", feature = "outbound-http", @@ -60,7 +62,6 @@ pub mod session; feature = "outbound-vless" ))] mod socks5; -#[cfg(any(feature = "tun", test))] mod tcp_sniffer; #[cfg(any( feature = "inbound-http", diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 39fa2f3..2f5e4f5 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -202,9 +202,16 @@ pub struct RoutingDispatcher { rules: Arc, geo_matcher: Arc, tun_config: TunConfig, + sniffer: Option>, } impl RoutingDispatcher { + pub(crate) fn with_sniffer(mut self, config: &crate::config::SnifferConfig) -> Self { + self.sniffer = config.enable.then(|| { + crate::sniffing::Policy::new(Arc::new(config.clone()), self.geo_matcher.clone()) + }); + self + } #[must_use] pub fn new( route_targets: ProxyDispatchers, @@ -233,6 +240,7 @@ impl RoutingDispatcher { rules: Arc::new(rules), geo_matcher, tun_config: TunConfig::default(), + sniffer: None, } } @@ -253,6 +261,7 @@ impl RoutingDispatcher { ipv6, rules: Arc::new(rules), geo_matcher, + sniffer: None, tun_config: TunConfig { udp_timeout: Duration::from_secs(30), ..TunConfig::default() @@ -307,8 +316,26 @@ impl RoutingDispatcher { } else { None }; - let destination = recovered.as_ref().unwrap_or(destination); - let accepts_domain_hint = inbound == InboundKind::Tun && self.rules.uses_domain_routing(); + let overridden = (network == Network::Udp + && self + .sniffer + .as_ref() + .is_some_and(|policy| policy.config.quic_override)) + .then(|| { + sniffed_domain.map(|host| Destination::Domain { + host: host.to_owned(), + port: destination.port(), + }) + }) + .flatten(); + let destination = overridden + .as_ref() + .or(recovered.as_ref()) + .unwrap_or(destination); + let accepts_domain_hint = matches!( + inbound, + InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 + ) && self.rules.uses_domain_routing(); let sniffed_domain = accepts_domain_hint.then_some(sniffed_domain).flatten(); let dns_domain_hint = match (accepts_domain_hint, destination, &self.dns, sniffed_domain) { (true, Destination::Ip(address), Some(dns), None) => { @@ -319,6 +346,9 @@ impl RoutingDispatcher { let domain_hint = sniffed_domain.or(dns_domain_hint.as_deref()); let mut context = RoutingContext::with_domain_hint(network, destination, domain_hint) .map_err(|_| DispatchError::HostUnreachable)?; + if let Some(host) = sniffed_domain { + context.set_sniffed_domain(host); + } context.set_source(source); @@ -331,8 +361,7 @@ impl RoutingDispatcher { RuleEvaluation::NeedsIpResolution { rule_index } => { let addresses = match (destination, &self.dns) { (Destination::Domain { host, .. }, Some(dns)) => { - self.resolve_addresses(dns, context.domain().unwrap_or(host)) - .await + self.resolve_addresses(dns, host).await } _ => Vec::new(), }; @@ -354,11 +383,9 @@ impl RoutingDispatcher { }; let routed_destination = match destination { - Destination::Domain { port, .. } => Destination::Domain { - host: context - .domain() - .expect("domain destination has a normalized routing domain") - .to_owned(), + Destination::Domain { host, port } => Destination::Domain { + host: super::normalize_domain_name(host) + .map_err(|_| DispatchError::HostUnreachable)?, port: *port, }, Destination::Ip(_) => destination.clone(), @@ -536,6 +563,35 @@ fn preferred_address(addresses: &[IpAddr]) -> Option { #[async_trait] impl Dispatcher for RoutingDispatcher { + async fn sniff_plan( + &self, + source: SocketAddr, + destination: &Destination, + ) -> Option { + let policy = self.sniffer.as_ref()?; + let (host, mapped) = match destination { + Destination::Domain { host, .. } => (Some(host.clone()), false), + Destination::Ip(address) => { + let fake = self + .dns + .as_ref() + .and_then(|dns| dns.recover_fake_ip(address.ip()).ok().flatten()); + if fake.is_some() { + (fake, false) + } else { + let hint = if let Some(dns) = &self.dns { + dns.domain_hint(address.ip()).await + } else { + None + }; + let mapped = hint.is_some(); + (hint, mapped) + } + } + }; + Some(policy.plan(source, destination, host.as_deref(), mapped)) + } + async fn connect_tcp(&self, mut session: StreamSession) -> Result { self.ensure_destination_allowed(&session.destination)?; if session.inbound == InboundKind::Tun @@ -604,6 +660,7 @@ impl RoutingDispatcher { rules: self.rules.clone(), geo_matcher: self.geo_matcher.clone(), tun_config: self.tun_config.clone(), + sniffer: self.sniffer.clone(), } } } @@ -929,7 +986,13 @@ impl DatagramTransport for RoutedDatagramTransport { .map(|dns| dns.recover_fake_ip(original.ip())) .transpose()? .flatten() - .is_some()) + .is_some() + || (datagram.sniffed_domain.is_some() + && self + .router + .sniffer + .as_ref() + .is_some_and(|policy| policy.config.quic_override))) { return self.send_fake(original, datagram).await; } @@ -1399,6 +1462,7 @@ mod tests { ), geo_matcher: Arc::new(EmptyGeoMatcher), tun_config: TunConfig::default(), + sniffer: None, }; router @@ -1805,61 +1869,31 @@ mod tests { } #[tokio::test] - async fn dns_and_sniffed_domain_hints_are_consumed_only_by_tun() { + async fn business_inbounds_use_sniffed_domains_without_exposing_them_to_outbounds() { let destination: SocketAddr = "198.51.100.8:443".parse().unwrap(); let proxy = Arc::new(RecordingDispatcher::default()); let direct = Arc::new(RecordingDispatcher::default()); - let mut dns = MockDns::new(Vec::new()); - dns.hint = Some((destination.ip(), "hint.example".to_owned())); - let dns = Arc::new(dns); let router = dispatcher( proxy.clone(), direct.clone(), - Some(dns.clone()), + None, vec![ - rule( - RuleKind::Domain("hint.example".to_owned()), - RuleAction::Direct, - ), + rule(RuleKind::Domain("hint.example".into()), RuleAction::Direct), rule(RuleKind::Match, proxy_action(0)), ], ); - - router - .connect_tcp(StreamSession { - inbound: InboundKind::Http, - source: "127.0.0.1:10000".parse().unwrap(), - destination: Destination::Ip(destination), - sniffed_domain: Some("hint.example".to_owned()), - }) - .await - .unwrap(); - assert_eq!(dns.hint_calls.load(Ordering::Relaxed), 0); - assert_eq!(proxy.tcp_sessions.lock().unwrap().len(), 1); - assert!(direct.tcp_sessions.lock().unwrap().is_empty()); - - router - .connect_tcp(stream(destination, InboundKind::Tun)) - .await - .unwrap(); - assert_eq!(dns.hint_calls.load(Ordering::Relaxed), 1); - assert_eq!(direct.tcp_sessions.lock().unwrap().len(), 1); - - let mut http_udp = router - .open_datagram(association(InboundKind::Http)) - .await - .unwrap(); - http_udp.send(datagram(destination, b"http")).await.unwrap(); - assert_eq!(dns.hint_calls.load(Ordering::Relaxed), 1); - - let mut tun_udp = router - .open_datagram(association(InboundKind::Tun)) - .await - .unwrap(); - tun_udp.send(datagram(destination, b"tun")).await.unwrap(); - assert_eq!(dns.hint_calls.load(Ordering::Relaxed), 2); - assert_eq!(proxy.datagrams.lock().unwrap().len(), 1); - assert_eq!(direct.datagrams.lock().unwrap().len(), 1); + for inbound in [InboundKind::Http, InboundKind::Socks5, InboundKind::Tun] { + let mut session = stream(destination, inbound); + session.sniffed_domain = Some("hint.example".into()); + router.connect_tcp(session).await.unwrap(); + } + assert!(proxy.tcp_sessions.lock().unwrap().is_empty()); + let sessions = direct.tcp_sessions.lock().unwrap(); + assert_eq!(sessions.len(), 3); + assert!(sessions.iter().all( + |session| session.destination == Destination::Ip(destination) + && session.sniffed_domain.is_none() + )); } #[tokio::test] @@ -3147,4 +3181,37 @@ mod tests { assert!(proxy.datagrams.lock().unwrap().iter().all(|packet| packet.remote == Destination::Ip("203.0.113.10:443".parse().unwrap()))); udp.close().await.unwrap(); } + #[tokio::test] + async fn quic_override_resolves_new_domain_and_restores_udp_response_target() { + let mut config = crate::config::SnifferConfig::disabled(); + config.enable = true; + let proxy = Arc::new(RecordingDispatcher::default()); + let direct = Arc::new(RecordingDispatcher::default()); + let dns = Arc::new(MockDns::new(vec!["203.0.113.8".parse().unwrap()])); + let router = dispatcher( + proxy.clone(), + direct, + Some(dns.clone()), + vec![rule(RuleKind::Match, proxy_action(0))], + ) + .with_sniffer(&config); + let original = "198.51.100.4:443".parse().unwrap(); + let mut transport = router + .open_datagram(association(InboundKind::Tun)) + .await + .unwrap(); + let mut packet = datagram(original, b"quic"); + packet.sniffed_domain = Some("sniffed.example".into()); + transport.send(packet).await.unwrap(); + assert_eq!( + proxy.datagrams.lock().unwrap()[0].remote, + Destination::Ip("203.0.113.8:443".parse().unwrap()) + ); + assert_eq!( + transport.receive().await.unwrap().remote, + Destination::Ip(original) + ); + assert_eq!(*dns.resolved_hosts.lock().unwrap(), ["sniffed.example"]); + transport.close().await.unwrap(); + } } diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 21f123d..7cee52f 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -47,6 +47,12 @@ pub struct RoutingContext { } impl RoutingContext { + pub(crate) fn set_sniffed_domain(&mut self, host: &str) { + if let Ok(host) = normalize_domain_name(host) { + self.normalized_domain = Some(host.into_boxed_str()); + } + } + /// Builds a context from the original destination without a redir-host /// domain hint. pub fn new(network: Network, destination: &Destination) -> Result { diff --git a/src/runtime.rs b/src/runtime.rs index f6800c0..7f7a7a7 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -364,22 +364,13 @@ impl PreparedCore { self.traffic_stats.clone() } - #[cfg(test)] - fn dns_redir_host_entries(&self) -> usize { - if self.has_tun() && self.rules.uses_domain_routing() { - self.limits.dns_redir_host_entries - } else { - 0 - } - } - // Sniffing remains a shared TUN capability, including Windows CLI. #[cfg_attr( all(feature = "cli", not(feature = "invoke"), unix, not(test)), allow(dead_code) )] fn domain_sniffer_config(&self) -> Option> { - (self.has_tun() && self.config.sniffer.enable && self.rules.uses_domain_routing()) + (self.has_tun() && self.config.sniffer.enable) .then(|| Arc::new(self.config.sniffer.clone())) } @@ -678,7 +669,9 @@ impl GraphBuilder { RouteTargetDispatchers::with_proxy_groups(proxy_dispatchers, &proxy_groups) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let rules = self.rules.clone(); - let redir_host_entries = if self.config.tun.enable && self.rules.uses_domain_routing() { + let redir_host_entries = if self.config.dns.enable + && (self.rules.uses_domain_routing() || self.config.sniffer.enable) + { self.limits.dns_redir_host_entries } else { 0 @@ -725,7 +718,8 @@ impl GraphBuilder { rules, geo_matcher, ) - .with_tun_config(&self.config.tun), + .with_tun_config(&self.config.tun) + .with_sniffer(&self.config.sniffer), ); let session_stats = RuntimeResourceStats::new("runtime_session_observation"); Ok(BuiltRuntimeParts { @@ -2043,55 +2037,7 @@ rules: } #[tokio::test] - async fn production_redir_host_store_requires_tun_and_domain_rules() { - let domain_rules = r#" -rules: - - DOMAIN-SUFFIX,example.com,DIRECT - - MATCH,proxy -"#; - let tun = r#" -tun: - enable: true - mtu: 1500 -"#; - - let no_tun = PreparedCore::prepare( - config_with_rules(domain_rules).as_bytes(), - &FixedResolver, - ResourceLimits::default(), - ) - .await - .unwrap(); - assert_eq!(no_tun.dns_redir_host_entries(), 0); - - let no_domain_rules = PreparedCore::prepare( - format!("{CONFIG}{tun}").as_bytes(), - &FixedResolver, - ResourceLimits::default(), - ) - .await - .unwrap(); - assert_eq!(no_domain_rules.dns_redir_host_entries(), 0); - - let enabled = PreparedCore::prepare( - format!("{}{tun}", config_with_rules(domain_rules)).as_bytes(), - &FixedResolver, - ResourceLimits::default(), - ) - .await - .unwrap(); - assert_eq!( - enabled.dns_redir_host_entries(), - ResourceLimits::default().dns_redir_host_entries - ); - assert!( - enabled.domain_sniffer_config().is_none(), - "redir-host remains available when the optional sniffer is omitted" - ); - } - - #[tokio::test] - async fn production_domain_sniffer_requires_tun_enable_and_domain_rules() { + async fn production_tun_sniffer_is_independent_of_domain_rules() { let domain_rules = r#" rules: - DOMAIN-SUFFIX,example.com,DIRECT @@ -2128,7 +2074,7 @@ sniffer: ) .await .unwrap(); - assert!(no_domain_rules.domain_sniffer_config().is_none()); + assert!(no_domain_rules.domain_sniffer_config().is_some()); let disabled = PreparedCore::prepare( format!( @@ -2142,11 +2088,6 @@ sniffer: .await .unwrap(); assert!(disabled.domain_sniffer_config().is_none()); - assert_eq!( - disabled.dns_redir_host_entries(), - ResourceLimits::default().dns_redir_host_entries, - "sniffer disablement must not remove DNS routing hints" - ); let enabled = PreparedCore::prepare( format!("{}{tun}{sniffer}", config_with_rules(domain_rules)).as_bytes(), @@ -2157,7 +2098,7 @@ sniffer: .unwrap(); let normalized = enabled .domain_sniffer_config() - .expect("all three production sniffer gates are enabled"); + .expect("the TUN sniffer is enabled"); assert!(normalized.matches_http_port(8080)); assert!(!normalized.matches_http_port(80)); assert!(normalized.matches_tls_port(8444)); diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index e76c7bf..edcfc96 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -482,6 +482,21 @@ impl RuntimeResources for ProviderRuntime { } #[async_trait] impl Dispatcher for ProviderRuntime { + async fn sniff_plan( + &self, + source: SocketAddr, + destination: &Destination, + ) -> Option { + if self.cancellation.is_cancelled() { + return None; + } + self.current + .load_full() + .dispatcher + .sniff_plan(source, destination) + .await + } + async fn connect_tcp(&self, session: StreamSession) -> Result { if self.cancellation.is_cancelled() { return Err(DispatchError::NotAllowed); @@ -514,6 +529,14 @@ struct PinnedDispatcher { } #[async_trait] impl Dispatcher for PinnedDispatcher { + async fn sniff_plan( + &self, + source: SocketAddr, + destination: &Destination, + ) -> Option { + self.dispatcher.sniff_plan(source, destination).await + } + async fn connect_tcp(&self, session: StreamSession) -> Result { let io = self.dispatcher.connect_tcp(session).await?; Ok(Box::new(GenerationStream { diff --git a/src/sniffing.rs b/src/sniffing.rs new file mode 100644 index 0000000..91989f6 --- /dev/null +++ b/src/sniffing.rs @@ -0,0 +1,261 @@ +//! Shared inbound sniffing policy. Configuration and GeoData are retained, not snapshots. +use crate::{ + config::{Network, RuleKind, RuleSpec, SnifferConfig}, + routing::{GeoMatcher, RoutingContext}, + session::Destination, +}; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, +}; + +pub(crate) struct Policy { + pub(crate) config: Arc, + geo: Arc, + force: crate::dns::fake_ip::DomainFilter, + skip: crate::dns::fake_ip::DomainFilter, +} +impl Policy { + pub(crate) fn new(config: Arc, geo: Arc) -> Arc { + Arc::new(Self { + force: crate::dns::fake_ip::DomainFilter::new(&config.force_domain), + skip: crate::dns::fake_ip::DomainFilter::new(&config.skip_domain), + config, + geo, + }) + } + pub(crate) fn plan( + self: &Arc, + source: SocketAddr, + destination: &Destination, + host: Option<&str>, + mapped: bool, + ) -> Plan { + let snapshot = self.geo.routing_snapshot(); + let geo = snapshot.as_deref().unwrap_or(self.geo.as_ref()); + let blocked = address_matches(&self.config.skip_src_address, source.ip(), geo) + || matches!(destination, Destination::Ip(address) if address_matches(&self.config.skip_dst_address, address.ip(), geo)); + let active = self.config.enable + && !blocked + && ((host.is_none() && self.config.parse_pure_ip) + || (mapped && self.config.force_dns_mapping) + || host.is_some_and(|host| self.force.matches(host, geo))); + Plan { + policy: self.clone(), + port: destination.port(), + active, + } + } +} +fn address_matches(rules: &[RuleSpec], ip: IpAddr, geo: &dyn GeoMatcher) -> bool { + let destination = Destination::Ip(SocketAddr::new(ip, 1)); + let context = RoutingContext::new(Network::Tcp, &destination).expect("literal destination"); + rules.iter().any(|rule| { + if let RuleKind::RuleSet { + content: Some(set), .. + } = &rule.kind + { + set.matches_ip(ip, geo) + } else { + crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) + } + }) +} + +/// Per-request decision shared by the inbound adapters. A disabled plan performs no reads. +#[derive(Clone)] +pub struct Plan { + policy: Arc, + port: u16, + active: bool, +} +impl Plan { + #[cfg(any(feature = "tun", test))] + pub(crate) fn standalone(config: Arc, port: u16) -> Self { + Self { + active: config.enable, + port, + policy: Policy::new(config, Arc::new(crate::routing::EmptyGeoMatcher)), + } + } + pub(crate) fn accepts(&self, host: &str) -> bool { + let snapshot = self.policy.geo.routing_snapshot(); + !self.policy.skip.matches( + host, + snapshot.as_deref().unwrap_or(self.policy.geo.as_ref()), + ) + } + pub(crate) fn tcp_enabled(&self) -> bool { + self.active + && (self.policy.config.matches_http_port(self.port) + || self.policy.config.matches_tls_port(self.port)) + } + pub(crate) fn quic_enabled(&self) -> bool { + self.active && self.policy.config.matches_quic_port(self.port) + } + pub(crate) async fn sniff( + &self, + reader: &mut R, + session: &mut crate::session::StreamSession, + ) -> Vec { + if !self.tcp_enabled() { + return Vec::new(); + } + use crate::tcp_sniffer::{SniffOutcome, SniffProtocol, TcpSniffer}; + let protocols = [ + ( + SniffProtocol::Tls, + self.policy.config.matches_tls_port(self.port), + ), + ( + SniffProtocol::Http, + self.policy.config.matches_http_port(self.port), + ), + ] + .into_iter() + .filter_map(|(protocol, enabled)| enabled.then_some(protocol)) + .collect(); + let mut sniffer = TcpSniffer::with_protocols(protocols); + if let Ok(SniffOutcome::Matched { protocol, domain }) = sniffer.sniff(reader).await + && self.accepts(&domain) + { + let override_destination = match protocol { + SniffProtocol::Http => self.policy.config.http_override, + SniffProtocol::Tls => self.policy.config.tls_override, + }; + if override_destination { + session.destination = Destination::Domain { + host: domain.clone(), + port: self.port, + }; + } + session.sniffed_domain = Some(domain); + } + sniffer.into_buffered() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::Config, + session::{InboundKind, StreamSession}, + }; + fn policy() -> Arc { + let config = Config::parse_yaml( + br#" +mixed-port: 1080 +sniffer: + enable: true + force-dns-mapping: true + parse-pure-ip: false + force-domain: ['+.force.example'] + skip-domain: ['+.skip.example'] + skip-src-address: [192.0.2.0/24] + skip-dst-address: [203.0.113.0/24] + override-destination: false + sniff: + HTTP: + ports: [443] + TLS: + ports: [443] + override-destination: true +"#, + ) + .unwrap(); + Policy::new( + Arc::new(config.sniffer), + Arc::new(crate::routing::EmptyGeoMatcher), + ) + } + #[test] + fn sniff_policy_distinguishes_mapping_forcing_and_exclusions() { + let policy = policy(); + let source = "198.51.100.1:8000".parse().unwrap(); + let destination = Destination::Ip("198.51.100.2:443".parse().unwrap()); + assert!(!policy.plan(source, &destination, None, false).tcp_enabled()); + assert!( + policy + .plan(source, &destination, Some("mapped.example"), true) + .tcp_enabled() + ); + assert!( + !policy + .plan(source, &destination, Some("fake.example"), false) + .tcp_enabled() + ); + assert!( + policy + .plan(source, &destination, Some("api.force.example"), false) + .tcp_enabled() + ); + assert!( + !policy + .plan( + "192.0.2.2:99".parse().unwrap(), + &destination, + Some("api.force.example"), + true + ) + .tcp_enabled() + ); + assert!( + !policy + .plan( + source, + &Destination::Ip("203.0.113.1:443".parse().unwrap()), + Some("api.force.example"), + true + ) + .tcp_enabled() + ); + } + #[tokio::test] + async fn sniff_shared_prefix_selects_protocol_override_and_replays_every_byte() { + let policy = policy(); + let destination = Destination::Ip("198.51.100.2:443".parse().unwrap()); + let source = "198.51.100.1:8000".parse().unwrap(); + let plan = policy.plan(source, &destination, Some("api.force.example"), false); + for (mut input, host, overridden) in [ + ( + b"GET / HTTP/1.1\r\nHost: http.example\r\n\r\n".to_vec(), + Some("http.example"), + false, + ), + ( + crate::tcp_sniffer::tests::tls_client_hello("tls.example"), + Some("tls.example"), + true, + ), + ( + crate::tcp_sniffer::tests::tls_client_hello("api.skip.example"), + None, + false, + ), + ] { + input.extend_from_slice(b"business-bytes"); + let mut reader = input.as_slice(); + let mut session = StreamSession { + inbound: InboundKind::Tun, + source, + destination: destination.clone(), + sniffed_domain: None, + }; + let prefix = plan.sniff(&mut reader, &mut session).await; + assert_eq!([prefix.as_slice(), reader].concat(), input); + assert_eq!(session.sniffed_domain.as_deref(), host); + if overridden { + assert_eq!( + session.destination, + Destination::Domain { + host: host.unwrap().into(), + port: 443 + } + ); + } else { + assert_eq!(session.destination, destination); + } + } + } +} diff --git a/src/tcp_sniffer.rs b/src/tcp_sniffer.rs index 741a858..9f2a2d4 100644 --- a/src/tcp_sniffer.rs +++ b/src/tcp_sniffer.rs @@ -1,7 +1,6 @@ //! Bounded TCP first-payload sniffing for the TUN inbound. //! -//! Sniffing is deliberately advisory: the extracted domain is routing -//! metadata and never replaces the original IP destination. All bytes read +//! The inbound applies the configured destination-override policy. All bytes read //! here remain available to the caller for replay after the outbound has been //! selected. @@ -14,8 +13,8 @@ use tokio::{ use crate::routing::normalize_domain_name; -pub(crate) const TCP_SNIFF_MAX_BYTES: usize = 32 * 1024; -pub(crate) const TCP_SNIFF_TIMEOUT: Duration = Duration::from_millis(200); +pub(crate) const TCP_SNIFF_MAX_BYTES: usize = 64 * 1024; +pub(crate) const TCP_SNIFF_TIMEOUT: Duration = Duration::from_secs(1); const READ_CHUNK_BYTES: usize = 2 * 1024; const TLS_HANDSHAKE_CONTENT_TYPE: u8 = 0x16; @@ -58,7 +57,7 @@ pub(crate) enum ParseOutcome { /// Stateful bounded sniffer which retains every byte consumed from the /// intercepted stream until the caller takes the prefix for replay. pub(crate) struct TcpSniffer { - protocol: SniffProtocol, + protocols: Vec, buffered: Vec, } @@ -67,9 +66,13 @@ impl TcpSniffer { /// configuration. Port selection remains outside the parser so the same /// HTTP/TLS implementation can serve every configured TCP port. #[must_use] + #[cfg(test)] pub(crate) fn new(protocol: SniffProtocol) -> Self { + Self::with_protocols(vec![protocol]) + } + pub(crate) fn with_protocols(protocols: Vec) -> Self { Self { - protocol, + protocols, buffered: Vec::with_capacity(READ_CHUNK_BYTES), } } @@ -83,20 +86,30 @@ impl TcpSniffer { where R: AsyncRead + Unpin, { - let deadline = Instant::now() + TCP_SNIFF_TIMEOUT; + let mut deadline = Instant::now() + TCP_SNIFF_TIMEOUT; loop { - match parse(self.protocol, &self.buffered) { - ParseOutcome::Matched(domain) => { - return Ok(SniffOutcome::Matched { - protocol: self.protocol, - domain, - }); - } - ParseOutcome::EchExtensionPresent => { - return Ok(SniffOutcome::EchExtensionPresent); + let mut active = Vec::with_capacity(self.protocols.len()); + let mut ech = false; + for protocol in &self.protocols { + match parse(*protocol, &self.buffered) { + ParseOutcome::Matched(domain) => { + return Ok(SniffOutcome::Matched { + protocol: *protocol, + domain, + }); + } + ParseOutcome::NeedMoreData => active.push(*protocol), + ParseOutcome::EchExtensionPresent => ech = true, + ParseOutcome::NotMatched => {} } - ParseOutcome::NotMatched => return Ok(SniffOutcome::NotMatched), - ParseOutcome::NeedMoreData => {} + } + self.protocols = active; + if self.protocols.is_empty() { + return Ok(if ech { + SniffOutcome::EchExtensionPresent + } else { + SniffOutcome::NotMatched + }); } let remaining = TCP_SNIFF_MAX_BYTES.saturating_sub(self.buffered.len()); @@ -113,15 +126,13 @@ impl TcpSniffer { Ok(Ok(count)) => count, Ok(Err(error)) => return Err(error), }; + if self.buffered.is_empty() { + deadline = Instant::now() + TCP_SNIFF_TIMEOUT; + } self.buffered.extend_from_slice(&chunk[..count]); } } - #[must_use] - pub(crate) fn buffered_len(&self) -> usize { - self.buffered.len() - } - #[must_use] pub(crate) fn into_buffered(self) -> Vec { self.buffered @@ -546,7 +557,7 @@ fn advance(cursor: &mut usize, count: usize, length: usize) -> bool { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use tokio::io::{AsyncWriteExt as _, duplex}; use super::*; @@ -555,7 +566,7 @@ mod tests { format!("GET / HTTP/1.1\r\nUser-Agent: test\r\nHost: {host}\r\n\r\n").into_bytes() } - fn tls_client_hello(host: &str) -> Vec { + pub(crate) fn tls_client_hello(host: &str) -> Vec { tls_client_hello_with_extensions(&tls_server_name_extension(host)) } @@ -605,18 +616,6 @@ mod tests { record } - #[test] - fn constructor_preserves_the_configured_protocol() { - assert!(matches!( - TcpSniffer::new(SniffProtocol::Http).protocol, - SniffProtocol::Http - )); - assert!(matches!( - TcpSniffer::new(SniffProtocol::Tls).protocol, - SniffProtocol::Tls - )); - } - #[test] fn http_host_is_normalized_and_optional_port_is_removed() { assert_eq!( diff --git a/src/tun_runtime.rs b/src/tun_runtime.rs index c5fc106..b99c9ad 100644 --- a/src/tun_runtime.rs +++ b/src/tun_runtime.rs @@ -41,12 +41,13 @@ use crate::{ ResourceActivity, ResourceActivityGuard, ResourceQueue, RuntimeResourceStats, observation, }, session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, - tcp_sniffer::{SniffOutcome, SniffProtocol, TcpSniffer}, traffic::TunTrafficStats, }; #[cfg(test)] use crate::dns::{ClassifiedDnsQuery, synthesize_servfail_response}; +#[cfg(test)] +use crate::tcp_sniffer::SniffProtocol; #[cfg(test)] const TUN_MTU: usize = 1_500; @@ -723,55 +724,23 @@ async fn relay_tcp( let destination = inbound.destination_addr(); let destination_port = destination.port(); let ip_version = if destination.is_ipv4() { 4 } else { 6 }; - let protocol = sniffer - .as_deref() - .and_then(|config| configured_sniff_protocol(config, destination_port)); - let (sniffed_domain, prefetched) = if let Some(protocol) = protocol { - let mut sniffer = TcpSniffer::new(protocol); - let outcome = tokio::select! { - biased; - () = cancellation.cancelled() => return, - outcome = sniffer.sniff(&mut inbound) => outcome, - }; - let sniffed_domain = match outcome { - Ok(SniffOutcome::Matched { protocol, domain }) => { - tracing::debug!( - session_id, - ?protocol, - destination_port, - buffered_bytes = sniffer.buffered_len(), - "TUN TCP domain sniffed" - ); - Some(domain) - } - Ok(outcome) => { - tracing::debug!( - session_id, - ?outcome, - destination_port, - "TUN TCP domain sniffing completed without a domain" - ); - None - } - Err(error) => { - tracing::debug!( - session_id, - error_kind = ?error.kind(), - destination_port, - "TUN TCP domain sniffing failed open" - ); - None - } - }; - (sniffed_domain, sniffer.into_buffered()) - } else { - (None, Vec::new()) - }; - let session = StreamSession { + let mut session = StreamSession { inbound: InboundKind::Tun, source: inbound.source_addr(), - destination: Destination::Ip(inbound.destination_addr()), - sniffed_domain, + destination: Destination::Ip(destination), + sniffed_domain: None, + }; + let plan = dispatcher + .sniff_plan(session.source, &session.destination) + .await + .or_else(|| { + sniffer.map(|config| crate::sniffing::Plan::standalone(config, destination_port)) + }); + let prefetched = if let Some(plan) = plan { + tokio::select! { biased; () = cancellation.cancelled() => return, + bytes = plan.sniff(&mut inbound, &mut session) => bytes } + } else { + Vec::new() }; let connected = tokio::select! { biased; @@ -864,6 +833,7 @@ async fn relay_tcp( } } +#[cfg(test)] fn configured_sniff_protocol(config: &SnifferConfig, port: u16) -> Option { if config.matches_http_port(port) { Some(SniffProtocol::Http) @@ -1925,9 +1895,24 @@ async fn send_tun_udp_datagram( ) -> io::Result { let PreparedTunUdpDatagram { datagram, - sniffed_domain, + mut sniffed_domain, flow_id, } = prepared; + if let Some(plan) = context + .dispatcher + .sniff_plan(context.source, &Destination::Ip(datagram.destination)) + .await + .or_else(|| { + context.sniffer.clone().map(|config| { + crate::sniffing::Plan::standalone(config, datagram.destination.port()) + }) + }) + && sniffed_domain + .as_ref() + .is_some_and(|host| !plan.accepts(host)) + { + sniffed_domain = None; + } let datagram = Datagram { remote: Destination::Ip(datagram.destination), payload: datagram.payload, @@ -2045,10 +2030,18 @@ where } } AssociationEvent::Inbound(Some(datagram)) => { - let sniff_quic = configured_quic_sniffing( - context.sniffer.as_deref(), - datagram.destination.port(), - ); + let sniff_quic = if let Some(plan) = context + .dispatcher + .sniff_plan(context.source, &Destination::Ip(datagram.destination)) + .await + { + plan.quic_enabled() + } else { + configured_quic_sniffing( + context.sniffer.as_deref(), + datagram.destination.port(), + ) + }; let result = if sniff_quic { quic_sniff.ingest_datagram(datagram, TokioInstant::now()) } else { diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index 4203458..e55c188 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -46,6 +46,10 @@ fn test_sniffer( http_ports: http_ports.into(), tls_ports: tls_ports.into(), quic_ports: quic_ports.into(), + http_override: false, + tls_override: false, + quic_override: false, + ..SnifferConfig::disabled() }) } diff --git a/tests/README.md b/tests/README.md index b69a49b..2bf8d9e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,6 +17,8 @@ cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --locked --all-features --lib fake_ip_ +cargo test --locked --all-features --lib sniffing::tests:: +cargo test --locked --all-features --lib tcp_sniffer::tests:: cargo test --locked --all-features --lib dns::fallback::tests:: cargo test --locked --all-features --lib dns::runtime::tests::dns_fallback_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets From a84ef8cacc66de563cbee5faf9408707bd79ba6c Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:32:23 +0800 Subject: [PATCH 012/445] Add load balancing and physical TCP connection settings Share healthy member selection across routing and upstream groups; pin UDP destination flows and cancel losing concurrent TCP attempts. Apply keep-alive settings to outbound and mixed sockets. Validated three focused regressions, 91 configuration cases, five group regressions, all-target compilation, Clippy, Linux CLI compilation, and the locked TLS dependency audit. --- Cargo.lock | 16 ++ Cargo.toml | 1 + ...t-proxy-groups-as-session-route-targets.md | 2 + docs/config.yaml | 18 +- docs/controller-api.md | 10 +- src/config/groups.rs | 12 + src/config/mod.rs | 33 ++- src/controller.rs | 8 +- src/dialer.rs | 25 +- src/dialer_connections.rs | 160 ++++++++++++ src/dns/runtime.rs | 1 + src/inbound/mixed.rs | 7 + src/outbound/anytls/client.rs | 1 + src/outbound/connector.rs | 38 ++- src/outbound/socks5.rs | 1 + src/routing/balance.rs | 174 +++++++++++++ src/routing/dispatcher.rs | 233 ++++++++++++++++-- src/routing/mod.rs | 1 + src/routing/proxy_group.rs | 103 +++++++- src/routing/selection.rs | 29 +++ src/runtime.rs | 24 +- tests/README.md | 3 + 22 files changed, 842 insertions(+), 58 deletions(-) create mode 100644 src/dialer_connections.rs create mode 100644 src/routing/balance.rs diff --git a/Cargo.lock b/Cargo.lock index 66c81f0..a148e33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1681,6 +1681,21 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl" +version = "2.1.241" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7319b480e204d67e759cad7886178335d26324c7c8573c126cc9dfa7350d064" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "quinn" version = "0.11.12" @@ -2667,6 +2682,7 @@ dependencies = [ "log", "md-5", "oslog", + "psl", "quinn", "quinn-proto", "rand", diff --git a/Cargo.toml b/Cargo.toml index 8ce61e0..01eca31 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ quinn-proto = { version = "=0.11.18", default-features = false, features = ["rus rand = "0.10" ring = { version = "0.17.14", optional = true } redb = "=4.3.0" +psl = "=2.1.241" regex = "1.13" regex-syntax = { version = "0.8", default-features = false, features = ["std", "unicode-case"] } rustls = { version = "=0.23.45", default-features = false, features = ["ring", "std", "tls12"] } diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index b367c2c..43a43f5 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -17,3 +17,5 @@ status: accepted Provider 成员通过完整资源候选生成。候选重新校验名称及节点/组依赖图后发布,新连接使用当前资源,既有 TCP/UDP 与在途 DNS 保留仍使用的旧资源;不再使用的资源按原停止屏障释放。失败候选不替换可用配置或 HTTP 缓存。动态组移除当前成员时回到有效默认项或首成员,空组按 empty-fallback 处理。下载使用已发布资源,不依赖候选发布完成。 url-test 与 fallback 复用同一组选择和依赖图。共享 HTTP(S) 健康探测分别执行延迟容差和按序恢复,检查遵循 lazy、超时、并发上限和停止取消。探测结果不声明 UDP 能力,也不迁移既有连接。 + +load-balance 与路由、dialer-proxy 共用健康状态与策略实例。一致性哈希、轮询和粘滞选择在新连接建链时执行;UDP 对含负载均衡组的目标分别保留 transport,过期或明确的新 QUIC 流才重新选择。动态资源代际仍固定在旧会话上。上游组以当前节点服务器为目标,单次建链的所有协议步骤共用同一选择。 diff --git a/docs/config.yaml b/docs/config.yaml index 35e880d..b519266 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -25,6 +25,14 @@ log-level: info # 启动时缓存中的有效选择优先于 default-selected;成员已删除时使用配置默认/首成员。 # 文件损坏时使用配置;写入失败保留已生效选择,并在 Controller 状态中报告。 unified-delay: false +# 物理 TCP 与 mixed 接入连接的探活。单位秒;0 使用 15 秒,负数保留对应 OS 值。 +# Android 始终禁用 TCP keep-alive;Windows 保留系统探测次数,其余平台为 9 次。 +# tcp-concurrent=true 同时尝试解析结果,首次成功取消其余尝试;ip-version 的偏好 +# 给首选地址族最多 300ms 优先窗口。已建立的复用连接保留创建时的设置。 +tcp-concurrent: false +keep-alive-idle: 0 +keep-alive-interval: 0 +disable-keep-alive: false profile: store-selected: true store-fake-ip: false # true:dataDir/fake-ip.redb 持久化双向映射与分配位置 @@ -429,7 +437,7 @@ proxies: # name-cert-verify: verified.example.com # 可选独立证书验证名 # client-fingerprint: chrome # 仅有插件时允许;共享七值,缺省/none/空串关闭命名模板 -# select/url-test/fallback 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, +# select/url-test/fallback/load-balance 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, # 也可分别使用 include-all-proxies / include-all-providers。后者启用时忽略 use。 # filter 筛选动态成员;exclude-filter / exclude-type 同时排除显式成员。 # 正则支持 Rust regex 子集,区分大小写;反引号分隔的多表达式按匹配顺序排列。 @@ -443,14 +451,18 @@ proxies: # select 的 default-selected 省略时选中第一项;有效缓存选择优先。名称精确匹配。 # url-test 按 URL 探测延迟选择,tolerance(毫秒,默认 0)抑制小幅切换;fallback # 按声明顺序选择首个健康成员,恢复后可回到前面的成员。全部失败时返回首成员。 -# 三种组均接受 url/interval/timeout/lazy/expected-status/max-failed-times。 +# 四种组均接受 url/interval/timeout/lazy/expected-status/max-failed-times。 # url 缺省使用首个 provider 的健康 URL,否则 https://www.gstatic.com/generate_204。 # interval 单位秒,自动组缺省/0 为 300;select 缺省 0,不主动周期探测。 # timeout 单位毫秒,缺省/0 为 5000;lazy 默认 true,无近期使用时跳过周期探测。 # expected-status 省略/空/* 接受所有最终 HTTP 状态,可用 200/204 或 200-299。 # max-failed-times 缺省/0 为 5,建链失败达到阈值后触发探测,不重试同一次业务连接。 # HTTP 探测结果不代表 UDP 可用性,业务 UDP 仍检查节点能力与 disable-udp。 -# Controller 可手动固定自动组成员,成员不可用时恢复自动选择。 +# Controller 可手动固定 url-test/fallback 成员,成员不可用时恢复自动选择。 +# load-balance 的 strategy 为 consistent-hashing(缺省)、round-robin、sticky-sessions。 +# 一致性哈希按目标可注册主域名或 IP 分桶;轮询跳过不健康成员;粘滞会话按来源 IP +# 与目标主域名/IP 缓存 10 分钟,最多 1000 项。哈希种子属于当前进程,不承诺重启后相同。 +# load-balance 没有 now/手动选择;UDP 按目标会话固定叶节点,健康变化不迁移旧会话。 # 自动或手动切换只影响新连接;既有连接、认证会话及其中复用的流仍保留原路径。 # 组作为 dialer-proxy 时,与路由共用当前选择,但不重新执行业务规则;DIRECT 连接 # 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。单次建链对每个组 diff --git a/docs/controller-api.md b/docs/controller-api.md index 18f0df3..4a1dd9e 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -1,6 +1,6 @@ # 运行时 Controller API -Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 +Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback`、`load-balance` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 ## 配置与生命周期 @@ -83,7 +83,7 @@ GET /group/{name} GET /proxies/{name} ``` -- `name` 是组定义名,`type` 分别为 `Selector`、`URLTest` 或 `Fallback`; +- `name` 是组定义名,`type` 分别为 `Selector`、`URLTest`、`Fallback` 或 `LoadBalance`; - `all` 是配置中的直接成员,严格保留顺序和重复项; - `now` 是当前选中的直接成员名。若该成员是嵌套组,`now` 仍返回组名,不展开为最终叶节点; - 名称和值大小写敏感并精确匹配,不做大小写折叠或 Unicode normalization。 @@ -92,7 +92,9 @@ GET /proxies/{name} `GET /proxies/{name}` 也可查询节点。history 当前保留最近一次探测,delay 单位毫秒, time 为 Unix 秒;未探测节点初始 alive 为 true、history 为空。HTTP 健康状态不声明 UDP 可用性。 -自动组的手动选择在该成员健康时保持固定,失败后恢复自动选择。url-test 使用 tolerance 抑制小幅切换,fallback 按成员顺序选择与恢复。 +load-balance 根据每条新连接的元数据选择成员,响应不含 `now`,不接受手动选择。业务 UDP 按目标会话固定出口。 + +url-test/fallback 的手动选择在该成员健康时保持固定,失败后恢复自动选择。url-test 使用 tolerance 抑制小幅切换,fallback 按成员顺序选择与恢复。 ## `PUT /proxies/{name}` @@ -170,7 +172,7 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 | `413 Payload Too Large` | PUT 正文超过 1 KiB | | `415 Unsupported Media Type` | PUT 不是严格 `application/json` | -当前不提供 `load-balance`、Controller 主动 delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 +当前不提供 Controller 主动 delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; diff --git a/src/config/groups.rs b/src/config/groups.rs index d02750b..7fa6a20 100644 --- a/src/config/groups.rs +++ b/src/config/groups.rs @@ -7,6 +7,7 @@ pub enum GroupKind { Select, UrlTest, Fallback, + LoadBalance, } impl GroupKind { pub fn controller_type(self) -> &'static str { @@ -14,6 +15,7 @@ impl GroupKind { Self::Select => "Selector", Self::UrlTest => "URLTest", Self::Fallback => "Fallback", + Self::LoadBalance => "LoadBalance", } } } @@ -86,3 +88,13 @@ pub(crate) fn status_ranges(value: &str) -> Result> { }) .collect() } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BalanceStrategy { + #[default] + #[serde(alias = "")] + ConsistentHashing, + RoundRobin, + StickySessions, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index dd863ab..e807bbf 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,7 +11,7 @@ pub use ip_version::IpVersion; mod groups; pub(crate) use groups::status_ranges; -pub use groups::{GroupKind, HealthCheckConfig, ProfileConfig}; +pub use groups::{BalanceStrategy, GroupKind, HealthCheckConfig, ProfileConfig}; use std::{ collections::{HashMap, VecDeque}, @@ -87,6 +87,7 @@ pub struct Config { pub global_ua: String, pub etag_support: bool, pub unified_delay: bool, + pub connections: ConnectionSettings, pub profile: ProfileConfig, pub geodata_update: Option, pub external_controller: Option, @@ -490,6 +491,7 @@ pub struct ProxyGroupMemberConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct SelectProxyGroupConfig { pub kind: GroupKind, + pub strategy: BalanceStrategy, pub health: HealthCheckConfig, pub tolerance: u16, pub max_failed_times: u32, @@ -937,6 +939,15 @@ struct RawVoleConfig { #[serde(rename = "skip-auth-prefixes", default)] skip_auth_prefixes: Vec, + #[serde(rename = "tcp-concurrent", default)] + tcp_concurrent: bool, + #[serde(rename = "keep-alive-idle", default)] + keep_alive_idle: i32, + #[serde(rename = "keep-alive-interval", default)] + keep_alive_interval: i32, + #[serde(rename = "disable-keep-alive", default)] + disable_keep_alive: bool, + #[serde(rename = "unified-delay", default)] unified_delay: bool, #[serde(default)] @@ -1010,6 +1021,8 @@ struct RawVoleConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawProxyGroup { + #[serde(default)] + strategy: BalanceStrategy, #[serde(default)] url: String, #[serde(default)] @@ -1888,6 +1901,7 @@ impl RawVoleConfig { let generated_global = !raw_groups.iter().any(|group| group.name == "GLOBAL"); if generated_global { raw_groups.push(RawProxyGroup { + strategy: BalanceStrategy::default(), disable_udp: false, hidden: false, icon: String::new(), @@ -1969,6 +1983,12 @@ impl RawVoleConfig { global_ua: self.global_ua, etag_support: self.etag_support, unified_delay: self.unified_delay, + connections: ConnectionSettings { + tcp_concurrent: self.tcp_concurrent, + keep_alive_idle: self.keep_alive_idle, + keep_alive_interval: self.keep_alive_interval, + disable_keep_alive: self.disable_keep_alive, + }, profile: self.profile, geodata_update, external_controller, @@ -2404,7 +2424,7 @@ fn normalize_proxy_groups( }; health.validate()?; Ok(SelectProxyGroupConfig { - kind: group.kind, health, tolerance: group.tolerance, + kind: group.kind, strategy: group.strategy, health, tolerance: group.tolerance, max_failed_times: if group.max_failed_times == 0 { 5 } else { group.max_failed_times }, name: group.name, members, @@ -6267,3 +6287,12 @@ authentication: assert!(Config::parse_yaml(yaml.as_bytes()).is_ok()); } } + +/// Global physical TCP policy. YAML keeps Mihomo's top-level field names. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ConnectionSettings { + pub tcp_concurrent: bool, + pub keep_alive_idle: i32, + pub keep_alive_interval: i32, + pub disable_keep_alive: bool, +} diff --git a/src/controller.rs b/src/controller.rs index 3517a20..77b1213 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -272,7 +272,9 @@ async fn handle_connection( Err(ProxyGroupError::UnknownGroup) => { write_error(&mut stream, 404, "Resource not found", &[]).await } - Err(ProxyGroupError::UnknownMember) => unreachable!("state does not select a member"), + Err(ProxyGroupError::UnknownMember | ProxyGroupError::NotSelectable) => { + unreachable!("state does not select a member") + } }; } @@ -403,7 +405,7 @@ async fn handle_select( Err(ProxyGroupError::UnknownGroup) => { write_error(stream, 404, "Resource not found", &[]).await } - Err(ProxyGroupError::UnknownMember) => { + Err(ProxyGroupError::UnknownMember | ProxyGroupError::NotSelectable) => { write_error( stream, 400, @@ -508,6 +510,7 @@ struct GroupResponse { #[serde(rename = "type")] kind: &'static str, all: Vec, + #[serde(skip_serializing_if = "String::is_empty")] now: String, hidden: bool, icon: String, @@ -625,6 +628,7 @@ mod tests { ProxyGroups::new( &[SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, + strategy: crate::config::BalanceStrategy::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, diff --git a/src/dialer.rs b/src/dialer.rs index 761a494..887a879 100644 --- a/src/dialer.rs +++ b/src/dialer.rs @@ -1,3 +1,6 @@ +#[path = "dialer_connections.rs"] +mod connections; +pub(crate) use connections::race_connections; use std::{ io, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs as _}, @@ -368,6 +371,7 @@ pub struct Dialer { #[cfg(all(target_os = "linux", feature = "tun"))] linux_policy: Option>, ip_version: crate::config::IpVersion, + connections: crate::config::ConnectionSettings, initialization: Arc, protector: Option>, source_binding: Option, @@ -398,6 +402,7 @@ impl Default for Dialer { #[cfg(all(target_os = "linux", feature = "tun"))] linux_policy: None, ip_version: crate::config::IpVersion::Dual, + connections: crate::config::ConnectionSettings::default(), initialization: Arc::new(SocketInitialization::default()), protector: None, source_binding: None, @@ -410,6 +415,13 @@ impl Default for Dialer { } impl Dialer { + pub(crate) fn with_connections(mut self, settings: crate::config::ConnectionSettings) -> Self { + self.connections = settings; + self + } + pub(crate) fn connections(&self) -> crate::config::ConnectionSettings { + self.connections + } #[cfg(all(target_os = "linux", feature = "tun"))] pub(crate) fn with_linux_policy( mut self, @@ -501,8 +513,18 @@ impl Dialer { } pub async fn connect(&self, endpoint: &ResolvedEndpoint) -> io::Result { + let addresses = self.ordered_addresses(&endpoint.addresses); + if self.connections.tcp_concurrent && !addresses.is_empty() { + let preferred = match self.ip_version { + crate::config::IpVersion::Ipv4Prefer => Some(false), + crate::config::IpVersion::Ipv6Prefer => Some(true), + _ => None, + }; + return race_connections(&addresses, preferred, |address| self.connect_one(address)) + .await; + } let mut last_error = None; - for address in self.ordered_addresses(&endpoint.addresses) { + for address in addresses { match self.connect_one(address).await { Ok(stream) => return Ok(stream), Err(error) => last_error = Some(error), @@ -662,6 +684,7 @@ impl Dialer { }; let guard = track(ResourceKind::Socket); check()?; + dialer.connections.apply(&socket2::SockRef::from(&socket))?; dialer.protect_socket(&socket)?; #[cfg(all(target_os = "linux", feature = "tun"))] if let Some(policy) = &dialer.linux_policy { diff --git a/src/dialer_connections.rs b/src/dialer_connections.rs new file mode 100644 index 0000000..f161707 --- /dev/null +++ b/src/dialer_connections.rs @@ -0,0 +1,160 @@ +//! Structured TCP races: dropping the winner's scope cancels every loser. +use futures_util::{StreamExt, stream::FuturesUnordered}; +use std::{future::Future, net::SocketAddr, time::Duration}; +use tokio::time::{Instant, sleep_until}; + +pub(crate) async fn race_connections( + addresses: &[SocketAddr], + prefer_v6: Option, + connect: C, +) -> Result +where + F: Future>, + C: Fn(SocketAddr) -> F, +{ + assert!(!addresses.is_empty()); + let mut primary = addresses + .iter() + .filter(|a| prefer_v6.is_none_or(|v6| a.is_ipv6() == v6)) + .count(); + let mut attempts = addresses + .iter() + .map(|&address| { + let attempt = connect(address); + async move { + ( + prefer_v6.is_none_or(|v6| address.is_ipv6() == v6), + attempt.await, + ) + } + }) + .collect::>(); + let deadline = Instant::now() + Duration::from_millis(300); + let mut fallback = None; + let mut error = None; + loop { + if primary == 0 + && let Some(stream) = fallback.take() + { + return Ok(stream); + } + tokio::select! { + biased; + result = attempts.next() => match result { + Some((is_primary, result)) => { + if is_primary { primary -= 1; } + match result { + Ok(stream) if is_primary => return Ok(stream), + Ok(stream) => { if fallback.is_none() { fallback = Some(stream); } } + Err(failed) => error = Some(failed), + } + } + None => return fallback.ok_or_else(|| error.expect("nonempty failed race")), + }, + () = sleep_until(deadline), if fallback.is_some() => return Ok(fallback.take().expect("fallback ready")), + } + } +} + +impl crate::config::ConnectionSettings { + pub(crate) fn apply(self, socket: &socket2::SockRef<'_>) -> std::io::Result<()> { + // Mihomo deliberately disables TCP probes on Android. + if self.disable_keep_alive || cfg!(target_os = "android") { + return socket.set_keepalive(false); + } + socket.set_keepalive(true)?; + let mut options = socket2::TcpKeepalive::new(); + if self.keep_alive_idle >= 0 { + options = options.with_time(Duration::from_secs(if self.keep_alive_idle == 0 { + 15 + } else { + self.keep_alive_idle as u64 + })); + } + if self.keep_alive_interval >= 0 { + options = + options.with_interval(Duration::from_secs(if self.keep_alive_interval == 0 { + 15 + } else { + self.keep_alive_interval as u64 + })); + } + // Old Windows 10 releases lack TCP_KEEPCNT; retain their OS count. + #[cfg(not(windows))] + { + options = options.with_retries(9); + } + socket.set_tcp_keepalive(&options) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; + #[tokio::test(start_paused = true)] + async fn concurrent_dial_cancels_losers_and_honors_family_preference() { + struct Guard(Arc); + impl Drop for Guard { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + let closed = Arc::new(AtomicUsize::new(0)); + let addresses = [ + "192.0.2.1:443".parse().unwrap(), + "[2001:db8::1]:443".parse().unwrap(), + ]; + let start = Instant::now(); + let result = race_connections(&addresses, Some(true), |a| { + let guard = Guard(closed.clone()); + async move { + let _guard = guard; + if a.is_ipv6() { + std::future::pending::<()>().await; + } + Ok::<_, ()>(a) + } + }) + .await + .unwrap(); + assert_eq!(result, addresses[0]); + assert_eq!(start.elapsed(), Duration::from_millis(300)); + assert_eq!(closed.load(Ordering::Relaxed), 2); + // This creates no peer or listener: inspect only an unconnected socket. + let socket = socket2::Socket::new( + socket2::Domain::IPV4, + socket2::Type::STREAM, + Some(socket2::Protocol::TCP), + ) + .unwrap(); + let options = crate::config::ConnectionSettings { + keep_alive_idle: 42, + keep_alive_interval: 7, + ..Default::default() + }; + options.apply(&socket2::SockRef::from(&socket)).unwrap(); + assert_eq!(socket.keepalive().unwrap(), !cfg!(target_os = "android")); + #[cfg(any(target_os = "linux", target_os = "macos"))] + { + assert_eq!( + socket.tcp_keepalive_time().unwrap(), + Duration::from_secs(42) + ); + assert_eq!( + socket.tcp_keepalive_interval().unwrap(), + Duration::from_secs(7) + ); + } + crate::config::ConnectionSettings { + disable_keep_alive: true, + ..options + } + .apply(&socket2::SockRef::from(&socket)) + .unwrap(); + assert!(!socket.keepalive().unwrap()); + } +} diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index aff6b2a..c3fec8a 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -3187,6 +3187,7 @@ dns: let groups = ProxyGroups::new( &[SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, + strategy: crate::config::BalanceStrategy::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, diff --git a/src/inbound/mixed.rs b/src/inbound/mixed.rs index 05d791f..23338f3 100644 --- a/src/inbound/mixed.rs +++ b/src/inbound/mixed.rs @@ -23,6 +23,7 @@ use super::{ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); pub struct MixedServer { + connections: crate::config::ConnectionSettings, access_rules: Arc, listeners: Vec, sockets: Vec>, @@ -32,6 +33,10 @@ pub struct MixedServer { } impl MixedServer { + pub(crate) fn with_connections(mut self, settings: crate::config::ConnectionSettings) -> Self { + self.connections = settings; + self + } pub fn bind(config: MixedInboundConfig, dispatcher: Arc) -> io::Result { let http = HttpServerConfig::proxy(config.port, config.access, config.auth.clone())?; let listeners = bind_proxy_tcp(config.port, config.access)?; @@ -51,6 +56,7 @@ impl MixedServer { dispatcher.clone(), ); Ok(Self { + connections: crate::config::ConnectionSettings::default(), access_rules: config.access_rules, listeners, sockets, @@ -103,6 +109,7 @@ impl MixedServer { Ok(value) => value, Err(error) => break Err(error), }; + if self.connections.apply(&socket2::SockRef::from(&stream)).is_err() { continue; } let relay = match stream.local_addr() { Ok(address) => address, Err(error) => break Err(error), diff --git a/src/outbound/anytls/client.rs b/src/outbound/anytls/client.rs index 8e5df73..f7f1f3d 100644 --- a/src/outbound/anytls/client.rs +++ b/src/outbound/anytls/client.rs @@ -655,6 +655,7 @@ mod tests { )); let client = AnyTlsClient::new( Arc::new(GroupDialer(UpstreamPath::Group { + server: crate::session::Destination::domain("server.example", 443).unwrap(), group, endpoint: None, dialer: Dialer::default(), diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index 951bd97..0ae620a 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -1,13 +1,13 @@ use crate::routing::GroupSelection; #[cfg(test)] -use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::{ collections::HashMap, fmt::{self, Write as _}, future::Future, io, net::IpAddr, - sync::{Arc, Mutex, atomic::Ordering}, + sync::{Arc, Mutex}, time::Duration, }; @@ -265,7 +265,7 @@ impl EstablishContext { /// One read per group for the entire setup, including both legs of a /// SOCKS5 UDP association. Retaining the atomic prevents address reuse; /// unrelated groups are not an atomic cross-group snapshot. - fn selected_member(&self, selection: &Arc) -> usize { + fn selected_member(&self, selection: &Arc, destination: &Destination) -> usize { selection.touch(); let key = Arc::as_ptr(selection) as usize; let mut selections = self @@ -274,7 +274,7 @@ impl EstablishContext { .unwrap_or_else(std::sync::PoisonError::into_inner); selections .entry(key) - .or_insert_with(|| (selection.clone(), selection.load(Ordering::Acquire))) + .or_insert_with(|| (selection.clone(), selection.choose(None, Some(destination)))) .1 } @@ -470,13 +470,20 @@ impl SelectUpstream { self.disable_udp = disabled; self } - fn check_udp(&self, context: &EstablishContext) -> Result<(), DispatchError> { + fn check_udp( + &self, + context: &EstablishContext, + destination: &Destination, + ) -> Result<(), DispatchError> { let mut group = self; loop { if group.disable_udp { return Err(DispatchError::NotAllowed); } - match group.members.get(context.selected_member(&group.selection)) { + match group + .members + .get(context.selected_member(&group.selection, destination)) + { Some(SelectUpstreamMember::Group(next)) => group = next, Some(_) => return Ok(()), None => return Err(DispatchError::NotAllowed), @@ -484,10 +491,14 @@ impl SelectUpstream { } } - fn resolve(&self, context: &EstablishContext) -> Result<&SelectUpstreamMember, DispatchError> { + fn resolve( + &self, + context: &EstablishContext, + destination: &Destination, + ) -> Result<&SelectUpstreamMember, DispatchError> { let mut group = self; loop { - let selected = context.selected_member(&group.selection); + let selected = context.selected_member(&group.selection, destination); match group.members.get(selected) { Some(SelectUpstreamMember::Group(next)) => group = next, Some(leaf) => return Ok(leaf), @@ -523,6 +534,7 @@ pub enum UpstreamPath { /// A runtime select group. DIRECT is relative to this node's prepared /// server, not the business destination or the ordinary routing engine. Group { + server: Destination, group: Arc, endpoint: Option, dialer: Dialer, @@ -571,7 +583,8 @@ impl UpstreamPath { group, endpoint, dialer, - } => match group.resolve(context)? { + server, + } => match group.resolve(context, server)? { SelectUpstreamMember::Proxy(connector) => Ok(ResolvedUpstream::Proxy(connector)), SelectUpstreamMember::Direct => endpoint .as_ref() @@ -631,8 +644,8 @@ impl UpstreamPath { request: DatagramRequest, context: &EstablishContext, ) -> Result, DispatchError> { - if let Self::Group { group, .. } = self { - group.check_udp(context)?; + if let Self::Group { group, server, .. } = self { + group.check_udp(context, server)?; } match self.resolve(context)? { ResolvedUpstream::Direct(_, dialer) | ResolvedUpstream::LazyDirect(dialer) => { @@ -977,6 +990,7 @@ mod tests { members: Vec, ) -> UpstreamPath { UpstreamPath::Group { + server: Destination::domain("server.example", 443).unwrap(), group: Arc::new(SelectUpstream::new(selection, members)), endpoint: None, dialer: Dialer::default(), @@ -1132,6 +1146,7 @@ mod tests { let address = listener.local_addr().unwrap(); let selection = Arc::new(GroupSelection::new(0)); let path = UpstreamPath::Group { + server: Destination::domain("server.example", 443).unwrap(), group: Arc::new(SelectUpstream::new( selection.clone(), vec![SelectUpstreamMember::Direct, SelectUpstreamMember::Reject], @@ -1197,6 +1212,7 @@ mod tests { let address = listener.local_addr().unwrap(); let protector = Arc::new(RejectProtector(AtomicUsize::new(0))); let path = UpstreamPath::Group { + server: Destination::domain("server.example", 443).unwrap(), group: Arc::new(SelectUpstream::new( Arc::new(GroupSelection::new(0)), vec![SelectUpstreamMember::Direct], diff --git a/src/outbound/socks5.rs b/src/outbound/socks5.rs index 979a983..41c0faa 100644 --- a/src/outbound/socks5.rs +++ b/src/outbound/socks5.rs @@ -872,6 +872,7 @@ mod tests { let outbound = Socks5Outbound::new_with_path( &config(None), UpstreamPath::Group { + server: crate::session::Destination::domain("server.example", 443).unwrap(), group, endpoint: None, dialer: Dialer::default(), diff --git a/src/routing/balance.rs b/src/routing/balance.rs new file mode 100644 index 0000000..c945751 --- /dev/null +++ b/src/routing/balance.rs @@ -0,0 +1,174 @@ +//! Per-group selection; transports retain the selected leaf, not this cursor. +use crate::{config::BalanceStrategy, session::Destination}; +use std::{ + collections::{HashMap, hash_map::RandomState}, + hash::BuildHasher, + net::SocketAddr, + sync::OnceLock, + time::Duration, +}; +use tokio::time::Instant; + +#[derive(Debug)] +pub(super) struct Balance { + strategy: BalanceStrategy, + healthy: Vec, + cursor: usize, + sticky: HashMap, + clock: u64, +} +#[derive(Debug)] +struct Entry { + index: usize, + expires: Instant, + used: u64, +} + +impl Balance { + pub fn new(strategy: BalanceStrategy, members: usize) -> Self { + Self { + strategy, + healthy: vec![true; members], + cursor: 0, + sticky: HashMap::new(), + clock: 0, + } + } + pub fn health(&mut self, samples: &[Option]) { + if samples.len() == self.healthy.len() { + for (alive, sample) in self.healthy.iter_mut().zip(samples) { + *alive = sample.is_some(); + } + } + } + pub fn choose( + &mut self, + source: Option, + destination: Option<&Destination>, + ) -> usize { + let count = self.healthy.len(); + if count == 0 { + return 0; + } + if self.strategy == BalanceStrategy::RoundRobin { + let selected = (0..count) + .map(|offset| (self.cursor + offset) % count) + .find(|&i| self.healthy[i]); + if let Some(index) = selected { + self.cursor = (index + 1) % count; + return index; + } + return 0; + } + let mut key = destination_key(destination); + if self.strategy == BalanceStrategy::StickySessions { + key.insert_str( + 0, + &source.map_or_else(|| "invalid IP".into(), |s| s.ip().to_string()), + ); + } + static HASH: OnceLock = OnceLock::new(); + let key = HASH.get_or_init(RandomState::new).hash_one(key); + if self.strategy == BalanceStrategy::ConsistentHashing { + return (0..5) + .map(|offset| jump(key.wrapping_add(offset), count)) + .find(|&i| self.healthy[i]) + .or_else(|| self.healthy.iter().position(|&v| v)) + .unwrap_or(0); + } + self.clock = self.clock.wrapping_add(1); + let now = Instant::now(); + if let Some(entry) = self.sticky.get_mut(&key) + && entry.expires > now + && entry.index < count + && self.healthy[entry.index] + { + entry.used = self.clock; + return entry.index; + } + let index = (0..4) + .map(|_| jump(key.wrapping_add(rand::random::()), count)) + .find(|&i| self.healthy[i]) + .unwrap_or(0); + if self.sticky.len() >= 1000 + && !self.sticky.contains_key(&key) + && let Some(oldest) = self + .sticky + .iter() + .min_by_key(|(_, e)| e.used) + .map(|(&k, _)| k) + { + self.sticky.remove(&oldest); + } + self.sticky.insert( + key, + Entry { + index, + expires: now + Duration::from_secs(600), + used: self.clock, + }, + ); + index + } +} +fn destination_key(destination: Option<&Destination>) -> String { + match destination { + Some(Destination::Ip(address)) => address.ip().to_string(), + Some(Destination::Domain { host, .. }) => host + .parse::() + .map(|ip| ip.to_string()) + .unwrap_or_else(|_| psl::domain_str(host).unwrap_or_default().to_owned()), + None => String::new(), + } +} +// Jump consistent hash (Lamping and Veach); only the bucket count affects remapping. +fn jump(mut key: u64, count: usize) -> usize { + let mut bucket = 0; + let mut next = 0; + while next < count { + bucket = next; + key = key.wrapping_mul(2862933555777941757).wrapping_add(1); + next = ((bucket + 1) as f64 * ((1_u64 << 31) as f64 / ((key >> 33) + 1) as f64)) as usize; + } + bucket +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test(start_paused = true)] + async fn balance_strategies_health_stickiness_and_membership() { + let source = Some("192.0.2.1:1234".parse().unwrap()); + let a = Destination::domain("a.example.co.uk", 443).unwrap(); + let b = Destination::domain("b.example.co.uk", 80).unwrap(); + let mut round = Balance::new(BalanceStrategy::RoundRobin, 3); + assert_eq!( + (0..4) + .map(|_| round.choose(source, Some(&a))) + .collect::>(), + [0, 1, 2, 0] + ); + round.health(&[None, Some(20), None]); + assert_eq!(round.choose(source, Some(&a)), 1); + let mut hash = Balance::new(BalanceStrategy::ConsistentHashing, 3); + assert_eq!(hash.choose(source, Some(&a)), hash.choose(source, Some(&b))); + let mut sticky = Balance::new(BalanceStrategy::StickySessions, 3); + let index = sticky.choose(source, Some(&a)); + for _ in 0..20 { + assert_eq!(sticky.choose(source, Some(&b)), index); + } + tokio::time::advance(Duration::from_secs(601)).await; + sticky.choose(source, Some(&a)); + assert!(sticky.sticky.values().all(|e| e.expires > Instant::now())); + // A new provider generation owns its own state; an old transport keeps its leaf. + let mut updated = Balance::new(BalanceStrategy::RoundRobin, 2); + assert_eq!( + ( + updated.choose(source, Some(&a)), + updated.choose(source, Some(&b)) + ), + (0, 1) + ); + assert_eq!(round.choose(source, Some(&a)), 1); + } +} diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 2f5e4f5..2ca4c1a 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -160,9 +160,15 @@ impl ProxyDispatchers { } } - pub(crate) fn resolve( + fn per_flow(&self, id: RouteTargetId) -> bool { + matches!(id, RouteTargetId::Group(id) if self.proxy_groups.as_ref().is_some_and(|groups| groups.contains_balance(id))) + } + fn resolve_for( &self, id: RouteTargetId, + udp: bool, + source: Option, + destination: Option<&Destination>, ) -> Result { match id { RouteTargetId::Proxy(id) => self @@ -180,7 +186,7 @@ impl ProxyDispatchers { .ok_or_else(|| { DispatchError::Other(format!("unknown proxy group id {}", id.index())) })? - .resolve_leaf(id), + .resolve_leaf_for(id, udp, source, destination), } } } @@ -203,9 +209,14 @@ pub struct RoutingDispatcher { geo_matcher: Arc, tun_config: TunConfig, sniffer: Option>, + tcp_concurrent: bool, } impl RoutingDispatcher { + pub(crate) fn with_tcp_concurrent(mut self, enabled: bool) -> Self { + self.tcp_concurrent = enabled; + self + } pub(crate) fn with_sniffer(mut self, config: &crate::config::SnifferConfig) -> Self { self.sniffer = config.enable.then(|| { crate::sniffing::Policy::new(Arc::new(config.clone()), self.geo_matcher.clone()) @@ -241,6 +252,7 @@ impl RoutingDispatcher { geo_matcher, tun_config: TunConfig::default(), sniffer: None, + tcp_concurrent: false, } } @@ -262,6 +274,7 @@ impl RoutingDispatcher { rules: Arc::new(rules), geo_matcher, sniffer: None, + tcp_concurrent: false, tun_config: TunConfig { udp_timeout: Duration::from_secs(30), ..TunConfig::default() @@ -335,7 +348,7 @@ impl RoutingDispatcher { let accepts_domain_hint = matches!( inbound, InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 - ) && self.rules.uses_domain_routing(); + ); let sniffed_domain = accepts_domain_hint.then_some(sniffed_domain).flatten(); let dns_domain_hint = match (accepts_domain_hint, destination, &self.dns, sniffed_domain) { (true, Destination::Ip(address), Some(dns), None) => { @@ -391,6 +404,13 @@ impl RoutingDispatcher { Destination::Ip(_) => destination.clone(), }; let mut decision = RouteDecision { + metadata: context.domain().map_or_else( + || destination.clone(), + |host| Destination::Domain { + host: host.to_owned(), + port: destination.port(), + }, + ), action: rule_match.action, destination: context.pinned_ip().map_or(routed_destination, |address| { Destination::Ip(std::net::SocketAddr::new(address, destination.port())) @@ -516,6 +536,7 @@ impl Drop for DnsTcpStream { } struct RouteDecision { + metadata: Destination, action: RuleAction, destination: Destination, resolved_addresses: Option>, @@ -611,13 +632,40 @@ impl Dispatcher for RoutingDispatcher { ) .await?; let resolved_target = match decision.action { - RuleAction::Route(id) => Some(self.route_targets.resolve(id)?), + RuleAction::Route(id) => Some(self.route_targets.resolve_for( + id, + false, + Some(session.source), + Some(&decision.metadata), + )?), RuleAction::Direct | RuleAction::Reject => None, }; if resolved_target.as_ref().is_some_and(|target| target.direct) { self.resolve_direct_destination(&mut decision).await?; } log_route_decision(Network::Tcp, &decision); + let direct = decision.action == RuleAction::Direct + || resolved_target.as_ref().is_some_and(|target| target.direct); + if direct + && self.tcp_concurrent + && let Some(addresses) = &decision.resolved_addresses + && addresses.len() > 1 + { + let dispatcher = resolved_target + .as_ref() + .map_or(&self.direct, |target| &target.dispatcher); + let addresses = addresses + .iter() + .map(|&ip| SocketAddr::new(ip, decision.destination.port())) + .collect::>(); + return crate::dialer::race_connections(&addresses, None, |address| { + let mut attempt = session.clone(); + attempt.destination = Destination::Ip(address); + attempt.sniffed_domain = None; + dispatcher.connect_tcp(attempt) + }) + .await; + } session.destination = decision.destination; session.sniffed_domain = None; match decision.action { @@ -661,6 +709,7 @@ impl RoutingDispatcher { geo_matcher: self.geo_matcher.clone(), tun_config: self.tun_config.clone(), sniffer: self.sniffer.clone(), + tcp_concurrent: self.tcp_concurrent, } } } @@ -668,7 +717,7 @@ impl RoutingDispatcher { struct RoutedDatagramTransport { router: RoutingDispatcher, session: DatagramSession, - route_transports: HashMap, + route_transports: HashMap<(RouteTargetId, Option), CachedRouteTransport>, fake_flows: HashMap, direct_transport: Option>, flows: DatagramRoutes, @@ -686,6 +735,7 @@ struct FakeDatagramFlow { } struct CachedRouteTransport { + last_used: Instant, transport: Box, direct: bool, } @@ -770,6 +820,21 @@ impl From for LoggedRouteAction { impl RoutedDatagramTransport { async fn expire_fake_flows(&mut self) { + let expired = self + .route_transports + .iter() + .filter(|((_, destination), cached)| { + destination.is_some() + && cached.last_used.elapsed() >= self.router.tun_config.udp_timeout + }) + .map(|(key, _)| key.clone()) + .collect::>(); + for key in expired { + if let Some(mut cached) = self.route_transports.remove(&key) { + let _ = cached.transport.close().await; + } + } + let now = Instant::now(); let expired: Vec<_> = self .fake_flows @@ -806,7 +871,12 @@ impl RoutedDatagramTransport { RuleAction::Reject => return Ok(()), RuleAction::Direct => (self.router.direct.clone(), true), RuleAction::Route(id) => { - let target = self.router.route_targets.resolve(id)?; + let target = self.router.route_targets.resolve_for( + id, + true, + Some(self.session.source), + Some(&decision.metadata), + )?; (target.dispatcher, target.direct) } }; @@ -862,11 +932,23 @@ impl RoutedDatagramTransport { mut decision: RouteDecision, mut datagram: Datagram, ) -> Result<(), DispatchError> { - let direct = self.route_transports.get(&id).map(|cached| cached.direct); + let key = ( + id, + self.router + .route_targets + .per_flow(id) + .then(|| datagram.remote.clone()), + ); + let direct = self.route_transports.get(&key).map(|cached| cached.direct); let direct = match direct { Some(direct) => direct, None => { - let target = self.router.route_targets.resolve(id)?; + let target = self.router.route_targets.resolve_for( + id, + true, + Some(self.session.source), + Some(&decision.metadata), + )?; let direct = target.direct; if direct { self.router @@ -877,8 +959,14 @@ impl RoutedDatagramTransport { .dispatcher .open_datagram(self.session.clone()) .await?; - self.route_transports - .insert(id, CachedRouteTransport { transport, direct }); + self.route_transports.insert( + key.clone(), + CachedRouteTransport { + transport, + direct, + last_used: Instant::now(), + }, + ); self.receive_wakeup.notify_one(); direct } @@ -890,12 +978,12 @@ impl RoutedDatagramTransport { } datagram.remote = decision.destination; datagram.sniffed_domain = None; - self.route_transports - .get_mut(&id) - .expect("route transport was inserted") - .transport - .send(datagram) - .await + let cached = self + .route_transports + .get_mut(&key) + .expect("route transport was inserted"); + cached.last_used = Instant::now(); + cached.transport.send(datagram).await } async fn send_direct(&mut self, datagram: Datagram) -> Result<(), DispatchError> { @@ -928,21 +1016,31 @@ impl RoutedDatagramTransport { && self.route_transports.len() == 1 && self.direct_transport.is_none() { - return self + let cached = self .route_transports .values_mut() .next() - .expect("the only route transport exists") - .transport - .receive() - .await; + .expect("the only route transport exists"); + let result = cached.transport.receive().await; + if result.is_ok() { + cached.last_used = Instant::now(); + } + return result; } let mut receives: Vec< futures_util::future::BoxFuture<'_, Result>, > = self .route_transports .values_mut() - .map(|cached| cached.transport.receive()) + .map(|cached| { + Box::pin(async move { + let result = cached.transport.receive().await; + if result.is_ok() { + cached.last_used = Instant::now(); + } + result + }) as futures_util::future::BoxFuture<'_, _> + }) .collect::>(); if let Some(direct) = self.direct_transport.as_mut() { receives.push(direct.receive()); @@ -1007,6 +1105,7 @@ impl DatagramTransport for RoutedDatagramTransport { let action = flow.and_then(|destination| self.flows.action(destination, now)); let decision = if let Some(action) = action { RouteDecision { + metadata: datagram.remote.clone(), action, destination: datagram.remote.clone(), resolved_addresses: None, @@ -1071,6 +1170,17 @@ impl DatagramTransport for RoutedDatagramTransport { .is_some_and(|route| route.flow_id.as_deref() != Some(flow_id)) { self.flows.entries.remove(&destination); + let keys = self + .route_transports + .keys() + .filter(|(_, target)| target.as_ref() == Some(&Destination::Ip(destination))) + .cloned() + .collect::>(); + for key in keys { + if let Some(mut cached) = self.route_transports.remove(&key) { + cached.transport.close().await?; + } + } } } if let Some(destination) = flow @@ -1098,8 +1208,13 @@ impl DatagramTransport for RoutedDatagramTransport { } async fn receive(&mut self) -> Result { - self.expire_fake_flows().await; - let datagram = self.receive_inner().await?; + let datagram = loop { + self.expire_fake_flows().await; + tokio::select! { + result = self.receive_inner() => break result?, + () = tokio::time::sleep(UDP_FLOW_CLEANUP_INTERVAL) => {}, + } + }; if self.session.inbound == InboundKind::Tun { let now = Instant::now(); self.flows.expire(now); @@ -1359,6 +1474,7 @@ mod tests { ) -> SelectProxyGroupConfig { SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, + strategy: crate::config::BalanceStrategy::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, @@ -1424,6 +1540,74 @@ mod tests { ) } + #[tokio::test] + async fn load_balance_routes_tcp_and_pins_udp_flows() { + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: + - {name: a, type: socks5, server: 192.0.2.10, port: 1080} + - {name: b, type: socks5, server: 192.0.2.11, port: 1080} +proxy-groups: + - {name: balance, type: load-balance, proxies: [a,b], strategy: round-robin} +rules: ["MATCH,balance"] +"#, + ) + .unwrap(); + let a = Arc::new(RecordingDispatcher::default()); + let b = Arc::new(RecordingDispatcher::default()); + let direct = Arc::new(RecordingDispatcher::default()); + let nodes: Vec> = vec![a.clone(), b.clone()]; + let groups = ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let router = RoutingDispatcher::new( + ProxyDispatchers::with_proxy_groups(nodes, &groups).unwrap(), + direct, + None, + RuleSet::compile(vec![rule(RuleKind::Match, group_action(0))]).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + let source = "192.0.2.1:5555".parse().unwrap(); + let dest = Destination::Ip("198.51.100.1:443".parse().unwrap()); + for _ in 0..2 { + router + .connect_tcp(StreamSession { + inbound: InboundKind::Socks5, + source, + destination: dest.clone(), + sniffed_domain: None, + }) + .await + .unwrap(); + } + assert_eq!(a.tcp_sessions.lock().unwrap().len(), 1); + assert_eq!(b.tcp_sessions.lock().unwrap().len(), 1); + let mut transport = router + .open_datagram(DatagramSession::new(InboundKind::Socks5, source)) + .await + .unwrap(); + let first = Datagram { + remote: dest, + payload: Bytes::from_static(b"x"), + sniffed_domain: None, + }; + transport.send(first.clone()).await.unwrap(); + groups.apply_health(0, &[None, Some(50)]); + transport.send(first).await.unwrap(); + transport + .send(Datagram { + remote: Destination::Ip("198.51.100.2:443".parse().unwrap()), + payload: Bytes::from_static(b"y"), + sniffed_domain: None, + }) + .await + .unwrap(); + assert_eq!(a.datagrams.lock().unwrap().len(), 2); + assert_eq!(b.datagrams.lock().unwrap().len(), 1); + assert_eq!(a.udp_opens.load(Ordering::Relaxed), 1); + assert!(groups.select("balance", "a").is_err()); + transport.close().await.unwrap(); + } + #[test] fn proxy_registry_accepts_ids_beyond_u8() { let proxies = (0..300) @@ -1463,6 +1647,7 @@ mod tests { geo_matcher: Arc::new(EmptyGeoMatcher), tun_config: TunConfig::default(), sniffer: None, + tcp_concurrent: false, }; router diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 7cee52f..4716b3e 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -16,6 +16,7 @@ use crate::{ session::Destination, }; +mod balance; mod selection; pub(crate) use selection::GroupSelection; diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index e03ca40..b22946e 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -29,6 +29,7 @@ pub(crate) struct ProxyGroupState { pub(crate) enum ProxyGroupError { UnknownGroup, UnknownMember, + NotSelectable, } impl fmt::Display for ProxyGroupError { @@ -36,6 +37,7 @@ impl fmt::Display for ProxyGroupError { formatter.write_str(match self { Self::UnknownGroup => "unknown proxy group", Self::UnknownMember => "unknown proxy group member", + Self::NotSelectable => "load-balance does not support manual selection", }) } } @@ -158,6 +160,7 @@ impl ProxyGroups { .zip(selections) .map(|(config, selection)| { selection.configure(config.max_failed_times, config.health.timeout); + selection.configure_balance(config); SelectProxyGroup { config: config.clone(), name: config.name.clone(), @@ -213,6 +216,9 @@ impl ProxyGroups { .copied() .ok_or(ProxyGroupError::UnknownGroup)?; let group = &self.groups[id.index()]; + if group.config.kind == crate::config::GroupKind::LoadBalance { + return Err(ProxyGroupError::NotSelectable); + } let selected = group .members .iter() @@ -226,17 +232,20 @@ impl ProxyGroups { Ok(()) } + #[cfg(test)] pub(crate) fn resolve_leaf( &self, group_id: ProxyGroupId, ) -> Result { - self.resolve_leaf_for(group_id, false) + self.resolve_leaf_for(group_id, false, None, None) } - fn resolve_leaf_for( + pub(crate) fn resolve_leaf_for( &self, mut group_id: ProxyGroupId, udp: bool, + source: Option, + destination: Option<&crate::session::Destination>, ) -> Result { for _ in 0..=self.groups.len() { let group = self.groups.get(group_id.index()).ok_or_else(|| { @@ -245,8 +254,7 @@ impl ProxyGroups { if udp && group.disable_udp { return Err(DispatchError::NotAllowed); } - group.selected.touch(); - let selected = group.selected.load(Ordering::Acquire); + let selected = group.selected.choose(source, destination); let member = group.members.get(selected).ok_or_else(|| { DispatchError::Other(format!( "proxy group {} has invalid selected index {selected}", @@ -287,6 +295,25 @@ impl ProxyGroups { )) } + pub(crate) fn contains_balance(&self, id: ProxyGroupId) -> bool { + let mut pending = vec![id]; + let mut seen = std::collections::HashSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let group = &self.groups[id.index()]; + if group.config.kind == crate::config::GroupKind::LoadBalance { + return true; + } + pending.extend(group.members.iter().filter_map(|m| match m.target { + ProxyGroupMemberTarget::Route(RouteTargetId::Group(id)) => Some(id), + _ => None, + })); + } + false + } + pub(crate) fn selection(&self, index: usize) -> Arc { self.groups[index].selected.clone() } @@ -297,6 +324,10 @@ impl ProxyGroups { { return; } + if group.config.kind == crate::config::GroupKind::LoadBalance { + group.selected.balance_health(samples); + return; + } if let Some(forced) = group.selected.forced() { if samples[forced].is_some() { group.selected.store(forced, Ordering::Release); @@ -346,7 +377,11 @@ impl SelectProxyGroup { .iter() .map(|member| member.name.clone()) .collect(), - now: self.members[selected].name.clone(), + now: if self.config.kind == crate::config::GroupKind::LoadBalance { + String::new() + } else { + self.members[selected].name.clone() + }, hidden: self.hidden, icon: self.icon.clone(), } @@ -361,7 +396,12 @@ struct SelectProxyGroupDispatcher { #[async_trait] impl Dispatcher for SelectProxyGroupDispatcher { async fn connect_tcp(&self, session: StreamSession) -> Result { - let leaf = self.groups.resolve_leaf(self.id)?; + let leaf = self.groups.resolve_leaf_for( + self.id, + false, + Some(session.source), + Some(&session.destination), + )?; let result = leaf.dispatcher.connect_tcp(session).await; if !leaf.direct && !matches!(result, Err(DispatchError::NotAllowed)) { self.groups.groups[self.id.index()] @@ -375,14 +415,62 @@ impl Dispatcher for SelectProxyGroupDispatcher { &self, session: DatagramSession, ) -> Result, DispatchError> { + if self.groups.contains_balance(self.id) { + return Ok(Box::new(BalancedDatagrams { + groups: self.groups.clone(), + id: self.id, + session, + transport: None, + })); + } self.groups - .resolve_leaf_for(self.id, true)? + .resolve_leaf_for(self.id, true, Some(session.source), None)? .dispatcher .open_datagram(session) .await } } +// Core-originated DNS/probes also enter groups without a first destination. +// Resolve their leaf on first send and retain it through the association. +struct BalancedDatagrams { + groups: Arc, + id: ProxyGroupId, + session: DatagramSession, + transport: Option>, +} +#[async_trait] +impl DatagramTransport for BalancedDatagrams { + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + if self.transport.is_none() { + let leaf = self.groups.resolve_leaf_for( + self.id, + true, + Some(self.session.source), + Some(&datagram.remote), + )?; + self.transport = Some(leaf.dispatcher.open_datagram(self.session.clone()).await?); + } + self.transport + .as_mut() + .expect("group association opened") + .send(datagram) + .await + } + async fn receive(&mut self) -> Result { + match &mut self.transport { + Some(transport) => transport.receive().await, + None => std::future::pending().await, + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + match self.transport.take() { + Some(mut transport) => transport.close().await, + None => Ok(()), + } + } +} + struct RejectDispatcher; #[async_trait] @@ -531,6 +619,7 @@ mod tests { ) -> SelectProxyGroupConfig { SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, + strategy: crate::config::BalanceStrategy::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, diff --git a/src/routing/selection.rs b/src/routing/selection.rs index 3a5b04c..2d9f546 100644 --- a/src/routing/selection.rs +++ b/src/routing/selection.rs @@ -8,6 +8,7 @@ use tokio::time::Instant; #[derive(Debug)] pub(crate) struct GroupSelection { index: AtomicUsize, + balance: Mutex>, forced: Mutex>, touched: Mutex, failures: Mutex<(Instant, u32)>, @@ -20,6 +21,7 @@ impl GroupSelection { let now = Instant::now(); Self { index: AtomicUsize::new(index), + balance: Mutex::new(None), forced: Mutex::new(None), touched: Mutex::new(now), failures: Mutex::new((now, 0)), @@ -28,6 +30,33 @@ impl GroupSelection { window_ms: AtomicU64::new(5000), } } + pub fn configure_balance(&self, config: &crate::config::SelectProxyGroupConfig) { + if config.kind == crate::config::GroupKind::LoadBalance { + let mut state = self.balance.lock().unwrap(); + if state.is_none() { + *state = Some(super::balance::Balance::new( + config.strategy, + config.members.len(), + )); + } + } + } + pub fn choose( + &self, + source: Option, + destination: Option<&crate::session::Destination>, + ) -> usize { + self.touch(); + self.balance.lock().unwrap().as_mut().map_or_else( + || self.load(Ordering::Acquire), + |s| s.choose(source, destination), + ) + } + pub fn balance_health(&self, samples: &[Option]) { + if let Some(balance) = self.balance.lock().unwrap().as_mut() { + balance.health(samples); + } + } pub fn load(&self, order: Ordering) -> usize { self.index.load(order) } diff --git a/src/runtime.rs b/src/runtime.rs index 7f7a7a7..49c2ba9 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -634,6 +634,7 @@ impl GraphBuilder { fn build(&self, dialer: Dialer) -> io::Result { let dialer = dialer .with_ipv6(self.config.ipv6) + .with_connections(self.config.connections) .with_fresh_initialization(); let handshake_stats = RuntimeResourceStats::new("runtime_handshake_observation"); let proxy_graph = self.build_proxy_graph(dialer.clone())?; @@ -719,7 +720,8 @@ impl GraphBuilder { geo_matcher, ) .with_tun_config(&self.config.tun) - .with_sniffer(&self.config.sniffer), + .with_sniffer(&self.config.sniffer) + .with_tcp_concurrent(self.config.connections.tcp_concurrent), ); let session_stats = RuntimeResourceStats::new("runtime_session_observation"); Ok(BuiltRuntimeParts { @@ -883,7 +885,11 @@ fn build_proxy_graph( lifecycle_order: Vec::with_capacity(order.len()), selections: groups .iter() - .map(|group| Arc::new(crate::routing::GroupSelection::new(group.initial_member))) + .map(|group| { + let selection = Arc::new(crate::routing::GroupSelection::new(group.initial_member)); + selection.configure_balance(group); + selection + }) .collect(), }; let mut nodes: Vec>> = vec![None; proxies.len()]; @@ -929,6 +935,7 @@ fn build_proxy_graph( let proxy = &proxies[index]; let node_dialer = dialer.clone().with_ip_version(proxy.ip_version); let upstream = build_upstream_path( + crate::outbound::server_destination(proxy.address(), proxy.port())?, proxy.dialer_proxy, endpoints[index].upload.clone(), &nodes, @@ -1065,8 +1072,12 @@ fn build_proxy_graph( { let download_upstream = config .download() - .map(|_| { + .map(|download| { build_upstream_path( + crate::outbound::server_destination( + &download.address, + download.port, + )?, proxy.dialer_proxy, endpoints[index].download.clone(), &nodes, @@ -1161,6 +1172,7 @@ fn build_proxy_graph( } fn build_upstream_path( + server: crate::session::Destination, parent: Option, endpoint: Option, nodes: &[Option>], @@ -1175,6 +1187,7 @@ fn build_upstream_path( .clone(), )), Some(RouteTargetId::Group(id)) => Ok(UpstreamPath::Group { + server, group: groups[id.index()] .as_ref() .expect("dependency built") @@ -1456,7 +1469,10 @@ impl RunningCore { )); } #[cfg(all(feature = "inbound-http", feature = "inbound-socks5"))] - mixed_servers.push(MixedServer::bind(config.clone(), dispatcher.clone())?); + mixed_servers.push( + MixedServer::bind(config.clone(), dispatcher.clone())? + .with_connections(proxy_graph.dialer.connections()), + ); } InboundConfig::Tun(_) => { if tun_runtime.is_none() { diff --git a/tests/README.md b/tests/README.md index 2bf8d9e..2731695 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,9 @@ cargo test --locked --lib geodata::tests:: cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: +cargo test --locked --all-features --lib routing::balance::tests:: +cargo test --locked --all-features --lib load_balance_routes_ +cargo test --locked --all-features --lib dialer::connections::tests:: cargo test --locked --all-features --lib fake_ip_ cargo test --locked --all-features --lib sniffing::tests:: cargo test --locked --all-features --lib tcp_sniffer::tests:: From e2c9c213df9ae40ab55dad45b9cc325ee4519006 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:44:26 +0800 Subject: [PATCH 013/445] Add connection management and bounded runtime diagnostics Expose authenticated connection snapshots and cancellation, effective settings, rule hits and DNS queries. Share connection ownership across provider generations and isolate UDP target lifetimes; keep credentials and upstream locations out of diagnostics. Validated memory connection cancellation, redaction, UDP flow isolation and provider/fake-IP preparation; 41 routing, DNS fallback and fake-IP regressions; all-target compilation, Clippy, Linux CLI compilation and TLS dependency audit. --- Cargo.lock | 1 + Cargo.toml | 1 + docs/config.yaml | 3 +- docs/controller-api.md | 45 +++- src/connections.rs | 268 +++++++++++++++++++ src/controller.rs | 121 +++++++++ src/dns/fallback.rs | 3 + src/dns/mod.rs | 1 + src/dns/runtime.rs | 20 ++ src/dns/trace.rs | 24 ++ src/lib.rs | 2 + src/resource/control.rs | 3 + src/routing/dispatcher.rs | 516 +++++++++++++++++++++++++++++++++---- src/routing/proxy_group.rs | 17 ++ src/runtime.rs | 14 +- src/runtime/diagnostics.rs | 204 +++++++++++++++ src/runtime/providers.rs | 36 +++ tests/README.md | 3 + 18 files changed, 1224 insertions(+), 58 deletions(-) create mode 100644 src/connections.rs create mode 100644 src/dns/trace.rs create mode 100644 src/runtime/diagnostics.rs diff --git a/Cargo.lock b/Cargo.lock index a148e33..81fe41a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2701,6 +2701,7 @@ dependencies = [ "socket2", "tempfile", "thiserror", + "time", "tokio", "tokio-boring", "tokio-rustls", diff --git a/Cargo.toml b/Cargo.toml index 01eca31..47a66ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,6 +126,7 @@ shake = { version = "0.1.0", optional = true } shadowsocks = { version = "=1.25.0", optional = true, default-features = false, features = ["aead-cipher-2022"] } socket2 = { version = "0.6", features = ["all"] } thiserror = "2" +time = { version = "=0.3.55", features = ["formatting"] } tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync", "time"] } tokio-boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", optional = true } tokio-rustls = { version = "=0.26.5", default-features = false, features = ["ring", "tls12"] } diff --git a/docs/config.yaml b/docs/config.yaml index b519266..8259417 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -75,7 +75,8 @@ authentication: # 只要 Controller 管理 proxy-groups,secret 就必填;仅有 TUN 流量接口时可省略。 # secret 出现时必须为 1–255 UTF-8 字节,并作为全部 Controller 路由的 Bearer token。 # Controller 提供 GET /traffic、GET /group、GET /group/{name}、 -# GET /proxies/{name} 和 PUT /proxies/{name},不属于 Invoke API。 +# GET /proxies/{name} 和 PUT /proxies/{name};另含连接查询/关闭、有效配置、规则 +# 与 A/AAAA DNS 查询,完整接口见 controller-api.md;不属于 Invoke API。 external-controller: 127.0.0.1:9090 secret: "vole-runtime-secret" diff --git a/docs/controller-api.md b/docs/controller-api.md index 4a1dd9e..fec5899 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -1,6 +1,6 @@ # 运行时 Controller API -Controller 是当前 Running Session 的回环 HTTP 接口。它提供一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback`、`load-balance` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 +Controller 是当前 Running Session 的回环 HTTP 接口。它提供业务连接管理、有效设置与规则查询、一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback`、`load-balance` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 ## 配置与生命周期 @@ -132,7 +132,7 @@ Content-Length: 26 静态成员来自配置,动态成员随 provider 成功更新;Controller 读取当前可用的成员和选择: - 新建的物理 TCP 连接使用切换后的当前叶节点;既有 TCP 连接保持原路径; -- 新建的 UDP association/物理 transport 使用切换后的当前叶节点;既有 association 不迁移; +- 新建 UDP 目标会话使用切换后的当前叶节点;既有目标会话不迁移; - 新建的 DNS transport 使用切换后的当前叶节点;DNS cache、singleflight 状态和已在池中的 TCP transport 不清空、不迁移; - 切换不主动断开连接、不刷新缓存、不重启运行时,也不修改 YAML;可按 profile 配置保存选择; - 选中节点、`DIRECT` 或 `REJECT` 时按原样执行;选中嵌套组时继续解析其当前成员。select 的失败按原样返回;自动组根据探测结果选择后续连接的成员。 @@ -156,6 +156,45 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 不等待候选资源。空动态组使用配置的 `empty-fallback`,默认 COMPATIBLE(直连)。 组响应另含 `hidden` 与 `icon`,`disable-udp` 会拒绝该组的业务 UDP。 +## 业务连接和诊断 + +`GET /connections` 返回 Mihomo 对应字段 `uploadTotal`、`downloadTotal`、`connections`、 +`memory`。业务连接包含 `id`、`metadata`、`upload`、`download`、`start`、`chains`、 +`providerChains`、`rule`、`rulePayload`。时间使用 RFC 3339;端口为字符串。 + +- 只统计 TUN、HTTP、SOCKS5 的业务 TCP/UDP 负载,排除 TUN DNS 拦截、ICMP、Controller、 + Core 资源下载和健康探测。累计值在本次 Running Session 内保留,provider 更新不会重置。 +- TCP 为一条业务流,UDP 为同一来源与目标的会话;每个 UDP 目标有独立 transport, + 会话超时、关闭或新的 QUIC flow-id 后重新选择出口。资源代际不会迁移既有会话。 +- `chains` 按叶节点到业务路由组排列;当前不展开节点内部的 `dialer-proxy` 协议链。 + `providerChains` 给出这些节点关联的 provider 名称,可与 provider 状态中的脱敏 `error` 对照。 +- `metadata` 提供来源、目标、入口、协议、嗅探域名与 `dnsMode`,不查询进程或 ASN。 + 扩展字段 `dnsPath` 是当前建链任务实际经过的有界 DNS 阶段标签,例如缓存、policy、 + fallback、fake-IP 与解析角色;不包含上游地址、URL 或认证头。 +- 扩展字段 `errors` 最多保留最近 32 次建链/数据报失败的连接信息与固定错误码, + 不返回底层错误原文。`memory` 当前为 0,表示未采集进程 RSS。 + +`DELETE /connections/{id}` 取消指定连接,`DELETE /connections` 取消当前全部连接。 +均返回 204,未知 ID 同样返回 204;取消唤醒正在等待的 I/O 并释放对应流资源。 +这不禁止用户重新建连,也不重置累计流量。正常 stop 会取消全部连接并沿既有停止屏障回收。 + +`GET /rules` 返回 `rules` 数组,包括规则索引、类型、payload、目标及命中次数和最后命中时间。 +计数属于当前资源代际,只记录业务的新选路,不将每个 UDP 包重复计为一次选路。 +没有采集未命中次数、GeoData 记录大小;`size` 为 -1。规则列表反映当前有效路由模式。 + +`GET /configs` 返回归一化后的运行设置:mixed 入口、mode、log-level、IPv6、sniffing、 +连接选项、profile 和 TUN 字段。它使用字段白名单,不提供完整 YAML、节点凭据、订阅位置、 +认证头、Controller secret 或认证用户列表;不支持 PUT/PATCH 配置修改。 + +`GET /dns/query?name=example.com&type=A` 使用当前 DNS 与相同缓存查询;支持 A/AAAA, +省略 type 使用 A。响应包含 `Status/Question/Answer/TC/RD/RA/AD/CD` 和扩展 `QueryPath`。 +fake-IP 模式仍返回合成地址;DNS 关闭或查询失败返回 500。这里只输出地址/CNAME 答案, +不提供其他 qtype、Authority/Additional 展开或后台 DNS 查询日志。 + +这些接口复用 Controller 的回环限制和 Bearer 鉴权。目标与 DNS 路径只通过显式查询返回, +不会写入普通日志。`/connections` 是业务负载统计,`/traffic` 继续表示 TUN 原始 IP 包流量, +`/proxies` 的节点健康数据继续表示探测结果,三者不可相互替代。 + ## HTTP 状态与协议边界 | 状态 | 含义 | @@ -172,7 +211,7 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 | `413 Payload Too Large` | PUT 正文超过 1 KiB | | `415 Unsupported Media Type` | PUT 不是严格 `application/json` | -当前不提供 Controller 主动 delay 测试、连接管理、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 +当前不提供 Controller 主动 delay 测试、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的连接、规则、配置、DNS 和 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; diff --git a/src/connections.rs b/src/connections.rs new file mode 100644 index 0000000..af9c61d --- /dev/null +++ b/src/connections.rs @@ -0,0 +1,268 @@ +//! Running-session connection ownership and opt-in Controller snapshots. +use crate::dispatch::{BoxStream, DispatchError}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, VecDeque}, + future::Future, + io, + pin::Pin, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, + task::{Context, Poll}, +}; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio_util::sync::CancellationToken; + +#[derive(Default)] +pub(crate) struct Registry { + entries: Mutex>>, + failures: Mutex>, + upload: AtomicU64, + download: AtomicU64, + closed: AtomicBool, +} +struct Entry { + metadata: Mutex, + start: String, + upload: AtomicU64, + download: AtomicU64, + cancel: CancellationToken, +} +pub(crate) struct Connection { + id: String, + entry: Arc, + registry: Weak, +} +impl Registry { + pub fn open(self: &Arc, metadata: Value) -> Connection { + let id = uuid::Builder::from_random_bytes(rand::random()) + .into_uuid() + .to_string(); + let entry = Arc::new(Entry { + metadata: Mutex::new(metadata), + start: timestamp(), + upload: AtomicU64::new(0), + download: AtomicU64::new(0), + cancel: CancellationToken::new(), + }); + let mut entries = self.entries.lock().unwrap(); + if self.closed.load(Ordering::Acquire) { + entry.cancel.cancel(); + } else { + entries.insert(id.clone(), entry.clone()); + } + Connection { + id, + entry, + registry: Arc::downgrade(self), + } + } + pub fn snapshot(&self) -> Value { + let connections = self + .entries + .lock() + .unwrap() + .iter() + .map(|(id, entry)| { + let mut value = entry.metadata.lock().unwrap().clone(); + value["id"] = json!(id); + value["start"] = json!(entry.start); + value["upload"] = json!(entry.upload.load(Ordering::Relaxed)); + value["download"] = json!(entry.download.load(Ordering::Relaxed)); + value + }) + .collect::>(); + json!({"uploadTotal": self.upload.load(Ordering::Relaxed), "downloadTotal": self.download.load(Ordering::Relaxed), "connections": connections, "memory": 0, "errors": self.failures.lock().unwrap().iter().cloned().collect::>()}) + } + pub fn close(&self, id: &str) { + if let Some(entry) = self.entries.lock().unwrap().remove(id) { + entry.cancel.cancel(); + } + } + pub fn close_all(&self) { + let entries = std::mem::take(&mut *self.entries.lock().unwrap()); + for entry in entries.into_values() { + entry.cancel.cancel(); + } + } + pub fn shutdown(&self) { + self.closed.store(true, Ordering::Release); + self.close_all(); + } +} +impl Connection { + pub fn dns_path(&self, path: Vec<&'static str>) { + self.entry.metadata.lock().unwrap()["dnsPath"] = json!(path); + } + pub fn token(&self) -> CancellationToken { + self.entry.cancel.clone() + } + pub fn cancelled(&self) -> bool { + self.entry.cancel.is_cancelled() + } + pub fn upload(&self, bytes: usize) { + saturating_add(&self.entry.upload, bytes); + if let Some(registry) = self.registry.upgrade() { + saturating_add(®istry.upload, bytes); + } + } + pub fn download(&self, bytes: usize) { + saturating_add(&self.entry.download, bytes); + if let Some(registry) = self.registry.upgrade() { + saturating_add(®istry.download, bytes); + } + } + pub fn failed(&self, error: &DispatchError) { + let Some(registry) = self.registry.upgrade() else { + return; + }; + let kind = match error { + DispatchError::NotAllowed => "not_allowed", + DispatchError::TimedOut => "timed_out", + DispatchError::HostUnreachable => "host_unreachable", + DispatchError::NetworkUnreachable => "network_unreachable", + _ => "outbound_failed", + }; + let mut failures = registry.failures.lock().unwrap(); + if failures.len() == 32 { + failures.pop_front(); + } + let mut info = self.entry.metadata.lock().unwrap().clone(); + info["id"] = json!(self.id); + info["time"] = json!(timestamp()); + info["error"] = json!(kind); + failures.push_back(info); + } + pub fn stream(self, inner: BoxStream) -> BoxStream { + let cancelled = Box::pin(self.token().cancelled_owned()); + Box::new(TrackedStream { + connection: self, + inner: Some(inner), + cancelled, + }) + } +} +impl Drop for Connection { + fn drop(&mut self) { + if let Some(registry) = self.registry.upgrade() { + registry.entries.lock().unwrap().remove(&self.id); + } + } +} +fn saturating_add(counter: &AtomicU64, bytes: usize) { + let mut old = counter.load(Ordering::Relaxed); + while let Err(value) = counter.compare_exchange_weak( + old, + old.saturating_add(bytes as u64), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + old = value; + } +} +pub(crate) fn timestamp() -> String { + time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .expect("current UTC date") +} +struct TrackedStream { + connection: Connection, + inner: Option, + cancelled: Pin>, +} +impl TrackedStream { + fn closed(&mut self, cx: &mut Context<'_>) -> bool { + if self.cancelled.as_mut().poll(cx).is_ready() { + self.inner.take(); + } + self.inner.is_none() + } +} +fn closed_error() -> io::Error { + io::Error::new(io::ErrorKind::ConnectionAborted, "connection closed") +} +impl AsyncRead for TrackedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + if self.closed(cx) { + return Poll::Ready(Err(closed_error())); + } + let before = buffer.filled().len(); + let result = Pin::new(self.inner.as_mut().expect("open stream")).poll_read(cx, buffer); + self.connection.download(buffer.filled().len() - before); + result + } +} +impl AsyncWrite for TrackedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + if self.closed(cx) { + return Poll::Ready(Err(closed_error())); + } + let result = Pin::new(self.inner.as_mut().expect("open stream")).poll_write(cx, bytes); + if let Poll::Ready(Ok(n)) = &result { + self.connection.upload(*n); + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closed(cx) { + return Poll::Ready(Err(closed_error())); + } + Pin::new(self.inner.as_mut().expect("open stream")).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.closed(cx) { + return Poll::Ready(Ok(())); + } + Pin::new(self.inner.as_mut().expect("open stream")).poll_shutdown(cx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + #[tokio::test] + async fn connection_query_counts_and_close_wakes_blocked_io() { + let registry = Arc::new(Registry::default()); + let connection = registry.open(json!({"metadata":{"network":"tcp"},"chains":["edge","auto"],"rule":"DomainSuffix","rulePayload":"example.com"})); + let id = connection.id.clone(); + let (client, mut peer) = tokio::io::duplex(32); + let mut stream = connection.stream(Box::new(client)); + stream.write_all(b"sent").await.unwrap(); + peer.write_all(b"ok").await.unwrap(); + assert_eq!(stream.read_u16().await.unwrap(), u16::from_be_bytes(*b"ok")); + let snapshot = registry.snapshot(); + assert_eq!(snapshot["connections"][0]["upload"], 4); + assert_eq!(snapshot["connections"][0]["download"], 2); + assert_eq!( + snapshot["connections"][0]["chains"], + json!(["edge", "auto"]) + ); + let waiting = tokio::spawn(async move { stream.read_u8().await }); + tokio::task::yield_now().await; + registry.close(&id); + assert_eq!( + waiting.await.unwrap().unwrap_err().kind(), + io::ErrorKind::ConnectionAborted + ); + assert!( + registry.snapshot()["connections"] + .as_array() + .unwrap() + .is_empty() + ); + assert_eq!(registry.snapshot()["uploadTotal"], 4); + registry.shutdown(); + assert!(registry.open(json!({})).cancelled()); + } +} diff --git a/src/controller.rs b/src/controller.rs index 77b1213..dac0531 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -164,10 +164,72 @@ async fn handle_connection( .await; } + if request.target == "/dns/query" || request.target.starts_with("/dns/query?") { + if request.method != "GET" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; + } + return dns_query( + &mut stream, + &request.target, + resources.as_deref(), + cancellation, + ) + .await; + } + if request.target.contains(['?', '#']) { return write_error(&mut stream, 400, "Bad request", &[]).await; } + if matches!(request.target.as_str(), "/configs" | "/rules") { + if request.method != "GET" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; + } + let Some(resources) = &resources else { + return write_error(&mut stream, 404, "Resource not found", &[]).await; + }; + let value = if request.target == "/configs" { + resources.configs() + } else { + resources.rules() + }; + return write_json(&mut stream, 200, &value, &[]).await; + } + if request.target == "/connections" || request.target.starts_with("/connections/") { + let Some(resources) = &resources else { + return write_error(&mut stream, 404, "Resource not found", &[]).await; + }; + let registry = resources.connections(); + if request.method == "GET" && request.target == "/connections" { + return write_json(&mut stream, 200, ®istry.snapshot(), &[]).await; + } + if request.method == "DELETE" { + if let Some(raw_id) = request.target.strip_prefix("/connections/") { + let Ok(id) = decode_group_name(raw_id) else { + return write_error(&mut stream, 400, "Bad request", &[]).await; + }; + registry.close(&id); + } else { + registry.close_all(); + } + return write_response(&mut stream, 204, &[], &[]).await; + } + return write_error( + &mut stream, + 405, + "Method not allowed", + &[( + "Allow", + if request.target == "/connections" { + "GET, DELETE" + } else { + "DELETE" + }, + )], + ) + .await; + } + if request.target == "/traffic" { if request.method != "GET" { return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; @@ -529,6 +591,65 @@ impl From for GroupResponse { } } +async fn dns_query( + stream: &mut TcpStream, + target: &str, + resources: Option<&dyn crate::resource::control::RuntimeResources>, + cancellation: CancellationToken, +) -> io::Result<()> { + use crate::dns::{QueryType, RecordData}; + let Some(snapshot) = resources + .and_then(|r| r.dns()) + .filter(|s| s.dns.is_enabled()) + else { + return write_error(stream, 500, "DNS is disabled", &[]).await; + }; + let mut name = None; + let mut qtype = None; + if target.contains('#') { + return write_error(stream, 400, "Bad request", &[]).await; + } + for (key, value) in + url::form_urlencoded::parse(target.split_once('?').map_or("", |(_, q)| q).as_bytes()) + { + match key.as_ref() { + "name" if name.is_none() => name = Some(value.into_owned()), + "type" if qtype.is_none() => qtype = Some(value.into_owned()), + _ => return write_error(stream, 400, "Bad request", &[]).await, + } + } + let query_type = match qtype.as_deref().unwrap_or("A") { + "A" | "" => QueryType::A, + "AAAA" => QueryType::Aaaa, + _ => return write_error(stream, 400, "Unsupported query type", &[]).await, + }; + let Ok(query) = crate::dns::build_query(1, name.as_deref().unwrap_or_default(), query_type) + else { + return write_error(stream, 400, "Bad request", &[]).await; + }; + let (result, path) = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), result = crate::dns::trace::capture(snapshot.dns.exchange(&query)) => result }; + let Ok(wire) = result else { + return write_error(stream, 500, "DNS query failed", &[]).await; + }; + let Ok(response) = crate::dns::parse_response(&wire) else { + return write_error(stream, 500, "DNS response invalid", &[]).await; + }; + let answers = response + .answers + .iter() + .map(|r| { + let (kind, data) = match &r.data { + RecordData::A(ip) => (1, ip.to_string()), + RecordData::Aaaa(ip) => (28, ip.to_string()), + RecordData::Cname(name) => (5, format!("{name}.")), + }; + serde_json::json!({"name":format!("{}.",r.name),"type":kind,"TTL":r.ttl,"data":data}) + }) + .collect::>(); + let value = serde_json::json!({"Status":response.rcode,"Question":[{"Name":format!("{}.",response.question.name),"Qtype":query_type as u16,"Qclass":1}],"Answer":answers,"TC":wire[2]&2!=0,"RD":response.recursion_desired,"RA":response.recursion_available,"AD":wire[3]&0x20!=0,"CD":wire[3]&0x10!=0,"QueryPath":path}); + write_json(stream, 200, &value, &[]).await +} + async fn write_json( stream: &mut TcpStream, status: u16, diff --git a/src/dns/fallback.rs b/src/dns/fallback.rs index f85d30f..f37f609 100644 --- a/src/dns/fallback.rs +++ b/src/dns/fallback.rs @@ -55,10 +55,13 @@ pub(super) async fn choose( } }; if acceptable(&first) { + super::trace::record("primary-answer"); first } else if let Some(answer) = ready { + super::trace::record("fallback-answer"); answer } else { + super::trace::record("fallback-answer"); fallback.await } } diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 3696979..3f7a9d4 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -10,6 +10,7 @@ mod fallback; pub(crate) mod hosts; mod https; pub(crate) mod policy; +pub(crate) mod trace; pub mod resolution; pub mod runtime; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index c3fec8a..3495a1c 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -1133,6 +1133,7 @@ impl RuntimeDns { /// Resolves a canonical domain through A followed by AAAA. AAAA is skipped /// completely when `dns.ipv6` is false. pub async fn resolve(&self, host: &str) -> Result, RuntimeDnsError> { + super::trace::record("resolve"); let host = canonicalize_name(host).map_err(RuntimeDnsError::InvalidQuery)?; let host = match self @@ -1190,6 +1191,7 @@ impl RuntimeDns { } pub async fn resolve_proxy(&self, host: &str) -> Result, RuntimeDnsError> { + super::trace::record("proxy-role"); self.proxy_resolver .as_deref() .unwrap_or(self) @@ -1197,6 +1199,7 @@ impl RuntimeDns { .await } pub async fn resolve_direct(&self, host: &str) -> Result, RuntimeDnsError> { + super::trace::record("direct-role"); self.direct_resolver .as_deref() .unwrap_or(self) @@ -1210,6 +1213,7 @@ impl RuntimeDns { let address = match &nameserver.address { DnsAddress::Ip(ip) => *ip, DnsAddress::Domain(host) => { + super::trace::record("bootstrap"); let resolver = self .bootstrap .as_ref() @@ -1334,6 +1338,7 @@ impl RuntimeDns { } fn synthetic_answer(&self, query: &DnsMessage, host: &str) -> Result, RuntimeDnsError> { + super::trace::record("fake-ip"); match self .fake_pools .as_ref() @@ -1382,6 +1387,7 @@ impl RuntimeDns { _ => None, }; if let Some(addresses) = addresses { + super::trace::record("hosts"); let addresses: Vec<_> = addresses .into_iter() .filter(|ip| match parsed_query.question.query_type { @@ -1411,6 +1417,7 @@ impl RuntimeDns { parsed_query.question.query_type, Instant::now(), ) { + super::trace::record("cache"); self.resource_stats.dns_cache_hit(); return synthesize_response(&parsed_query, &cached, remaining_ttl); } @@ -1433,6 +1440,7 @@ impl RuntimeDns { return finish_shared_result(result, classified, failure_mode); } FlightRole::Follower(updates) => { + super::trace::record("shared-query"); let Some(result) = wait_for_flight(updates).await else { continue; }; @@ -1452,9 +1460,11 @@ impl RuntimeDns { let upstream_parsed_query = parse_query(&upstream_query).map_err(RuntimeDnsError::InvalidQuery)?; let (mut response, parsed_response) = if let Some(policy) = self.policy_for(classified) { + super::trace::record("nameserver-policy"); self.exchange_upstreams(policy, &upstream_query, &upstream_parsed_query, deadline) .await? } else if self.fallback.nameservers.is_empty() { + super::trace::record("nameserver"); self.exchange_upstreams( &self.nameservers, &upstream_query, @@ -1463,6 +1473,7 @@ impl RuntimeDns { ) .await? } else if self.fallback_domain(&classified.question.name) { + super::trace::record("fallback-domain"); self.exchange_upstreams( &self.fallback.nameservers, &upstream_query, @@ -1506,6 +1517,7 @@ impl RuntimeDns { let role = { let mut cache = self.opaque_cache.lock().await; if let Some(response) = cache.get(classified, Instant::now()) { + super::trace::record("cache"); self.resource_stats.dns_cache_hit(); return Ok(response); } @@ -1528,6 +1540,7 @@ impl RuntimeDns { return finish_shared_result(result, classified, failure_mode); } FlightRole::Follower(updates) => { + super::trace::record("shared-query"); let Some(result) = wait_for_flight(updates).await else { continue; }; @@ -1693,6 +1706,13 @@ impl RuntimeDns { M: Fn(&[u8]) -> Result, V: Fn(&[u8]) -> Result, { + super::trace::record(match nameserver.transport { + DnsTransport::Udp => "udp", + DnsTransport::Tcp => "tcp", + DnsTransport::Tls => "tls", + DnsTransport::Https => "https", + DnsTransport::Http => "http", + }); let mut server = self.resolve_nameserver(&nameserver).await?; if server.transport == DnsTransport::Udp { let (_, egress) = self.egress_for(&server, Network::Udp)?; diff --git a/src/dns/trace.rs b/src/dns/trace.rs new file mode 100644 index 0000000..c8232c7 --- /dev/null +++ b/src/dns/trace.rs @@ -0,0 +1,24 @@ +//! Bounded, request-local path labels. No addresses, questions or credentials. +use std::{future::Future, sync::Mutex}; +tokio::task_local! { static TRACE: Mutex>; } +pub(crate) fn record(step: &'static str) { + let _ = TRACE.try_with(|trace| { + let mut trace = trace.lock().unwrap(); + if trace.len() < 32 { + trace.push(step); + } + }); +} +pub(crate) fn snapshot() -> Vec<&'static str> { + TRACE + .try_with(|trace| trace.lock().unwrap().clone()) + .unwrap_or_default() +} +pub(crate) async fn capture(future: impl Future) -> (T, Vec<&'static str>) { + TRACE + .scope(Mutex::new(Vec::new()), async { + let result = future.await; + (result, snapshot()) + }) + .await +} diff --git a/src/lib.rs b/src/lib.rs index 51ebcd7..060ef17 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -101,3 +101,5 @@ pub const ENGINE: &str = "rust"; /// Implementation identity embedded in every native artifact. pub const BUILD_IDENTITY: &str = concat!("Vole;engine=rust;coreVersion=", env!("CARGO_PKG_VERSION")); + +mod connections; diff --git a/src/resource/control.rs b/src/resource/control.rs index f1bbc3a..3d78344 100644 --- a/src/resource/control.rs +++ b/src/resource/control.rs @@ -12,6 +12,9 @@ pub(crate) struct DnsSnapshot { } #[async_trait] pub(crate) trait RuntimeResources: Send + Sync { + fn connections(&self) -> Arc; + fn configs(&self) -> serde_json::Value; + fn rules(&self) -> serde_json::Value; fn groups(&self) -> Arc; fn select(&self, group: &str, member: &str) -> Result<(), ProxyGroupError>; fn health(&self) -> serde_json::Value; diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 2ca4c1a..56d599f 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -176,6 +176,7 @@ impl ProxyDispatchers { .get(id.index()) .cloned() .map(|dispatcher| ResolvedProxyGroupLeaf { + chain: Vec::new(), dispatcher, direct: false, }) @@ -210,9 +211,17 @@ pub struct RoutingDispatcher { tun_config: TunConfig, sniffer: Option>, tcp_concurrent: bool, + diagnostics: Option>, } impl RoutingDispatcher { + pub(crate) fn with_diagnostics( + mut self, + diagnostics: Arc, + ) -> Self { + self.diagnostics = Some(diagnostics); + self + } pub(crate) fn with_tcp_concurrent(mut self, enabled: bool) -> Self { self.tcp_concurrent = enabled; self @@ -253,6 +262,7 @@ impl RoutingDispatcher { tun_config: TunConfig::default(), sniffer: None, tcp_concurrent: false, + diagnostics: None, } } @@ -275,6 +285,7 @@ impl RoutingDispatcher { geo_matcher, sniffer: None, tcp_concurrent: false, + diagnostics: None, tun_config: TunConfig { udp_timeout: Duration::from_secs(30), ..TunConfig::default() @@ -345,10 +356,14 @@ impl RoutingDispatcher { .as_ref() .or(recovered.as_ref()) .unwrap_or(destination); - let accepts_domain_hint = matches!( - inbound, - InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 - ); + let accepts_domain_hint = business(inbound) + && (self.rules.uses_domain_routing() + || self.diagnostics.is_some() + || self + .route_targets + .proxy_groups + .as_ref() + .is_some_and(|groups| groups.has_balance())); let sniffed_domain = accepts_domain_hint.then_some(sniffed_domain).flatten(); let dns_domain_hint = match (accepts_domain_hint, destination, &self.dns, sniffed_domain) { (true, Destination::Ip(address), Some(dns), None) => { @@ -395,6 +410,14 @@ impl RoutingDispatcher { )); }; + if business(inbound) + && let Some(diagnostics) = &self.diagnostics + { + diagnostics.hit(rule_match.rule_index); + } + if recovered.is_some() { + crate::dns::trace::record("fake-ip-recovery"); + } let routed_destination = match destination { Destination::Domain { host, port } => Destination::Domain { host: super::normalize_domain_name(host) @@ -404,6 +427,14 @@ impl RoutingDispatcher { Destination::Ip(_) => destination.clone(), }; let mut decision = RouteDecision { + rule_index: rule_match.rule_index, + dns_mode: if recovered.is_some() { + "fake-ip" + } else if dns_domain_hint.is_some() { + "mapping" + } else { + "normal" + }, metadata: context.domain().map_or_else( || destination.clone(), |host| Destination::Domain { @@ -536,6 +567,8 @@ impl Drop for DnsTcpStream { } struct RouteDecision { + rule_index: usize, + dns_mode: &'static str, metadata: Destination, action: RuleAction, destination: Destination, @@ -613,7 +646,39 @@ impl Dispatcher for RoutingDispatcher { Some(policy.plan(source, destination, host.as_deref(), mapped)) } - async fn connect_tcp(&self, mut session: StreamSession) -> Result { + async fn connect_tcp(&self, session: StreamSession) -> Result { + if self.diagnostics.is_some() && business(session.inbound) { + crate::dns::trace::capture(self.connect_tcp_inner(session)) + .await + .0 + } else { + self.connect_tcp_inner(session).await + } + } + + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + Ok(Box::new(RoutedDatagramTransport { + router: self.clone_parts(), + session, + route_transports: HashMap::new(), + fake_flows: HashMap::new(), + managed_flows: HashMap::new(), + direct_transport: None, + flows: DatagramRoutes::new(self.tun_config.udp_timeout), + logged_route_actions: HashSet::new(), + receive_wakeup: Arc::new(Notify::new()), + })) + } +} + +impl RoutingDispatcher { + async fn connect_tcp_inner( + &self, + mut session: StreamSession, + ) -> Result { self.ensure_destination_allowed(&session.destination)?; if session.inbound == InboundKind::Tun && matches!(&session.destination, Destination::Ip(address) if self.tun_config.hijacks_dns(*address)) @@ -644,61 +709,118 @@ impl Dispatcher for RoutingDispatcher { self.resolve_direct_destination(&mut decision).await?; } log_route_decision(Network::Tcp, &decision); - let direct = decision.action == RuleAction::Direct - || resolved_target.as_ref().is_some_and(|target| target.direct); - if direct - && self.tcp_concurrent - && let Some(addresses) = &decision.resolved_addresses - && addresses.len() > 1 - { - let dispatcher = resolved_target - .as_ref() - .map_or(&self.direct, |target| &target.dispatcher); - let addresses = addresses - .iter() - .map(|&ip| SocketAddr::new(ip, decision.destination.port())) - .collect::>(); - return crate::dialer::race_connections(&addresses, None, |address| { - let mut attempt = session.clone(); - attempt.destination = Destination::Ip(address); - attempt.sniffed_domain = None; - dispatcher.connect_tcp(attempt) - }) - .await; - } - session.destination = decision.destination; - session.sniffed_domain = None; - match decision.action { - RuleAction::Route(_) => { - resolved_target - .expect("route target was resolved before dispatch") - .dispatcher - .connect_tcp(session) - .await + let chain = resolved_target + .as_ref() + .map_or_else(Vec::new, |target| target.chain.clone()); + let connection = self.connection( + Network::Tcp, + session.inbound, + session.source, + &session.destination, + session.sniffed_domain.as_deref(), + &decision, + chain, + ); + let setup = async { + let direct = decision.action == RuleAction::Direct + || resolved_target.as_ref().is_some_and(|target| target.direct); + if direct + && self.tcp_concurrent + && let Some(addresses) = &decision.resolved_addresses + && addresses.len() > 1 + { + let dispatcher = resolved_target + .as_ref() + .map_or(&self.direct, |target| &target.dispatcher); + let addresses = addresses + .iter() + .map(|&ip| SocketAddr::new(ip, decision.destination.port())) + .collect::>(); + return crate::dialer::race_connections(&addresses, None, |address| { + let mut attempt = session.clone(); + attempt.destination = Destination::Ip(address); + attempt.sniffed_domain = None; + dispatcher.connect_tcp(attempt) + }) + .await; + } + session.destination = decision.destination; + session.sniffed_domain = None; + match decision.action { + RuleAction::Route(_) => { + resolved_target + .expect("route target was resolved before dispatch") + .dispatcher + .connect_tcp(session) + .await + } + RuleAction::Direct => self.direct.connect_tcp(session).await, + RuleAction::Reject => Err(DispatchError::NotAllowed), } - RuleAction::Direct => self.direct.connect_tcp(session).await, - RuleAction::Reject => Err(DispatchError::NotAllowed), + }; + if let Some(connection) = connection { + let token = connection.token(); + let result = tokio::select! { biased; () = token.cancelled() => Err(DispatchError::NotAllowed), result = setup => result }; + connection.dns_path(crate::dns::trace::snapshot()); + match result { + Ok(stream) => Ok(connection.stream(stream)), + Err(error) => { + if !connection.cancelled() { + connection.failed(&error); + } + Err(error) + } + } + } else { + setup.await } } - async fn open_datagram( + #[allow(clippy::too_many_arguments)] + fn connection( &self, - session: DatagramSession, - ) -> Result, DispatchError> { - Ok(Box::new(RoutedDatagramTransport { - router: self.clone_parts(), - session, - route_transports: HashMap::new(), - fake_flows: HashMap::new(), - direct_transport: None, - flows: DatagramRoutes::new(self.tun_config.udp_timeout), - logged_route_actions: HashSet::new(), - receive_wakeup: Arc::new(Notify::new()), - })) + network: Network, + inbound: InboundKind, + source: SocketAddr, + original: &Destination, + sniffed: Option<&str>, + decision: &RouteDecision, + mut chain: Vec, + ) -> Option { + if !business(inbound) { + return None; + } + let diagnostics = self.diagnostics.as_ref()?; + if chain.is_empty() { + chain.push(match decision.action { + RuleAction::Direct => "DIRECT".into(), + RuleAction::Reject => "REJECT".into(), + RuleAction::Route(RouteTargetId::Proxy(id)) => { + diagnostics.proxy_names[id.index()].clone() + } + RuleAction::Route(RouteTargetId::Group(_)) => { + unreachable!("group leaf supplies chain") + } + }); + } + let (rule, payload) = diagnostics.matched(decision.rule_index); + let host = match &decision.metadata { + Destination::Domain { host, .. } => host.as_str(), + _ => "", + }; + let destination_ip = match &decision.destination { + Destination::Ip(addr) => Some(addr.ip().to_string()), + _ => None, + }; + let providers = diagnostics.providers(&chain); + Some(diagnostics.connections.open(serde_json::json!({ + "metadata":{"network":if network == Network::Tcp { "tcp" } else { "udp" }, "type":match inbound { InboundKind::Tun=>"Tun", InboundKind::Http=>"HTTP", _=>"Socks5" }, + "sourceIP":source.ip().to_string(),"sourcePort":source.port().to_string(),"destinationIP":destination_ip, + "destinationPort":original.port().to_string(),"host":host,"dnsMode":decision.dns_mode,"sniffHost":sniffed.unwrap_or_default()}, + "chains":chain,"providerChains":providers,"rule":rule,"rulePayload":payload,"dnsPath":crate::dns::trace::snapshot() + }))) } -} -impl RoutingDispatcher { fn clone_parts(&self) -> Self { Self { route_targets: self.route_targets.clone(), @@ -710,6 +832,7 @@ impl RoutingDispatcher { tun_config: self.tun_config.clone(), sniffer: self.sniffer.clone(), tcp_concurrent: self.tcp_concurrent, + diagnostics: self.diagnostics.clone(), } } } @@ -719,6 +842,7 @@ struct RoutedDatagramTransport { session: DatagramSession, route_transports: HashMap<(RouteTargetId, Option), CachedRouteTransport>, fake_flows: HashMap, + managed_flows: HashMap, direct_transport: Option>, flows: DatagramRoutes, logged_route_actions: HashSet, @@ -734,6 +858,15 @@ struct FakeDatagramFlow { flow_id: Option>, } +struct ManagedDatagramFlow { + transport: Box, + connection: crate::connections::Connection, + destination: Destination, + restore: bool, + last_used: Instant, + flow_id: Option>, +} + struct CachedRouteTransport { last_used: Instant, transport: Box, @@ -819,6 +952,175 @@ impl From for LoggedRouteAction { } impl RoutedDatagramTransport { + fn tracked(&self) -> bool { + self.router.diagnostics.is_some() && business(self.session.inbound) + } + async fn clean_managed(&mut self) { + let expired = self + .managed_flows + .iter() + .filter(|(_, flow)| { + flow.connection.cancelled() + || flow.last_used.elapsed() >= self.router.tun_config.udp_timeout + }) + .map(|(key, _)| key.clone()) + .collect::>(); + for key in expired { + if let Some(mut flow) = self.managed_flows.remove(&key) { + let _ = flow.transport.close().await; + } + } + } + async fn send_managed(&mut self, mut datagram: Datagram) -> Result<(), DispatchError> { + self.clean_managed().await; + let key = datagram.remote.clone(); + if !self.managed_flows.contains_key(&key) { + let mut decision = self + .router + .route( + Network::Udp, + self.session.inbound, + self.session.source, + &key, + datagram.sniffed_domain.as_deref(), + ) + .await?; + if decision.action == RuleAction::Reject { + return Ok(()); + } + let (dispatcher, direct, chain) = match decision.action { + RuleAction::Direct => (self.router.direct.clone(), true, Vec::new()), + RuleAction::Route(id) => { + let leaf = self.router.route_targets.resolve_for( + id, + true, + Some(self.session.source), + Some(&decision.metadata), + )?; + (leaf.dispatcher, leaf.direct, leaf.chain) + } + RuleAction::Reject => unreachable!(), + }; + if direct { + self.router + .resolve_direct_destination(&mut decision) + .await?; + } + let restore = matches!(key, Destination::Ip(_)) + && (decision.dns_mode == "fake-ip" + || (datagram.sniffed_domain.is_some() + && self + .router + .sniffer + .as_ref() + .is_some_and(|p| p.config.quic_override))); + if restore && let Destination::Domain { host, port } = &decision.destination { + let dns = self + .router + .dns + .as_ref() + .ok_or(DispatchError::HostUnreachable)?; + let addresses = self.router.resolve_addresses(dns, host).await; + decision.destination = Destination::Ip(SocketAddr::new( + preferred_address(&addresses).ok_or(DispatchError::HostUnreachable)?, + *port, + )); + } + let connection = self + .router + .connection( + Network::Udp, + self.session.inbound, + self.session.source, + &key, + datagram.sniffed_domain.as_deref(), + &decision, + chain, + ) + .expect("tracked business flow"); + let token = connection.token(); + let result = tokio::select! { biased; () = token.cancelled() => Err(DispatchError::NotAllowed), result = dispatcher.open_datagram(self.session.clone()) => result }; + connection.dns_path(crate::dns::trace::snapshot()); + let transport = match result { + Ok(transport) => transport, + Err(error) => { + if !connection.cancelled() { + connection.failed(&error); + } + return Err(error); + } + }; + self.managed_flows.insert( + key.clone(), + ManagedDatagramFlow { + transport, + connection, + destination: decision.destination, + restore, + last_used: Instant::now(), + flow_id: None, + }, + ); + } + let flow = self.managed_flows.get_mut(&key).expect("flow created"); + flow.last_used = Instant::now(); + let size = datagram.payload.len(); + datagram.remote = flow.destination.clone(); + datagram.sniffed_domain = None; + let token = flow.connection.token(); + let result = tokio::select! { biased; () = token.cancelled() => Err(DispatchError::NotAllowed), result = flow.transport.send(datagram) => result }; + match &result { + Ok(()) => flow.connection.upload(size), + Err(error) => { + if !flow.connection.cancelled() { + flow.connection.failed(error); + } + if let Some(mut flow) = self.managed_flows.remove(&key) { + let _ = flow.transport.close().await; + } + } + } + result + } + async fn receive_managed(&mut self) -> Result { + loop { + self.clean_managed().await; + if self.managed_flows.is_empty() { + std::future::pending::<()>().await; + } + let result = { + let receives = self.managed_flows.iter_mut().map(|(key, flow)| Box::pin(async move { + let token = flow.connection.token(); + loop { + let result = tokio::select! { biased; () = token.cancelled() => None, result = flow.transport.receive() => Some(result) }; + if let Some(Ok(mut datagram)) = result { + if flow.restore { + if datagram.remote != flow.destination { continue; } + datagram.remote = key.clone(); + } + flow.connection.download(datagram.payload.len()); + flow.last_used = Instant::now(); + return (key.clone(), Some(Ok(datagram))); + } + return (key.clone(), result); + } + }) as futures_util::future::BoxFuture<'_, _>).collect::>(); + tokio::select! { (result, _, _) = select_all(receives) => Some(result), () = tokio::time::sleep(UDP_FLOW_CLEANUP_INTERVAL) => None } + }; + if let Some((key, result)) = result { + if let Some(Ok(datagram)) = result { + return Ok(datagram); + } + if let Some(mut flow) = self.managed_flows.remove(&key) { + if let Some(Err(error)) = result { + flow.connection.failed(&error); + } + let _ = flow.transport.close().await; + } + } + } + } + async fn expire_fake_flows(&mut self) { let expired = self .route_transports @@ -1073,6 +1375,11 @@ impl RoutedDatagramTransport { #[async_trait] impl DatagramTransport for RoutedDatagramTransport { async fn send(&mut self, mut datagram: Datagram) -> Result<(), DispatchError> { + if self.tracked() { + return crate::dns::trace::capture(self.send_managed(datagram)) + .await + .0; + } self.expire_fake_flows().await; if matches!(self.session.inbound, InboundKind::Tun | InboundKind::Socks5) && let Destination::Ip(original) = datagram.remote @@ -1105,6 +1412,8 @@ impl DatagramTransport for RoutedDatagramTransport { let action = flow.and_then(|destination| self.flows.action(destination, now)); let decision = if let Some(action) = action { RouteDecision { + rule_index: self.router.rules.len(), + dns_mode: "normal", metadata: datagram.remote.clone(), action, destination: datagram.remote.clone(), @@ -1157,6 +1466,22 @@ impl DatagramTransport for RoutedDatagramTransport { if flow_id.is_empty() || flow_id.len() > 25 { return Err(DispatchError::NotAllowed); } + if self.tracked() { + let key = datagram.remote.clone(); + if self + .managed_flows + .get(&key) + .is_some_and(|flow| flow.flow_id.as_deref() != Some(flow_id)) + && let Some(mut old) = self.managed_flows.remove(&key) + { + old.transport.close().await?; + } + self.send(datagram).await?; + if let Some(flow) = self.managed_flows.get_mut(&key) { + flow.flow_id = Some(flow_id.into()); + } + return Ok(()); + } let flow = match (self.session.inbound, &datagram.remote) { (InboundKind::Tun, Destination::Ip(address)) => Some(*address), _ => None, @@ -1208,6 +1533,9 @@ impl DatagramTransport for RoutedDatagramTransport { } async fn receive(&mut self) -> Result { + if self.tracked() { + return self.receive_managed().await; + } let datagram = loop { self.expire_fake_flows().await; tokio::select! { @@ -1227,6 +1555,13 @@ impl DatagramTransport for RoutedDatagramTransport { async fn close(&mut self) -> Result<(), DispatchError> { let mut first_error = None; + for (_, mut flow) in self.managed_flows.drain() { + if let Err(error) = flow.transport.close().await + && first_error.is_none() + { + first_error = Some(error); + } + } for flow in self.fake_flows.values_mut() { if let Err(error) = flow.transport.close().await && first_error.is_none() @@ -1540,6 +1875,73 @@ mod tests { ) } + #[tokio::test] + async fn managed_udp_connections_close_one_flow_and_preserve_others() { + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: [{name: edge, type: socks5, server: 192.0.2.1, port: 1080, udp: true}] +proxy-groups: [{name: pool, type: select, proxies: [edge]}] +rules: ["MATCH,pool"] +"#, + ) + .unwrap(); + let proxy = Arc::new(RecordingDispatcher::default()); + let direct = Arc::new(RecordingDispatcher::default()); + let nodes = vec![proxy.clone() as Arc]; + let groups = ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let rules = RuleSet::compile(config.rules.clone()).unwrap(); + let registry = Arc::new(crate::connections::Registry::default()); + let diagnostics = Arc::new(crate::runtime::diagnostics::Diagnostics::new( + &config, + &rules, + registry.clone(), + )); + let router = RoutingDispatcher::new( + ProxyDispatchers::with_proxy_groups(nodes, &groups).unwrap(), + direct, + None, + rules, + Arc::new(EmptyGeoMatcher), + ) + .with_diagnostics(diagnostics); + let mut transport = router + .open_datagram(association(InboundKind::Socks5)) + .await + .unwrap(); + let first = "198.51.100.1:443".parse().unwrap(); + let second = "198.51.100.2:443".parse().unwrap(); + transport.send(datagram(first, b"one")).await.unwrap(); + transport.send(datagram(second, b"two")).await.unwrap(); + let snapshot = registry.snapshot(); + let entries = snapshot["connections"].as_array().unwrap(); + assert_eq!(entries.len(), 2); + let entry = entries + .iter() + .find(|e| e["metadata"]["destinationIP"] == "198.51.100.1") + .unwrap(); + assert_eq!(entry["chains"], serde_json::json!(["edge", "pool"])); + assert_eq!(entry["rule"], "Match"); + registry.close(entry["id"].as_str().unwrap()); + assert_eq!( + transport.receive().await.unwrap().remote, + Destination::Ip(second) + ); + assert_eq!( + registry.snapshot()["connections"].as_array().unwrap().len(), + 1 + ); + transport.send(datagram(first, b"new")).await.unwrap(); + assert_eq!(proxy.udp_opens.load(Ordering::Relaxed), 3); + transport.close().await.unwrap(); + assert!( + registry.snapshot()["connections"] + .as_array() + .unwrap() + .is_empty() + ); + } + #[tokio::test] async fn load_balance_routes_tcp_and_pins_udp_flows() { let config = crate::config::Config::parse_yaml( @@ -1648,6 +2050,7 @@ rules: ["MATCH,balance"] tun_config: TunConfig::default(), sniffer: None, tcp_concurrent: false, + diagnostics: None, }; router @@ -3400,3 +3803,10 @@ rules: ["MATCH,balance"] transport.close().await.unwrap(); } } + +fn business(inbound: InboundKind) -> bool { + matches!( + inbound, + InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 + ) +} diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index b22946e..d26ae44 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -60,6 +60,7 @@ struct ProxyGroupMember { } pub(crate) struct ResolvedProxyGroupLeaf { + pub(crate) chain: Vec, pub(crate) dispatcher: Arc, pub(crate) direct: bool, } @@ -247,10 +248,12 @@ impl ProxyGroups { source: Option, destination: Option<&crate::session::Destination>, ) -> Result { + let mut chain = Vec::new(); for _ in 0..=self.groups.len() { let group = self.groups.get(group_id.index()).ok_or_else(|| { DispatchError::Other(format!("unknown proxy group id {}", group_id.index())) })?; + chain.push(group.name.clone()); if udp && group.disable_udp { return Err(DispatchError::NotAllowed); } @@ -263,11 +266,14 @@ impl ProxyGroups { })?; match member.target { ProxyGroupMemberTarget::Route(RouteTargetId::Proxy(id)) => { + chain.push(member.name.clone()); + chain.reverse(); return self .proxies .get(id.index()) .cloned() .map(|dispatcher| ResolvedProxyGroupLeaf { + chain, dispatcher, direct: false, }) @@ -277,13 +283,19 @@ impl ProxyGroups { } ProxyGroupMemberTarget::Route(RouteTargetId::Group(id)) => group_id = id, ProxyGroupMemberTarget::Direct => { + chain.push("DIRECT".into()); + chain.reverse(); return Ok(ResolvedProxyGroupLeaf { + chain, dispatcher: self.direct.clone(), direct: true, }); } ProxyGroupMemberTarget::Reject => { + chain.push("REJECT".into()); + chain.reverse(); return Ok(ResolvedProxyGroupLeaf { + chain, dispatcher: self.reject.clone(), direct: false, }); @@ -295,6 +307,11 @@ impl ProxyGroups { )) } + pub(crate) fn has_balance(&self) -> bool { + self.groups + .iter() + .any(|g| g.config.kind == crate::config::GroupKind::LoadBalance) + } pub(crate) fn contains_balance(&self, id: ProxyGroupId) -> bool { let mut pending = vec![id]; let mut seen = std::collections::HashSet::new(); diff --git a/src/runtime.rs b/src/runtime.rs index 49c2ba9..63021c9 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,5 +1,7 @@ #![cfg_attr(not(any(feature = "invoke", feature = "cli")), allow(dead_code))] +pub(crate) mod diagnostics; + use tracing::instrument::WithSubscriber; #[cfg(all( @@ -164,6 +166,7 @@ impl Drop for MeasurementRuntime { } struct BuiltRuntimeParts { + diagnostics: Arc, dispatcher: Arc, // Local dispatch owns its DNS reference; this copy feeds shared TUN startup. #[cfg_attr( @@ -388,6 +391,7 @@ impl PreparedCore { fn build_dispatcher(&self, dialer: Dialer) -> io::Result { let mut parts = GraphBuilder { + connections: Arc::default(), config: self.config.clone(), endpoints: self.endpoints.clone(), limits: self.limits, @@ -622,6 +626,7 @@ impl PreparedCore { } struct GraphBuilder { + connections: Arc, config: Config, endpoints: Vec, limits: ResourceLimits, @@ -710,6 +715,11 @@ impl GraphBuilder { activities, dns.clone(), ); + let diagnostics = Arc::new(diagnostics::Diagnostics::new( + &self.config, + &rules, + self.connections.clone(), + )); let router: Arc = Arc::new( RoutingDispatcher::new_with_ipv6( route_targets.clone(), @@ -721,10 +731,12 @@ impl GraphBuilder { ) .with_tun_config(&self.config.tun) .with_sniffer(&self.config.sniffer) - .with_tcp_concurrent(self.config.connections.tcp_concurrent), + .with_tcp_concurrent(self.config.connections.tcp_concurrent) + .with_diagnostics(diagnostics.clone()), ); let session_stats = RuntimeResourceStats::new("runtime_session_observation"); Ok(BuiltRuntimeParts { + diagnostics, dispatcher: observe_sessions_with_stats(router, session_stats), dns, geodata_updater: None, diff --git a/src/runtime/diagnostics.rs b/src/runtime/diagnostics.rs new file mode 100644 index 0000000..7eba656 --- /dev/null +++ b/src/runtime/diagnostics.rs @@ -0,0 +1,204 @@ +//! Current effective settings and rule counters; never serialize raw node config. +use crate::{ + config::{Config, RouteTargetId, RuleAction, RuleKind}, + routing::RuleSet, +}; +use serde_json::{Value, json}; +use std::sync::{ + Mutex, + atomic::{AtomicU64, Ordering}, +}; + +pub(crate) struct Diagnostics { + pub connections: std::sync::Arc, + pub proxy_names: Vec, + providers: std::collections::BTreeMap>, + config: Value, + rules: Vec, +} +struct Rule { + kind: &'static str, + payload: String, + target: String, + hits: AtomicU64, + hit_at: Mutex>, +} +impl Diagnostics { + pub fn new( + config: &Config, + rules: &RuleSet, + connections: std::sync::Arc, + ) -> Self { + Self { + connections, + providers: config + .provider_members + .iter() + .map(|(name, members)| (name.clone(), members.clone())) + .collect(), + proxy_names: config.proxies.iter().map(|p| p.tag.clone()).collect(), + config: effective_config(config), + rules: (0..rules.len()) + .filter_map(|i| rules.rule(i)) + .map(|spec| { + let (kind, payload) = rule_description(&spec.kind); + let target = match spec.action { + RuleAction::Direct => "DIRECT".into(), + RuleAction::Reject => "REJECT".into(), + RuleAction::Route(RouteTargetId::Proxy(id)) => { + config.proxies[id.index()].tag.clone() + } + RuleAction::Route(RouteTargetId::Group(id)) => { + config.proxy_groups[id.index()].name.clone() + } + }; + Rule { + kind, + payload, + target, + hits: AtomicU64::new(0), + hit_at: Mutex::new(None), + } + }) + .collect(), + } + } + pub fn providers(&self, chain: &[String]) -> Vec { + self.providers + .iter() + .filter(|(_, members)| chain.iter().any(|node| members.contains(node))) + .map(|(name, _)| name.clone()) + .collect() + } + pub fn config(&self) -> Value { + self.config.clone() + } + pub fn hit(&self, index: usize) { + if let Some(rule) = self.rules.get(index) { + rule.hits.fetch_add(1, Ordering::Relaxed); + *rule.hit_at.lock().unwrap() = Some(crate::connections::timestamp()); + } + } + pub fn matched(&self, index: usize) -> (&'static str, &str) { + self.rules + .get(index) + .map_or(("", ""), |r| (r.kind, r.payload.as_str())) + } + pub fn rules(&self) -> Value { + json!({"rules": self.rules.iter().enumerate().map(|(index, r)| json!({"index":index,"type":r.kind,"payload":r.payload,"proxy":r.target,"size":-1,"extra":{"disabled":false,"hitCount":r.hits.load(Ordering::Relaxed),"hitAt":*r.hit_at.lock().unwrap()}})).collect::>()}) + } +} +fn effective_config(config: &Config) -> Value { + use crate::config::{InboundConfig, LogLevel, Mode}; + let mixed = config.inbounds.iter().find_map(|inbound| match inbound { + InboundConfig::Mixed(mixed) => Some(mixed), + _ => None, + }); + json!({ + "mixed-port":config.mixed_port.unwrap_or(0), "allow-lan":mixed.is_some_and(|m| m.access.allow_lan), + "bind-address":mixed.and_then(|m| m.access.bind_address).map_or_else(|| "*".into(), |ip| ip.to_string()), + "mode": match config.mode { Mode::Rule => "rule", Mode::Direct => "direct", Mode::Global => "global" }, + "log-level":match config.log_level { LogLevel::Silent => "silent", LogLevel::Error => "error", LogLevel::Warning => "warning", LogLevel::Info => "info", LogLevel::Debug => "debug" }, + "ipv6": config.ipv6, "sniffing": config.sniffer.enable, "tcp-concurrent": config.connections.tcp_concurrent, + "keep-alive-idle": config.connections.keep_alive_idle, "keep-alive-interval":config.connections.keep_alive_interval, "disable-keep-alive":config.connections.disable_keep_alive, + "unified-delay":config.unified_delay, "profile":{"store-selected":config.profile.store_selected,"store-fake-ip":config.profile.store_fake_ip}, + "tun":{"enable":config.tun.enable,"device":config.tun.device,"file-descriptor":config.tun.file_descriptor,"mtu":config.tun.mtu,"udp-timeout":config.tun.udp_timeout.as_secs(),"auto-route":config.tun.auto_route,"auto-detect-interface":config.tun.auto_detect_interface,"dns-hijack":config.tun.dns_hijack.iter().map(ToString::to_string).collect::>()} + }) +} +fn rule_description(rule: &RuleKind) -> (&'static str, String) { + match rule { + RuleKind::Domain(s) => ("Domain", s.clone()), + RuleKind::DomainSuffix(s) => ("DomainSuffix", s.clone()), + RuleKind::DomainKeyword(s) => ("DomainKeyword", s.clone()), + RuleKind::DomainWildcard(s) => ("DomainWildcard", s.clone()), + RuleKind::DomainRegex(r) => ("DomainRegex", r.as_str().into()), + RuleKind::GeoSite(s) => ("GeoSite", s.clone()), + RuleKind::GeoIp(s) => ("GeoIP", s.clone()), + RuleKind::RuleSet { name, .. } => ("RuleSet", name.clone()), + RuleKind::IpCidr(cidr) => ("IPCIDR", format!("{}/{}", cidr.network, cidr.prefix_len)), + RuleKind::SrcIpCidr(cidr) => ("SrcIPCIDR", format!("{}/{}", cidr.network, cidr.prefix_len)), + RuleKind::SrcPorts(ports) | RuleKind::DstPorts(ports) => ( + if matches!(rule, RuleKind::SrcPorts(_)) { + "SrcPort" + } else { + "DstPort" + }, + ports + .iter() + .map(|p| { + if p.start == p.end { + p.start.to_string() + } else { + format!("{}-{}", p.start, p.end) + } + }) + .collect::>() + .join("/"), + ), + RuleKind::Network(network) => ( + "Network", + match network { + crate::config::Network::Tcp => "tcp", + crate::config::Network::Udp => "udp", + } + .into(), + ), + RuleKind::Match => ("Match", String::new()), + RuleKind::And(rules) | RuleKind::Or(rules) => ( + if matches!(rule, RuleKind::And(_)) { + "AND" + } else { + "OR" + }, + logical(rules.iter().map(|r| &r.kind)), + ), + RuleKind::Not(rule) => ("NOT", logical(std::iter::once(&rule.kind))), + } +} +fn logical<'a>(rules: impl Iterator) -> String { + format!( + "({})", + rules + .map(|r| { + let (kind, payload) = rule_description(r); + format!("({kind},{payload})") + }) + .collect::>() + .join(",") + ) +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn effective_configuration_excludes_credentials_and_resource_locations() { + let config = Config::parse_yaml(br#" +mixed-port: 1080 +authentication: [user:secret-sentinel] +external-controller: 127.0.0.1:9090 +secret: controller-sentinel +proxy-providers: + nodes: + type: inline + payload: + - {name: edge, type: socks5, server: 192.0.2.1, port: 1080, password: node-sentinel, username: private-user} +proxy-groups: [{name: pool, type: select, use: [nodes]}] +rules: ["MATCH,pool"] +"#).unwrap(); + let rules = RuleSet::compile(config.rules.clone()).unwrap(); + let diagnostics = Diagnostics::new(&config, &rules, Default::default()); + let view = diagnostics.config().to_string(); + for secret in [ + "secret-sentinel", + "controller-sentinel", + "node-sentinel", + "private-user", + "192.0.2.1", + ] { + assert!(!view.contains(secret)); + } + assert_eq!(diagnostics.config()["mixed-port"], 1080); + diagnostics.hit(0); + assert_eq!(diagnostics.rules()["rules"][0]["extra"]["hitCount"], 1); + } +} diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index edcfc96..b1967fc 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -25,6 +25,7 @@ use tokio::{ }; struct Generation { + diagnostics: Arc, health: Arc, requirements: GeoRequirements, config: Config, @@ -42,6 +43,7 @@ impl Generation { requirements: GeoRequirements, ) -> Self { Self { + diagnostics: parts.diagnostics.clone(), health: parts.health.clone(), requirements, config, @@ -129,6 +131,7 @@ impl ProviderRuntime { } pub(super) fn begin_shutdown(&self) { self.cancellation.cancel(); + self.current.load().diagnostics.connections.shutdown(); self.current.load().graph.begin_shutdown(); for generation in self.retired.lock().unwrap().iter() { generation.graph.begin_shutdown(); @@ -267,6 +270,7 @@ impl ProviderRuntime { let dialer = self.dialer.clone(); let geo = self.geo.clone(); let previous_dns = generation.dns.clone(); + let connections = generation.diagnostics.connections.clone(); let data_dir = self .manager .store_dir() @@ -281,6 +285,7 @@ impl ProviderRuntime { RuleSet::compile(config.take_runtime_rules()).map_err(io::Error::other)?; let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; let parts = GraphBuilder { + connections, config: config.clone(), endpoints, limits, @@ -420,6 +425,16 @@ impl ProviderRuntime { } #[async_trait] impl RuntimeResources for ProviderRuntime { + fn connections(&self) -> Arc { + self.current.load().diagnostics.connections.clone() + } + fn configs(&self) -> serde_json::Value { + self.current.load().diagnostics.config() + } + fn rules(&self) -> serde_json::Value { + self.current.load().diagnostics.rules() + } + fn groups(&self) -> Arc { self.current.load().groups.clone() } @@ -630,6 +645,11 @@ proxy-providers: remote: {type: file, path: nodes.yaml} proxy-groups: - {name: choice, type: select, use: [remote]} +dns: + enable: true + enhanced-mode: fake-ip + nameserver: [192.0.2.53] +profile: {store-fake-ip: true} rules: ['MATCH,choice'] "#, ) @@ -647,6 +667,21 @@ rules: ['MATCH,choice'] let parts = prepared.build_dispatcher(Dialer::default()).unwrap(); let live = parts.resources.clone().unwrap(); let generation = live.current.load_full(); + let query = crate::dns::build_query(1, "example.com", crate::dns::QueryType::A).unwrap(); + let wire = generation + .dns + .as_ref() + .unwrap() + .exchange(&query) + .await + .unwrap(); + assert!( + !crate::dns::parse_response(&wire) + .unwrap() + .answers + .is_empty() + ); + let registry = live.connections(); let weak = Arc::downgrade(&generation); let (io, mut peer) = tokio::io::duplex(32); let mut old_session = GenerationStream { @@ -657,6 +692,7 @@ rules: ['MATCH,choice'] std::fs::write(&file, payload("new-node")).unwrap(); live.update(0).await.unwrap(); assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); + assert!(Arc::ptr_eq(®istry, &live.connections())); peer.write_all(b"active").await.unwrap(); let mut buffer = [0; 6]; old_session.read_exact(&mut buffer).await.unwrap(); diff --git a/tests/README.md b/tests/README.md index 2731695..b3826c4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,9 @@ cargo test --locked --lib geodata::tests:: cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: +cargo test --locked --all-features --lib connections::tests:: +cargo test --locked --all-features --lib runtime::diagnostics::tests:: +cargo test --locked --all-features --lib managed_udp_connections_ cargo test --locked --all-features --lib routing::balance::tests:: cargo test --locked --all-features --lib load_balance_routes_ cargo test --locked --all-features --lib dialer::connections::tests:: From 8457a57edf145dca110ae7ee3652562a815f0ff2 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:53:13 +0800 Subject: [PATCH 014/445] Fix routed group health feedback and configuration limit registry --- src/routing/dispatcher.rs | 64 ++++++++++++++++++++++++++++++++++--- src/routing/proxy_group.rs | 22 ++++++++++--- tests/protocols/limits.json | 2 +- 3 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 56d599f..aa60411 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -177,6 +177,7 @@ impl ProxyDispatchers { .cloned() .map(|dispatcher| ResolvedProxyGroupLeaf { chain: Vec::new(), + selections: Vec::new(), dispatcher, direct: false, }) @@ -748,11 +749,12 @@ impl RoutingDispatcher { session.sniffed_domain = None; match decision.action { RuleAction::Route(_) => { - resolved_target - .expect("route target was resolved before dispatch") - .dispatcher - .connect_tcp(session) - .await + let target = resolved_target + .as_ref() + .expect("route target was resolved before dispatch"); + let result = target.dispatcher.connect_tcp(session).await; + target.report(&result); + result } RuleAction::Direct => self.direct.connect_tcp(session).await, RuleAction::Reject => Err(DispatchError::NotAllowed), @@ -1942,6 +1944,58 @@ rules: ["MATCH,pool"] ); } + #[tokio::test] + async fn routed_group_failures_notify_each_selected_group() { + struct Failed; + #[async_trait] + impl Dispatcher for Failed { + async fn connect_tcp(&self, _: StreamSession) -> Result { + Err(DispatchError::HostUnreachable) + } + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + Err(DispatchError::NetworkUnreachable) + } + } + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: [{name: edge, type: socks5, server: 192.0.2.1, port: 1080}] +proxy-groups: + - {name: inner, type: fallback, proxies: [edge], max-failed-times: 2} + - {name: outer, type: fallback, proxies: [inner], max-failed-times: 2} +rules: ["MATCH,outer"] +"#, + ) + .unwrap(); + let nodes: Vec> = vec![Arc::new(Failed)]; + let direct = Arc::new(RecordingDispatcher::default()); + let groups = ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let router = RoutingDispatcher::new( + ProxyDispatchers::with_proxy_groups(nodes, &groups).unwrap(), + direct, + None, + RuleSet::compile(config.rules).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + for attempt in 0..2 { + let result = router + .connect_tcp(StreamSession { + inbound: InboundKind::Socks5, + source: "192.0.2.2:5555".parse().unwrap(), + destination: Destination::Ip("198.51.100.1:443".parse().unwrap()), + sniffed_domain: None, + }) + .await; + assert!(matches!(result, Err(DispatchError::HostUnreachable))); + for index in 0..2 { + assert_eq!(groups.selection(index).take_urgent(), attempt == 1); + } + } + } + #[tokio::test] async fn load_balance_routes_tcp_and_pins_udp_flows() { let config = crate::config::Config::parse_yaml( diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index d26ae44..20268e7 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -61,10 +61,21 @@ struct ProxyGroupMember { pub(crate) struct ResolvedProxyGroupLeaf { pub(crate) chain: Vec, + pub(crate) selections: Vec>, pub(crate) dispatcher: Arc, pub(crate) direct: bool, } +impl ResolvedProxyGroupLeaf { + pub(crate) fn report(&self, result: &Result) { + if !self.direct && !matches!(result, Err(DispatchError::NotAllowed)) { + for selection in &self.selections { + selection.outcome(result.is_ok()); + } + } + } +} + /// Immutable select-group graph with one atomically replaceable member index /// per group. Concrete node dispatchers and DIRECT are shared with the rest of /// the runtime; groups own no protocol resources or background tasks. @@ -249,11 +260,13 @@ impl ProxyGroups { destination: Option<&crate::session::Destination>, ) -> Result { let mut chain = Vec::new(); + let mut selections = Vec::new(); for _ in 0..=self.groups.len() { let group = self.groups.get(group_id.index()).ok_or_else(|| { DispatchError::Other(format!("unknown proxy group id {}", group_id.index())) })?; chain.push(group.name.clone()); + selections.push(group.selected.clone()); if udp && group.disable_udp { return Err(DispatchError::NotAllowed); } @@ -274,6 +287,7 @@ impl ProxyGroups { .cloned() .map(|dispatcher| ResolvedProxyGroupLeaf { chain, + selections, dispatcher, direct: false, }) @@ -287,6 +301,7 @@ impl ProxyGroups { chain.reverse(); return Ok(ResolvedProxyGroupLeaf { chain, + selections, dispatcher: self.direct.clone(), direct: true, }); @@ -296,6 +311,7 @@ impl ProxyGroups { chain.reverse(); return Ok(ResolvedProxyGroupLeaf { chain, + selections, dispatcher: self.reject.clone(), direct: false, }); @@ -420,11 +436,7 @@ impl Dispatcher for SelectProxyGroupDispatcher { Some(&session.destination), )?; let result = leaf.dispatcher.connect_tcp(session).await; - if !leaf.direct && !matches!(result, Err(DispatchError::NotAllowed)) { - self.groups.groups[self.id.index()] - .selected - .outcome(result.is_ok()); - } + leaf.report(&result); result } diff --git a/tests/protocols/limits.json b/tests/protocols/limits.json index 16e8c7b..6a072b4 100644 --- a/tests/protocols/limits.json +++ b/tests/protocols/limits.json @@ -367,7 +367,7 @@ }, { "id": "config-bytes", - "value": 262144, + "value": 8388608, "unit": "bytes", "owner": "config::MAX_CONFIG_BYTES", "saturation": "reject oversized YAML", From cc455c0c106697fcdd498598efb7d2305ceddff1 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 19:58:17 +0800 Subject: [PATCH 015/445] Align controller DNS queries with upstream resolver semantics --- docs/controller-api.md | 5 +++-- src/controller.rs | 2 +- src/dns/runtime.rs | 50 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/controller-api.md b/docs/controller-api.md index fec5899..a565e04 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -186,9 +186,10 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 连接选项、profile 和 TUN 字段。它使用字段白名单,不提供完整 YAML、节点凭据、订阅位置、 认证头、Controller secret 或认证用户列表;不支持 PUT/PATCH 配置修改。 -`GET /dns/query?name=example.com&type=A` 使用当前 DNS 与相同缓存查询;支持 A/AAAA, +`GET /dns/query?name=example.com&type=A` 使用当前上游 resolver 与相同缓存查询;支持 A/AAAA, 省略 type 使用 A。响应包含 `Status/Question/Answer/TC/RD/RA/AD/CD` 和扩展 `QueryPath`。 -fake-IP 模式仍返回合成地址;DNS 关闭或查询失败返回 500。这里只输出地址/CNAME 答案, +与 Mihomo 一样,此诊断接口跳过入口的 hosts、fake-IP 与 IPv6 响应过滤,返回上游真实答案; +policy 与 fallback 仍生效。DNS 关闭或查询失败返回 500。这里只输出地址/CNAME 答案, 不提供其他 qtype、Authority/Additional 展开或后台 DNS 查询日志。 这些接口复用 Controller 的回环限制和 Bearer 鉴权。目标与 DNS 路径只通过显式查询返回, diff --git a/src/controller.rs b/src/controller.rs index dac0531..2dda463 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -627,7 +627,7 @@ async fn dns_query( else { return write_error(stream, 400, "Bad request", &[]).await; }; - let (result, path) = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), result = crate::dns::trace::capture(snapshot.dns.exchange(&query)) => result }; + let (result, path) = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), result = crate::dns::trace::capture(snapshot.dns.query_upstream(&query)) => result }; let Ok(wire) = result else { return write_error(stream, 500, "DNS query failed", &[]).await; }; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 3495a1c..afd85e8 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -1241,6 +1241,29 @@ impl RuntimeDns { .map(DnsWireResponse::into_wire) } + /// Controller queries inspect the upstream resolver, as in Mihomo, without + /// the DNS listener's hosts, fake-IP or IPv6 response middleware. + pub(crate) async fn query_upstream(&self, query: &[u8]) -> Result, RuntimeDnsError> { + if !self.enabled { + return Err(RuntimeDnsError::Disabled); + } + let classified = classify_query(query).map_err(RuntimeDnsError::InvalidQuery)?; + let permit = self.begin_query(); + timeout_at( + permit.deadline, + self.exchange_address_upstream( + query, + &classified, + permit.deadline, + FailureMode::BoundedError, + ), + ) + .await + .map_err(|_| RuntimeDnsError::UpstreamsExhausted { + last_error: "runtime DNS total query deadline expired".into(), + })? + } + /// Keeps an observed query attached to the returned wire response. TUN TCP /// uses this until the framed response write completes. pub(crate) async fn exchange_retained( @@ -1404,6 +1427,19 @@ impl RuntimeDns { return self.synthetic_answer(&parsed_query, &parsed_query.question.name); } + self.exchange_address_upstream(query, classified, deadline, failure_mode) + .await + } + + async fn exchange_address_upstream( + &self, + query: &[u8], + classified: &ClassifiedDnsQuery, + deadline: TokioInstant, + failure_mode: FailureMode, + ) -> Result, RuntimeDnsError> { + let parsed_query = parse_query(query).map_err(RuntimeDnsError::InvalidQuery)?; + loop { let key = SingleflightKey::from(classified); let role = { @@ -5025,6 +5061,7 @@ dns: mixed-port: 1080 hosts: alias.example: synthetic.example + fixed.example: 192.0.2.9 dns: enable: true enhanced-mode: fake-ip @@ -5039,6 +5076,7 @@ dns: vec![ MockReply::Response(ResponseSpec::answer(real)), MockReply::Response(ResponseSpec::answer(real)), + MockReply::Response(ResponseSpec::answer(real)), ], vec![], )); @@ -5059,6 +5097,18 @@ dns: } assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); assert_eq!(resolver.resolve("synthetic.example").await.unwrap(), [real]); + for name in ["synthetic.example", "fixed.example"] { + let wire = resolver + .query_upstream(&build_query(10, name, QueryType::A).unwrap()) + .await + .unwrap(); + assert_eq!( + matching_answer_set(&parse_response(&wire).unwrap()) + .unwrap() + .addresses, + [real] + ); + } let wire = resolver .exchange(&build_query(11, "real.example", QueryType::A).unwrap()) .await From 166dd2fe0e7cc29c343588981627caf67f0ebf54 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 20:11:38 +0800 Subject: [PATCH 016/445] Preserve zero fake-IP TTL and cover usability regressions in CI --- .github/workflows/test.yml | 6 ++++++ docs/config.yaml | 2 +- src/config/fake_ip.rs | 2 +- src/dns/runtime.rs | 2 ++ 4 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c807b9d..b5550f3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -184,6 +184,10 @@ jobs: # Keep this explicit allowlist: unfiltered legacy tests may open host listeners. filters=( geodata:: routing:: security:: outbound::vless::encryption config:: + connections::tests:: runtime::diagnostics::tests:: dialer::connections::tests:: + resource::http::tests:: fake_ip_ sniffing::tests:: tcp_sniffer::tests:: + dns::fallback::tests:: dns::runtime::tests::dns_fallback_ + dns::runtime::encrypted_tests:: outbound::http::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: inbound::http::parser::tests:: inbound::http::framing::tests:: outbound::hysteria2:: outbound::shadowsocks::tests::official_tcp_ @@ -207,6 +211,8 @@ jobs: invoke::tests::runtime_thread_cannot_reenter_invoke invoke::tests::engine_workers_preserve_invoke_admission_and_shutdown invoke::tests::engine_completion_notifies_after_panic_and_join_reports_failure + dns::runtime::tests::runtime_dns_separates_bootstrap_proxy_direct_and_hosts + dns::runtime::tests::udp_truncation_retries_the_same_upstream_over_tcp dns::runtime::tests::concurrent_redir_host_hint_reads_and_dns_updates_complete dns::runtime::tests::domain_policies_use_specificity_and_preserve_geosite_order dns::runtime::tests::dns_policy_selection_is_consistent_across_snapshot_swaps diff --git a/docs/config.yaml b/docs/config.yaml index 8259417..3be8ce8 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -563,7 +563,7 @@ dns: enhanced-mode: redir-host # 或 fake-ip;内部节点/直连解析始终取得真实地址 fake-ip-range: 198.18.0.1/16 fake-ip-range6: "" # 设置 IPv6 CIDR 且启用 IPv6 后分配 AAAA - fake-ip-ttl: 1 # 秒,最小 1;不决定映射寿命 + fake-ip-ttl: 1 # 秒,0 表示不缓存 DNS 答案;不决定映射寿命 fake-ip-filter-mode: blacklist # blacklist / whitelist / rule fake-ip-filter: [dns.msftnsci.com, www.msftnsci.com, www.msftconnecttest.com] # blacklist:命中返回真实地址;whitelist:只有命中才分配 fake-IP。 diff --git a/src/config/fake_ip.rs b/src/config/fake_ip.rs index af474d9..9442426 100644 --- a/src/config/fake_ip.rs +++ b/src/config/fake_ip.rs @@ -129,7 +129,7 @@ impl RawDnsConfig { enabled, range, range6, - ttl: self.fake_ip_ttl.clamp(1, u32::MAX.into()) as u32, + ttl: self.fake_ip_ttl.clamp(0, u32::MAX.into()) as u32, mode: self.fake_ip_filter_mode, filter, rules, diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index afd85e8..980f32b 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -5066,6 +5066,7 @@ dns: enable: true enhanced-mode: fake-ip fake-ip-filter: [real.example] + fake-ip-ttl: 0 nameserver: [192.0.2.1] use-system-hosts: false "#, @@ -5088,6 +5089,7 @@ dns: .unwrap(); let answer = parse_response(&wire).unwrap(); assert!(answer.authoritative); + assert!(answer.answers.iter().all(|record| record.ttl == 0)); let address = matching_answer_set(&answer).unwrap().addresses[0]; assert_eq!(address, "198.18.0.4".parse::().unwrap()); assert_eq!( From a385e94e29dd685b0ebee1f0465042085087c94c Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 20:28:06 +0800 Subject: [PATCH 017/445] Align UDP rule capability and fake-IP TTL with Mihomo Propagate node capabilities through runtime wrappers and skip incompatible rule targets only in rule mode. Preserve the minimum fake-IP TTL, keep routing diagnostics available in minimal builds, and remove the HTTP-only UDP option. --- .github/workflows/test.yml | 3 +- docs/config.yaml | 5 +- docs/outbounds.md | 2 +- docs/tun-icmp-dns.md | 1 + src/config/fake_ip.rs | 2 +- src/config/http_proxy.rs | 5 -- src/config/providers.rs | 1 + src/dispatch.rs | 13 ++++ src/dns/runtime.rs | 6 +- src/outbound/connector.rs | 4 ++ src/{runtime => routing}/diagnostics.rs | 0 src/routing/dispatcher.rs | 86 +++++++++++++++++++++++-- src/routing/mod.rs | 80 ++++++++++++++++++++++- src/routing/proxy_group.rs | 32 +++++++++ src/runtime.rs | 53 ++++++++++++++- src/runtime/health.rs | 4 ++ src/runtime/providers.rs | 8 +++ tests/README.md | 2 +- 18 files changed, 284 insertions(+), 23 deletions(-) rename src/{runtime => routing}/diagnostics.rs (100%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b5550f3..d1bd8ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -184,7 +184,7 @@ jobs: # Keep this explicit allowlist: unfiltered legacy tests may open host listeners. filters=( geodata:: routing:: security:: outbound::vless::encryption config:: - connections::tests:: runtime::diagnostics::tests:: dialer::connections::tests:: + connections::tests:: routing::diagnostics::tests:: dialer::connections::tests:: resource::http::tests:: fake_ip_ sniffing::tests:: tcp_sniffer::tests:: dns::fallback::tests:: dns::runtime::tests::dns_fallback_ dns::runtime::encrypted_tests:: @@ -217,6 +217,7 @@ jobs: dns::runtime::tests::domain_policies_use_specificity_and_preserve_geosite_order dns::runtime::tests::dns_policy_selection_is_consistent_across_snapshot_swaps runtime::tests::prepares_builtin_routes_without_static_nodes_or_bootstrap + runtime::tests::prepares_udp_capabilities_through_runtime_wrappers invoke::tests::null_invalid_utf8_and_oversized_input_return_json_failures invoke::tests::same_instance_command_is_fail_fast invoke::tests::android_protector_is_required_only_for_tun diff --git a/docs/config.yaml b/docs/config.yaml index 3be8ce8..904cf06 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -444,7 +444,8 @@ proxies: # 正则支持 Rust regex 子集,区分大小写;反引号分隔的多表达式按匹配顺序排列。 # lookaround/backreference 明确拒绝。静态成员保留顺序与重复项。 # 动态组为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 -# disable-udp 缺省 false;true 拒绝通过该组的业务 UDP。hidden/icon 为 Controller 元数据。 +# disable-udp 缺省 false;true 禁用该组的业务 UDP。规则模式跳过不支持 UDP 的目标, +# 继续匹配后续规则;global 模式及显式 DNS 出口仍会拒绝。hidden/icon 为 Controller 元数据。 # 节点与组的完整混合图必须无环。资源更新后保留仍存在的当前选择,否则使用默认/首成员。 # 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 # 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 @@ -563,7 +564,7 @@ dns: enhanced-mode: redir-host # 或 fake-ip;内部节点/直连解析始终取得真实地址 fake-ip-range: 198.18.0.1/16 fake-ip-range6: "" # 设置 IPv6 CIDR 且启用 IPv6 后分配 AAAA - fake-ip-ttl: 1 # 秒,0 表示不缓存 DNS 答案;不决定映射寿命 + fake-ip-ttl: 1 # 秒;运行时最小为 1,不决定映射寿命 fake-ip-filter-mode: blacklist # blacklist / whitelist / rule fake-ip-filter: [dns.msftnsci.com, www.msftnsci.com, www.msftconnecttest.com] # blacklist:命中返回真实地址;whitelist:只有命中才分配 fake-IP。 diff --git a/docs/outbounds.md b/docs/outbounds.md index a4e3bf8..c9b0b5a 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -27,7 +27,7 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 `type: http` 提供 TCP CONNECT;`tls: true` 在代理链路使用共享 TLS 与证书策略。 支持 `username/password`、`headers`、`sni`、`skip-cert-verify`、`name-cert-verify`、 `fingerprint` 及成对的内联 `certificate/private-key`。用户名和密码均非空时发送 Basic 认证。 -HTTP CONNECT 不提供 UDP,`udp: true` 在校验时拒绝。握手沿用原期限,失败关闭, +HTTP CONNECT 不提供 UDP,也不接受 `udp` 字段。握手沿用原期限,失败关闭, 成功后保留响应头后的业务字节。启用此协议需要 `outbound-http` feature。 ## SOCKS5 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 6f34428..59b3aea 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -115,6 +115,7 @@ dns: - 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 - Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或支持的代理组名。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 +- 规则模式下,UDP 跳过不支持 UDP 的节点或组,继续匹配后续规则;显式出口和 global 模式不做此跳过。 - 规则结果为 `REJECT` 时,当前尝试失败并继续当前组下一项。 代理组成员可以是具体节点、嵌套组、`DIRECT` 或 `REJECT`。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。 diff --git a/src/config/fake_ip.rs b/src/config/fake_ip.rs index 9442426..af474d9 100644 --- a/src/config/fake_ip.rs +++ b/src/config/fake_ip.rs @@ -129,7 +129,7 @@ impl RawDnsConfig { enabled, range, range6, - ttl: self.fake_ip_ttl.clamp(0, u32::MAX.into()) as u32, + ttl: self.fake_ip_ttl.clamp(1, u32::MAX.into()) as u32, mode: self.fake_ip_filter_mode, filter, rules, diff --git a/src/config/http_proxy.rs b/src/config/http_proxy.rs index 6753bc4..4b2efbd 100644 --- a/src/config/http_proxy.rs +++ b/src/config/http_proxy.rs @@ -28,8 +28,6 @@ pub(super) struct RawHttp { #[serde(default)] tls: bool, #[serde(default)] - udp: bool, - #[serde(default)] sni: Option, #[serde(default)] skip_cert_verify: bool, @@ -56,9 +54,6 @@ impl RawHttp { if !cfg!(feature = "outbound-http") { return invalid("HTTP outbound support is disabled at build time"); } - if self.udp { - return invalid("HTTP CONNECT outbound does not support UDP"); - } validate_host(&self.server, "HTTP server")?; validate_port(self.port, "HTTP")?; let auth = if !self.username.is_empty() && !self.password.is_empty() { diff --git a/src/config/providers.rs b/src/config/providers.rs index 4b6ffbe..b7c4ec4 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -620,6 +620,7 @@ fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Res } let kind = map.get("type").and_then(Value::as_str).unwrap_or_default(); let applicable = match key.as_str() { + "udp" => kind != "http", "up" | "down" => kind == "hysteria2", "udp-over-tcp" => kind == "ss", "skip-cert-verify" => matches!( diff --git a/src/dispatch.rs b/src/dispatch.rs index 430d6d2..8434cf9 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -110,6 +110,11 @@ pub trait DatagramTransport: Send { #[async_trait] pub trait Dispatcher: Send + Sync { + /// Configured datagram capability, independent of reachability or health. + fn supports_udp(&self) -> bool { + true + } + async fn sniff_plan( &self, _source: std::net::SocketAddr, @@ -146,6 +151,10 @@ impl SessionObservedDispatcher { #[async_trait] impl Dispatcher for SessionObservedDispatcher { + fn supports_udp(&self) -> bool { + self.inner.supports_udp() + } + async fn sniff_plan( &self, source: std::net::SocketAddr, @@ -194,6 +203,10 @@ impl HandshakeObservedDispatcher { #[async_trait] impl Dispatcher for HandshakeObservedDispatcher { + fn supports_udp(&self) -> bool { + self.inner.supports_udp() + } + async fn sniff_plan( &self, source: std::net::SocketAddr, diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 980f32b..6715f1d 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -1109,7 +1109,9 @@ impl RuntimeDns { ))) })?; context.pin_ip(nameserver.endpoint.ip()); - match rules.evaluate_with_geo(&context, self.geo_matcher.as_ref()) { + match rules.evaluate_with_egress(&context, self.geo_matcher.as_ref(), |action| { + proxies.supports_udp(action) + }) { RuleEvaluation::Matched(rule_match) => match rule_match.action { RuleAction::Route(id) => proxies .get_route(id) @@ -5089,7 +5091,7 @@ dns: .unwrap(); let answer = parse_response(&wire).unwrap(); assert!(answer.authoritative); - assert!(answer.answers.iter().all(|record| record.ttl == 0)); + assert!(answer.answers.iter().all(|record| record.ttl == 1)); let address = matching_answer_set(&answer).unwrap().addresses[0]; assert_eq!(address, "198.18.0.4".parse::().unwrap()); assert_eq!( diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index 0ae620a..e8e46dc 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -810,6 +810,10 @@ impl ConnectorDispatcher { #[async_trait] impl Dispatcher for ConnectorDispatcher { + fn supports_udp(&self) -> bool { + self.allow_udp + } + async fn connect_tcp(&self, session: StreamSession) -> Result { let context = self.context(); let result = self.inner.connect_stream(session, &context).await; diff --git a/src/runtime/diagnostics.rs b/src/routing/diagnostics.rs similarity index 100% rename from src/runtime/diagnostics.rs rename to src/routing/diagnostics.rs diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index aa60411..2df1413 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -107,6 +107,13 @@ impl std::fmt::Debug for ProxyDispatchers { } impl ProxyDispatchers { + pub(crate) fn supports_udp(&self, action: RuleAction) -> bool { + match action { + RuleAction::Route(id) => self.get_route(id).is_ok_and(|d| d.supports_udp()), + RuleAction::Direct | RuleAction::Reject => true, + } + } + pub fn new(proxies: Vec>) -> Result { Ok(Self { proxies, @@ -212,13 +219,13 @@ pub struct RoutingDispatcher { tun_config: TunConfig, sniffer: Option>, tcp_concurrent: bool, - diagnostics: Option>, + diagnostics: Option>, } impl RoutingDispatcher { pub(crate) fn with_diagnostics( mut self, - diagnostics: Arc, + diagnostics: Arc, ) -> Self { self.diagnostics = Some(diagnostics); self @@ -386,7 +393,11 @@ impl RoutingDispatcher { let geo_matcher = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); let mut resolved = None; - let evaluation = match self.rules.evaluate_with_geo(&context, geo_matcher) { + let supports_udp = |action| self.route_targets.supports_udp(action); + let evaluation = match self + .rules + .evaluate_with_egress(&context, geo_matcher, supports_udp) + { RuleEvaluation::NeedsIpResolution { rule_index } => { let addresses = match (destination, &self.dns) { (Destination::Domain { host, .. }, Some(dns)) => { @@ -395,11 +406,12 @@ impl RoutingDispatcher { _ => Vec::new(), }; resolved = Some(addresses); - self.rules.evaluate_with_resolved_ips( + self.rules.evaluate_with_resolved_egress( &mut context, geo_matcher, rule_index, resolved.as_deref().unwrap_or_default(), + supports_udp, ) } evaluation => evaluation, @@ -1620,6 +1632,7 @@ mod tests { #[derive(Default)] struct RecordingDispatcher { + tcp_only: bool, tcp_sessions: Mutex>, datagrams: Arc>>, udp_opens: AtomicUsize, @@ -1627,6 +1640,10 @@ mod tests { #[async_trait] impl Dispatcher for RecordingDispatcher { + fn supports_udp(&self) -> bool { + !self.tcp_only + } + async fn connect_tcp(&self, session: StreamSession) -> Result { self.tcp_sessions.lock().unwrap().push(session); let (client, _server) = tokio::io::duplex(64); @@ -1894,7 +1911,7 @@ rules: ["MATCH,pool"] let groups = ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); let rules = RuleSet::compile(config.rules.clone()).unwrap(); let registry = Arc::new(crate::connections::Registry::default()); - let diagnostics = Arc::new(crate::runtime::diagnostics::Diagnostics::new( + let diagnostics = Arc::new(crate::routing::diagnostics::Diagnostics::new( &config, &rules, registry.clone(), @@ -1944,6 +1961,65 @@ rules: ["MATCH,pool"] ); } + #[tokio::test] + async fn udp_rules_skip_incapable_targets_without_changing_explicit_modes() { + for target in ["edge", "outer", "disabled"] { + for mode in ["rule", "global"] { + let mut config = crate::config::Config::parse_yaml( + format!( + r#" +mixed-port: 1080 +mode: {mode} +proxies: [{{name: edge, type: socks5, server: 192.0.2.1, port: 1080}}] +proxy-groups: + - {{name: inner, type: select, proxies: [edge]}} + - {{name: outer, type: select, proxies: [inner]}} + - {{name: disabled, type: select, proxies: [DIRECT], disable-udp: true}} + - {{name: GLOBAL, type: select, proxies: [{target}]}} +rules: ["DST-PORT,443,{target}", "MATCH,DIRECT"] +"# + ) + .as_bytes(), + ) + .unwrap(); + let nodes: Vec> = + vec![crate::dispatch::observe_handshakes_with_stats( + Arc::new(RecordingDispatcher { + tcp_only: true, + ..Default::default() + }), + crate::resources::RuntimeResourceStats::new("udp-capability"), + )]; + let direct = Arc::new(RecordingDispatcher::default()); + let groups = + ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let router = RoutingDispatcher::new( + ProxyDispatchers::with_proxy_groups(nodes, &groups).unwrap(), + direct, + None, + RuleSet::compile_config(&mut config).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + for network in [Network::Tcp, Network::Udp] { + let decision = router + .route( + network, + InboundKind::Socks5, + "192.0.2.2:1234".parse().unwrap(), + &Destination::Ip("203.0.113.1:443".parse().unwrap()), + None, + ) + .await + .unwrap(); + assert_eq!( + decision.action == RuleAction::Direct, + mode == "rule" && network == Network::Udp + ); + } + } + } + } + #[tokio::test] async fn routed_group_failures_notify_each_selected_group() { struct Failed; diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 4716b3e..7e40619 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -20,6 +20,7 @@ mod balance; mod selection; pub(crate) use selection::GroupSelection; +pub(crate) mod diagnostics; mod dispatcher; mod proxy_group; @@ -353,6 +354,7 @@ impl std::error::Error for RuleSetError {} pub struct RuleSet { rules: Box<[RuleSpec]>, uses_domain_routing: bool, + filter_udp: bool, } impl RuleSet { @@ -374,9 +376,17 @@ impl RuleSet { Ok(Self { rules: rules.into_boxed_slice(), uses_domain_routing, + filter_udp: true, }) } + #[cfg(any(feature = "invoke", test))] + pub(crate) fn compile_config(config: &mut crate::config::Config) -> Result { + let mut rules = Self::compile(config.take_runtime_rules())?; + rules.filter_udp = config.mode == crate::config::Mode::Rule; + Ok(rules) + } + #[must_use] pub fn len(&self) -> usize { self.rules.len() @@ -415,6 +425,15 @@ impl RuleSet { &self, context: &RoutingContext, geo_matcher: &dyn GeoMatcher, + ) -> RuleEvaluation { + self.evaluate_with_egress(context, geo_matcher, |_| true) + } + + pub(crate) fn evaluate_with_egress( + &self, + context: &RoutingContext, + geo_matcher: &dyn GeoMatcher, + supports_udp: impl Fn(RuleAction) -> bool, ) -> RuleEvaluation { for (index, rule) in self.rules.iter().enumerate() { let matched = match matches_rule(rule, context, geo_matcher) { @@ -422,7 +441,7 @@ impl RuleSet { Err(()) => return RuleEvaluation::NeedsIpResolution { rule_index: index }, }; - if matched { + if matched && self.accepts_egress(context, rule.action, &supports_udp) { return RuleEvaluation::Matched(RuleMatch { action: rule.action, rule_index: index, @@ -457,6 +476,23 @@ impl RuleSet { geo_matcher: &dyn GeoMatcher, resolution_rule_index: usize, addresses: &[IpAddr], + ) -> RuleEvaluation { + self.evaluate_with_resolved_egress( + context, + geo_matcher, + resolution_rule_index, + addresses, + |_| true, + ) + } + + pub(crate) fn evaluate_with_resolved_egress( + &self, + context: &mut RoutingContext, + geo_matcher: &dyn GeoMatcher, + resolution_rule_index: usize, + addresses: &[IpAddr], + supports_udp: impl Fn(RuleAction) -> bool, ) -> RuleEvaluation { for (index, rule) in self.rules.iter().enumerate().skip(resolution_rule_index) { let mut selected_address = None; @@ -476,7 +512,7 @@ impl RuleSet { matches_rule(rule, &candidate, geo_matcher).unwrap_or(false) }; - if matched { + if matched && self.accepts_egress(context, rule.action, &supports_udp) { if let Some(address) = selected_address { context.pin_ip(address); } @@ -491,6 +527,15 @@ impl RuleSet { rule_index: self.rules.len(), }) } + + fn accepts_egress( + &self, + context: &RoutingContext, + action: RuleAction, + supports_udp: &impl Fn(RuleAction) -> bool, + ) -> bool { + !self.filter_udp || context.network != Network::Udp || supports_udp(action) + } } fn normalize_rule( @@ -809,6 +854,37 @@ mod tests { ); } + #[test] + fn udp_capability_filter_continues_after_ip_resolution() { + let rules = RuleSet::compile(vec![ + spec( + RuleKind::IpCidr(IpCidr { + network: "192.0.2.0".parse().unwrap(), + prefix_len: 24, + }), + proxy_action(0), + ), + spec(RuleKind::Match, RuleAction::Reject), + ]) + .unwrap(); + let mut context = domain_context(Network::Udp, "example.com", 53); + let available = |action| action != proxy_action(0); + assert!(matches!( + rules.evaluate_with_egress(&context, &EmptyGeoMatcher, available), + RuleEvaluation::NeedsIpResolution { rule_index: 0 } + )); + assert_eq!( + action(rules.evaluate_with_resolved_egress( + &mut context, + &EmptyGeoMatcher, + 0, + &["192.0.2.1".parse().unwrap()], + available + )), + RuleAction::Reject + ); + } + #[test] fn first_matching_rule_wins() { let rules = RuleSet::compile(vec![ diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index 20268e7..69f5565 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -428,6 +428,38 @@ struct SelectProxyGroupDispatcher { #[async_trait] impl Dispatcher for SelectProxyGroupDispatcher { + fn supports_udp(&self) -> bool { + let mut id = self.id; + for _ in 0..=self.groups.groups.len() { + let Some(group) = self.groups.groups.get(id.index()) else { + return false; + }; + if group.disable_udp { + return false; + } + // Mihomo advertises balance-group UDP independently of the next + // selected leaf. Capability queries must not advance round-robin. + if group.config.kind == crate::config::GroupKind::LoadBalance { + return true; + } + let Some(member) = group.members.get(group.selected.load(Ordering::Acquire)) else { + return false; + }; + match member.target { + ProxyGroupMemberTarget::Route(RouteTargetId::Proxy(id)) => { + return self + .groups + .proxies + .get(id.index()) + .is_some_and(|d| d.supports_udp()); + } + ProxyGroupMemberTarget::Route(RouteTargetId::Group(nested)) => id = nested, + ProxyGroupMemberTarget::Direct | ProxyGroupMemberTarget::Reject => return true, + } + } + false + } + async fn connect_tcp(&self, session: StreamSession) -> Result { let leaf = self.groups.resolve_leaf_for( self.id, diff --git a/src/runtime.rs b/src/runtime.rs index 63021c9..d759343 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(any(feature = "invoke", feature = "cli")), allow(dead_code))] -pub(crate) mod diagnostics; +use crate::routing::diagnostics; use tracing::instrument::WithSubscriber; @@ -322,7 +322,7 @@ impl PreparedCore { pub(crate) fn validate_config(mut config: Config) -> io::Result<()> { GeoRequirements::for_config(&config) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; - RuleSet::compile(config.take_runtime_rules()) + RuleSet::compile_config(&mut config) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; Ok(()) } @@ -1412,7 +1412,7 @@ fn prepare_routing( // GeoData must inspect the normalized rule specifications first. Once that // borrowing load is complete, move the rules directly into the compiled // set so PreparedCore does not retain a second owned rule graph. - let rules = RuleSet::compile(config.take_runtime_rules()) + let rules = RuleSet::compile_config(config) .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; Ok((rules, geodata_registration)) } @@ -1747,6 +1747,7 @@ mod socks_tests; #[cfg(test)] mod tests { + use crate::config::ProxyGroupId; use std::net::{IpAddr, SocketAddr}; use std::sync::{ Mutex, @@ -1957,6 +1958,52 @@ rules: ) } + #[tokio::test] + async fn prepares_udp_capabilities_through_runtime_wrappers() { + let prepared = PreparedCore::prepare( + br#" +mixed-port: 1080 +proxies: [{name: tcp-only, type: socks5, server: 192.0.2.1, port: 1080}] +proxy-groups: + - {name: selected, type: select, proxies: [tcp-only, DIRECT]} + - {name: disabled, type: select, proxies: [DIRECT], disable-udp: true} + - {name: balanced, type: load-balance, proxies: [tcp-only]} +"#, + &FixedResolver, + ResourceLimits::default(), + ) + .await + .unwrap(); + let parts = prepared.build_dispatcher(Dialer::default()).unwrap(); + assert!( + !parts + .resource_targets + .get(ProxyId::new(0).unwrap()) + .unwrap() + .supports_udp() + ); + for (index, expected) in [false, false, true].into_iter().enumerate() { + let route = RouteTargetId::Group(ProxyGroupId::new(index).unwrap()); + assert_eq!( + parts + .resource_targets + .get_route(route) + .unwrap() + .supports_udp(), + expected + ); + } + parts.proxy_groups.select("selected", "DIRECT").unwrap(); + assert!( + parts + .resource_targets + .get_route(RouteTargetId::Group(ProxyGroupId::new(0).unwrap())) + .unwrap() + .supports_udp() + ); + parts.proxy_graph.shutdown().await; + } + #[tokio::test] async fn prepares_builtin_routes_without_static_nodes_or_bootstrap() { let resolver = RecordingResolver::default(); diff --git a/src/runtime/health.rs b/src/runtime/health.rs index f1b73f5..e67427e 100644 --- a/src/runtime/health.rs +++ b/src/runtime/health.rs @@ -29,6 +29,10 @@ pub(super) struct ActiveDispatcher { } #[async_trait::async_trait] impl Dispatcher for ActiveDispatcher { + fn supports_udp(&self) -> bool { + self.dispatcher.supports_udp() + } + async fn connect_tcp(&self, session: StreamSession) -> Result { if session.inbound != crate::session::InboundKind::InternalMeasure { self.activity.touch(); diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index b1967fc..eb13cbb 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -497,6 +497,10 @@ impl RuntimeResources for ProviderRuntime { } #[async_trait] impl Dispatcher for ProviderRuntime { + fn supports_udp(&self) -> bool { + self.current.load().dispatcher.supports_udp() + } + async fn sniff_plan( &self, source: SocketAddr, @@ -544,6 +548,10 @@ struct PinnedDispatcher { } #[async_trait] impl Dispatcher for PinnedDispatcher { + fn supports_udp(&self) -> bool { + self.dispatcher.supports_udp() + } + async fn sniff_plan( &self, source: SocketAddr, diff --git a/tests/README.md b/tests/README.md index b3826c4..f10c4d7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,7 +17,7 @@ cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --locked --all-features --lib connections::tests:: -cargo test --locked --all-features --lib runtime::diagnostics::tests:: +cargo test --locked --all-features --lib routing::diagnostics::tests:: cargo test --locked --all-features --lib managed_udp_connections_ cargo test --locked --all-features --lib routing::balance::tests:: cargo test --locked --all-features --lib load_balance_routes_ From 495aeef61deb16425c41acd01b7fbbee7b949a1b Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 20:47:13 +0800 Subject: [PATCH 018/445] Document current routing, DNS, provider and platform capabilities --- README.md | 26 ++++++++++++++------------ docs/README.md | 7 ++++--- docs/mihomo-compatibility.md | 35 +++++++++++++++++++++++++++++++++++ readme/README.ru.md | 28 +++++++++++++++------------- readme/README.zh_CN.md | 28 +++++++++++++++------------- 5 files changed, 83 insertions(+), 41 deletions(-) create mode 100644 docs/mihomo-compatibility.md diff --git a/README.md b/README.md index d47c58e..dc7822f 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ Configuration uses **Mihomo-compatible YAML for the supported feature set**. Vol ## What Vole can do - **Accept application and VPN traffic:** HTTP forwarding, CONNECT and Upgrade; SOCKS5 CONNECT and UDP ASSOCIATE; host-provided IPv4/IPv6 TUN packets. -- **Route by destination:** domain, domain suffix/keyword, IP CIDR, destination port, TCP/UDP, GeoSite and GeoIP rules, with explicit DIRECT and REJECT actions. -- **Choose and chain proxies:** nested `select` groups, live group selection, and `dialer-proxy` chains that can reference nodes or groups. Selection changes apply to new physical transports without moving existing connections. -- **Handle DNS:** controlled UDP/TCP nameservers, GeoSite-based nameserver policies, sequential failover, caching and duplicate-query coalescing; intercept configured TCP/UDP TUN DNS targets (port 53 by default). -- **Identify traffic for routing:** HTTP, TLS and QUIC domain sniffing, plus TUN DNS hints, without rewriting the actual destination. ICMPv4/ICMPv6 Echo is answered locally. -- **Manage routing assets:** load referenced categories from `geosite.dat` / `geoip.dat` on demand and update them through the configured route. GeoSite supports Domain, Full, Plain and Regex records, attribute intersections and inverted selectors; GeoIP supports inverted selectors. All platforms retain selected records without a count cap or truncation; GeoData has no total memory quota. See [GeoData boundaries](docs/geodata.md#内存与安全边界). -- **Expose client controls:** a loopback Controller for group selection and TUN traffic rates/totals, plus isolated node/chain delay measurement through Invoke API. +- **Route traffic:** rule/global/direct modes; domain, source/destination IP and port, TCP/UDP, GeoSite/GeoIP, rule providers and logical rules, with DIRECT and REJECT actions. +- **Choose and chain proxies:** nested select/url-test/fallback/load-balance groups, health checks and persistent selection; `dialer-proxy` chains can reference nodes or groups. Existing connections retain their selected path. +- **Handle DNS:** hosts, separate bootstrap/node/direct resolvers, UDP/TCP/DoH/DoT, domain/GeoSite/rule-set policies, fallback, bounded caching, redir-host and persistent fake-IP mappings; intercept configured TUN DNS targets. +- **Identify traffic for routing:** HTTP/TLS sniffing for TUN and mixed TCP, QUIC sniffing for TUN UDP, with force/skip conditions and configurable destination override. ICMPv4/ICMPv6 Echo is answered locally. +- **Manage routing assets:** HTTP/file/inline proxy and rule providers, dynamic groups, filters and overrides; YAML/Text and domain/ipcidr MRS. GeoData loads complete referenced categories and supports routed downloads and periodic updates. Failed updates retain usable state. See [GeoData boundaries](docs/geodata.md#内存与安全边界). +- **Expose client controls:** group/resource state, connection snapshots and cancellation, rule counters, effective configuration and bounded DNS diagnostics, alongside TUN traffic metrics and isolated node/chain delay measurement. ## Proxy protocols -All eight protocols below are enabled in the default build. UDP support is configured per node. +All nine protocols below are enabled in the default build. HTTP CONNECT is TCP-only; UDP support for other protocols is configured per node. | Protocol | Capabilities | | --- | --- | @@ -29,6 +29,7 @@ All eight protocols below are enabled in the default build. UDP support is confi | [Trojan](docs/outbounds.md#trojan) | TCP/UDP over TLS TCP, WebSocket / HTTPUpgrade or gRPC | | [Shadowsocks 2022](docs/outbounds.md#shadowsocks-2022) | TCP/UDP; AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305; AES identity chains; optional strict ShadowTLS v3 TCP wrapping and UoT v2 | | [AnyTLS](docs/outbounds.md#anytls) | TLS sessions and UDP over TCP v2 | +| [HTTP / HTTPS CONNECT](docs/outbounds.md#http--https-connect) | TCP CONNECT, optional proxy authentication and TLS | | [SOCKS5](docs/outbounds.md#socks5) | CONNECT and UDP ASSOCIATE, with optional username/password authentication | | [Hysteria2](docs/outbounds.md#hysteria2) | QUIC TCP/UDP, bandwidth control, Salamander, port hopping and mTLS | | [TUIC v5](docs/outbounds.md#tuic-v5) | QUIC TCP/UDP, native/quic UDP relay modes and selectable congestion control | @@ -70,13 +71,13 @@ rules: - MATCH,Proxy ``` -Replace the example endpoint and credentials. GeoSite/GeoIP rules require the corresponding assets under `/geodata`; missing assets leave those rule types unavailable. See the [complete configuration reference](docs/config.yaml) and [GeoData behavior](docs/geodata.md). +Replace the example endpoint and credentials. GeoSite/GeoIP rules use assets under `/geodata`; missing required assets are downloaded through the configured route, and affected rules remain unavailable until loading succeeds. See the [complete configuration reference](docs/config.yaml) and [GeoData behavior](docs/geodata.md). `mixed-port` shares one TCP port between HTTP and SOCKS5 and enables SOCKS5 UDP on the same port. `allow-lan` controls binding independently of `authentication`: omitting `authentication` or using `[]` allows unauthenticated access on either loopback or wildcard addresses; configured credentials are checked by both HTTP and SOCKS5. Top-level `port`, `socks-port`, `udp` and `listeners` are rejected; proxy-node `port` and `udp` keep their outbound meanings. -Compatibility is scoped to documented fields and behavior, not arbitrary Mihomo configurations. Groups currently support static `select`; DNS nameservers use literal IPs over UDP/TCP. Providers, automatic group selection, encrypted DNS and fake-IP are outside the current feature set. Unknown fields and invalid combinations are rejected rather than silently ignored; Vole-specific semantics are called out in the relevant contracts. +Compatibility covers the documented subset of Mihomo fields. Unknown fields and unsupported combinations are rejected. See [current compatibility scope](docs/mihomo-compatibility.md) and the individual contracts for platform and protocol limits. -Library hosts start an instance with inline `configYaml`; TUN device, file descriptor, MTU, DNS interception and UDP timeout belong to the `tun` configuration. Platform callbacks remain runtime-local. The CLI sends options through the same Invoke API, whose foreground operation reads the file selected by `-f`. Vole leaves interface addresses, DNS and system routes to the host. +Library hosts start an instance with inline `configYaml`; TUN device, file descriptor, MTU, DNS interception and UDP timeout belong to the `tun` configuration. Platform callbacks remain runtime-local. The CLI sends options through the same Invoke API, whose foreground operation reads the file selected by `-f`. Linux core-owned TUN can configure automatic routing and systemd-resolved DNS; borrowed descriptors and App platform networks remain host-owned. ## CLI @@ -97,10 +98,10 @@ vole -t -f ./config.yaml | iOS / macOS | Host-provided utun file descriptor; macOS can also open/create a native utun | | tvOS 17+ | Host-provided utun file descriptor; ARM64 device and simulator targets | | Android | `VpnService` file descriptor with outbound socket protection | -| Linux | Real single-queue raw-IP TUN from a borrowed fd or a core-opened device; the host owns system network configuration | +| Linux | Real single-queue raw-IP TUN from a borrowed fd or a core-opened device; core-owned Linux TUN supports automatic routes and system DNS | | Windows | Mutually exclusive `windows-wintun` desktop and `windows-uwp` packaged Provider/Session Host builds sharing the core | -Vole provides native libraries and a foreground CLI; platform hosts still own system network configuration. Unix hosts own the original TUN descriptor; Vole uses and closes its own duplicate. Apple's public packetFlow API does not guarantee raw-fd access, so actual Network Extension integration and device validation remain host responsibilities. See [TUN integration](docs/tun-platform.md) and [platform acceptance boundaries](docs/acceptance.md). +Vole provides native libraries and a foreground CLI. Apple, Android and Windows hosts own system network configuration and application lifecycle; Linux core-owned TUN additionally supports automatic routes and system DNS. Unix hosts own the original TUN descriptor; Vole uses and closes its own duplicate. Apple's public packetFlow API does not guarantee raw-fd access, so actual Network Extension integration and device validation remain host responsibilities. See [TUN integration](docs/tun-platform.md) and [platform acceptance boundaries](docs/acceptance.md). Desktop Wintun loads a host-provided `wintun.dll` from the process executable's directory. The host configures interface addresses, DNS, routes and physical egress; Windows Wintun device validation is separate from the existing packaged VPN results. See [CLI usage and tag releases](docs/cli.md) for command-line delivery. @@ -126,6 +127,7 @@ See [acceptance boundaries](docs/acceptance.md) for the scope of build, network - [Documentation index](docs/README.md) - [Configuration reference](docs/config.yaml) +- [Mihomo compatibility scope](docs/mihomo-compatibility.md) - [Core and platform builds](scripts/README.md) - [CLI usage and tag releases](docs/cli.md) - [Core regression tests](tests/README.md) diff --git a/docs/README.md b/docs/README.md index 3de2a63..c0e8d5b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,11 +4,12 @@ ## 配置与接口 -- [配置协议](config.yaml):严格 YAML、节点、静态 select 组、DNS 与规则。 +- [配置协议](config.yaml):严格 YAML、节点、providers、动态与自动策略组、DNS 与规则。 +- [Mihomo 兼容范围](mihomo-compatibility.md):已实现能力、协议与平台边界。 - [Invoke API](invoke-api.md):严格请求格式、核心软件版本与构建身份、生命周期、平台回调及内联配置。 -- [Controller](controller-api.md):认证、运行中组选择和 TUN 流量。 +- [Controller](controller-api.md):认证、组与资源、连接管理、规则/配置/DNS 诊断和 TUN 流量。 - [混合入站](inbounds.md):同端口 HTTP 转发/隧道、SOCKS5 TCP/授权 UDP、监听及认证。 -- [出站](outbounds.md):SOCKS5、AnyTLS、Trojan、VMess AEAD、Hysteria2、SS 2022。 +- [出站](outbounds.md):HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、Hysteria2、TUIC v5、SS 2022。 - [VLESS](vless.md):传输、Vision、Encryption、REALITY、JLS、静态 ECH。 - [XHTTP 与 sing-mux](xhttp.md):双腿、H1/H2/H3、连接池及复用。 - [TLS 证书与指纹](tls-client-fingerprint.md):证书 pin、ClientHello 模板、身份与缓存。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md new file mode 100644 index 0000000..31301c3 --- /dev/null +++ b/docs/mihomo-compatibility.md @@ -0,0 +1,35 @@ +# Mihomo 配置兼容范围 + +配置名称以 [Mihomo v1.19.32](https://github.com/MetaCubeX/mihomo/tree/88dcbf7f1614a67c3b36b848ee3592dfa92ada36) 为参考。 +Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配置子集不等于完整替代 Mihomo。 +未知字段和不支持的组合明确拒绝,不静默修改输入。 + +| 范围 | 当前支持 | +| --- | --- | +| 配置与选路 | YAML anchor/merge;rule/global/direct;内置 DIRECT/REJECT;域名、地址、端口、网络、GeoData、RULE-SET 和 AND/OR/NOT | +| 资源 | HTTP/file/inline proxy/rule providers;动态成员、筛选、覆盖、健康检查;YAML/Text 与 domain/ipcidr MRS;失败保留旧代 | +| 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | +| DNS | hosts、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | +| mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | +| 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022;依赖启用的 Cargo feature | +| 嗅探 | TUN/mixed TCP 的 HTTP/TLS;TUN UDP 的 QUIC;域名/地址排除、强制条件和目标覆盖 | +| Linux 自建设备 | auto-detect-interface、auto-route、systemd-resolved DNS 接管;失败和停止时恢复本次修改 | +| 运行状态 | 连接快照、流量计数和关闭;组与资源状态;规则命中、有效配置、受限 DNS 诊断 | + +## 必须保留的边界 + +- 业务入口只有 mixed-port 与 TUN;不支持 port、socks-port、listeners、redir/tproxy 或服务端协议配置。 +- Shadowsocks 只支持三个 2022 cipher;插件仅 strict ShadowTLS v3,UoT 仅 v2。 +- DNS 不提供独立 listen、DoQ、DoH/H3、ECS、system/DHCP nameserver 或可配置 ARC 缓存。 + DoH 使用 H1 POST;详细解析角色、缓存与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 +- 正则使用 Rust regex 子集,明确拒绝 lookaround/backreference。规则与 provider 的协议覆盖仅限已实现字段。 +- 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 + global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 +- 未配置入站的配置不能启动。Controller 沿用回环地址、secret 及运行配置准入,见 [Controller API](controller-api.md)。 + /dns/query 查询真实上游;连接表按 UDP 目标流管理,memory 不代表进程 RSS。 +- fake-IP TTL 最小一秒;映射生命周期独立于 TTL。本地选择和映射存储不保证与 Mihomo 数据库互读。 +- Apple、Android、Windows UWP 的网络设置、权限和 App 生命周期仍归宿主;Windows Wintun 使用外置 DLL。 + 借用 Unix fd 只复制并保留原描述符。Linux 扩展路由、旁路由、防火墙/NAT、进程/UID 规则不在当前范围。 + +平台编译、内存回归、双 CLI 行为比较、官方协议互通和压力测试是不同的验证层次,见 +[验收边界](acceptance.md)。任一层通过都不代表所有字段取值、组合、平台或真机行为已验证。 diff --git a/readme/README.ru.md b/readme/README.ru.md index 7b6f811..60b24b2 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -11,16 +11,16 @@ Vole — прокси-ядро на Rust для VPN-клиентов и лока ## Что умеет Vole - **Принимать трафик приложений и VPN:** пересылка HTTP, CONNECT и Upgrade; SOCKS5 CONNECT и UDP ASSOCIATE; IPv4/IPv6-пакеты TUN, предоставленные хостом. -- **Маршрутизировать по назначению:** правила по домену, суффиксу или ключевому слову домена, IP CIDR, порту назначения, TCP/UDP, GeoSite и GeoIP, с явными действиями DIRECT и REJECT. -- **Выбирать прокси и строить цепочки:** вложенные группы `select`, переключение групп во время работы и цепочки `dialer-proxy`, ссылающиеся на узлы или группы. Изменения выбора применяются к новым физическим транспортным соединениям, не перенося существующие. -- **Обрабатывать DNS:** управляемые UDP/TCP-серверы, политики выбора серверов по GeoSite, последовательное переключение при отказе, кеширование и объединение одинаковых запросов; перехват заданных TCP/UDP DNS-адресов в TUN (по умолчанию порт 53). -- **Определять трафик для маршрутизации:** извлечение домена из HTTP, TLS и QUIC, а также DNS-подсказки TUN, без изменения фактического назначения. На ICMPv4/ICMPv6 Echo ядро отвечает локально. -- **Управлять данными маршрутизации:** загрузка используемых категорий из `geosite.dat` / `geoip.dat` по запросу и обновление файлов через заданный маршрут. GeoSite поддерживает Domain, Full, Plain и Regex, пересечение атрибутов и инверсию; GeoIP поддерживает инверсию. На всех платформах выбранные записи сохраняются без жёсткого лимита количества или усечения; общая квота памяти GeoData не задана. См. [границы GeoData](../docs/geodata.md#内存与安全边界). -- **Предоставлять средства управления клиенту:** Controller на loopback-интерфейсе для выбора групп, текущей скорости и общего объёма TUN-трафика, а также изолированное измерение задержки узлов и цепочек через Invoke API. +- **Маршрутизировать трафик:** режимы rule/global/direct; домены, IP и порты источника и назначения, TCP/UDP, GeoSite/GeoIP, наборы правил и логические условия, действия DIRECT и REJECT. +- **Выбирать прокси и строить цепочки:** вложенные группы select/url-test/fallback/load-balance, проверки доступности и сохранение выбора; `dialer-proxy` ссылается на узлы или группы. Существующие соединения сохраняют свой маршрут. +- **Обрабатывать DNS:** hosts, отдельные bootstrap/узловые/прямые резолверы, UDP/TCP/DoH/DoT, политики по доменам/GeoSite/rule-set, fallback, ограниченный кеш, redir-host и постоянные fake-IP; перехват DNS в TUN по конфигурации. +- **Определять трафик для маршрутизации:** HTTP/TLS для TUN и mixed TCP, QUIC для TUN UDP, условия принудительного анализа и исключения, настраиваемая замена назначения. Ответы на ICMPv4/ICMPv6 Echo формируются локально. +- **Управлять ресурсами:** HTTP/file/inline providers узлов и правил, динамические группы, фильтры и переопределения; YAML/Text и domain/ipcidr MRS. GeoData полностью загружает выбранные категории, поддерживает маршрутизируемые загрузки и периодические обновления; ошибки обновления сохраняют рабочее состояние. См. [границы GeoData](../docs/geodata.md#内存与安全边界). +- **Предоставлять управление:** состояние групп и ресурсов, список и закрытие соединений, счётчики правил, действующая конфигурация и ограниченная DNS-диагностика, а также метрики TUN и изолированное измерение задержки. ## Прокси-протоколы -Все восемь протоколов ниже включены в сборку по умолчанию. Поддержка UDP настраивается для каждого узла. +Все девять протоколов ниже включены по умолчанию. HTTP CONNECT поддерживает только TCP; UDP для остальных протоколов настраивается на уровне узла. | Протокол | Возможности | | --- | --- | @@ -29,6 +29,7 @@ Vole — прокси-ядро на Rust для VPN-клиентов и лока | [Trojan](../docs/outbounds.md#trojan) | TCP/UDP через TLS TCP, WebSocket / HTTPUpgrade или gRPC | | [Shadowsocks 2022](../docs/outbounds.md#shadowsocks-2022) | TCP/UDP; AES-128-GCM, AES-256-GCM и ChaCha20-Poly1305; цепочки идентификации AES; опциональный strict ShadowTLS v3 для TCP и UoT v2 | | [AnyTLS](../docs/outbounds.md#anytls) | TLS-сессии и UDP over TCP v2 | +| [HTTP / HTTPS CONNECT](../docs/outbounds.md#http--https-connect) | TCP CONNECT, опциональная аутентификация прокси и TLS | | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT и UDP ASSOCIATE, с опциональной аутентификацией по имени пользователя и паролю | | [Hysteria2](../docs/outbounds.md#hysteria2) | TCP/UDP через QUIC, управление пропускной способностью, Salamander, смена портов и mTLS | | [TUIC v5](../docs/outbounds.md#tuic-v5) | TCP/UDP через QUIC, режимы передачи UDP native/quic и выбор алгоритма управления перегрузкой | @@ -70,13 +71,13 @@ rules: - MATCH,Proxy ``` -Замените адрес сервера и учётные данные в примере. Для правил GeoSite/GeoIP нужны соответствующие файлы в `/geodata`; без них эти типы правил недоступны. См. [полный справочник конфигурации](../docs/config.yaml) и [поведение GeoData](../docs/geodata.md). +Замените адрес сервера и учётные данные. GeoSite/GeoIP используют файлы в `/geodata`; отсутствующие необходимые ресурсы загружаются через заданный маршрут. До успешной загрузки соответствующие правила недоступны. См. [конфигурацию](../docs/config.yaml) и [поведение GeoData](../docs/geodata.md). `mixed-port` принимает HTTP и SOCKS5 на одном TCP-порту и включает SOCKS5 UDP на том же порту. `allow-lan` управляет адресом привязки независимо от `authentication`: если `authentication` отсутствует или равен `[]`, аутентификация не требуется ни на loopback, ни на всех интерфейсах; заданные учётные данные проверяются HTTP и SOCKS5. Поля верхнего уровня `port`, `socks-port`, `udp` и `listeners` отклоняются; `port` и `udp` прокси-узла сохраняют смысл параметров исходящего соединения. -Совместимость ограничена документированными полями и поведением и не распространяется на произвольные конфигурации Mihomo. Группы пока поддерживают статический `select`; DNS-серверы задаются буквальными IP-адресами и используют UDP/TCP. Providers, автоматический выбор в группах, зашифрованный DNS и fake-IP не входят в текущий набор возможностей. Неизвестные поля и недопустимые сочетания отклоняются, а не игнорируются; особенности семантики Vole отмечены в соответствующих контрактах. +Поддерживается документированное подмножество конфигурации Mihomo. Неизвестные поля и неподдерживаемые сочетания отклоняются. Ограничения платформ и протоколов описаны в [границах совместимости](../docs/mihomo-compatibility.md) и соответствующих контрактах. -Хост запускает экземпляр библиотеки с inline `configYaml`; устройство TUN, дескриптор, MTU, перехват DNS и тайм-аут UDP задаются в `tun`. Платформенные callbacks регистрируются локально в runtime. CLI передаёт параметры через тот же Invoke API, чья операция foreground читает файл `-f`. Адреса интерфейса, DNS и системные маршруты настраивает хост. +Хост запускает экземпляр библиотеки с inline `configYaml`; устройство TUN, дескриптор, MTU, перехват DNS и тайм-аут UDP задаются в `tun`. Платформенные callbacks регистрируются локально в runtime. CLI передаёт параметры через тот же Invoke API, чья операция foreground читает файл `-f`. Для собственного TUN в Linux ядро поддерживает автоматические маршруты и DNS через systemd-resolved; заимствованные fd и сеть платформ приложений остаются под управлением хоста. ## CLI @@ -97,10 +98,10 @@ vole -t -f ./config.yaml | iOS / macOS | Дескриптор utun от хоста; macOS также может открыть или создать нативный utun | | tvOS 17+ | Дескриптор файла utun, предоставленный хостом; ARM64-устройства и симулятор | | Android | Дескриптор `VpnService` с защитой исходящих сокетов | -| Linux | Настоящий raw-IP TUN с одной очередью через заимствованный fd или устройство, открытое ядром; системную сеть настраивает хост | +| Linux | Настоящий raw-IP TUN с одной очередью через заимствованный fd или устройство, открытое ядром; собственный TUN в Linux поддерживает автоматические маршруты и системный DNS | | Windows | Взаимоисключающие сборки `windows-wintun` для настольного процесса и `windows-uwp` для пакетного Provider/Session Host; общее ядро | -Vole предоставляет нативные библиотеки и CLI; настройка системной сети остаётся обязанностью хоста. На Unix исходный дескриптор TUN принадлежит хосту; Vole использует и закрывает собственную копию. Публичный API packetFlow от Apple не гарантирует доступ к raw fd, поэтому интеграция с Network Extension и проверка на устройствах остаются ответственностью хоста. См. [интеграцию TUN](../docs/tun-platform.md) и [границы приёмки платформ](../docs/acceptance.md). +Vole предоставляет нативные библиотеки и CLI. На Apple, Android и Windows системной сетью и жизненным циклом приложения управляет хост; собственный TUN в Linux дополнительно поддерживает автоматические маршруты и системный DNS. На Unix исходный дескриптор TUN принадлежит хосту; Vole использует и закрывает собственную копию. Публичный API packetFlow от Apple не гарантирует доступ к raw fd, поэтому интеграция с Network Extension и проверка на устройствах остаются ответственностью хоста. См. [интеграцию TUN](../docs/tun-platform.md) и [границы приёмки платформ](../docs/acceptance.md). Настольный Wintun загружает предоставленный хостом `wintun.dll` из каталога исполняемого файла процесса. Адреса интерфейса, DNS, маршруты и физический выход настраивает хост; проверка Wintun на устройстве независима от результатов пакетного VPN. Использование CLI и поставка описаны в [документации CLI и релизов по тегам](../docs/cli.md). @@ -121,12 +122,13 @@ void VoleFree(char *response); Он измеряет смешанный TCP/UDP-трафик на **1 / 1,5 / 2 Гбит/с** с **1 000 DNS-запросов/с** и расширенными правилами `geosite:cn` / `geoip:cn`, показывая фактическую пропускную способность, CPU, наблюдаемый пиковый Linux RSS, потери UDP-пакетов и успешные DNS-запросы. Проверяется путь TUN/DNS/маршрутизации с выходом через DIRECT, а не пропускная способность зашифрованных прокси; Linux RSS не отражает потребление памяти Apple Network Extension. -Отдельная команда `stress` использует те же полные категории `geosite:cn` / `geoip:cn` с нагрузкой 2 Гбит/с, 60 секунд и 1 000 DNS-запросов/с. Исходные DAT загружаются без усечения, но в память выбираются только категории CN. Опция `--geodata-update` добавляет реальную загрузку и перезагрузку во время трафика. Типы входных записей, измерения и ошибки приведены в README benchmark. Отдельное наблюдение не гарантирует память ниже 50 000 000 байт для произвольного входа и не заменяет проверку на устройствах Apple. +Отдельная команда `stress` использует те же полные расширенные категории CN. CLI проверяет загрузку DAT при запуске и не предоставляет режим измерений `--geodata-update` во время работы. Фактические входные данные, метрики и ошибки приведены в результатах benchmark. ## Документация - [Указатель документации](../docs/README.md) - [Справочник конфигурации](../docs/config.yaml) +- [Границы совместимости с Mihomo](../docs/mihomo-compatibility.md) - [Сборка ядра и платформенных артефактов](../scripts/README.md) - [CLI и релизы по тегам](../docs/cli.md) - [Регрессионные тесты ядра](../tests/README.md) diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 0585008..a1a4a51 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -11,16 +11,16 @@ Vole 是用于 VPN 客户端和本地代理的 Rust 代理内核,提供原生 ## 内核能做什么 - **接入应用与 VPN 流量:**HTTP 转发、CONNECT 和 Upgrade;SOCKS5 CONNECT 和 UDP ASSOCIATE;宿主提供的 IPv4/IPv6 TUN 数据包。 -- **按目标路由:**支持域名、域名后缀/关键字、IP CIDR、目标端口、TCP/UDP、GeoSite 和 GeoIP 规则,以及明确的 DIRECT 和 REJECT 动作。 -- **选择和串联代理:**支持嵌套 `select` 组、实时切换组选择,以及引用节点或组的 `dialer-proxy` 代理链。选择变化只影响新建的物理传输连接,不迁移既有连接。 -- **处理 DNS:**支持指定出口的 UDP/TCP 上游、基于 GeoSite 的上游策略、顺序故障转移、缓存与重复查询合并;按配置拦截 TUN TCP/UDP DNS 目标,默认 53 端口。 -- **识别流量用于路由:**通过 HTTP、TLS、QUIC 域名嗅探与 TUN DNS 提示辅助路由,不改写实际目标地址。ICMPv4/ICMPv6 Echo 在本地应答。 -- **管理路由数据:**按需从 `geosite.dat` / `geoip.dat` 加载被引用的类别,并通过配置的路由更新文件。GeoSite 支持 Domain、Full、Plain 和 Regex、属性交集及反选;GeoIP 支持反选。所有平台完整保留所选记录,不设条数硬上限或截断,也不设 GeoData 总内存预算。详见 [GeoData 边界](../docs/geodata.md#内存与安全边界)。 -- **提供客户端控制:**回环 Controller 支持代理组选择及 TUN 流量速率/累计量查询;Invoke API 提供隔离的节点/代理链延迟测量。 +- **路由流量:**支持 rule/global/direct 模式,域名、来源/目标 IP 与端口、TCP/UDP、GeoSite/GeoIP、规则集合及逻辑规则,提供 DIRECT 和 REJECT 动作。 +- **选择和串联代理:**嵌套 select/url-test/fallback/load-balance 组、健康检查与选择持久化;`dialer-proxy` 可引用节点或组。既有连接保留原路径。 +- **处理 DNS:**hosts、独立 bootstrap/节点/直连解析角色,UDP/TCP/DoH/DoT、域名/GeoSite/rule-set policy、fallback、有界缓存、redir-host 和持久 fake-IP;按配置接管 TUN DNS。 +- **识别流量用于路由:**TUN/mixed TCP 的 HTTP/TLS 嗅探与 TUN UDP 的 QUIC 嗅探,支持强制/排除条件和目标覆盖开关;本地应答 ICMPv4/ICMPv6 Echo。 +- **管理路由数据:**HTTP/file/inline 节点与规则 providers、动态组、筛选和覆盖;YAML/Text 及 domain/ipcidr MRS。GeoData 完整加载引用类别,支持指定出口下载和周期更新,坏更新保留旧状态。详见 [GeoData 边界](../docs/geodata.md#内存与安全边界)。 +- **提供客户端控制:**组与资源状态、连接快照与关闭、规则命中、有效配置和受限 DNS 诊断,以及 TUN 流量和隔离的节点/代理链延迟测量。 ## 代理协议 -默认构建启用下列全部八种协议。UDP 支持按节点配置。 +默认构建启用下列全部九种协议。HTTP CONNECT 仅支持 TCP;其他协议的 UDP 能力按节点配置。 | 协议 | 能力 | | --- | --- | @@ -29,6 +29,7 @@ Vole 是用于 VPN 客户端和本地代理的 Rust 代理内核,提供原生 | [Trojan](../docs/outbounds.md#trojan) | 通过 TLS TCP、WebSocket / HTTPUpgrade 或 gRPC 传输 TCP/UDP | | [Shadowsocks 2022](../docs/outbounds.md#shadowsocks-2022) | TCP/UDP;AES-128-GCM、AES-256-GCM 和 ChaCha20-Poly1305;AES 身份链;可选 strict ShadowTLS v3 TCP 包装和 UoT v2 | | [AnyTLS](../docs/outbounds.md#anytls) | TLS 会话与 UDP over TCP v2 | +| [HTTP / HTTPS CONNECT](../docs/outbounds.md#http--https-connect) | TCP CONNECT、可选代理认证与 TLS | | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT 和 UDP ASSOCIATE,可选用户名/密码认证 | | [Hysteria2](../docs/outbounds.md#hysteria2) | QUIC TCP/UDP、带宽控制、Salamander、端口跳跃和 mTLS | | [TUIC v5](../docs/outbounds.md#tuic-v5) | QUIC TCP/UDP、native/quic UDP 转发模式,以及可选的拥塞控制算法 | @@ -70,13 +71,13 @@ rules: - MATCH,Proxy ``` -请替换示例中的服务器地址与凭据。GeoSite/GeoIP 规则需要 `/geodata` 下的对应数据文件;缺失文件时,相应规则类型不可用。详见[完整配置参考](../docs/config.yaml)与 [GeoData 行为](../docs/geodata.md)。 +请替换示例中的服务器地址与凭据。GeoSite/GeoIP 使用 `/geodata` 下的数据文件;缺失的必需资产通过配置出口下载,加载成功前对应规则不可用。详见[配置参考](../docs/config.yaml)与 [GeoData 行为](../docs/geodata.md)。 `mixed-port` 在同一 TCP 端口接入 HTTP 与 SOCKS5,并在同端口启用 SOCKS5 UDP。`allow-lan` 控制绑定地址,与 `authentication` 独立:`authentication` 省略或为 `[]` 时,回环与通配绑定均免认证;配置凭据后 HTTP 与 SOCKS5 均校验。顶层 `port`、`socks-port`、`udp` 和 `listeners` 都会拒绝;代理节点的 `port` 与 `udp` 保留出站含义。 -兼容范围限于文档列出的字段和行为,不覆盖任意 Mihomo 配置。代理组目前支持静态 `select`;DNS 上游使用固定 IP,通过 UDP/TCP 查询。Providers、自动代理组选择、加密 DNS 和 fake-IP 不在当前功能范围内。未知字段与无效组合会被拒绝,不会静默忽略;Vole 特有的语义会在相应契约中说明。 +兼容范围限于文档列出的 Mihomo 配置子集。未知字段与不支持的组合明确拒绝;当前平台和协议边界见 [Mihomo 兼容范围](../docs/mihomo-compatibility.md)与各项契约。 -库接口由宿主使用内联 `configYaml` 启动实例;TUN 设备、描述符、MTU、DNS 拦截和 UDP 超时归属于 `tun` 配置,平台回调仍在运行时本地注册。CLI 通过同一 Invoke API 传递参数,由其前台操作读取 `-f` 指定的配置。接口地址、DNS 与系统路由由宿主配置。 +库接口由宿主使用内联 `configYaml` 启动实例;TUN 设备、描述符、MTU、DNS 拦截和 UDP 超时归属于 `tun` 配置,平台回调仍在运行时本地注册。CLI 通过同一 Invoke API 传递参数,由其前台操作读取 `-f` 指定的配置。Linux 自建 TUN 可配置自动路由与 systemd-resolved DNS;借用 fd 和 App 平台的网络仍由宿主管理。 ## CLI @@ -97,10 +98,10 @@ vole -t -f ./config.yaml | iOS / macOS | 宿主提供 utun 文件描述符;macOS 也可打开或创建原生 utun | | tvOS 17+ | 宿主提供 utun 文件描述符;支持 ARM64 真机和模拟器目标 | | Android | `VpnService` 文件描述符与出站 socket 保护 | -| Linux | 借用 fd 或由内核打开设备的真实单队列 raw-IP TUN;宿主负责系统网络配置 | +| Linux | 借用 fd 或由内核打开设备的真实单队列 raw-IP TUN;Linux 自建设备支持自动路由与系统 DNS | | Windows | 编译时互斥的 `windows-wintun` 桌面后端与 `windows-uwp` 安装包 Provider/Session Host;共用内核 | -Vole 提供原生库和前台 CLI;系统网络配置仍由平台宿主负责。Unix 宿主持有原始 TUN 描述符;Vole 使用并关闭自己复制的描述符。Apple 公开的 packetFlow API 不保证可获取 raw fd,因此实际 Network Extension 集成和设备验证仍由宿主负责。详见 [TUN 集成](../docs/tun-platform.md)与[平台验收边界](../docs/acceptance.md)。 +Vole 提供原生库和前台 CLI。Apple、Android、Windows 的系统网络与 App 生命周期由宿主负责;Linux 自建 TUN 另外支持自动路由和系统 DNS。Unix 宿主持有原始 TUN 描述符;Vole 使用并关闭自己复制的描述符。Apple 公开的 packetFlow API 不保证可获取 raw fd,因此实际 Network Extension 集成和设备验证仍由宿主负责。详见 [TUN 集成](../docs/tun-platform.md)与[平台验收边界](../docs/acceptance.md)。 桌面 Wintun 从进程可执行文件所在目录加载宿主提供的 `wintun.dll`。接口地址、DNS、路由与物理出口由宿主配置;Wintun 设备验收独立于已有安装包 VPN 结果。命令行使用与交付见 [CLI 与 tag 发布](../docs/cli.md)。 @@ -121,12 +122,13 @@ benchmark 工程同时负责协议互通(`interop`)与内存压力(`stress 测试使用 **1 / 1.5 / 2 Gbps** 混合 TCP/UDP 流量、**每秒 1,000 次 DNS 查询**和增强的 `geosite:cn` / `geoip:cn` 规则,报告实际吞吐量、CPU、观察到的 Linux 峰值 RSS、UDP 丢包与成功的 DNS 查询数。它使用 DIRECT 出口评估 TUN/DNS/路由路径,不衡量加密代理吞吐量;Linux RSS 不等同于 Apple Network Extension 内存占用。 -独立 `stress` 命令使用同样的完整增强 `geosite:cn` / `geoip:cn`,叠加 2 Gbps / 60 秒 / 1,000 QPS DNS。原始 DAT 不裁剪,但只加载 CN 分类;可选 `--geodata-update` 在流量期间执行真实下载与重载。实际输入类型、测量和失败见 benchmark README。任何单次观测都不是任意输入低于 50,000,000 字节的保证,也不替代 Apple 真机验收。 +独立 `stress` 使用相同的完整增强 CN 分类。CLI 压力入口验证启动时 DAT 加载,不提供 `--geodata-update` 运行时采样。实际输入、指标和失败以 benchmark 运行记录为准。 ## 文档 - [文档索引](../docs/README.md) - [配置参考](../docs/config.yaml) +- [Mihomo 兼容范围](../docs/mihomo-compatibility.md) - [核心与平台构建](../scripts/README.md) - [CLI 与 tag 发布](../docs/cli.md) - [核心回归测试](../tests/README.md) From b5b4fb68302a5ff7388a4f6e2cc5018056796539 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 21:19:07 +0800 Subject: [PATCH 019/445] Enable business diagnostics only with the runtime Controller Avoid per-target UDP tracking and receive traversal when no Controller can query or close connections. Preserve diagnostics for configured Controllers and keep group health, resource updates and raw TUN counters independent. Validated a failing then passing memory assembly regression, routing/provider memory cases, Clippy and all-target compilation. The unchanged full-CN 1 Gbps reproduction improved from 681 to 997 Mbps over 15 seconds with zero UDP loss; the pre-development control reached 993 Mbps. --- .github/workflows/test.yml | 1 + docs/controller-api.md | 2 +- src/runtime.rs | 70 ++++++++++++++++++++++++++++++-------- tests/README.md | 1 + 4 files changed, 59 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1bd8ff..b4bae2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -218,6 +218,7 @@ jobs: dns::runtime::tests::dns_policy_selection_is_consistent_across_snapshot_swaps runtime::tests::prepares_builtin_routes_without_static_nodes_or_bootstrap runtime::tests::prepares_udp_capabilities_through_runtime_wrappers + runtime::tests::controller_diagnostics_are_opt_in_for_business_routes invoke::tests::null_invalid_utf8_and_oversized_input_return_json_failures invoke::tests::same_instance_command_is_fail_fast invoke::tests::android_protector_is_required_only_for_tun diff --git a/docs/controller-api.md b/docs/controller-api.md index a565e04..fc24876 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -9,7 +9,7 @@ external-controller: 127.0.0.1:9090 secret: "vole-runtime-secret" ``` -- 省略 `external-controller` 表示不启动 Controller,此时不能单独配置 `secret`。含代理组但没有 Controller 的配置合法,组在本次 session 内保持初始选择。 +- 省略 `external-controller` 表示不启动 Controller,也不采集业务连接、规则命中和建链诊断,此时不能单独配置 `secret`。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 - `external-controller` 只接受带显式非零端口的回环 IP 地址。它要求启用 TUN 或至少显式定义一个 `proxy-groups`;非 TUN 配置须通过 `mixed-port` 满足运行配置的入站要求。 - 只要同时显式配置代理组与 Controller,`secret` 就必填;仅有 TUN 流量接口时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 diff --git a/src/runtime.rs b/src/runtime.rs index d759343..f3e8be5 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -720,20 +720,21 @@ impl GraphBuilder { &rules, self.connections.clone(), )); - let router: Arc = Arc::new( - RoutingDispatcher::new_with_ipv6( - route_targets.clone(), - direct.clone(), - dns.clone(), - self.config.ipv6, - rules, - geo_matcher, - ) - .with_tun_config(&self.config.tun) - .with_sniffer(&self.config.sniffer) - .with_tcp_concurrent(self.config.connections.tcp_concurrent) - .with_diagnostics(diagnostics.clone()), - ); + let mut router = RoutingDispatcher::new_with_ipv6( + route_targets.clone(), + direct.clone(), + dns.clone(), + self.config.ipv6, + rules, + geo_matcher, + ) + .with_tun_config(&self.config.tun) + .with_sniffer(&self.config.sniffer) + .with_tcp_concurrent(self.config.connections.tcp_concurrent); + if self.config.external_controller.is_some() { + router = router.with_diagnostics(diagnostics.clone()); + } + let router: Arc = Arc::new(router); let session_stats = RuntimeResourceStats::new("runtime_session_observation"); Ok(BuiltRuntimeParts { diagnostics, @@ -1958,6 +1959,47 @@ rules: ) } + #[tokio::test] + async fn controller_diagnostics_are_opt_in_for_business_routes() { + for controller in [false, true] { + let mut yaml = String::from( + "mixed-port: 1080\nproxy-groups: [{name: local, type: select, proxies: [REJECT]}]\nrules: ['MATCH,REJECT']\n", + ); + if controller { + yaml.push_str("external-controller: 127.0.0.1:9090\nsecret: test-token\n"); + } + let prepared = + PreparedCore::prepare(yaml.as_bytes(), &FixedResolver, ResourceLimits::default()) + .await + .unwrap(); + let parts = prepared.build_dispatcher(Dialer::default()).unwrap(); + let result = parts + .dispatcher + .connect_tcp(crate::session::StreamSession { + inbound: crate::session::InboundKind::Socks5, + source: "127.0.0.1:12345".parse().unwrap(), + destination: crate::session::Destination::Ip("192.0.2.1:443".parse().unwrap()), + sniffed_domain: None, + }) + .await; + assert!(matches!( + result, + Err(crate::dispatch::DispatchError::NotAllowed) + )); + // REJECT exercises the assembled business path without opening a socket. + let snapshot = parts.diagnostics.connections.snapshot(); + assert_eq!( + snapshot["errors"].as_array().unwrap().len(), + usize::from(controller) + ); + assert_eq!( + parts.diagnostics.rules()["rules"][0]["extra"]["hitCount"], + usize::from(controller) + ); + parts.proxy_graph.shutdown().await; + } + } + #[tokio::test] async fn prepares_udp_capabilities_through_runtime_wrappers() { let prepared = PreparedCore::prepare( diff --git a/tests/README.md b/tests/README.md index f10c4d7..082fc78 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,6 +19,7 @@ cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --locked --all-features --lib connections::tests:: cargo test --locked --all-features --lib routing::diagnostics::tests:: cargo test --locked --all-features --lib managed_udp_connections_ +cargo test --locked --all-features --lib runtime::tests::controller_diagnostics_are_opt_in_for_business_routes cargo test --locked --all-features --lib routing::balance::tests:: cargo test --locked --all-features --lib load_balance_routes_ cargo test --locked --all-features --lib dialer::connections::tests:: From cedbd0cade96aaf41fdbe48ed6d465ca2c111398 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 21:42:37 +0800 Subject: [PATCH 020/445] Reconcile dynamic resource and group lifecycle contracts --- docs/geodata.md | 2 +- docs/invoke-api.md | 6 +++--- docs/runtime-resource-policy.md | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/geodata.md b/docs/geodata.md index c7d30f8..86da137 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -25,7 +25,7 @@ GEOIP,[!],[,no-resolve] ``` - 规则类型和 `code` 不区分 ASCII 大小写,route target 名称大小写敏感并精确匹配。 -- `target` 只能是 `DIRECT`、`REJECT`、实际代理节点名或静态 `select` 组名。 +- `target` 可以是 `DIRECT`、`REJECT`、当前资源中的代理节点或已支持的代理组。 - 基础 `code` 必须匹配 `[A-Za-z0-9][A-Za-z0-9._+!-]{0,63}`,按 ASCII 大小写折叠后定位。 - `GEOSITE` 恰好三段;`GEOIP` 只允许可选且大小写敏感的 `no-resolve`。 - `GEOSITE,cn@ads@cn,DIRECT` 只保留同时存在 `ads` 和 `cn` 属性 key 的记录,多个属性为 AND。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 4d3f8c5..44dcd34 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -90,7 +90,7 @@ stopped -> preparing -> prepared -> starting -> running - `validateConfig` 是可并发的纯校验方法,不要求或执行 `initialize`。 - `measureDelay` 使用独立批次和私有工作器,不进入公共实例表。 - 公开生命周期为 `initialize -> createInstance -> start(configYaml) -> stop -> destroyInstance`。`preparing`、`prepared` 和 `starting` 是 `start` 内部阶段,没有公共 `prepare` 方法。 -- 不支持配置热重载;切换配置需要 `stop -> start(新 configYaml)`。唯一的运行期路由变更是通过 Controller 修改静态 `select` 组的当前选择,它不修改启动配置。 +- 不支持配置整体热重载;切换配置需要 `stop -> start(新 configYaml)`。运行期间可更新 providers、自动选择健康成员或通过 Controller 切组;这些操作不修改启动 YAML,既有连接保留原资源和路径。 - `stop` 在 stopped 状态幂等;`destroyInstance` 是最终同步清理屏障。 ## 方法 @@ -212,7 +212,7 @@ TUN 资源参数来自 YAML 的 `tun`,平台包格式由内核选择。 - 读取当时可用的本地 GeoData,不启动或等待下载。 - 准备阶段只构建并校验代理图;实际建立物理首跳时,通过运行时节点 resolver 解析服务器及独立 VLESS 下载端点。未选中的备用节点不会因 DNS 故障阻塞启动。仅经代理访问的域名交给下一跳;系统解析仅用于 DNS 关闭的配置。 -- 校验每个静态 `select` 组的初始选择;省略 `default-selected` 时使用第一项,显式值必须是直接成员。启动时创建本次 session 的可变选择状态。 +- 按当前资源构建代理组。`profile.store-selected` 开启时优先恢复仍有效的缓存选择,否则使用有效的 `default-selected` 或首成员;动态空组使用 `empty-fallback`。自动组随后依据健康探测选择成员。 - 含 TUN 的配置从内部 preparing 阶段起持有运行时本地的 TUN/protect 租约,直到停止或销毁。Android 必须在 `start` 前注册 protect controller。 - 所有监听器和关键数据面成功后才进入 running。准备、资源获取或启动失败均清理本次临时资源并回到 stopped;重试需要再次提交完整 `configYaml`。 - GeoData 更新只在启动后按需后台运行,不属于启动关键路径。 @@ -391,7 +391,7 @@ ProtectFd(fd) -> bool 配置 `external-controller` 后,运行时可提供回环 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的 `mixed-port` 配置中运行;此时 `/traffic` 不存在。只要 Controller 管理代理组,`secret` 就必填并保护全部路由。 -组成员列表是配置期固定的,选择只存于当前 Running Session。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不触发 failover。已认证 Hysteria2 会话的后续逻辑流和跳端口 socket 保留初始上游选择;新的认证会话才读取新选择。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 +静态成员来自配置,动态成员随 provider 成功更新;显式选择按 `profile.store-selected` 保存。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool。自动组根据健康状态处理后续连接的选择与恢复;select 的连接失败按原样返回。已认证 Hysteria2 会话的后续逻辑流和跳端口 socket 保留初始上游选择;新的认证会话才读取新选择。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 ## Windows 安装包桥接 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 41235e8..652e0f5 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -146,7 +146,8 @@ IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不 ## 代理组与 Controller -- 静态 `select` 组只拥有不可变的有序成员和每组一个可原子替换的当前索引,不拥有协议连接、后台任务、队列、缓存或独立 worker。 +- 每个资源代际内的组成员保持有序且不可变;组保存当前选择、健康与策略状态,协议连接仍由节点拥有。健康探测由该资源代际统一调度,最多并发 10 项,停止时取消并等待完成。 +- provider 候选整体验证后发布,既有会话保留原资源代际;不再被使用的旧代际停止探测并释放资源。选择与 fake-IP 持久化由各自的存储所有者管理。 - 组数、每组成员数、重复成员数和嵌套深度没有独立固定上限,统一受 8 MiB YAML 输入与 32 MiB 展开计费上限约束;配置期使用 O(V+E) 的迭代 DAG 校验,运行时也以迭代方式解析当前叶节点。 - 节点上游和全部组成员在同一 DAG 中校验;组连接器只持有声明依赖,按逆依赖顺序释放。建链快照只按需记录本次遇到的组,不成为全局缓存。Hysteria2 的已认证连接为后续端口跳跃保留该快照,直到此物理会话退出;它不再读取实时选择,也不延长原建链期限。 - 同组成功选择是线性化的,不同组独立;选择失败不改变状态,也不创建重试或自动 failover 任务。 From fd2656f51ca2ad7f510ad46ec1b6687d6c3628d9 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 21:47:25 +0800 Subject: [PATCH 021/445] Count remote HTTP proxy rejections as connection failures Keep CONNECT rejection distinct from local policy denial so automatic groups include failed HTTP proxy setup in health feedback. Cover 407 and 503 with memory I/O; verify threshold-triggered probes against both production CLIs in isolated containers. --- src/outbound/http.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/outbound/http.rs b/src/outbound/http.rs index d0a1633..952f0c2 100644 --- a/src/outbound/http.rs +++ b/src/outbound/http.rs @@ -116,8 +116,10 @@ async fn handshake( continue; } if status != 200 { + // A remote rejection is a failed proxy connection. PermissionDenied + // becomes NotAllowed, which group health excludes as a local policy. return Err(io::Error::new( - io::ErrorKind::PermissionDenied, + io::ErrorKind::ConnectionRefused, "HTTP CONNECT was rejected", )); } @@ -192,7 +194,7 @@ mod tests { } #[tokio::test] async fn connect_auth_and_prebuffer_are_preserved() { - for status in [200, 407] { + for status in [200, 407, 503] { let (client, mut peer) = tokio::io::duplex(4096); let remote = tokio::spawn(async move { let mut request = Vec::new(); @@ -224,7 +226,7 @@ mod tests { } else { assert_eq!( result.err().unwrap().kind(), - io::ErrorKind::PermissionDenied + io::ErrorKind::ConnectionRefused ); } remote.await.unwrap(); From 29e375268eecd6721ff0c47e241c5ca50aa812bb Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 22:03:54 +0800 Subject: [PATCH 022/445] Clarify lazy resolution of XHTTP download endpoints --- docs/xhttp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/xhttp.md b/docs/xhttp.md index 48e87c6..92e8039 100644 --- a/docs/xhttp.md +++ b/docs/xhttp.md @@ -58,7 +58,7 @@ packet-up 在发送间隔内聚合小块写入,而不是把每个 write 变成 - jls-opts 缺省继承整个身份,非空对象必须完整替换 username/password,`{}` 清除;同腿与 REALITY 和证书策略互斥,切换时须显式清除旧对象。JLS 仅 H1/H2,实际 TLS1.3,无恢复;完整约束见 [JLS](vless.md#jls)。 - ech-opts 缺省继承、非空对象整体替换,启用必须同时提供 enable=true 和静态 config;`{}` 清除。仅标准 TLS,可用于 H1/H2/H3。没有动态查询或自动重试;切换到 REALITY/JLS/明文前必须显式清除继承的 ECH。 - 切换到明文或 REALITY 不会悄悄丢弃继承的证书策略;须显式清除冲突字段。每条腿独立重新校验最终安全配置。 -- 独立地址/端口在 prepare 时分别准备;两腿共享原绝对期限和每个上游组的选择快照,下载失败不会另起超时预算或绕过原图。 +- 独立地址/端口在实际建立物理连接时通过节点 resolver 分别解析;两腿共享原绝对期限和每个上游组的选择快照,下载失败不会另起超时预算或绕过原图。 ## HTTP transport 复用 From 6cc8245cff57f2efc6b19c421d816fb67c2127b5 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 23:10:24 +0800 Subject: [PATCH 023/445] perf: reduce connection diagnostics allocation and snapshot cost --- docs/controller-api.md | 4 + src/connections.rs | 430 ++++++++++++++++++++++++++++++++----- src/controller.rs | 3 +- src/routing/diagnostics.rs | 217 +++++++++++++++---- src/routing/dispatcher.rs | 64 ++++-- src/runtime.rs | 28 +-- src/runtime/providers.rs | 21 +- 7 files changed, 634 insertions(+), 133 deletions(-) diff --git a/docs/controller-api.md b/docs/controller-api.md index fc24876..af83f40 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -174,6 +174,10 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 - 扩展字段 `errors` 最多保留最近 32 次建链/数据报失败的连接信息与固定错误码, 不返回底层错误原文。`memory` 当前为 0,表示未采集进程 RSS。 +连接元数据保存原生地址、端口和时间,重复的节点、组及规则文本共享存储。 +查询只在短锁内取得连接引用,再固定计数和 DNS 路径并直接序列化;快照不保活 +业务 I/O 或资源代际。字节计数即时饱和累计,零字节 I/O 不更新计数。 + `DELETE /connections/{id}` 取消指定连接,`DELETE /connections` 取消当前全部连接。 均返回 204,未知 ID 同样返回 204;取消唤醒正在等待的 I/O 并释放对应流资源。 这不禁止用户重新建连,也不重置累计流量。正常 stop 会取消全部连接并沿既有停止屏障回收。 diff --git a/src/connections.rs b/src/connections.rs index af9c61d..4207f8d 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -1,10 +1,14 @@ //! Running-session connection ownership and opt-in Controller snapshots. use crate::dispatch::{BoxStream, DispatchError}; -use serde_json::{Value, json}; +use serde::{ + Serialize, Serializer, + ser::{SerializeSeq, SerializeStruct}, +}; use std::{ collections::{BTreeMap, VecDeque}, future::Future, io, + net::{IpAddr, SocketAddr}, pin::Pin, sync::{ Arc, Mutex, Weak, @@ -15,34 +19,183 @@ use std::{ use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio_util::sync::CancellationToken; +/// Only owned presentation data is retained, never its resource generation. +pub(crate) struct ConnectionInfo { + pub metadata: ConnectionMetadata, + pub chains: Vec>, + pub provider_chains: Vec>, + pub rule: &'static str, + pub rule_payload: Arc, + pub dns_path: Vec<&'static str>, +} +pub(crate) struct ConnectionMetadata { + pub network: &'static str, + pub inbound_type: &'static str, + pub source: SocketAddr, + pub destination_ip: Option, + pub destination_port: u16, + pub host: Arc, + pub dns_mode: &'static str, + pub sniff_host: Arc, +} +impl Serialize for ConnectionMetadata { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("ConnectionMetadata", 9)?; + state.serialize_field("network", self.network)?; + state.serialize_field("type", self.inbound_type)?; + state.serialize_field("sourceIP", &self.source.ip())?; + state.serialize_field("sourcePort", &Port(self.source.port()))?; + state.serialize_field("destinationIP", &self.destination_ip)?; + state.serialize_field("destinationPort", &Port(self.destination_port))?; + state.serialize_field("host", self.host.as_ref())?; + state.serialize_field("dnsMode", self.dns_mode)?; + state.serialize_field("sniffHost", self.sniff_host.as_ref())?; + state.end() + } +} +struct Port(u16); +impl Serialize for Port { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(&self.0) + } +} +struct Details { + metadata: ConnectionMetadata, + chains: Box<[Arc]>, + provider_chains: Box<[Arc]>, + rule: &'static str, + rule_payload: Arc, +} +impl Details { + fn serialize_fields( + &self, + state: &mut S, + dns_path: &[&'static str], + ) -> Result<(), S::Error> { + state.serialize_field("metadata", &self.metadata)?; + state.serialize_field("chains", &SharedSlice(&self.chains))?; + state.serialize_field("providerChains", &SharedSlice(&self.provider_chains))?; + state.serialize_field("rule", self.rule)?; + state.serialize_field("rulePayload", self.rule_payload.as_ref())?; + state.serialize_field("dnsPath", dns_path) + } +} #[derive(Default)] -pub(crate) struct Registry { - entries: Mutex>>, - failures: Mutex>, +struct SessionCounters { upload: AtomicU64, download: AtomicU64, +} +#[derive(Default)] +pub(crate) struct Registry { + entries: Mutex>>, + failures: Mutex>>, + totals: Arc, closed: AtomicBool, } struct Entry { - metadata: Mutex, - start: String, + id: uuid::Uuid, + details: Arc
, + dns_path: Mutex>, + start: Timestamp, upload: AtomicU64, download: AtomicU64, cancel: CancellationToken, } +struct ConnectionSnapshot { + id: uuid::Uuid, + details: Arc
, + dns_path: Arc<[&'static str]>, + start: Timestamp, + upload: u64, + download: u64, +} +impl ConnectionSnapshot { + fn capture(entry: &Entry) -> Self { + Self { + id: entry.id, + details: entry.details.clone(), + dns_path: entry.dns_path.lock().unwrap().clone(), + start: entry.start, + upload: entry.upload.load(Ordering::Relaxed), + download: entry.download.load(Ordering::Relaxed), + } + } +} +impl Serialize for ConnectionSnapshot { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("Connection", 10)?; + self.details.serialize_fields(&mut state, &self.dns_path)?; + state.serialize_field("id", &self.id)?; + state.serialize_field("start", &self.start)?; + state.serialize_field("upload", &self.upload)?; + state.serialize_field("download", &self.download)?; + state.end() + } +} +struct Failure { + id: uuid::Uuid, + details: Arc
, + dns_path: Arc<[&'static str]>, + time: Timestamp, + error: &'static str, +} +impl Serialize for Failure { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("ConnectionFailure", 9)?; + self.details.serialize_fields(&mut state, &self.dns_path)?; + state.serialize_field("id", &self.id)?; + state.serialize_field("time", &self.time)?; + state.serialize_field("error", self.error)?; + state.end() + } +} +/// The query freezes values outside the registry lock and shares immutable text. +/// It can outlive removal without extending transport/cancellation ownership. +pub(crate) struct Snapshot { + connections: Vec, + failures: Vec>, + upload: u64, + download: u64, +} +impl Serialize for Snapshot { + fn serialize(&self, serializer: S) -> Result { + let mut state = serializer.serialize_struct("Connections", 5)?; + state.serialize_field("uploadTotal", &self.upload)?; + state.serialize_field("downloadTotal", &self.download)?; + state.serialize_field("connections", &self.connections)?; + state.serialize_field("memory", &0u8)?; + state.serialize_field("errors", &SharedSlice(&self.failures))?; + state.end() + } +} +struct SharedSlice<'a, T: ?Sized>(&'a [Arc]); +impl Serialize for SharedSlice<'_, T> { + fn serialize(&self, serializer: S) -> Result { + let mut sequence = serializer.serialize_seq(Some(self.0.len()))?; + for item in self.0 { + sequence.serialize_element(item.as_ref())?; + } + sequence.end() + } +} pub(crate) struct Connection { - id: String, entry: Arc, + totals: Arc, registry: Weak, } impl Registry { - pub fn open(self: &Arc, metadata: Value) -> Connection { - let id = uuid::Builder::from_random_bytes(rand::random()) - .into_uuid() - .to_string(); + pub fn open(self: &Arc, info: ConnectionInfo) -> Connection { let entry = Arc::new(Entry { - metadata: Mutex::new(metadata), - start: timestamp(), + id: uuid::Builder::from_random_bytes(rand::random()).into_uuid(), + details: Arc::new(Details { + metadata: info.metadata, + chains: info.chains.into_boxed_slice(), + provider_chains: info.provider_chains.into_boxed_slice(), + rule: info.rule, + rule_payload: info.rule_payload, + }), + dns_path: Mutex::new(info.dns_path.into()), + start: Timestamp::now(), upload: AtomicU64::new(0), download: AtomicU64::new(0), cancel: CancellationToken::new(), @@ -51,33 +204,48 @@ impl Registry { if self.closed.load(Ordering::Acquire) { entry.cancel.cancel(); } else { - entries.insert(id.clone(), entry.clone()); + entries.insert(entry.id, entry.clone()); } Connection { - id, entry, + totals: self.totals.clone(), registry: Arc::downgrade(self), } } - pub fn snapshot(&self) -> Value { - let connections = self + pub fn snapshot(&self) -> Snapshot { + let entries = self .entries .lock() .unwrap() - .iter() - .map(|(id, entry)| { - let mut value = entry.metadata.lock().unwrap().clone(); - value["id"] = json!(id); - value["start"] = json!(entry.start); - value["upload"] = json!(entry.upload.load(Ordering::Relaxed)); - value["download"] = json!(entry.download.load(Ordering::Relaxed)); - value - }) + .values() + .cloned() .collect::>(); - json!({"uploadTotal": self.upload.load(Ordering::Relaxed), "downloadTotal": self.download.load(Ordering::Relaxed), "connections": connections, "memory": 0, "errors": self.failures.lock().unwrap().iter().cloned().collect::>()}) + let connections = entries + .into_iter() + .map(|entry| ConnectionSnapshot::capture(&entry)) + .collect(); + let failures = self.failures.lock().unwrap().iter().cloned().collect(); + Snapshot { + connections, + failures, + upload: self.totals.upload.load(Ordering::Relaxed), + download: self.totals.download.load(Ordering::Relaxed), + } } pub fn close(&self, id: &str) { - if let Some(entry) = self.entries.lock().unwrap().remove(id) { + let Ok(parsed) = uuid::Uuid::parse_str(id) else { + return; + }; + // Preserve exact canonical IDs: noncanonical spellings remain unknown. + if parsed + .hyphenated() + .encode_lower(&mut uuid::Uuid::encode_buffer()) + != id + { + return; + } + let entry = self.entries.lock().unwrap().remove(&parsed); + if let Some(entry) = entry { entry.cancel.cancel(); } } @@ -94,7 +262,7 @@ impl Registry { } impl Connection { pub fn dns_path(&self, path: Vec<&'static str>) { - self.entry.metadata.lock().unwrap()["dnsPath"] = json!(path); + *self.entry.dns_path.lock().unwrap() = path.into(); } pub fn token(&self) -> CancellationToken { self.entry.cancel.clone() @@ -103,16 +271,18 @@ impl Connection { self.entry.cancel.is_cancelled() } pub fn upload(&self, bytes: usize) { - saturating_add(&self.entry.upload, bytes); - if let Some(registry) = self.registry.upgrade() { - saturating_add(®istry.upload, bytes); + if bytes == 0 { + return; } + saturating_add(&self.entry.upload, bytes); + saturating_add(&self.totals.upload, bytes); } pub fn download(&self, bytes: usize) { - saturating_add(&self.entry.download, bytes); - if let Some(registry) = self.registry.upgrade() { - saturating_add(®istry.download, bytes); + if bytes == 0 { + return; } + saturating_add(&self.entry.download, bytes); + saturating_add(&self.totals.download, bytes); } pub fn failed(&self, error: &DispatchError) { let Some(registry) = self.registry.upgrade() else { @@ -125,15 +295,18 @@ impl Connection { DispatchError::NetworkUnreachable => "network_unreachable", _ => "outbound_failed", }; + let failure = Arc::new(Failure { + id: self.entry.id, + details: self.entry.details.clone(), + dns_path: self.entry.dns_path.lock().unwrap().clone(), + time: Timestamp::now(), + error: kind, + }); let mut failures = registry.failures.lock().unwrap(); if failures.len() == 32 { failures.pop_front(); } - let mut info = self.entry.metadata.lock().unwrap().clone(); - info["id"] = json!(self.id); - info["time"] = json!(timestamp()); - info["error"] = json!(kind); - failures.push_back(info); + failures.push_back(failure); } pub fn stream(self, inner: BoxStream) -> BoxStream { let cancelled = Box::pin(self.token().cancelled_owned()); @@ -147,7 +320,7 @@ impl Connection { impl Drop for Connection { fn drop(&mut self) { if let Some(registry) = self.registry.upgrade() { - registry.entries.lock().unwrap().remove(&self.id); + registry.entries.lock().unwrap().remove(&self.entry.id); } } } @@ -162,10 +335,23 @@ fn saturating_add(counter: &AtomicU64, bytes: usize) { old = value; } } -pub(crate) fn timestamp() -> String { - time::OffsetDateTime::now_utc() - .format(&time::format_description::well_known::Rfc3339) - .expect("current UTC date") +#[derive(Clone, Copy)] +struct Timestamp(i128); +impl Timestamp { + fn now() -> Self { + Self(time::OffsetDateTime::now_utc().unix_timestamp_nanos()) + } + fn format(&self) -> String { + time::OffsetDateTime::from_unix_timestamp_nanos(self.0) + .expect("recorded UTC date") + .format(&time::format_description::well_known::Rfc3339) + .expect("recorded UTC date") + } +} +impl Serialize for Timestamp { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.format()) + } } struct TrackedStream { connection: Connection, @@ -230,24 +416,53 @@ impl AsyncWrite for TrackedStream { #[cfg(test)] mod tests { use super::*; + use serde_json::{Value, json}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn info() -> ConnectionInfo { + ConnectionInfo { + metadata: ConnectionMetadata { + network: "tcp", + inbound_type: "Socks5", + source: "127.0.0.1:1234".parse().unwrap(), + destination_ip: Some("198.51.100.1".parse().unwrap()), + destination_port: 443, + host: "example.com".into(), + dns_mode: "normal", + sniff_host: "".into(), + }, + chains: vec!["edge".into(), "auto".into()], + provider_chains: vec!["nodes".into()], + rule: "DomainSuffix", + rule_payload: "example.com".into(), + dns_path: vec!["cache"], + } + } + fn view(registry: &Registry) -> Value { + serde_json::to_value(registry.snapshot()).unwrap() + } #[tokio::test] async fn connection_query_counts_and_close_wakes_blocked_io() { let registry = Arc::new(Registry::default()); - let connection = registry.open(json!({"metadata":{"network":"tcp"},"chains":["edge","auto"],"rule":"DomainSuffix","rulePayload":"example.com"})); - let id = connection.id.clone(); + let connection = registry.open(info()); + let id = connection.entry.id.to_string(); let (client, mut peer) = tokio::io::duplex(32); let mut stream = connection.stream(Box::new(client)); stream.write_all(b"sent").await.unwrap(); peer.write_all(b"ok").await.unwrap(); assert_eq!(stream.read_u16().await.unwrap(), u16::from_be_bytes(*b"ok")); - let snapshot = registry.snapshot(); + let snapshot = view(®istry); assert_eq!(snapshot["connections"][0]["upload"], 4); assert_eq!(snapshot["connections"][0]["download"], 2); assert_eq!( snapshot["connections"][0]["chains"], json!(["edge", "auto"]) ); + assert_eq!(snapshot["connections"][0]["metadata"]["sourcePort"], "1234"); + assert_eq!( + snapshot["connections"][0]["metadata"]["destinationPort"], + "443" + ); let waiting = tokio::spawn(async move { stream.read_u8().await }); tokio::task::yield_now().await; registry.close(&id); @@ -256,13 +471,126 @@ mod tests { io::ErrorKind::ConnectionAborted ); assert!( - registry.snapshot()["connections"] + view(®istry)["connections"] .as_array() .unwrap() .is_empty() ); - assert_eq!(registry.snapshot()["uploadTotal"], 4); + assert_eq!(view(®istry)["uploadTotal"], 4); registry.shutdown(); - assert!(registry.open(json!({})).cancelled()); + assert!(registry.open(info()).cancelled()); + } + + #[test] + fn query_snapshot_freezes_counters_and_dns_path_before_later_io() { + let registry = Arc::new(Registry::default()); + let connection = registry.open(info()); + connection.upload(3); + connection.download(5); + let snapshot = registry.snapshot(); + let before = serde_json::to_value(&snapshot).unwrap(); + connection.upload(7); + connection.download(11); + connection.dns_path(vec!["fallback"]); + connection.failed(&DispatchError::TimedOut); + let after = serde_json::to_value(&snapshot).unwrap(); + assert_eq!(after, before, "query data must not change after capture"); + assert_eq!(after["connections"][0]["upload"], 3); + assert_eq!(after["connections"][0]["download"], 5); + assert_eq!(after["connections"][0]["dnsPath"], json!(["cache"])); + assert_eq!(after["uploadTotal"], 3); + assert!(after["errors"].as_array().unwrap().is_empty()); + assert_eq!(view(®istry)["uploadTotal"], 10); + assert_eq!(view(®istry)["downloadTotal"], 16); + } + + #[test] + fn snapshot_shares_metadata_without_holding_registry_or_live_connection() { + let registry = Arc::new(Registry::default()); + let connection = registry.open(info()); + let details = Arc::downgrade(&connection.entry.details); + let entry = Arc::downgrade(&connection.entry); + let snapshot = registry.snapshot(); + assert!(Arc::ptr_eq( + &snapshot.connections[0].details, + &connection.entry.details + )); + registry.close_all(); + assert!(connection.cancelled()); + drop(connection); + assert!( + entry.upgrade().is_none(), + "a query must release the I/O cancellation owner" + ); + assert!( + view(®istry)["connections"] + .as_array() + .unwrap() + .is_empty() + ); + let weak_registry = Arc::downgrade(®istry); + drop(registry); + assert!(weak_registry.upgrade().is_none()); + assert_eq!( + serde_json::to_value(&snapshot).unwrap()["connections"][0]["chains"], + json!(["edge", "auto"]) + ); + drop(snapshot); + assert!(details.upgrade().is_none()); + } + + #[test] + fn exact_counters_saturate_and_outlive_registry_without_io_weak_upgrade() { + let registry = Arc::new(Registry::default()); + let connection = registry.open(info()); + connection.upload(0); + connection.download(0); + assert_eq!(view(®istry)["uploadTotal"], 0); + std::thread::scope(|scope| { + for _ in 0..6 { + scope.spawn(|| { + for _ in 0..1_000 { + connection.upload(1); + connection.download(2); + } + }); + } + }); + assert_eq!(view(®istry)["uploadTotal"], 6_000); + assert_eq!(view(®istry)["downloadTotal"], 12_000); + assert_eq!(connection.entry.upload.load(Ordering::Relaxed), 6_000); + assert_eq!(connection.entry.download.load(Ordering::Relaxed), 12_000); + connection + .entry + .upload + .store(u64::MAX - 1, Ordering::Relaxed); + registry + .totals + .upload + .store(u64::MAX - 1, Ordering::Relaxed); + connection.upload(4); + assert_eq!(connection.entry.upload.load(Ordering::Relaxed), u64::MAX); + assert_eq!(view(®istry)["uploadTotal"], u64::MAX); + let totals = registry.totals.clone(); + drop(registry); + connection.download(7); + assert_eq!(totals.download.load(Ordering::Relaxed), 12_007); + } + + #[test] + fn failure_history_is_bounded_and_keeps_its_dns_path_after_connection_updates() { + let registry = Arc::new(Registry::default()); + let connection = registry.open(info()); + for _ in 0..33 { + connection.failed(&DispatchError::TimedOut); + } + connection.dns_path(vec!["fallback"]); + let snapshot = view(®istry); + assert_eq!(snapshot["errors"].as_array().unwrap().len(), 32); + assert_eq!(snapshot["errors"][0]["error"], "timed_out"); + assert_eq!(snapshot["errors"][0]["dnsPath"], json!(["cache"])); + assert_eq!(snapshot["connections"][0]["dnsPath"], json!(["fallback"])); + assert!(snapshot["errors"][0].get("start").is_none()); + assert!(snapshot["errors"][0].get("upload").is_none()); } } diff --git a/src/controller.rs b/src/controller.rs index 2dda463..0a0433c 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -201,7 +201,8 @@ async fn handle_connection( }; let registry = resources.connections(); if request.method == "GET" && request.target == "/connections" { - return write_json(&mut stream, 200, ®istry.snapshot(), &[]).await; + let snapshot = registry.snapshot(); + return write_json(&mut stream, 200, &snapshot, &[]).await; } if request.method == "DELETE" { if let Some(raw_id) = request.target.strip_prefix("/connections/") { diff --git a/src/routing/diagnostics.rs b/src/routing/diagnostics.rs index 7eba656..e7b1539 100644 --- a/src/routing/diagnostics.rs +++ b/src/routing/diagnostics.rs @@ -4,69 +4,107 @@ use crate::{ routing::RuleSet, }; use serde_json::{Value, json}; -use std::sync::{ - Mutex, - atomic::{AtomicU64, Ordering}, +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, }; pub(crate) struct Diagnostics { - pub connections: std::sync::Arc, - pub proxy_names: Vec, - providers: std::collections::BTreeMap>, + pub connections: Arc, + pub proxy_names: Vec>, + providers: BTreeMap, Vec>>, + names: BTreeSet>, config: Value, rules: Vec, } struct Rule { kind: &'static str, - payload: String, - target: String, + payload: Arc, + target: Arc, hits: AtomicU64, - hit_at: Mutex>, + // A numeric UTC value; formatting belongs to the Controller query path. + hit_at: Mutex>, } impl Diagnostics { pub fn new( config: &Config, rules: &RuleSet, - connections: std::sync::Arc, + connections: Arc, ) -> Self { + // Intern only display text. Connections can retain these small strings without + // pinning a Config or an entire retired resource generation. + let mut texts = BTreeSet::new(); + let mut names = BTreeSet::new(); + for name in ["", "DIRECT", "REJECT"] + .into_iter() + .chain(config.proxies.iter().map(|p| p.tag.as_str())) + .chain(config.proxy_groups.iter().map(|g| g.name.as_str())) + .chain(config.provider_members.keys().map(String::as_str)) + { + names.insert(intern(&mut texts, name)); + } + let proxy_names = config + .proxies + .iter() + .map(|p| intern(&mut texts, &p.tag)) + .collect(); + let providers = config + .provider_members + .iter() + .map(|(name, members)| { + let mut members = members + .iter() + .map(|member| intern(&mut texts, member)) + .collect::>(); + members.sort_unstable(); + members.dedup(); + (intern(&mut texts, name), members) + }) + .collect(); + let rules = (0..rules.len()) + .filter_map(|i| rules.rule(i)) + .map(|spec| { + let (kind, payload) = rule_description(&spec.kind); + let target = match spec.action { + RuleAction::Direct => "DIRECT", + RuleAction::Reject => "REJECT", + RuleAction::Route(RouteTargetId::Proxy(id)) => &config.proxies[id.index()].tag, + RuleAction::Route(RouteTargetId::Group(id)) => { + &config.proxy_groups[id.index()].name + } + }; + Rule { + kind, + payload: intern(&mut texts, &payload), + target: intern(&mut texts, target), + hits: AtomicU64::new(0), + hit_at: Mutex::new(None), + } + }) + .collect(); Self { connections, - providers: config - .provider_members - .iter() - .map(|(name, members)| (name.clone(), members.clone())) - .collect(), - proxy_names: config.proxies.iter().map(|p| p.tag.clone()).collect(), + proxy_names, + providers, + names, config: effective_config(config), - rules: (0..rules.len()) - .filter_map(|i| rules.rule(i)) - .map(|spec| { - let (kind, payload) = rule_description(&spec.kind); - let target = match spec.action { - RuleAction::Direct => "DIRECT".into(), - RuleAction::Reject => "REJECT".into(), - RuleAction::Route(RouteTargetId::Proxy(id)) => { - config.proxies[id.index()].tag.clone() - } - RuleAction::Route(RouteTargetId::Group(id)) => { - config.proxy_groups[id.index()].name.clone() - } - }; - Rule { - kind, - payload, - target, - hits: AtomicU64::new(0), - hit_at: Mutex::new(None), - } - }) - .collect(), + rules, } } - pub fn providers(&self, chain: &[String]) -> Vec { + /// Reuse known route/provider names, without retaining dynamic destinations. + pub fn text(&self, text: &str) -> Arc { + self.names + .get(text) + .cloned() + .unwrap_or_else(|| Arc::from(text)) + } + pub fn providers(&self, chain: &[Arc]) -> Vec> { self.providers .iter() - .filter(|(_, members)| chain.iter().any(|node| members.contains(node))) + .filter(|(_, members)| chain.iter().any(|node| members.binary_search(node).is_ok())) .map(|(name, _)| name.clone()) .collect() } @@ -75,18 +113,41 @@ impl Diagnostics { } pub fn hit(&self, index: usize) { if let Some(rule) = self.rules.get(index) { - rule.hits.fetch_add(1, Ordering::Relaxed); - *rule.hit_at.lock().unwrap() = Some(crate::connections::timestamp()); + let mut old = rule.hits.load(Ordering::Relaxed); + while let Err(current) = rule.hits.compare_exchange_weak( + old, + old.saturating_add(1), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + old = current; + } + *rule.hit_at.lock().unwrap() = Some(time::OffsetDateTime::now_utc()); } } - pub fn matched(&self, index: usize) -> (&'static str, &str) { + pub fn matched(&self, index: usize) -> (&'static str, Arc) { self.rules .get(index) - .map_or(("", ""), |r| (r.kind, r.payload.as_str())) + .map_or_else(|| ("", self.text("")), |r| (r.kind, r.payload.clone())) } pub fn rules(&self) -> Value { - json!({"rules": self.rules.iter().enumerate().map(|(index, r)| json!({"index":index,"type":r.kind,"payload":r.payload,"proxy":r.target,"size":-1,"extra":{"disabled":false,"hitCount":r.hits.load(Ordering::Relaxed),"hitAt":*r.hit_at.lock().unwrap()}})).collect::>()}) + json!({"rules": self.rules.iter().enumerate().map(|(index, r)| { + let hit_at = *r.hit_at.lock().unwrap(); + let hit_at = hit_at.map(|time| { + time.format(&time::format_description::well_known::Rfc3339) + .expect("recorded UTC date") + }); + json!({"index":index,"type":r.kind,"payload":r.payload.as_ref(),"proxy":r.target.as_ref(),"size":-1,"extra":{"disabled":false,"hitCount":r.hits.load(Ordering::Relaxed),"hitAt":hit_at}}) + }).collect::>()}) + } +} +fn intern(texts: &mut BTreeSet>, text: &str) -> Arc { + if let Some(shared) = texts.get(text) { + return shared.clone(); } + let shared: Arc = text.into(); + texts.insert(shared.clone()); + shared } fn effective_config(config: &Config) -> Value { use crate::config::{InboundConfig, LogLevel, Mode}; @@ -170,6 +231,70 @@ fn logical<'a>(rules: impl Iterator) -> String { #[cfg(test)] mod tests { use super::*; + #[test] + fn shared_diagnostic_text_outlives_only_its_small_value() { + let mut config = Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: [{name: edge, type: socks5, server: 192.0.2.1, port: 1080}] +proxy-groups: [{name: pool, type: select, proxies: [edge]}] +rules: ["DOMAIN-SUFFIX,example.com,pool", "DOMAIN-SUFFIX,example.com,pool", "MATCH,edge"] +"#, + ) + .unwrap(); + config + .provider_members + .insert("z-provider".into(), vec!["edge".into()]); + config + .provider_members + .insert("a-provider".into(), vec!["edge".into(), "edge".into()]); + let rules = RuleSet::compile(config.rules.clone()).unwrap(); + let diagnostics = Arc::new(Diagnostics::new(&config, &rules, Default::default())); + let (_, payload) = diagnostics.matched(0); + assert!(Arc::ptr_eq(&payload, &diagnostics.matched(1).1)); + assert!(Arc::ptr_eq( + &diagnostics.proxy_names[0], + &diagnostics.text("edge") + )); + assert!(Arc::ptr_eq( + &diagnostics.rules[0].target, + &diagnostics.text("pool") + )); + assert!(Arc::ptr_eq( + &diagnostics.rules[2].target, + &diagnostics.proxy_names[0] + )); + let providers = + diagnostics.providers(&[diagnostics.text("edge"), diagnostics.text("pool")]); + assert_eq!( + providers.iter().map(AsRef::as_ref).collect::>(), + ["a-provider", "z-provider"] + ); + assert!(Arc::ptr_eq(&providers[0], &diagnostics.text("a-provider"))); + assert!(diagnostics.rules()["rules"][0]["extra"]["hitAt"].is_null()); + diagnostics.hit(0); + let view = diagnostics.rules(); + let recorded = diagnostics.rules[0].hit_at.lock().unwrap().unwrap(); + assert_eq!( + view["rules"][0]["extra"]["hitAt"], + recorded + .format(&time::format_description::well_known::Rfc3339) + .unwrap() + ); + assert_eq!(view["rules"][0]["extra"]["hitCount"], 1); + diagnostics.rules[0].hits.store(u64::MAX, Ordering::Relaxed); + diagnostics.hit(0); + assert_eq!( + diagnostics.rules()["rules"][0]["extra"]["hitCount"], + u64::MAX + ); + let owner = Arc::downgrade(&diagnostics); + drop(diagnostics); + assert!(owner.upgrade().is_none()); + assert_eq!(payload.as_ref(), "example.com"); + assert_eq!(providers[0].as_ref(), "a-provider"); + } + #[test] fn effective_configuration_excludes_credentials_and_resource_locations() { let config = Config::parse_yaml(br#" diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 2df1413..c230658 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -799,16 +799,20 @@ impl RoutingDispatcher { original: &Destination, sniffed: Option<&str>, decision: &RouteDecision, - mut chain: Vec, + chain: Vec, ) -> Option { if !business(inbound) { return None; } let diagnostics = self.diagnostics.as_ref()?; + let mut chain = chain + .iter() + .map(|name| diagnostics.text(name)) + .collect::>(); if chain.is_empty() { chain.push(match decision.action { - RuleAction::Direct => "DIRECT".into(), - RuleAction::Reject => "REJECT".into(), + RuleAction::Direct => diagnostics.text("DIRECT"), + RuleAction::Reject => diagnostics.text("REJECT"), RuleAction::Route(RouteTargetId::Proxy(id)) => { diagnostics.proxy_names[id.index()].clone() } @@ -817,22 +821,45 @@ impl RoutingDispatcher { } }); } - let (rule, payload) = diagnostics.matched(decision.rule_index); + let (rule, rule_payload) = diagnostics.matched(decision.rule_index); let host = match &decision.metadata { - Destination::Domain { host, .. } => host.as_str(), - _ => "", + Destination::Domain { host, .. } => diagnostics.text(host), + _ => diagnostics.text(""), }; let destination_ip = match &decision.destination { - Destination::Ip(addr) => Some(addr.ip().to_string()), + Destination::Ip(addr) => Some(addr.ip()), _ => None, }; - let providers = diagnostics.providers(&chain); - Some(diagnostics.connections.open(serde_json::json!({ - "metadata":{"network":if network == Network::Tcp { "tcp" } else { "udp" }, "type":match inbound { InboundKind::Tun=>"Tun", InboundKind::Http=>"HTTP", _=>"Socks5" }, - "sourceIP":source.ip().to_string(),"sourcePort":source.port().to_string(),"destinationIP":destination_ip, - "destinationPort":original.port().to_string(),"host":host,"dnsMode":decision.dns_mode,"sniffHost":sniffed.unwrap_or_default()}, - "chains":chain,"providerChains":providers,"rule":rule,"rulePayload":payload,"dnsPath":crate::dns::trace::snapshot() - }))) + let provider_chains = diagnostics.providers(&chain); + Some( + diagnostics + .connections + .open(crate::connections::ConnectionInfo { + metadata: crate::connections::ConnectionMetadata { + network: if network == Network::Tcp { + "tcp" + } else { + "udp" + }, + inbound_type: match inbound { + InboundKind::Tun => "Tun", + InboundKind::Http => "HTTP", + _ => "Socks5", + }, + source, + destination_ip, + destination_port: original.port(), + host, + dns_mode: decision.dns_mode, + sniff_host: diagnostics.text(sniffed.unwrap_or_default()), + }, + chains: chain, + provider_chains, + rule, + rule_payload, + dns_path: crate::dns::trace::snapshot(), + }), + ) } fn clone_parts(&self) -> Self { @@ -1932,7 +1959,7 @@ rules: ["MATCH,pool"] let second = "198.51.100.2:443".parse().unwrap(); transport.send(datagram(first, b"one")).await.unwrap(); transport.send(datagram(second, b"two")).await.unwrap(); - let snapshot = registry.snapshot(); + let snapshot = serde_json::to_value(registry.snapshot()).unwrap(); let entries = snapshot["connections"].as_array().unwrap(); assert_eq!(entries.len(), 2); let entry = entries @@ -1947,14 +1974,17 @@ rules: ["MATCH,pool"] Destination::Ip(second) ); assert_eq!( - registry.snapshot()["connections"].as_array().unwrap().len(), + serde_json::to_value(registry.snapshot()).unwrap()["connections"] + .as_array() + .unwrap() + .len(), 1 ); transport.send(datagram(first, b"new")).await.unwrap(); assert_eq!(proxy.udp_opens.load(Ordering::Relaxed), 3); transport.close().await.unwrap(); assert!( - registry.snapshot()["connections"] + serde_json::to_value(registry.snapshot()).unwrap()["connections"] .as_array() .unwrap() .is_empty() diff --git a/src/runtime.rs b/src/runtime.rs index f3e8be5..8cfafaf 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -166,7 +166,8 @@ impl Drop for MeasurementRuntime { } struct BuiltRuntimeParts { - diagnostics: Arc, + connections: Arc, + diagnostics: Option>, dispatcher: Arc, // Local dispatch owns its DNS reference; this copy feeds shared TUN startup. #[cfg_attr( @@ -715,11 +716,13 @@ impl GraphBuilder { activities, dns.clone(), ); - let diagnostics = Arc::new(diagnostics::Diagnostics::new( - &self.config, - &rules, - self.connections.clone(), - )); + let diagnostics = self.config.external_controller.as_ref().map(|_| { + Arc::new(diagnostics::Diagnostics::new( + &self.config, + &rules, + self.connections.clone(), + )) + }); let mut router = RoutingDispatcher::new_with_ipv6( route_targets.clone(), direct.clone(), @@ -731,12 +734,13 @@ impl GraphBuilder { .with_tun_config(&self.config.tun) .with_sniffer(&self.config.sniffer) .with_tcp_concurrent(self.config.connections.tcp_concurrent); - if self.config.external_controller.is_some() { + if let Some(diagnostics) = &diagnostics { router = router.with_diagnostics(diagnostics.clone()); } let router: Arc = Arc::new(router); let session_stats = RuntimeResourceStats::new("runtime_session_observation"); Ok(BuiltRuntimeParts { + connections: self.connections.clone(), diagnostics, dispatcher: observe_sessions_with_stats(router, session_stats), dns, @@ -1987,15 +1991,15 @@ rules: Err(crate::dispatch::DispatchError::NotAllowed) )); // REJECT exercises the assembled business path without opening a socket. - let snapshot = parts.diagnostics.connections.snapshot(); + assert_eq!(parts.diagnostics.is_some(), controller); + let snapshot = serde_json::to_value(parts.connections.snapshot()).unwrap(); assert_eq!( snapshot["errors"].as_array().unwrap().len(), usize::from(controller) ); - assert_eq!( - parts.diagnostics.rules()["rules"][0]["extra"]["hitCount"], - usize::from(controller) - ); + if let Some(diagnostics) = &parts.diagnostics { + assert_eq!(diagnostics.rules()["rules"][0]["extra"]["hitCount"], 1); + } parts.proxy_graph.shutdown().await; } } diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index eb13cbb..5e99c30 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -25,7 +25,8 @@ use tokio::{ }; struct Generation { - diagnostics: Arc, + connections: Arc, + diagnostics: Option>, health: Arc, requirements: GeoRequirements, config: Config, @@ -43,6 +44,7 @@ impl Generation { requirements: GeoRequirements, ) -> Self { Self { + connections: parts.connections.clone(), diagnostics: parts.diagnostics.clone(), health: parts.health.clone(), requirements, @@ -131,7 +133,7 @@ impl ProviderRuntime { } pub(super) fn begin_shutdown(&self) { self.cancellation.cancel(); - self.current.load().diagnostics.connections.shutdown(); + self.current.load().connections.shutdown(); self.current.load().graph.begin_shutdown(); for generation in self.retired.lock().unwrap().iter() { generation.graph.begin_shutdown(); @@ -270,7 +272,7 @@ impl ProviderRuntime { let dialer = self.dialer.clone(); let geo = self.geo.clone(); let previous_dns = generation.dns.clone(); - let connections = generation.diagnostics.connections.clone(); + let connections = generation.connections.clone(); let data_dir = self .manager .store_dir() @@ -426,13 +428,20 @@ impl ProviderRuntime { #[async_trait] impl RuntimeResources for ProviderRuntime { fn connections(&self) -> Arc { - self.current.load().diagnostics.connections.clone() + self.current.load().connections.clone() } fn configs(&self) -> serde_json::Value { - self.current.load().diagnostics.config() + self.current + .load() + .diagnostics + .as_ref() + .map_or(serde_json::Value::Null, |diagnostics| diagnostics.config()) } fn rules(&self) -> serde_json::Value { - self.current.load().diagnostics.rules() + self.current.load().diagnostics.as_ref().map_or_else( + || serde_json::json!({"rules": []}), + |diagnostics| diagnostics.rules(), + ) } fn groups(&self) -> Arc { From cc519eed8a29c36cf0ff6dbf84a48344bd38f6b6 Mon Sep 17 00:00:00 2001 From: yiguo Date: Fri, 9 Oct 2026 23:18:15 +0800 Subject: [PATCH 024/445] perf: multiplex managed UDP readiness and reuse receive buffers --- .github/workflows/test.yml | 1 + docs/runtime-resource-policy.md | 7 + src/dispatch.rs | 89 ++++++++ src/dispatch/datagram.rs | 168 +++++++++++++++ src/outbound/direct.rs | 143 +++++++------ src/routing/datagram_ready.rs | 226 +++++++++++++++++++++ src/routing/dispatcher.rs | 350 +++++++++++++++++++++++++++++--- src/routing/mod.rs | 1 + tests/README.md | 2 + 9 files changed, 901 insertions(+), 86 deletions(-) create mode 100644 src/routing/datagram_ready.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4bae2c..107a158 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -185,6 +185,7 @@ jobs: filters=( geodata:: routing:: security:: outbound::vless::encryption config:: connections::tests:: routing::diagnostics::tests:: dialer::connections::tests:: + polled_datagram_ resource::http::tests:: fake_ip_ sniffing::tests:: tcp_sniffer::tests:: dns::fallback::tests:: dns::runtime::tests::dns_fallback_ dns::runtime::encrypted_tests:: diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 652e0f5..0009112 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -115,6 +115,13 @@ XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层 响应队列满不会延长源关联所有者的生命周期。 - reader EOF、错误或取消时,先取消其 UDP 子作用域,再删除、取消并等待全部关联和 DNS 任务;父运行时统一取消并等待唯一 writer 与 netstack。UDP 子作用域停止不取消调用者令牌。 +开启 Controller 的业务 UDP 按目标拥有独立 transport,以支持单目标取消与固定出口。 +DIRECT 在源关联内共用一个延迟分配的接收暂存缓冲,保留预算加一字节的超长包检测; +交付包只持有实际负载。持续的就绪队列每目标最多一个通知,接收只轮询被唤醒的目标, +不新增逐目标任务或负载队列。其他协议继续使用取消安全的异步接收。 +目标访问时检查自身过期,整个表每 10 秒清理并回收多余容量;流量不会推迟该期限。 +DELETE 通过取消令牌立即唤醒等待中的接收,关闭与 flow-id 替换移除旧就绪注册。 + ### 定向数据报预算与受控 QUIC `DatagramBudget` 分别表达当前层的发送和接收 payload 上限。嵌套协议先为下层申请有界 envelope 空间,再按实际协议/地址族开销扣除下层能力,与调用方预算取交集;不能把接收上限直接当作发送能力。DIRECT、SOCKS5、SS 2022、SS/AnyTLS UoT 和 VLESS XUDP 保留对端/地址校验。超出发送预算在写入前失败,超出接收预算丢当前包并有界让出执行权,close 后不能恢复收发。UoT 的流头不从 UDP payload 预算扣除;SS 首包门控、读取任务与单响应队列的局部上限见[出站](outbounds.md#udp-over-tcp-v2)。 diff --git a/src/dispatch.rs b/src/dispatch.rs index 8434cf9..6039fee 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -103,6 +103,21 @@ pub trait DatagramTransport: Send { /// when another datagram path becomes ready. async fn receive(&mut self) -> Result; + /// Optional cancellation-safe receive seam for readiness multiplexers. + /// The caller owns one reusable scratch buffer; it must never be retained + /// across polls. Returned datagrams own only their complete payload. + fn supports_poll_receive(&self) -> bool { + false + } + + fn poll_receive( + &mut self, + _context: &mut Context<'_>, + _scratch: &mut Vec, + ) -> Poll> { + Poll::Ready(Err(DispatchError::NotAllowed)) + } + async fn close(&mut self) -> Result<(), DispatchError> { Ok(()) } @@ -288,6 +303,18 @@ struct ObservedDatagramTransport { #[async_trait] impl DatagramTransport for ObservedDatagramTransport { + fn supports_poll_receive(&self) -> bool { + self.inner.supports_poll_receive() + } + + fn poll_receive( + &mut self, + context: &mut Context<'_>, + scratch: &mut Vec, + ) -> Poll> { + self.inner.poll_receive(context, scratch) + } + fn payload_budget(&self, peer: &crate::session::Destination) -> DatagramBudget { self.inner.payload_budget(peer) } @@ -446,6 +473,68 @@ mod tests { assert_eq!(stats.snapshot().udp_current, 0); } + #[test] + fn polled_datagram_observer_forwards_scratch_readiness_and_retains_activity() { + struct PolledTransport { + scratch_pointer: usize, + parent: std::task::Waker, + polls: Arc, + } + #[async_trait] + impl DatagramTransport for PolledTransport { + fn supports_poll_receive(&self) -> bool { + true + } + fn poll_receive( + &mut self, + context: &mut Context<'_>, + scratch: &mut Vec, + ) -> Poll> { + assert!(context.waker().will_wake(&self.parent)); + assert_eq!(scratch.as_ptr() as usize, self.scratch_pointer); + assert_eq!(scratch.as_slice(), b"supplied"); + scratch.clear(); + scratch.extend_from_slice(b"used"); + self.polls.fetch_add(1, Ordering::Relaxed); + Poll::Ready(Ok(Datagram { + remote: Destination::Ip("198.51.100.1:443".parse().unwrap()), + payload: bytes::Bytes::from_static(b"ready"), + sniffed_domain: None, + })) + } + async fn send(&mut self, _datagram: Datagram) -> Result<(), DispatchError> { + Ok(()) + } + async fn receive(&mut self) -> Result { + panic!("polled observer must not use asynchronous receive") + } + } + let polls = Arc::new(AtomicUsize::new(0)); + let stats = RuntimeResourceStats::new("polled_observer_test"); + let parent = futures_util::task::noop_waker(); + let mut scratch = b"supplied".to_vec(); + let mut observed = ObservedDatagramTransport { + inner: Box::new(PolledTransport { + scratch_pointer: scratch.as_ptr() as usize, + parent: parent.clone(), + polls: polls.clone(), + }), + _activity: stats.begin(ResourceActivity::UdpAssociation), + }; + assert_eq!(stats.snapshot().udp_current, 1); + assert!(observed.supports_poll_receive()); + let mut context = Context::from_waker(&parent); + let Poll::Ready(Ok(datagram)) = observed.poll_receive(&mut context, &mut scratch) else { + panic!("observer must forward the ready datagram") + }; + assert_eq!(datagram.payload, bytes::Bytes::from_static(b"ready")); + assert_eq!(scratch.as_slice(), b"used"); + assert_eq!(polls.load(Ordering::Relaxed), 1); + assert_eq!(stats.snapshot().udp_current, 1); + drop(observed); + assert_eq!(stats.snapshot().udp_current, 0); + } + #[tokio::test] async fn session_observation_does_not_reject_former_tcp_or_udp_boundaries() { const TCP_SESSIONS: usize = 160; diff --git a/src/dispatch/datagram.rs b/src/dispatch/datagram.rs index 760e309..13f9c07 100644 --- a/src/dispatch/datagram.rs +++ b/src/dispatch/datagram.rs @@ -93,6 +93,36 @@ struct Bounded { #[async_trait::async_trait] impl DatagramTransport for Bounded { + fn supports_poll_receive(&self) -> bool { + self.inner.supports_poll_receive() + } + + fn poll_receive( + &mut self, + context: &mut std::task::Context<'_>, + scratch: &mut Vec, + ) -> std::task::Poll> { + if self.closing { + return std::task::Poll::Ready(Err(DispatchError::NotAllowed)); + } + for _ in 0..crate::limits::IO_POLL_BUDGET { + let datagram = match self.inner.poll_receive(context, scratch) { + std::task::Poll::Pending => return std::task::Poll::Pending, + std::task::Poll::Ready(result) => match result { + Ok(datagram) => datagram, + Err(error) => return std::task::Poll::Ready(Err(error)), + }, + }; + if datagram.payload.len() + <= usize::from(self.payload_budget(&datagram.remote).receive()) + { + return std::task::Poll::Ready(Ok(datagram)); + } + } + context.waker().wake_by_ref(); + std::task::Poll::Pending + } + fn payload_budget(&self, peer: &Destination) -> DatagramBudget { self.budget.intersect(self.inner.payload_budget(peer)) } @@ -132,3 +162,141 @@ impl DatagramTransport for Bounded { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + use futures_util::task::{ArcWake, waker}; + use std::{ + collections::VecDeque, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + task::{Context, Poll}, + }; + + struct PolledPackets { + packets: VecDeque, + polls: Arc, + closes: Arc, + } + #[async_trait::async_trait] + impl DatagramTransport for PolledPackets { + fn payload_budget(&self, _peer: &Destination) -> DatagramBudget { + DatagramBudget::new(2, 3) + } + fn supports_poll_receive(&self) -> bool { + true + } + fn poll_receive( + &mut self, + _context: &mut Context<'_>, + _scratch: &mut Vec, + ) -> Poll> { + self.polls.fetch_add(1, Ordering::Relaxed); + match self.packets.pop_front() { + Some(datagram) => Poll::Ready(Ok(datagram)), + None => Poll::Pending, + } + } + async fn send(&mut self, _datagram: Datagram) -> Result<(), DispatchError> { + Ok(()) + } + async fn receive(&mut self) -> Result { + panic!("polled test must not use asynchronous receive") + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.closes.fetch_add(1, Ordering::Relaxed); + Ok(()) + } + } + + #[derive(Default)] + struct WakeCount(AtomicUsize); + impl ArcWake for WakeCount { + fn wake_by_ref(arc_self: &Arc) { + arc_self.0.fetch_add(1, Ordering::Relaxed); + } + } + fn packet(payload: &'static [u8]) -> Datagram { + Datagram { + remote: Destination::Ip("198.51.100.1:443".parse().unwrap()), + payload: Bytes::from_static(payload), + sniffed_domain: None, + } + } + + #[tokio::test] + async fn polled_datagram_bounded_filters_complete_packets_and_blocks_after_close() { + let polls = Arc::new(AtomicUsize::new(0)); + let closes = Arc::new(AtomicUsize::new(0)); + let mut transport = bounded( + Box::new(PolledPackets { + packets: [packet(b"oversized"), packet(b"four"), packet(b"ok!")].into(), + polls: polls.clone(), + closes: closes.clone(), + }), + DatagramBudget::new(10, 4), + ); + assert!(transport.supports_poll_receive()); + assert_eq!( + transport.payload_budget(&packet(b"").remote), + DatagramBudget::new(2, 3) + ); + let parent = futures_util::task::noop_waker(); + let mut context = Context::from_waker(&parent); + let mut scratch = Vec::new(); + let Poll::Ready(Ok(datagram)) = transport.poll_receive(&mut context, &mut scratch) else { + panic!("valid packet must follow dropped oversized packets") + }; + assert_eq!(datagram.payload, Bytes::from_static(b"ok!")); + assert_eq!(polls.load(Ordering::Relaxed), 3); + transport.close().await.unwrap(); + transport.close().await.unwrap(); + assert_eq!(closes.load(Ordering::Relaxed), 1); + assert!(matches!( + transport.poll_receive(&mut context, &mut scratch), + Poll::Ready(Err(DispatchError::NotAllowed)) + )); + assert_eq!(polls.load(Ordering::Relaxed), 3); + } + + #[test] + fn polled_datagram_bounded_yields_and_wakes_after_oversized_packet_budget() { + let polls = Arc::new(AtomicUsize::new(0)); + let mut packets = VecDeque::new(); + for _ in 0..=crate::limits::IO_POLL_BUDGET { + packets.push_back(packet(b"four")); + } + packets.push_back(packet(b"ok!")); + let mut transport = bounded( + Box::new(PolledPackets { + packets, + polls: polls.clone(), + closes: Arc::default(), + }), + DatagramBudget::new(10, 4), + ); + let wakes = Arc::new(WakeCount::default()); + let parent = waker(wakes.clone()); + let mut context = Context::from_waker(&parent); + let mut scratch = Vec::new(); + assert!( + transport + .poll_receive(&mut context, &mut scratch) + .is_pending() + ); + assert_eq!(polls.load(Ordering::Relaxed), crate::limits::IO_POLL_BUDGET); + assert_eq!(wakes.0.load(Ordering::Relaxed), 1); + let Poll::Ready(Ok(datagram)) = transport.poll_receive(&mut context, &mut scratch) else { + panic!("next poll must consume the remaining oversized packet and valid packet") + }; + assert_eq!(datagram.payload, Bytes::from_static(b"ok!")); + assert_eq!( + polls.load(Ordering::Relaxed), + crate::limits::IO_POLL_BUDGET + 2 + ); + } +} diff --git a/src/outbound/direct.rs b/src/outbound/direct.rs index cc91539..30c5141 100644 --- a/src/outbound/direct.rs +++ b/src/outbound/direct.rs @@ -1,10 +1,12 @@ -use std::{future::Future, io, net::SocketAddr, pin::Pin}; +use std::{ + io, + net::SocketAddr, + task::{Context, Poll}, +}; use crate::dialer::PhysicalDatagram as UdpSocket; use async_trait::async_trait; use bytes::Bytes; -use futures_util::future::select_all; -use tokio::io::ReadBuf; use crate::{ dialer::Dialer, @@ -89,6 +91,7 @@ struct DirectDatagramTransport { sockets: Vec<(UdpSocketClass, UdpSocket)>, max_response_payload_size: usize, receive_buffer: Vec, + receive_cursor: usize, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -99,18 +102,12 @@ struct UdpSocketClass { impl DirectDatagramTransport { fn new(dialer: Dialer, max_response_payload_size: usize) -> Self { - // `recv_from` is allowed to truncate a datagram to the supplied - // buffer without reporting the original length. One sentinel byte is - // enough to distinguish an allowed maximum-size payload from every - // oversized payload while preserving a hard allocation ceiling. - let receive_buffer_size = max_response_payload_size - .checked_add(1) - .expect("direct UDP response ceiling fits usize"); Self { dialer, sockets: Vec::with_capacity(4), max_response_payload_size, - receive_buffer: vec![0; receive_buffer_size], + receive_buffer: Vec::new(), + receive_cursor: 0, } } @@ -135,60 +132,55 @@ impl DirectDatagramTransport { .1) } - async fn receive_from_ready_socket(&mut self) -> Result { - loop { - if self.sockets.is_empty() { - return Err(DispatchError::Other( - "direct UDP receive requested before the first datagram".to_owned(), - )); - } - // A single physical socket has only this reader. Poll it directly - // instead of rebuilding boxed readiness waiters after cancellation. - if self.sockets.len() == 1 { - let socket = &self.sockets[0].1; - let mut receive_buffer = ReadBuf::new(&mut self.receive_buffer); - let remote = std::future::poll_fn(|context| { - socket.poll_recv_from(context, &mut receive_buffer) - }) - .await - .map_err(DispatchError::from)?; - if receive_buffer.filled().len() > self.max_response_payload_size { - continue; + fn poll_sockets( + sockets: &[(UdpSocketClass, UdpSocket)], + max_payload: usize, + cursor: &mut usize, + context: &mut Context<'_>, + scratch: &mut Vec, + ) -> Poll> { + if sockets.is_empty() { + return Poll::Ready(Err(DispatchError::Other( + "direct UDP receive requested before the first datagram".to_owned(), + ))); + } + for _ in 0..crate::limits::IO_POLL_BUDGET { + let mut retry = false; + for offset in 0..sockets.len() { + let index = (*cursor + offset) % sockets.len(); + let socket = &sockets[index].1; + match socket.poll_recv_ready(context) { + Poll::Pending => continue, + Poll::Ready(Err(error)) => return Poll::Ready(Err(error.into())), + Poll::Ready(Ok(())) => {} } - return Ok(Datagram { - remote: Destination::Ip(remote), - payload: Bytes::copy_from_slice(receive_buffer.filled()), - sniffed_domain: None, - }); - } - let readiness: Vec> + Send + '_>>> = self - .sockets - .iter() - .enumerate() - .map(|(index, (_, socket))| { - Box::pin(async move { socket.readable().await.map(|()| index) }) - as Pin> + Send + '_>> - }) - .collect(); - let (selected, _, _) = select_all(readiness).await; - let selected = selected.map_err(DispatchError::from)?; - let socket = &self.sockets[selected].1; - match socket.try_recv_from(&mut self.receive_buffer) { - // A UDP datagram is indivisible. Never turn an oversized - // response into a shorter, apparently valid datagram for the - // SOCKS5 client or TUN netstack. - Ok((length, _)) if length > self.max_response_payload_size => continue, - Ok((length, remote)) => { - return Ok(Datagram { - remote: Destination::Ip(remote), - payload: Bytes::copy_from_slice(&self.receive_buffer[..length]), - sniffed_domain: None, - }); + // Allocate only once a socket is readable. The extra byte + // detects oversized datagrams rather than delivering a prefix. + let required = max_payload + 1; + if scratch.len() < required { + scratch.reserve_exact(required - scratch.len()); + scratch.resize(required, 0); } - Err(error) if error.kind() == io::ErrorKind::WouldBlock => continue, - Err(error) => return Err(DispatchError::from(error)), + match socket.try_recv_from(&mut scratch[..required]) { + Ok((length, remote)) if length <= max_payload => { + *cursor = (index + 1) % sockets.len(); + return Poll::Ready(Ok(Datagram { + remote: Destination::Ip(remote), + payload: Bytes::copy_from_slice(&scratch[..length]), + sniffed_domain: None, + })); + } + Ok(_) => retry = true, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => retry = true, + Err(error) => return Poll::Ready(Err(error.into())), + } + } + if !retry { + return Poll::Pending; } } + context.waker().wake_by_ref(); + Poll::Pending } } @@ -223,8 +215,35 @@ impl DatagramTransport for DirectDatagramTransport { Ok(()) } + fn supports_poll_receive(&self) -> bool { + true + } + + fn poll_receive( + &mut self, + context: &mut Context<'_>, + scratch: &mut Vec, + ) -> Poll> { + Self::poll_sockets( + &self.sockets, + self.max_response_payload_size, + &mut self.receive_cursor, + context, + scratch, + ) + } + async fn receive(&mut self) -> Result { - self.receive_from_ready_socket().await + std::future::poll_fn(|context| { + Self::poll_sockets( + &self.sockets, + self.max_response_payload_size, + &mut self.receive_cursor, + context, + &mut self.receive_buffer, + ) + }) + .await } async fn close(&mut self) -> Result<(), DispatchError> { diff --git a/src/routing/datagram_ready.rs b/src/routing/datagram_ready.rs new file mode 100644 index 0000000..4161cba --- /dev/null +++ b/src/routing/datagram_ready.rs @@ -0,0 +1,226 @@ +//! Deduplicated readiness for independently polled datagram receivers. +use crate::session::Destination; +use futures_util::task::{ArcWake, AtomicWaker}; +use std::{ + collections::VecDeque, + sync::{Arc, Mutex, Weak}, + task::Waker, +}; + +#[derive(Default)] +pub(super) struct ReadyQueue { + entries: Mutex>>, + parent: AtomicWaker, +} + +pub(super) struct ReadyFlow { + key: Destination, + queue: Weak, + state: Mutex, +} + +struct FlowState { + active: bool, + queued: bool, +} + +impl ReadyQueue { + pub fn registration(self: &Arc, key: Destination) -> Arc { + Arc::new(ReadyFlow { + key, + queue: Arc::downgrade(self), + state: Mutex::new(FlowState { + active: true, + queued: false, + }), + }) + } + + /// Register before inspecting readiness on each poll. An enqueue before + /// registration remains visible in the queue; one after it wakes the task. + pub fn register(&self, waker: &Waker) { + self.parent.register(waker); + } + + pub fn pop(&self) -> Option> { + let mut entries = self.entries.lock().unwrap(); + while let Some(flow) = entries.pop_front() { + let active = { + let mut state = flow.state.lock().unwrap(); + state.queued = false; + state.active + }; + if active { + return Some(flow); + } + } + None + } + + pub fn is_empty(&self) -> bool { + self.entries.lock().unwrap().is_empty() + } + + pub fn shrink(&self) { + let mut entries = self.entries.lock().unwrap(); + if entries.capacity() > entries.len().saturating_mul(4).max(8) { + let retained = entries.len().saturating_mul(2); + entries.shrink_to(retained); + } + } +} + +impl ReadyFlow { + pub fn key(&self) -> &Destination { + &self.key + } + + pub fn enqueue(self: &Arc) { + let Some(queue) = self.queue.upgrade() else { + return; + }; + { + // Every operation needing both locks takes queue before state. + let mut entries = queue.entries.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + if !state.active || state.queued { + return; + } + state.queued = true; + entries.push_back(self.clone()); + } + // A parent wake is allowed to poll immediately and reacquire the locks. + queue.parent.wake(); + } + + /// Permanently retire this registration, including its outstanding item. + /// Replacements for the same endpoint have a distinct registration owner. + pub fn deactivate(&self) { + let Some(queue) = self.queue.upgrade() else { + let mut state = self.state.lock().unwrap(); + state.active = false; + state.queued = false; + return; + }; + let mut entries = queue.entries.lock().unwrap(); + let mut state = self.state.lock().unwrap(); + if !state.active { + return; + } + state.active = false; + if state.queued { + entries.retain(|flow| !std::ptr::eq(flow.as_ref(), self)); + state.queued = false; + } + } +} + +impl ArcWake for ReadyFlow { + fn wake_by_ref(arc_self: &Arc) { + arc_self.enqueue(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures_util::task::{waker, waker_ref}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + #[derive(Default)] + struct WakeCount(AtomicUsize); + impl ArcWake for WakeCount { + fn wake_by_ref(arc_self: &Arc) { + arc_self.0.fetch_add(1, Ordering::Relaxed); + } + } + fn key() -> Destination { + Destination::Ip("198.51.100.1:443".parse().unwrap()) + } + + #[test] + fn readiness_before_registration_and_cross_thread_wake_are_not_lost() { + let queue = Arc::new(ReadyQueue::default()); + let flow = queue.registration(key()); + let wakes = Arc::new(WakeCount::default()); + let parent = waker(wakes.clone()); + + flow.enqueue(); + queue.register(&parent); + assert!(Arc::ptr_eq(&queue.pop().unwrap(), &flow)); + assert!(queue.is_empty()); + + queue.register(&parent); + std::thread::scope(|scope| { + scope.spawn(|| waker_ref(&flow).wake_by_ref()); + }); + assert_eq!(wakes.0.load(Ordering::Relaxed), 1); + assert_eq!(queue.pop().unwrap().key(), &key()); + assert!(queue.is_empty()); + } + + #[test] + fn duplicate_notifications_have_one_item_and_reenqueue_is_fifo() { + let queue = Arc::new(ReadyQueue::default()); + let first = queue.registration(key()); + let second = queue.registration(Destination::Ip("198.51.100.2:443".parse().unwrap())); + std::thread::scope(|scope| { + for _ in 0..4 { + scope.spawn(|| { + for _ in 0..100 { + waker_ref(&first).wake_by_ref(); + } + }); + } + }); + second.enqueue(); + assert!(Arc::ptr_eq(&queue.pop().unwrap(), &first)); + first.enqueue(); + assert!(Arc::ptr_eq(&queue.pop().unwrap(), &second)); + assert!(Arc::ptr_eq(&queue.pop().unwrap(), &first)); + assert!(queue.is_empty()); + } + + #[test] + fn retirement_removes_queued_items_and_old_waker_cannot_notify_replacement() { + let queue = Arc::new(ReadyQueue::default()); + let old = queue.registration(key()); + let stale_waker = waker(old.clone()); + old.enqueue(); + old.deactivate(); + assert!(queue.is_empty()); + let replacement = queue.registration(key()); + stale_waker.wake_by_ref(); + assert!(queue.is_empty()); + replacement.enqueue(); + assert!(Arc::ptr_eq(&queue.pop().unwrap(), &replacement)); + old.deactivate(); + replacement.enqueue(); + assert!(Arc::ptr_eq(&queue.pop().unwrap(), &replacement)); + assert!(queue.is_empty()); + let weak_queue = Arc::downgrade(&queue); + drop(queue); + assert!(weak_queue.upgrade().is_none()); + stale_waker.wake_by_ref(); + replacement.deactivate(); + } + + #[test] + fn synchronous_parent_notification_does_not_hold_queue_locks() { + struct PopWake(Arc, AtomicUsize); + impl ArcWake for PopWake { + fn wake_by_ref(arc_self: &Arc) { + if arc_self.0.pop().is_some() { + arc_self.1.fetch_add(1, Ordering::Relaxed); + } + } + } + let queue = Arc::new(ReadyQueue::default()); + let flow = queue.registration(key()); + let parent = Arc::new(PopWake(queue.clone(), AtomicUsize::new(0))); + queue.register(&waker(parent.clone())); + flow.enqueue(); + assert_eq!(parent.1.load(Ordering::Relaxed), 1); + assert!(queue.is_empty()); + } +} diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index c230658..da1e609 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, HashSet}, + future::Future, io, net::{IpAddr, SocketAddr}, pin::Pin, @@ -29,6 +30,7 @@ use crate::{ use super::{ GeoMatcher, ProxyGroups, ResolvedProxyGroupLeaf, RoutingContext, RuleEvaluation, RuleSet, + datagram_ready::{ReadyFlow, ReadyQueue}, }; const TCP_DNS_DUPLEX_CAPACITY: usize = 8 * 1024; @@ -673,21 +675,30 @@ impl Dispatcher for RoutingDispatcher { &self, session: DatagramSession, ) -> Result, DispatchError> { - Ok(Box::new(RoutedDatagramTransport { + Ok(Box::new(self.routed_datagrams(session))) + } +} + +impl RoutingDispatcher { + fn routed_datagrams(&self, session: DatagramSession) -> RoutedDatagramTransport { + let managed_ready = (self.diagnostics.is_some() && business(session.inbound)) + .then(|| Arc::new(ReadyQueue::default())); + RoutedDatagramTransport { router: self.clone_parts(), session, route_transports: HashMap::new(), fake_flows: HashMap::new(), - managed_flows: HashMap::new(), + managed_flows: ManagedDatagramFlows::default(), + managed_ready, + managed_receive_buffer: Vec::new(), + managed_next_cleanup: Instant::now() + UDP_FLOW_CLEANUP_INTERVAL, direct_transport: None, flows: DatagramRoutes::new(self.tun_config.udp_timeout), logged_route_actions: HashSet::new(), receive_wakeup: Arc::new(Notify::new()), - })) + } } -} -impl RoutingDispatcher { async fn connect_tcp_inner( &self, mut session: StreamSession, @@ -883,7 +894,10 @@ struct RoutedDatagramTransport { session: DatagramSession, route_transports: HashMap<(RouteTargetId, Option), CachedRouteTransport>, fake_flows: HashMap, - managed_flows: HashMap, + managed_flows: ManagedDatagramFlows, + managed_ready: Option>, + managed_receive_buffer: Vec, + managed_next_cleanup: Instant, direct_transport: Option>, flows: DatagramRoutes, logged_route_actions: HashSet, @@ -899,7 +913,57 @@ struct FakeDatagramFlow { flow_id: Option>, } +struct ManagedReceive { + wake: Arc, + cancelled: Pin>, +} + +#[derive(Default)] +struct ManagedDatagramFlows { + polled: HashMap, + asynchronous: HashMap, +} +impl ManagedDatagramFlows { + fn get(&self, key: &Destination) -> Option<&ManagedDatagramFlow> { + self.polled.get(key).or_else(|| self.asynchronous.get(key)) + } + fn get_mut(&mut self, key: &Destination) -> Option<&mut ManagedDatagramFlow> { + self.polled + .get_mut(key) + .or_else(|| self.asynchronous.get_mut(key)) + } + fn contains_key(&self, key: &Destination) -> bool { + self.polled.contains_key(key) || self.asynchronous.contains_key(key) + } + fn insert(&mut self, key: Destination, flow: ManagedDatagramFlow) { + if flow.receiver.is_some() { + self.polled.insert(key, flow); + } else { + self.asynchronous.insert(key, flow); + } + } + fn remove(&mut self, key: &Destination) -> Option { + self.polled + .remove(key) + .or_else(|| self.asynchronous.remove(key)) + } + fn iter(&self) -> impl Iterator { + self.polled.iter().chain(self.asynchronous.iter()) + } + fn drain(&mut self) -> impl Iterator { + self.polled.drain().chain(self.asynchronous.drain()) + } + fn shrink(&mut self) { + for map in [&mut self.polled, &mut self.asynchronous] { + if map.capacity() > map.len().saturating_mul(4).max(8) { + map.shrink_to(map.len().saturating_mul(2)); + } + } + } +} + struct ManagedDatagramFlow { + receiver: Option, transport: Box, connection: crate::connections::Connection, destination: Destination, @@ -908,6 +972,14 @@ struct ManagedDatagramFlow { flow_id: Option>, } +impl Drop for ManagedDatagramFlow { + fn drop(&mut self) { + if let Some(receiver) = &self.receiver { + receiver.wake.deactivate(); + } + } +} + struct CachedRouteTransport { last_used: Instant, transport: Box, @@ -997,6 +1069,11 @@ impl RoutedDatagramTransport { self.router.diagnostics.is_some() && business(self.session.inbound) } async fn clean_managed(&mut self) { + let now = Instant::now(); + if now < self.managed_next_cleanup { + return; + } + self.managed_next_cleanup = now + UDP_FLOW_CLEANUP_INTERVAL; let expired = self .managed_flows .iter() @@ -1008,13 +1085,31 @@ impl RoutedDatagramTransport { .collect::>(); for key in expired { if let Some(mut flow) = self.managed_flows.remove(&key) { + if let Some(receiver) = &flow.receiver { + receiver.wake.deactivate(); + } let _ = flow.transport.close().await; } } + self.managed_flows.shrink(); + self.managed_ready + .as_ref() + .expect("tracked receive queue") + .shrink(); } async fn send_managed(&mut self, mut datagram: Datagram) -> Result<(), DispatchError> { self.clean_managed().await; let key = datagram.remote.clone(); + if self.managed_flows.get(&key).is_some_and(|flow| { + flow.connection.cancelled() + || flow.last_used.elapsed() >= self.router.tun_config.udp_timeout + }) && let Some(mut flow) = self.managed_flows.remove(&key) + { + if let Some(receiver) = &flow.receiver { + receiver.wake.deactivate(); + } + let _ = flow.transport.close().await; + } if !self.managed_flows.contains_key(&key) { let mut decision = self .router @@ -1091,9 +1186,22 @@ impl RoutedDatagramTransport { return Err(error); } }; + let receiver = transport.supports_poll_receive().then(|| { + let wake = self + .managed_ready + .as_ref() + .expect("tracked receive queue") + .registration(key.clone()); + wake.enqueue(); + ManagedReceive { + wake, + cancelled: Box::pin(connection.token().cancelled_owned()), + } + }); self.managed_flows.insert( key.clone(), ManagedDatagramFlow { + receiver, transport, connection, destination: decision.destination, @@ -1123,36 +1231,117 @@ impl RoutedDatagramTransport { } result } + fn poll_managed( + flows: &mut HashMap, + ready: &ReadyQueue, + scratch: &mut Vec, + idle_timeout: Duration, + context: &mut Context<'_>, + ) -> Poll<(Destination, Option>)> { + ready.register(context.waker()); + for _ in 0..crate::limits::IO_POLL_BUDGET { + let Some(wake) = ready.pop() else { + return Poll::Pending; + }; + let Some(flow) = flows.get_mut(wake.key()) else { + continue; + }; + let receiver = flow.receiver.as_mut().expect("polled receiver"); + if !Arc::ptr_eq(&receiver.wake, &wake) { + continue; + } + let waker = futures_util::task::waker_ref(&wake); + let mut flow_context = Context::from_waker(&waker); + if receiver + .cancelled + .as_mut() + .poll(&mut flow_context) + .is_ready() + || flow.last_used.elapsed() >= idle_timeout + { + return Poll::Ready((wake.key().clone(), None)); + } + match flow.transport.poll_receive(&mut flow_context, scratch) { + Poll::Pending => {} + Poll::Ready(Ok(mut datagram)) => { + wake.enqueue(); + if flow.restore { + if datagram.remote != flow.destination { + continue; + } + datagram.remote = wake.key().clone(); + } + flow.connection.download(datagram.payload.len()); + flow.last_used = Instant::now(); + return Poll::Ready((wake.key().clone(), Some(Ok(datagram)))); + } + Poll::Ready(Err(error)) => { + return Poll::Ready((wake.key().clone(), Some(Err(error)))); + } + } + } + if !ready.is_empty() { + context.waker().wake_by_ref(); + } + Poll::Pending + } + async fn receive_managed(&mut self) -> Result { loop { self.clean_managed().await; - if self.managed_flows.is_empty() { - std::future::pending::<()>().await; - } + let idle_timeout = self.router.tun_config.udp_timeout; let result = { - let receives = self.managed_flows.iter_mut().map(|(key, flow)| Box::pin(async move { - let token = flow.connection.token(); - loop { - let result = tokio::select! { biased; () = token.cancelled() => None, result = flow.transport.receive() => Some(result) }; - if let Some(Ok(mut datagram)) = result { - if flow.restore { - if datagram.remote != flow.destination { continue; } - datagram.remote = key.clone(); + let polled = std::future::poll_fn(|context| { + Self::poll_managed( + &mut self.managed_flows.polled, + self.managed_ready.as_ref().expect("tracked receive queue"), + &mut self.managed_receive_buffer, + idle_timeout, + context, + ) + }); + let asynchronous = async { + if self.managed_flows.asynchronous.is_empty() { + std::future::pending::<()>().await; + } + let receives = self.managed_flows.asynchronous.iter_mut().map(|(key, flow)| Box::pin(async move { + let token = flow.connection.token(); + loop { + if flow.last_used.elapsed() >= idle_timeout { + return (key.clone(), None); + } + let result = tokio::select! { biased; () = token.cancelled() => None, result = flow.transport.receive() => Some(result) }; + if let Some(Ok(mut datagram)) = result { + if flow.last_used.elapsed() >= idle_timeout { + return (key.clone(), None); + } + if flow.restore { + if datagram.remote != flow.destination { continue; } + datagram.remote = key.clone(); + } + flow.connection.download(datagram.payload.len()); + flow.last_used = Instant::now(); + return (key.clone(), Some(Ok(datagram))); } - flow.connection.download(datagram.payload.len()); - flow.last_used = Instant::now(); - return (key.clone(), Some(Ok(datagram))); + return (key.clone(), result); } - return (key.clone(), result); - } - }) as futures_util::future::BoxFuture<'_, _>).collect::>(); - tokio::select! { (result, _, _) = select_all(receives) => Some(result), () = tokio::time::sleep(UDP_FLOW_CLEANUP_INTERVAL) => None } + }) as futures_util::future::BoxFuture<'_, _>).collect::>(); + select_all(receives).await.0 + }; + tokio::select! { + result = polled => Some(result), + result = asynchronous => Some(result), + () = tokio::time::sleep_until(self.managed_next_cleanup) => None, + } }; if let Some((key, result)) = result { if let Some(Ok(datagram)) = result { return Ok(datagram); } if let Some(mut flow) = self.managed_flows.remove(&key) { + if let Some(receiver) = &flow.receiver { + receiver.wake.deactivate(); + } if let Some(Err(error)) = result { flow.connection.failed(&error); } @@ -1660,6 +1849,9 @@ mod tests { #[derive(Default)] struct RecordingDispatcher { tcp_only: bool, + pollable: bool, + receive_polls: Arc, + udp_closes: Arc, tcp_sessions: Mutex>, datagrams: Arc>>, udp_opens: AtomicUsize, @@ -1685,6 +1877,10 @@ mod tests { Ok(Box::new(RecordingDatagrams { sent: self.datagrams.clone(), responses: VecDeque::new(), + pollable: self.pollable, + receive_polls: self.receive_polls.clone(), + closes: self.udp_closes.clone(), + receive_waker: None, })) } } @@ -1692,13 +1888,40 @@ mod tests { struct RecordingDatagrams { sent: Arc>>, responses: VecDeque, + pollable: bool, + receive_polls: Arc, + closes: Arc, + receive_waker: Option, } #[async_trait] impl DatagramTransport for RecordingDatagrams { + fn supports_poll_receive(&self) -> bool { + self.pollable + } + fn poll_receive( + &mut self, + context: &mut Context<'_>, + _scratch: &mut Vec, + ) -> Poll> { + self.receive_polls.fetch_add(1, Ordering::Relaxed); + self.receive_waker = Some(context.waker().clone()); + match self.responses.pop_front() { + Some(response) => Poll::Ready(Ok(response)), + None => Poll::Pending, + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.closes.fetch_add(1, Ordering::Relaxed); + self.receive_waker = None; + Ok(()) + } async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { self.sent.lock().unwrap().push(datagram.clone()); self.responses.push_back(datagram); + if let Some(waker) = &self.receive_waker { + waker.wake_by_ref(); + } Ok(()) } @@ -1991,6 +2214,85 @@ rules: ["MATCH,pool"] ); } + #[tokio::test(start_paused = true)] + async fn managed_udp_connections_poll_only_ready_targets_and_close_pending_flow() { + let config = + crate::config::Config::parse_yaml(b"mixed-port: 1080\nrules: [\"MATCH,DIRECT\"]\n") + .unwrap(); + let direct = Arc::new(RecordingDispatcher { + pollable: true, + ..Default::default() + }); + let rules = RuleSet::compile(config.rules.clone()).unwrap(); + let registry = Arc::new(crate::connections::Registry::default()); + let diagnostics = Arc::new(crate::routing::diagnostics::Diagnostics::new( + &config, + &rules, + registry.clone(), + )); + let router = RoutingDispatcher::new( + ProxyDispatchers::new(Vec::new()).unwrap(), + direct.clone(), + None, + rules, + Arc::new(EmptyGeoMatcher), + ) + .with_diagnostics(diagnostics); + let mut transport = router.routed_datagrams(association(InboundKind::Socks5)); + let addresses = (0..256) + .map(|port| SocketAddr::new("198.51.100.1".parse().unwrap(), 10000 + port)) + .collect::>(); + for address in &addresses { + transport.send(datagram(*address, b"first")).await.unwrap(); + } + for _ in &addresses { + transport.receive().await.unwrap(); + } + // Register all idle readers without retaining futures across calls. + while !transport.managed_ready.as_ref().unwrap().is_empty() { + assert!(futures_util::poll!(transport.receive()).is_pending()); + } + direct.receive_polls.store(0, Ordering::Relaxed); + let deadline = transport.managed_next_cleanup; + transport + .send(datagram(addresses[0], b"ready")) + .await + .unwrap(); + assert_eq!( + transport.receive().await.unwrap().remote, + Destination::Ip(addresses[0]) + ); + assert_eq!(direct.receive_polls.load(Ordering::Relaxed), 1); + assert_eq!(transport.managed_next_cleanup, deadline); + let snapshot = serde_json::to_value(registry.snapshot()).unwrap(); + let entry = snapshot["connections"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["metadata"]["destinationPort"] == addresses[1].port().to_string()) + .unwrap(); + registry.close(entry["id"].as_str().unwrap()); + // Cancellation wakes an idle reader immediately; no timer advance is needed. + assert!(futures_util::poll!(transport.receive()).is_pending()); + assert!( + transport + .managed_flows + .get(&Destination::Ip(addresses[1])) + .is_none() + ); + assert_eq!(direct.udp_closes.load(Ordering::Relaxed), 1); + assert_eq!(Instant::now() + UDP_FLOW_CLEANUP_INTERVAL, deadline); + transport.close().await.unwrap(); + assert_eq!(direct.udp_closes.load(Ordering::Relaxed), 256); + assert!(transport.managed_ready.as_ref().unwrap().is_empty()); + assert!( + serde_json::to_value(registry.snapshot()).unwrap()["connections"] + .as_array() + .unwrap() + .is_empty() + ); + } + #[tokio::test] async fn udp_rules_skip_incapable_targets_without_changing_explicit_modes() { for target in ["edge", "outer", "disabled"] { diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 7e40619..74adf81 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -17,6 +17,7 @@ use crate::{ }; mod balance; +pub(super) mod datagram_ready; mod selection; pub(crate) use selection::GroupSelection; diff --git a/tests/README.md b/tests/README.md index 082fc78..315fb73 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,6 +18,8 @@ cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --locked --all-features --lib connections::tests:: cargo test --locked --all-features --lib routing::diagnostics::tests:: +cargo test --locked --all-features --lib routing::datagram_ready::tests:: +cargo test --locked --all-features --lib polled_datagram_ cargo test --locked --all-features --lib managed_udp_connections_ cargo test --locked --all-features --lib runtime::tests::controller_diagnostics_are_opt_in_for_business_routes cargo test --locked --all-features --lib routing::balance::tests:: From 58847c6216cfbb2048320ec449c1e2a381ee1e17 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 09:45:45 +0800 Subject: [PATCH 025/445] Fix concurrent DNS IPv6 resolution and bounded IPv6 wait --- .github/workflows/test.yml | 1 + docs/config.yaml | 1 + docs/tun-icmp-dns.md | 8 +- src/config/dns.rs | 8 ++ src/config/mod.rs | 19 ++++ src/dns/runtime.rs | 211 ++++++++++++++++++++++++++++++++++--- tests/README.md | 1 + 7 files changed, 231 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 107a158..08a10f8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -188,6 +188,7 @@ jobs: polled_datagram_ resource::http::tests:: fake_ip_ sniffing::tests:: tcp_sniffer::tests:: dns::fallback::tests:: dns::runtime::tests::dns_fallback_ + dns::runtime::tests::resolve_ipv6_ dns::runtime::encrypted_tests:: outbound::http::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: inbound::http::parser::tests:: inbound::http::framing::tests:: diff --git a/docs/config.yaml b/docs/config.yaml index 904cf06..ae8a7f8 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -553,6 +553,7 @@ hosts: dns: enable: true ipv6: true + ipv6-timeout: 100 # 毫秒;内部 A/AAAA 并发,A 完成后最多等待 AAAA;0 同缺省 100 fallback: [] # A/AAAA 的备用解析组;不是 nameserver 内部的故障转移 fallback-lazy-query: false # false:与主组同时查询;true:确定需要时才查询 fallback-filter: diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 59b3aea..7807ed0 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -205,6 +205,12 @@ A/AAAA 缓存最多 256 项,从空容量按需增长。TUN 域名提示存储 `default-nameserver` 仅引导上游域名。`proxy-server-nameserver` 与其 policy 用于节点解析;`direct-nameserver` 用于直连,`direct-nameserver-follow-policy` 决定是否使用业务 policy。各角色拥有独立 cache/singleflight,未配置节点或直连 resolver 时复用主 resolver。有限并发、取消与连接池适用于各角色,依赖循环不会等待自身的 singleflight。 +启用有效 IPv6 时,内部地址解析并发请求 A/AAAA,结果按 IPv4、IPv6 排列。 +`dns.ipv6-timeout` 单位为毫秒,缺省或 0 使用 100;从 A 完成后开始等待 AAAA, +不改变整个解析的五秒期限。任一地址族失败不丢弃另一族的有效地址;等待到期或调用方 +取消时释放未完成查询。IPv6 关闭时只查询 A。TUN 客户端直接 A/AAAA 查询仍保留原有 +wire/RCODE 语义,不使用这段地址合并等待。 + ## 查询、故障转移与连接复用 - 查询总期限 5 秒;单 nameserver 尝试最多 3 秒,并受剩余时间限制。 @@ -262,7 +268,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; - DoQ、DoH HTTP/2/3、显式 system/DHCP nameserver; - 动态 hosts: lan; -- 用户可配置的缓存容量、超时、重试或并发度; +- 用户可配置的缓存容量、查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 自动化和物理 TUN 覆盖范围见 [验收矩阵](acceptance.md)。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 5fead33..f22279e 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -13,6 +13,9 @@ pub(super) fn default_bootstrap() -> Vec { pub(super) fn redir_host() -> String { "redir-host".into() } +pub(super) fn default_ipv6_timeout() -> u64 { + 100 +} impl Default for DnsConfig { fn default() -> Self { @@ -70,6 +73,11 @@ impl RawDnsConfig { fake_ip, enable: self.enable, ipv6: self.ipv6, + ipv6_timeout: Duration::from_millis(if self.ipv6_timeout == 0 { + default_ipv6_timeout() + } else { + self.ipv6_timeout + }), nameservers: parse(self.nameserver, main_route)?, nameserver_policies: normalize_dns_nameserver_policies( self.nameserver_policies, diff --git a/src/config/mod.rs b/src/config/mod.rs index e807bbf..b6146d0 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -273,6 +273,7 @@ pub struct DnsConfig { pub fallback: DnsFallbackConfig, pub enable: bool, pub ipv6: bool, + pub ipv6_timeout: Duration, pub nameservers: Vec, pub nameserver_policies: Vec, pub default_nameservers: Vec, @@ -1246,6 +1247,8 @@ struct RawDnsConfig { enable: bool, #[serde(default)] ipv6: bool, + #[serde(rename = "ipv6-timeout", default = "dns::default_ipv6_timeout")] + ipv6_timeout: u64, #[serde(default = "dns::default_nameservers")] nameserver: Vec, #[serde(rename = "default-nameserver", default = "dns::default_bootstrap")] @@ -5212,6 +5215,22 @@ authentication: assert!(!dns_disabled.dns.ipv6); } + #[test] + fn dns_ipv6_timeout_uses_milliseconds_and_zero_retains_the_default() { + for (field, milliseconds) in [ + ("", 100), + (" ipv6-timeout: 0\n", 100), + (" ipv6-timeout: 37\n", 37), + ] { + let config = Config::parse_yaml( + format!("mixed-port: 1080\ndns:\n ipv6: true\n{field}").as_bytes(), + ) + .unwrap(); + assert_eq!(config.dns.ipv6_timeout, Duration::from_millis(milliseconds)); + } + assert!(Config::parse_yaml(b"mixed-port: 1080\ndns:\n ipv6-timeout: -1\n").is_err()); + } + #[test] fn current_config_requires_listener_and_valid_references() { for yaml in [ diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 6715f1d..13eeb94 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -90,6 +90,7 @@ pub enum RuntimeDnsError { pub struct RuntimeDns { enabled: bool, ipv6: bool, + ipv6_timeout: Duration, nameservers: Box<[DnsNameserver]>, hosts: super::hosts::Hosts, fallback: crate::config::DnsFallbackConfig, @@ -1005,6 +1006,7 @@ impl RuntimeDns { Self { enabled: config.enable, ipv6: config.ipv6, + ipv6_timeout: config.ipv6_timeout, hosts: super::hosts::Hosts::new( &config.hosts, config.use_hosts, @@ -1132,8 +1134,9 @@ impl RuntimeDns { } } - /// Resolves a canonical domain through A followed by AAAA. AAAA is skipped - /// completely when `dns.ipv6` is false. + /// Resolves A and AAAA concurrently, retaining IPv4 first and waiting at + /// most `dns.ipv6-timeout` for IPv6 after A completes. Dropping this future + /// cancels both lookups; no detached query outlives its caller. pub async fn resolve(&self, host: &str) -> Result, RuntimeDnsError> { super::trace::record("resolve"); let host = canonicalize_name(host).map_err(RuntimeDnsError::InvalidQuery)?; @@ -1170,21 +1173,47 @@ impl RuntimeDns { .filter(|ip| self.ipv6 || ip.is_ipv4()) .collect()); } - let mut addresses = Vec::new(); - - match self.resolve_type(&host, QueryType::A).await? { - LookupResult::Positive(mut values) => addresses.append(&mut values), - LookupResult::NoData => {} - LookupResult::NxDomain => return Ok(addresses), + let deadline = super::resolution::inherited_deadline(TokioInstant::now() + QUERY_TIMEOUT); + let ipv4 = self.resolve_type(&host, QueryType::A, deadline); + if !self.ipv6 { + return match ipv4.await? { + LookupResult::Positive(addresses) => Ok(addresses), + LookupResult::NoData | LookupResult::NxDomain => Ok(Vec::new()), + }; } - - if self.ipv6 { - match self.resolve_type(&host, QueryType::Aaaa).await? { - LookupResult::Positive(mut values) => addresses.append(&mut values), - LookupResult::NoData | LookupResult::NxDomain => {} + let ipv6 = self.resolve_type(&host, QueryType::Aaaa, deadline); + tokio::pin!(ipv4, ipv6); + let mut ipv6_result = None; + let ipv4_result = tokio::select! { + biased; + result = &mut ipv4 => result, + result = &mut ipv6 => { + ipv6_result = Some(result); + ipv4.await } + }; + if ipv6_result.is_none() { + let wait_until = TokioInstant::now() + .checked_add(self.ipv6_timeout) + .unwrap_or(deadline) + .min(deadline); + ipv6_result = timeout_at(wait_until, &mut ipv6).await.ok(); + } + let mut addresses = match ipv4_result { + Ok(LookupResult::Positive(addresses)) => addresses, + Ok(LookupResult::NoData | LookupResult::NxDomain) => Vec::new(), + Err(error) => { + return match ipv6_result { + Some(Ok(LookupResult::Positive(addresses))) => Ok(addresses), + _ => Err(error), + }; + } + }; + match ipv6_result { + Some(Ok(LookupResult::Positive(mut values))) => addresses.append(&mut values), + Some(Err(error)) if addresses.is_empty() => return Err(error), + _ => {} } - Ok(addresses) } @@ -1627,11 +1656,15 @@ impl RuntimeDns { &self, host: &str, query_type: QueryType, + deadline: TokioInstant, ) -> Result { let id = self.next_id.fetch_add(1, Ordering::Relaxed); let query = build_query(id, host, query_type).map_err(RuntimeDnsError::InvalidQuery)?; let classified = classify_query(&query).map_err(RuntimeDnsError::InvalidQuery)?; - let permit = self.begin_query(); + let permit = DnsQueryPermit { + deadline, + ..self.begin_query() + }; let response = self .exchange_classified_admitted(&query, &classified, &permit, FailureMode::BoundedError) .await?; @@ -4456,7 +4489,7 @@ dns: } #[tokio::test] - async fn ipv6_false_returns_local_nodata_and_resolve_never_queries_aaaa() { + async fn resolve_ipv6_disabled_returns_local_nodata_and_skips_aaaa() { let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 14)); let dispatcher = Arc::new(MockDispatcher::with_replies( vec![MockReply::Response(ResponseSpec::answer(address))], @@ -4484,7 +4517,7 @@ dns: } #[tokio::test] - async fn ipv6_true_resolves_a_before_aaaa() { + async fn resolve_ipv6_merges_answers_with_ipv4_first() { let ipv4 = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 15)); let ipv6 = IpAddr::V6("2001:db8::15".parse().unwrap()); let dispatcher = Arc::new(MockDispatcher::with_replies( @@ -4506,6 +4539,150 @@ dns: ); } + #[tokio::test] + async fn resolve_ipv6_keeps_successful_answers_when_the_other_stack_fails() { + let ipv4 = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 15)); + let ipv6 = IpAddr::V6("2001:db8::15".parse().unwrap()); + for (replies, expected) in [ + ( + vec![ + MockReply::Response(ResponseSpec::answer(ipv4)), + MockReply::TransportError, + ], + Some(ipv4), + ), + ( + vec![ + MockReply::TransportError, + MockReply::Response(ResponseSpec::answer(ipv6)), + ], + Some(ipv6), + ), + ( + vec![MockReply::TransportError, MockReply::TransportError], + None, + ), + ] { + let dispatcher = Arc::new(MockDispatcher::with_replies(replies, vec![])); + let resolver = RuntimeDns::new( + &config(true, vec![nameserver(1, DnsTransport::Udp)]), + dispatcher, + ); + let result = resolver.resolve("example.com").await; + if let Some(address) = expected { + assert_eq!(result.unwrap(), [address]); + } else { + assert!(result.is_err()); + } + assert_eq!(resolver.resource_stats.snapshot().dns_current, 0); + assert_eq!(resolver.resource_stats.snapshot().singleflight_current, 0); + } + } + + #[tokio::test(start_paused = true)] + async fn resolve_ipv6_queries_concurrently_and_waits_from_ipv4_completion() { + let ipv4 = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 15)); + let ipv6 = IpAddr::V6("2001:db8::15".parse().unwrap()); + let release_a = Arc::new(Notify::new()); + let release_aaaa = Arc::new(Notify::new()); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Gated { + response: ResponseSpec::answer(ipv4), + release: release_a.clone(), + }, + MockReply::Gated { + response: ResponseSpec::answer(ipv6), + release: release_aaaa.clone(), + }, + ], + vec![], + )); + let mut dns_config = config(true, vec![nameserver(1, DnsTransport::Udp)]); + dns_config.ipv6_timeout = Duration::from_millis(50); + let resolver = Arc::new(RuntimeDns::new(&dns_config, dispatcher.clone())); + let lookup = tokio::spawn({ + let resolver = resolver.clone(); + async move { resolver.resolve("example.com").await } + }); + for _ in 0..10 { + if dispatcher.udp_queries.lock().unwrap().len() == 2 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!( + *dispatcher.udp_queries.lock().unwrap(), + [QueryType::A as u16, QueryType::Aaaa as u16] + ); + tokio::time::advance(Duration::from_millis(500)).await; + assert!(!lookup.is_finished()); + release_a.notify_one(); + for _ in 0..10 { + if resolver.resource_stats.snapshot().dns_current == 1 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(resolver.resource_stats.snapshot().dns_current, 1); + tokio::time::advance(Duration::from_millis(49)).await; + assert!(!lookup.is_finished()); + release_aaaa.notify_one(); + assert_eq!(lookup.await.unwrap().unwrap(), [ipv4, ipv6]); + } + + #[tokio::test(start_paused = true)] + async fn resolve_ipv6_timeout_releases_the_pending_query() { + let ipv4 = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 15)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Response(ResponseSpec::answer(ipv4)), + MockReply::Pending, + ], + vec![], + )); + let resolver = RuntimeDns::new( + &config(true, vec![nameserver(1, DnsTransport::Udp)]), + dispatcher.clone(), + ); + let started = TokioInstant::now(); + assert_eq!(resolver.resolve("example.com").await.unwrap(), [ipv4]); + assert_eq!(TokioInstant::now() - started, Duration::from_millis(100)); + assert_eq!( + *dispatcher.udp_queries.lock().unwrap(), + [QueryType::A as u16, QueryType::Aaaa as u16] + ); + assert_eq!(resolver.resource_stats.snapshot().dns_current, 0); + assert_eq!(resolver.resource_stats.snapshot().singleflight_current, 0); + } + + #[tokio::test] + async fn resolve_ipv6_cancellation_releases_both_queries() { + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![MockReply::Pending, MockReply::Pending], + vec![], + )); + let resolver = Arc::new(RuntimeDns::new( + &config(true, vec![nameserver(1, DnsTransport::Udp)]), + dispatcher.clone(), + )); + let lookup = tokio::spawn({ + let resolver = resolver.clone(); + async move { resolver.resolve("example.com").await } + }); + for _ in 0..10 { + if dispatcher.udp_queries.lock().unwrap().len() == 2 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(resolver.resource_stats.snapshot().dns_current, 2); + lookup.abort(); + assert!(lookup.await.unwrap_err().is_cancelled()); + assert_eq!(resolver.resource_stats.snapshot().dns_current, 0); + assert_eq!(resolver.resource_stats.snapshot().singleflight_current, 0); + } + #[tokio::test] async fn configured_tcp_tc_response_fails_over() { let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 18)); diff --git a/tests/README.md b/tests/README.md index 315fb73..b17a852 100644 --- a/tests/README.md +++ b/tests/README.md @@ -30,6 +30,7 @@ cargo test --locked --all-features --lib sniffing::tests:: cargo test --locked --all-features --lib tcp_sniffer::tests:: cargo test --locked --all-features --lib dns::fallback::tests:: cargo test --locked --all-features --lib dns::runtime::tests::dns_fallback_ +cargo test --locked --all-features --lib dns::runtime::tests::resolve_ipv6_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 50d9fe4ca87ff8b1cb80383067b8643b20b616ab Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 10:17:58 +0800 Subject: [PATCH 026/445] Report the actual TUIC congestion controller at session startup --- docs/outbounds.md | 2 ++ src/outbound/tuic/outbound.rs | 44 +++++++++++++++++++++++++++-------- tests/tuic_memory.rs | 1 + 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/docs/outbounds.md b/docs/outbounds.md index c9b0b5a..6f8b4e6 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -182,6 +182,8 @@ pacer;这是混淆不是认证,错误密钥由 QUIC 拒绝,短包有界丢 SNI 缺省 server,ALPN 缺省 h3;显式空列表拒绝,非空有序列表必须实际协商命中。 使用共享 pin/skip/独立验证名;无命名 TCP 指纹、mTLS、ECH、0-RTT 或跨节点恢复。 拥塞使用官方 Quinn cubic(默认)、new_reno 或 bbr,不承诺与其他实现的吞吐相同。 +`log-level: debug` 在物理会话建立后记录实际 controller 的固定算法标签; +标签不包含节点、凭据或业务目标,配置验证和复用同一会话不重复记录。 认证使用当前真实 TLS 会话 exporter,UUID 为 label,原样 password 为 context。 Authenticate 与 Connect 没有成功 ACK;本地 open 成功不是密码通过证明,错误身份 diff --git a/src/outbound/tuic/outbound.rs b/src/outbound/tuic/outbound.rs index b3c94ef..4d3a2f0 100644 --- a/src/outbound/tuic/outbound.rs +++ b/src/outbound/tuic/outbound.rs @@ -47,6 +47,38 @@ struct Session { activity: Arc, } +fn active_congestion(connection: &quinn::Connection) -> Option { + let controller = connection.congestion_state().into_any(); + if controller.is::() { + Some(TuicCongestion::Cubic) + } else if controller.is::() { + Some(TuicCongestion::NewReno) + } else if controller.is::() { + Some(TuicCongestion::Bbr) + } else { + None + } +} + +fn log_active_congestion(connection: &quinn::Connection) { + // Fixed targets survive the CLI's restricted field renderer without + // exposing peer, credential or traffic metadata. + match active_congestion(connection) { + Some(TuicCongestion::Cubic) => { + tracing::debug!(target: "vole::outbound::tuic::congestion::cubic", "TUIC controller ready"); + } + Some(TuicCongestion::NewReno) => { + tracing::debug!(target: "vole::outbound::tuic::congestion::new_reno", "TUIC controller ready"); + } + Some(TuicCongestion::Bbr) => { + tracing::debug!(target: "vole::outbound::tuic::congestion::bbr", "TUIC controller ready"); + } + None => { + tracing::debug!(target: "vole::outbound::tuic::congestion::unknown", "TUIC controller ready"); + } + } +} + impl std::fmt::Debug for TuicOutbound { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TuicOutbound").finish_non_exhaustive() @@ -68,16 +100,7 @@ impl TuicOutbound { #[cfg(feature = "interop-test")] pub async fn congestion_observation(&self) -> Option { let current = self.current.lock().await; - let controller = current.as_ref()?.connection.congestion_state().into_any(); - if controller.is::() { - Some(TuicCongestion::Cubic) - } else if controller.is::() { - Some(TuicCongestion::NewReno) - } else if controller.is::() { - Some(TuicCongestion::Bbr) - } else { - None - } + active_congestion(¤t.as_ref()?.connection) } pub fn new_with_path(config: &TuicOutboundConfig, upstream: UpstreamPath) -> io::Result { @@ -318,6 +341,7 @@ async fn drive( .map_err(failure)?; send.finish().map_err(failure)?; auth.fill(0); + log_active_congestion(&connection); drop(handshake); let session = Arc::new(Session { connection: connection.clone(), diff --git a/tests/tuic_memory.rs b/tests/tuic_memory.rs index b78e327..e58e97f 100644 --- a/tests/tuic_memory.rs +++ b/tests/tuic_memory.rs @@ -183,6 +183,7 @@ async fn authenticates_current_session_without_ack_and_preserves_stream_isolatio ] { let probe = ResourceProbe::default(); let (outbound, endpoint) = fixture(1200, true, 8, algorithm); + assert_eq!(outbound.congestion_observation().await, None); let ep = endpoint.clone(); let peer = tokio::spawn(async move { let connection = authenticated(&ep).await; From 19571f8e9d26c8a0e1f65b5e94c5c2b94412f680 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 10:18:31 +0800 Subject: [PATCH 027/445] Add indexed DNS LRU and configurable per-role cache capacity --- docs/config.yaml | 1 + docs/tun-icmp-dns.md | 12 +- src/config/dns.rs | 5 + src/config/mod.rs | 20 +++ src/dns/cache.rs | 275 ++++++++++++++++++++++++++++ src/dns/mod.rs | 416 ++++++++++++++++++++----------------------- src/dns/runtime.rs | 92 ++++++++-- src/limits.rs | 3 +- src/runtime.rs | 2 +- tests/README.md | 3 + 10 files changed, 590 insertions(+), 239 deletions(-) create mode 100644 src/dns/cache.rs diff --git a/docs/config.yaml b/docs/config.yaml index ae8a7f8..b197ce3 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -554,6 +554,7 @@ dns: enable: true ipv6: true ipv6-timeout: 100 # 毫秒;内部 A/AAAA 并发,A 完成后最多等待 AAAA;0 同缺省 100 + cache-max-size: 4096 # 每个 DNS 角色的结果总条目数;0 同缺省 4096,A/AAAA 和 wire 共用 LRU fallback: [] # A/AAAA 的备用解析组;不是 nameserver 内部的故障转移 fallback-lazy-query: false # false:与主组同时查询;true:确定需要时才查询 fallback-filter: diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 7807ed0..9e7f54a 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -176,13 +176,19 @@ bootstrap、单独配置的节点/直连 resolver,以及非地址 qtype 不使 ## 缓存与 singleflight -### 类型化缓存 +### 结果缓存 -A/AAAA 缓存最多 256 项,从空容量按需增长。TUN 域名提示存储启用时同样最多 256 项,并按 TTL 失效。 +`dns.cache-max-size` 控制每个 DNS 角色的结果总条目数,缺省或零值为 4,096。 +A/AAAA 的类型化结果和其他 qtype 的原始响应共享这份容量与 LRU 顺序。 +命中和替换将条目移到最近使用端;满容量时淘汰最久未使用的结果,命中不延长 TTL。 +哈希索引和安全槽位链表按需分配,查询时只检查命中条目的寿命,过期结果重新查询。 +类型化结果仍最多保留 16 个地址,负缓存仍保留 30 秒。 +TUN 域名提示独立保留最多 256 项并按 TTL 失效;fake-IP 映射不计入这份结果缓存。 ### 原始响应缓存 -- 最多 64 项,总保留内存 256 KiB,单响应最大 4,096 字节。 +- 在结果总容量内额外限制为最多 64 项、总保留内存 256 KiB,单响应最大 4,096 字节。 + 原始响应的 key、TTL 偏移及条目索引/链表元数据计入字节预算;此预算满时淘汰最久未使用的原始响应。 - Key 包含规范化 question、qclass、qtype 和移除 transaction ID 后的完整查询语义 SHA-256。 - 命中时恢复调用方 transaction ID 和 RD,并按剩余寿命更新所有可缓存 TTL。 - 正缓存寿命取可缓存记录的最小 TTL,并限制在 30–3,600 秒。 diff --git a/src/config/dns.rs b/src/config/dns.rs index f22279e..35a15cd 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -78,6 +78,11 @@ impl RawDnsConfig { } else { self.ipv6_timeout }), + cache_max_size: if self.cache_max_size == 0 { + crate::dns::MAX_CACHE_ENTRIES + } else { + self.cache_max_size + }, nameservers: parse(self.nameserver, main_route)?, nameserver_policies: normalize_dns_nameserver_policies( self.nameserver_policies, diff --git a/src/config/mod.rs b/src/config/mod.rs index b6146d0..0dcaf95 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -274,6 +274,7 @@ pub struct DnsConfig { pub enable: bool, pub ipv6: bool, pub ipv6_timeout: Duration, + pub cache_max_size: usize, pub nameservers: Vec, pub nameserver_policies: Vec, pub default_nameservers: Vec, @@ -1249,6 +1250,8 @@ struct RawDnsConfig { ipv6: bool, #[serde(rename = "ipv6-timeout", default = "dns::default_ipv6_timeout")] ipv6_timeout: u64, + #[serde(rename = "cache-max-size", default)] + cache_max_size: usize, #[serde(default = "dns::default_nameservers")] nameserver: Vec, #[serde(rename = "default-nameserver", default = "dns::default_bootstrap")] @@ -5231,6 +5234,23 @@ authentication: assert!(Config::parse_yaml(b"mixed-port: 1080\ndns:\n ipv6-timeout: -1\n").is_err()); } + #[test] + fn dns_cache_max_size_uses_entries_and_zero_retains_the_default() { + for (field, entries) in [ + ("", 4096), + (" cache-max-size: 0\n", 4096), + (" cache-max-size: 2\n", 2), + (" cache-max-size: 8192\n", 8192), + ] { + let config = + Config::parse_yaml(format!("mixed-port: 1080\ndns:\n{field}").as_bytes()).unwrap(); + assert_eq!(config.dns.cache_max_size, entries); + } + assert!(Config::parse_yaml(b"mixed-port: 1080\ndns:\n cache-max-size: -1\n").is_err()); + // ARC is not accepted until its actual replacement policy exists. + assert!(Config::parse_yaml(b"mixed-port: 1080\ndns:\n cache-algorithm: arc\n").is_err()); + } + #[test] fn current_config_requires_listener_and_valid_references() { for yaml in [ diff --git a/src/dns/cache.rs b/src/dns/cache.rs new file mode 100644 index 0000000..26e8a01 --- /dev/null +++ b/src/dns/cache.rs @@ -0,0 +1,275 @@ +//! Single-owner hash index and safe slot-linked DNS retention order. + +use std::{collections::HashMap, sync::Arc, time::Instant}; + +use super::{ + CacheValue, DnsError, MAX_OPAQUE_CACHE_BYTES, MAX_OPAQUE_CACHE_ENTRIES, QueryType, Result, + WireQuestion, +}; + +#[derive(Debug, PartialEq, Eq, Hash)] +pub(super) enum Key { + Address(String, QueryType), + Opaque(WireQuestion, [u8; 32]), +} + +#[derive(Debug)] +pub(super) enum Value { + Address(CacheValue), + Opaque { + wire: Box<[u8]>, + ttl_offsets: Box<[u16]>, + accounted_bytes: usize, + }, +} + +impl Value { + fn opaque_bytes(&self) -> Option { + match self { + Self::Address(_) => None, + Self::Opaque { + accounted_bytes, .. + } => Some(*accounted_bytes), + } + } +} + +#[derive(Debug)] +struct Entry { + key: Arc, + value: Value, + expires_at: Instant, + previous: Option, + next: Option, + opaque_previous: Option, + opaque_next: Option, +} + +#[derive(Debug)] +enum Slot { + Occupied(Entry), + Vacant(Option), +} + +/// Metadata retained alongside each opaque payload is part of its byte budget. +pub(super) const fn opaque_metadata_bytes() -> usize { + std::mem::size_of::() + + std::mem::size_of::() + + std::mem::size_of::<(Arc, usize)>() + + 2 * std::mem::size_of::() // Arc reference counters +} + +/// One role's total result budget, shared by address and opaque questions. +/// Slots and the hash table start empty and grow only after an actual insert. +/// Separate opaque links permit byte-budget eviction without scanning typed +/// entries or evicting an unrelated address result. +#[derive(Debug)] +pub(super) struct Store { + index: HashMap, usize>, + slots: Vec, + free: Option, + least_recent: Option, + most_recent: Option, + opaque_least_recent: Option, + opaque_most_recent: Option, + max_entries: usize, + opaque_entries: usize, + opaque_bytes: usize, +} + +impl Store { + pub(super) fn new(max_entries: usize) -> Self { + assert!(max_entries != 0, "DNS cache capacity must be non-zero"); + Self { + index: HashMap::new(), + slots: Vec::new(), + free: None, + least_recent: None, + most_recent: None, + opaque_least_recent: None, + opaque_most_recent: None, + max_entries, + opaque_entries: 0, + opaque_bytes: 0, + } + } + + pub(super) fn len(&self) -> usize { + self.index.len() + } + + pub(super) const fn opaque_len(&self) -> usize { + self.opaque_entries + } + + pub(super) const fn opaque_bytes(&self) -> usize { + self.opaque_bytes + } + + pub(super) fn clear(&mut self) { + *self = Self::new(self.max_entries); + } + + pub(super) fn get(&mut self, key: &Key, now: Instant) -> Option<(&Value, Instant)> { + let slot = *self.index.get(key)?; + if self.entry(slot).expires_at <= now { + self.remove(slot); + return None; + } + self.unlink(slot); + self.append(slot); + let entry = self.entry(slot); + Some((&entry.value, entry.expires_at)) + } + + pub(super) fn insert(&mut self, key: Key, value: Value, expires_at: Instant) -> Result<()> { + let replaced = self.index.get(&key).copied(); + if replaced.is_none() && self.len() < self.max_entries { + self.index + .try_reserve(1) + .map_err(|_| DnsError::CacheAllocation)?; + if self.free.is_none() { + super::reserve_entry_slot(&mut self.slots, self.max_entries)?; + } + } + if let Some(slot) = replaced { + self.remove(slot); + } + if let Some(bytes) = value.opaque_bytes() { + while self.opaque_entries >= MAX_OPAQUE_CACHE_ENTRIES + || self.opaque_bytes.saturating_add(bytes) > MAX_OPAQUE_CACHE_BYTES + { + let Some(slot) = self.opaque_least_recent else { + return Err(DnsError::CacheAllocation); + }; + self.remove(slot); + } + } + if self.len() >= self.max_entries { + self.remove( + self.least_recent + .expect("full cache has a least recent entry"), + ); + } + let slot = if let Some(slot) = self.free { + let Slot::Vacant(next) = self.slots[slot] else { + unreachable!("free cache slot must be vacant"); + }; + self.free = next; + slot + } else { + self.slots.push(Slot::Vacant(None)); + self.slots.len() - 1 + }; + let key = Arc::new(key); + if let Some(bytes) = value.opaque_bytes() { + self.opaque_entries += 1; + self.opaque_bytes += bytes; + } + self.index.insert(key.clone(), slot); + self.slots[slot] = Slot::Occupied(Entry { + key, + value, + expires_at, + previous: None, + next: None, + opaque_previous: None, + opaque_next: None, + }); + self.append(slot); + debug_assert!(self.len() <= self.max_entries); + debug_assert!(self.slots.len() <= self.max_entries); + debug_assert!(self.opaque_entries <= MAX_OPAQUE_CACHE_ENTRIES); + debug_assert!(self.opaque_bytes <= MAX_OPAQUE_CACHE_BYTES); + Ok(()) + } + + fn entry(&self, slot: usize) -> &Entry { + match &self.slots[slot] { + Slot::Occupied(entry) => entry, + Slot::Vacant(_) => unreachable!("linked cache slot must be occupied"), + } + } + + fn entry_mut(&mut self, slot: usize) -> &mut Entry { + match &mut self.slots[slot] { + Slot::Occupied(entry) => entry, + Slot::Vacant(_) => unreachable!("linked cache slot must be occupied"), + } + } + + fn unlink(&mut self, slot: usize) { + let entry = self.entry(slot); + let (previous, next) = (entry.previous, entry.next); + let opaque = entry.value.opaque_bytes().is_some(); + let (opaque_previous, opaque_next) = (entry.opaque_previous, entry.opaque_next); + if let Some(previous) = previous { + self.entry_mut(previous).next = next; + } else { + self.least_recent = next; + } + if let Some(next) = next { + self.entry_mut(next).previous = previous; + } else { + self.most_recent = previous; + } + if opaque { + if let Some(previous) = opaque_previous { + self.entry_mut(previous).opaque_next = opaque_next; + } else { + self.opaque_least_recent = opaque_next; + } + if let Some(next) = opaque_next { + self.entry_mut(next).opaque_previous = opaque_previous; + } else { + self.opaque_most_recent = opaque_previous; + } + } + } + + fn append(&mut self, slot: usize) { + let previous = self.most_recent; + let opaque_previous = self.opaque_most_recent; + let entry = self.entry_mut(slot); + entry.previous = previous; + entry.next = None; + let opaque = entry.value.opaque_bytes().is_some(); + if let Some(previous) = previous { + self.entry_mut(previous).next = Some(slot); + } else { + self.least_recent = Some(slot); + } + self.most_recent = Some(slot); + if opaque { + let entry = self.entry_mut(slot); + entry.opaque_previous = opaque_previous; + entry.opaque_next = None; + if let Some(previous) = opaque_previous { + self.entry_mut(previous).opaque_next = Some(slot); + } else { + self.opaque_least_recent = Some(slot); + } + self.opaque_most_recent = Some(slot); + } + } + + fn remove(&mut self, slot: usize) { + self.unlink(slot); + let Slot::Occupied(entry) = + std::mem::replace(&mut self.slots[slot], Slot::Vacant(self.free)) + else { + unreachable!("removed cache slot must be occupied"); + }; + self.free = Some(slot); + self.index.remove(entry.key.as_ref()); + if let Some(bytes) = entry.value.opaque_bytes() { + self.opaque_entries -= 1; + self.opaque_bytes -= bytes; + } + } + + #[cfg(test)] + pub(super) fn allocated_slots(&self) -> usize { + self.slots.capacity() + } +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 3f7a9d4..ca2768d 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -2,9 +2,11 @@ //! //! This module deliberately contains no network transport. Callers own query //! scheduling and cancellation. Every allocation retained by the cache and -//! redir-host table is bounded by the constants below, while concurrent query +//! redir-host table is bounded by the configured count and wire/record limits, +//! while concurrent query //! activity is observed rather than rejected by a fixed local admission cap. +mod cache; pub(crate) mod fake_ip; mod fallback; pub(crate) mod hosts; @@ -30,7 +32,8 @@ pub const MAX_RESPONSE_RECORDS: usize = 64; pub const MAX_ANSWERS: usize = 16; pub const MAX_NAME_LEN: usize = 253; pub const MAX_COMPRESSION_JUMPS: usize = 16; -pub const MAX_CACHE_ENTRIES: usize = 256; +/// Default result count per DNS role; configuration may choose another count. +pub const MAX_CACHE_ENTRIES: usize = 4_096; pub const MAX_REDIR_HOST_ENTRIES: usize = 256; pub const MAX_OPAQUE_RECORDS: usize = 64; pub const MAX_OPAQUE_CACHE_ENTRIES: usize = 64; @@ -942,23 +945,12 @@ pub enum CacheValue { }, } -#[derive(Debug)] -struct CacheEntry { - name: String, - query_type: QueryType, - value: CacheValue, - expires_at: Instant, - sequence: u64, -} - -/// Fixed-capacity, single-owner DNS cache. It can be placed behind the -/// runtime's existing lock when shared; mutation requires `&mut self` so no -/// internal task or allocation can grow independently. +/// Single-owner result cache with a shared count for typed and opaque questions. +/// Lookups, recency updates and capacity eviction use a hash index and linked +/// slots; payloads remain bounded by their respective address/wire budgets. #[derive(Debug)] pub struct DnsCache { - entries: Vec, - max_entries: usize, - next_sequence: u64, + store: cache::Store, } impl Default for DnsCache { @@ -975,26 +967,23 @@ impl DnsCache { #[must_use] pub(crate) fn with_max_entries(max_entries: usize) -> Self { - assert!(max_entries != 0, "DNS cache capacity must be non-zero"); Self { - entries: Vec::new(), - max_entries, - next_sequence: 0, + store: cache::Store::new(max_entries), } } #[must_use] pub fn len(&self) -> usize { - self.entries.len() + self.store.len() } #[must_use] pub fn is_empty(&self) -> bool { - self.entries.is_empty() + self.len() == 0 } pub fn clear(&mut self) { - self.entries.clear(); + self.store.clear(); } pub fn get(&mut self, name: &str, query_type: QueryType, now: Instant) -> Option { @@ -1002,28 +991,18 @@ impl DnsCache { .map(|(value, _remaining_ttl)| value) } - /// Returns a cached value together with its remaining wire TTL. Keeping - /// the remaining TTL prevents a synthesized cache response from extending - /// an upstream record beyond this cache entry's own expiry. + /// Hits refresh recency without extending the entry's own expiry. pub fn get_with_ttl( &mut self, name: &str, query_type: QueryType, now: Instant, ) -> Option<(CacheValue, u32)> { - self.remove_expired(now); - let name = canonicalize_name(name).ok()?; - self.entries - .iter() - .find(|entry| entry.name == name && entry.query_type == query_type) - .map(|entry| { - let remaining_ttl = entry - .expires_at - .saturating_duration_since(now) - .as_secs() - .min(u64::from(u32::MAX)) as u32; - (entry.value.clone(), remaining_ttl) - }) + let key = cache::Key::Address(canonicalize_name(name).ok()?, query_type); + let (cache::Value::Address(value), expires_at) = self.store.get(&key, now)? else { + return None; + }; + Some((value.clone(), remaining_ttl(expires_at, now))) } pub fn insert_positive( @@ -1048,12 +1027,10 @@ impl DnsCache { }) { return Err(DnsError::AddressFamilyMismatch); } - let mut retained = Vec::with_capacity(addresses.len()); - retained.extend_from_slice(addresses); self.insert( name, query_type, - CacheValue::Positive(retained), + CacheValue::Positive(addresses.to_vec()), clamp_ttl(ttl), now, ) @@ -1086,62 +1063,87 @@ impl DnsCache { ttl: u32, now: Instant, ) -> Result<()> { - let name = canonicalize_name(name)?; - self.remove_expired(now); - let expires_at = expiry(now, ttl); - let sequence = self.take_sequence(); - - if let Some(entry) = self - .entries - .iter_mut() - .find(|entry| entry.name == name && entry.query_type == query_type) - { - *entry = CacheEntry { - name, - query_type, - value, - expires_at, - sequence, - }; - return Ok(()); - } + self.store.insert( + cache::Key::Address(canonicalize_name(name)?, query_type), + cache::Value::Address(value), + expiry(now, ttl), + ) + } - if self.entries.len() >= self.max_entries { - let index = eviction_index(&self.entries, |entry| (entry.expires_at, entry.sequence)); - self.entries.swap_remove(index); + /// An opaque hit restores the caller's ID/RD and remaining record TTLs. + pub(crate) fn get_opaque( + &mut self, + query: &ClassifiedDnsQuery, + now: Instant, + ) -> Option> { + if !query.is_opaque() { + return None; } - reserve_entry_slot(&mut self.entries, self.max_entries)?; - self.entries.push(CacheEntry { - name, - query_type, - value, + let key = cache::Key::Opaque(query.question.clone(), query.semantics_digest); + let ( + cache::Value::Opaque { + wire, ttl_offsets, .. + }, expires_at, - sequence, - }); - debug_assert!(self.entries.len() <= self.max_entries); - Ok(()) - } - - fn remove_expired(&mut self, now: Instant) { - self.entries.retain(|entry| entry.expires_at > now); + ) = self.store.get(&key, now)? + else { + return None; + }; + let mut wire = wire.to_vec(); + wire[..2].copy_from_slice(&query.id.to_be_bytes()); + let mut flags = u16::from_be_bytes([wire[2], wire[3]]); + if query.recursion_desired { + flags |= 0x0100; + } else { + flags &= !0x0100; + } + wire[2..4].copy_from_slice(&flags.to_be_bytes()); + let ttl = remaining_ttl(expires_at, now).to_be_bytes(); + for offset in ttl_offsets { + let offset = usize::from(*offset); + wire[offset..offset + 4].copy_from_slice(&ttl); + } + Some(wire) } - fn take_sequence(&mut self) -> u64 { - let sequence = self.next_sequence; - self.next_sequence = self.next_sequence.wrapping_add(1); - sequence + /// Opaque payloads share the role's LRU count while retaining their own + /// 64-response, 256 KiB, 4096-byte and 64-record safety boundaries. + pub(crate) fn insert_opaque( + &mut self, + response: &ValidatedOpaqueResponse, + now: Instant, + ) -> bool { + let Some(lifetime_secs) = response.cache_policy.lifetime_secs() else { + return false; + }; + let Some(accounted_bytes) = opaque_entry_accounted_bytes(response) else { + return false; + }; + if response.wire.len() > MAX_OPAQUE_CACHE_ENTRY_BYTES + || accounted_bytes > MAX_OPAQUE_CACHE_BYTES + || response.ttl_offsets.len() > MAX_OPAQUE_RECORDS + { + return false; + } + self.store + .insert( + cache::Key::Opaque(response.question.clone(), response.semantics_digest), + cache::Value::Opaque { + wire: response.wire.clone(), + ttl_offsets: response.ttl_offsets.clone(), + accounted_bytes, + }, + expiry(now, lifetime_secs), + ) + .is_ok() } } -#[derive(Debug)] -struct OpaqueCacheEntry { - question: WireQuestion, - semantics_digest: [u8; 32], - wire: Box<[u8]>, - ttl_offsets: Box<[u16]>, - accounted_bytes: usize, - expires_at: Instant, - sequence: u64, +fn remaining_ttl(expires_at: Instant, now: Instant) -> u32 { + expires_at + .saturating_duration_since(now) + .as_secs() + .min(u64::from(u32::MAX)) as u32 } fn opaque_entry_accounted_bytes(response: &ValidatedOpaqueResponse) -> Option { @@ -1156,21 +1158,15 @@ fn opaque_entry_accounted_bytes(response: &ValidatedOpaqueResponse) -> Option())?, - ) + )? + .checked_add(cache::opaque_metadata_bytes()) } -/// Hard-bounded cache for non-A/AAAA response wire payloads. -/// -/// The cache stores at most [`MAX_OPAQUE_CACHE_ENTRIES`] responses and at most -/// [`MAX_OPAQUE_CACHE_BYTES`] bytes of response wire, normalized question key, -/// full-query semantics digest and TTL-offset metadata in total. A hit clones -/// at most one 4096-byte response, rewrites its transaction ID and reduces -/// every cacheable RR TTL to the entry's remaining lifetime. +/// Standalone opaque-only view of the same bounded result store. +/// Runtime roles use DnsCache directly so all question types share capacity. #[derive(Debug)] pub struct OpaqueDnsCache { - entries: Vec, - retained_bytes: usize, - next_sequence: u64, + cache: DnsCache, } impl Default for OpaqueDnsCache { @@ -1183,142 +1179,35 @@ impl OpaqueDnsCache { #[must_use] pub fn new() -> Self { Self { - entries: Vec::new(), - retained_bytes: 0, - next_sequence: 0, + cache: DnsCache::with_max_entries(MAX_OPAQUE_CACHE_ENTRIES), } } #[must_use] pub fn len(&self) -> usize { - self.entries.len() + self.cache.store.opaque_len() } #[must_use] pub fn is_empty(&self) -> bool { - self.entries.is_empty() + self.len() == 0 } #[must_use] pub const fn retained_bytes(&self) -> usize { - self.retained_bytes + self.cache.store.opaque_bytes() } pub fn clear(&mut self) { - self.entries.clear(); - self.retained_bytes = 0; + self.cache.clear(); } - /// Returns a rewritten wire response for an opaque query, or `None` for an - /// address query, cache miss or expired entry. pub fn get(&mut self, query: &ClassifiedDnsQuery, now: Instant) -> Option> { - if !query.is_opaque() { - return None; - } - self.remove_expired(now); - let entry = self.entries.iter().find(|entry| { - entry.question == query.question && entry.semantics_digest == query.semantics_digest - })?; - let remaining_ttl = entry - .expires_at - .saturating_duration_since(now) - .as_secs() - .min(u64::from(u32::MAX)) as u32; - let mut wire = entry.wire.to_vec(); - wire[..2].copy_from_slice(&query.id.to_be_bytes()); - let mut flags = u16::from_be_bytes([wire[2], wire[3]]); - if query.recursion_desired { - flags |= 0x0100; - } else { - flags &= !0x0100; - } - wire[2..4].copy_from_slice(&flags.to_be_bytes()); - let ttl = remaining_ttl.to_be_bytes(); - for offset in &entry.ttl_offsets { - let offset = usize::from(*offset); - wire[offset..offset + 4].copy_from_slice(&ttl); - } - Some(wire) + self.cache.get_opaque(query, now) } - /// Inserts a validated response when its cache policy permits it. Returns - /// `false` for retryable/error responses and positive responses with a zero - /// or absent cacheable TTL. pub fn insert(&mut self, response: &ValidatedOpaqueResponse, now: Instant) -> bool { - let Some(lifetime_secs) = response.cache_policy.lifetime_secs() else { - return false; - }; - let wire_bytes = response.wire.len(); - let Some(entry_bytes) = opaque_entry_accounted_bytes(response) else { - return false; - }; - if wire_bytes > MAX_OPAQUE_CACHE_ENTRY_BYTES - || entry_bytes > MAX_OPAQUE_CACHE_BYTES - || response.ttl_offsets.len() > MAX_OPAQUE_RECORDS - { - return false; - } - - self.remove_expired(now); - if let Some(index) = self.entries.iter().position(|entry| { - entry.question == response.question - && entry.semantics_digest == response.semantics_digest - }) { - self.remove_index(index); - } - - while !self.entries.is_empty() - && (self.entries.len() >= MAX_OPAQUE_CACHE_ENTRIES - || self.retained_bytes.saturating_add(entry_bytes) > MAX_OPAQUE_CACHE_BYTES) - { - let index = eviction_index(&self.entries, |entry| (entry.expires_at, entry.sequence)); - self.remove_index(index); - } - if self.entries.len() >= MAX_OPAQUE_CACHE_ENTRIES - || self.retained_bytes.saturating_add(entry_bytes) > MAX_OPAQUE_CACHE_BYTES - { - return false; - } - - let sequence = self.take_sequence(); - if reserve_entry_slot(&mut self.entries, MAX_OPAQUE_CACHE_ENTRIES).is_err() { - return false; - } - self.entries.push(OpaqueCacheEntry { - question: response.question.clone(), - semantics_digest: response.semantics_digest, - wire: response.wire.clone(), - ttl_offsets: response.ttl_offsets.clone(), - accounted_bytes: entry_bytes, - expires_at: expiry(now, lifetime_secs), - sequence, - }); - self.retained_bytes += entry_bytes; - debug_assert!(self.entries.len() <= MAX_OPAQUE_CACHE_ENTRIES); - debug_assert!(self.retained_bytes <= MAX_OPAQUE_CACHE_BYTES); - true - } - - fn remove_expired(&mut self, now: Instant) { - let mut index = 0; - while index < self.entries.len() { - if self.entries[index].expires_at <= now { - self.remove_index(index); - } else { - index += 1; - } - } - } - - fn remove_index(&mut self, index: usize) { - let removed = self.entries.swap_remove(index); - self.retained_bytes -= removed.accounted_bytes; - } - - fn take_sequence(&mut self) -> u64 { - let sequence = self.next_sequence; - self.next_sequence = self.next_sequence.wrapping_add(1); - sequence + self.cache.insert_opaque(response, now) } } @@ -1738,7 +1627,7 @@ mod tests { } #[test] - fn cache_is_fixed_capacity_and_evicts_the_oldest_expiry() { + fn cache_is_fixed_capacity_and_evicts_the_least_recent_entry() { let now = Instant::now(); let address = IpAddr::V4(Ipv4Addr::LOCALHOST); let mut cache = DnsCache::new(); @@ -1767,7 +1656,7 @@ mod tests { let now = Instant::now(); let address = IpAddr::V4(Ipv4Addr::LOCALHOST); let mut cache = DnsCache::with_max_entries(2); - assert_eq!(cache.entries.capacity(), 0); + assert_eq!(cache.store.allocated_slots(), 0); for name in ["first.example", "second.example", "third.example"] { cache @@ -1780,6 +1669,88 @@ mod tests { assert!(cache.get("third.example", QueryType::A, now).is_some()); } + #[test] + fn cache_lru_hits_and_replacements_preserve_ttl_and_promote_entries() { + let now = Instant::now(); + let address = IpAddr::V4(Ipv4Addr::LOCALHOST); + let mut cache = DnsCache::with_max_entries(2); + cache + .insert_positive("short.example", QueryType::A, &[address], 30, now) + .unwrap(); + cache + .insert_negative("negative.example", QueryType::Aaaa, 3, now) + .unwrap(); + assert_eq!( + cache.get_with_ttl( + "SHORT.example.", + QueryType::A, + now + Duration::from_secs(10) + ), + Some((CacheValue::Positive(vec![address]), 20)) + ); + cache + .insert_positive("long.example", QueryType::A, &[address], 3600, now) + .unwrap(); + assert_eq!(cache.get("negative.example", QueryType::Aaaa, now), None); + // Updating the short entry is also a use, independent of its expiry. + cache + .insert_negative("short.example", QueryType::A, 0, now) + .unwrap(); + cache + .insert_negative("other.example", QueryType::A, 3, now) + .unwrap(); + assert_eq!(cache.get("long.example", QueryType::A, now), None); + assert_eq!( + cache.get("short.example", QueryType::A, now), + Some(CacheValue::Negative { rcode: 0 }) + ); + assert_eq!( + cache.get("short.example", QueryType::A, now + Duration::from_secs(30)), + None + ); + // Freed slots are reused, and clear releases all retained allocation. + for index in 0..100 { + cache + .insert_negative(&format!("cycle{index}.example"), QueryType::A, 3, now) + .unwrap(); + } + assert_eq!(cache.len(), 2); + assert_eq!(cache.store.allocated_slots(), 2); + cache.clear(); + assert!(cache.is_empty()); + assert_eq!(cache.store.allocated_slots(), 0); + } + + #[test] + fn cache_shares_typed_and_opaque_capacity_and_reclaims_opaque_bytes() { + let now = Instant::now(); + let mut cache = DnsCache::with_max_entries(2); + let query_wire = wire_query(1, "opaque.example", 16); + let query = classify_query(&query_wire).unwrap(); + let mut response = opaque_response(&query_wire, RCODE_NOERROR, false); + set_counts(&mut response, 1, 0, 0); + append_wire_record(&mut response, &[0xc0, 0x0c], 16, CLASS_IN, 60, &[]); + let response = validate_opaque_response(&query, &response).unwrap(); + cache + .insert_negative("typed.example", QueryType::A, 3, now) + .unwrap(); + assert!(cache.insert_opaque(&response, now)); + assert!(cache.store.opaque_bytes() > 0); + assert!(cache.get("typed.example", QueryType::A, now).is_some()); + cache + .insert_negative("next.example", QueryType::A, 3, now) + .unwrap(); + assert_eq!(cache.get_opaque(&query, now), None); + assert_eq!(cache.len(), 2); + assert_eq!(cache.store.opaque_bytes(), 0); + assert!(cache.insert_opaque(&response, now)); + assert_eq!( + cache.get_opaque(&query, now + Duration::from_secs(60)), + None + ); + assert_eq!(cache.store.opaque_bytes(), 0); + } + #[test] fn cache_rejects_unbounded_or_mismatched_values() { let now = Instant::now(); @@ -2220,7 +2191,7 @@ mod tests { } #[test] - fn opaque_cache_is_bounded_and_evicts_earliest_expiry() { + fn opaque_cache_is_bounded_and_evicts_the_least_recent_entry() { let now = Instant::now(); let mut cache = OpaqueDnsCache::new(); for index in 0..MAX_OPAQUE_CACHE_ENTRIES { @@ -2239,6 +2210,8 @@ mod tests { let response = validate_opaque_response(&query, &response).unwrap(); assert!(cache.insert(&response, now)); } + let first_query = classify_query(&wire_query(0, "host0.example", 16)).unwrap(); + assert!(cache.get(&first_query, now).is_some()); let new_wire = wire_query(999, "new.example", 16); let new_query = classify_query(&new_wire).unwrap(); let mut new_response = opaque_response(&new_wire, RCODE_NOERROR, false); @@ -2249,8 +2222,9 @@ mod tests { assert_eq!(cache.len(), MAX_OPAQUE_CACHE_ENTRIES); assert!(cache.retained_bytes() <= MAX_OPAQUE_CACHE_BYTES); - let first_query = classify_query(&wire_query(0, "host0.example", 16)).unwrap(); - assert_eq!(cache.get(&first_query, now), None); + assert!(cache.get(&first_query, now).is_some()); + let second_query = classify_query(&wire_query(1, "host1.example", 16)).unwrap(); + assert_eq!(cache.get(&second_query, now), None); assert!(cache.get(&new_query, now).is_some()); } diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 13eeb94..8ed185a 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -31,10 +31,10 @@ use tokio_util::sync::CancellationToken; use super::policy::NameserverPolicies; use super::{ CacheValue, ClassifiedDnsQuery, DnsCache, DnsError, DnsMessage, DnsQueryKind, MAX_ANSWERS, - MAX_CACHE_ENTRIES, MAX_MESSAGE_SIZE, MAX_REDIR_HOST_ENTRIES, MAX_RESPONSE_RECORDS, - MIN_TTL_SECS, OpaqueDnsCache, QueryType, RecordData, RedirHostHints, ValidatedOpaqueResponse, - WireQuestion, build_query, canonicalize_name, classify_query, parse_query, parse_response, - synthesize_servfail_response, validate_opaque_response, validate_response_identity, + MAX_MESSAGE_SIZE, MAX_REDIR_HOST_ENTRIES, MAX_RESPONSE_RECORDS, MIN_TTL_SECS, QueryType, + RecordData, RedirHostHints, ValidatedOpaqueResponse, WireQuestion, build_query, + canonicalize_name, classify_query, parse_query, parse_response, synthesize_servfail_response, + validate_opaque_response, validate_response_identity, }; use crate::{ config::{ @@ -106,7 +106,6 @@ pub struct RuntimeDns { geo_matcher: Arc, egress: DnsEgress, cache: Mutex, - opaque_cache: Mutex, // Hint lookup and insertion are bounded synchronous work. Keep this // shared TUN hot path free of asynchronous mutex scheduling. hints: Option>>, @@ -871,7 +870,7 @@ impl RuntimeDns { config, DnsEgress::Single(dispatcher), geo_matcher, - MAX_CACHE_ENTRIES, + config.cache_max_size, MAX_REDIR_HOST_ENTRIES, ) } @@ -912,7 +911,7 @@ impl RuntimeDns { direct, rules, geo_matcher, - MAX_CACHE_ENTRIES, + config.cache_max_size, MAX_REDIR_HOST_ENTRIES, ) } @@ -1026,7 +1025,6 @@ impl RuntimeDns { geo_matcher, egress, cache: Mutex::new(DnsCache::with_max_entries(address_cache_entries)), - opaque_cache: Mutex::new(OpaqueDnsCache::new()), hints: (redir_host_entries != 0).then(|| { Arc::new(StdMutex::new(RedirHostHints::with_max_entries( redir_host_entries, @@ -1582,8 +1580,8 @@ impl RuntimeDns { loop { let key = SingleflightKey::from(classified); let role = { - let mut cache = self.opaque_cache.lock().await; - if let Some(response) = cache.get(classified, Instant::now()) { + let mut cache = self.cache.lock().await; + if let Some(response) = cache.get_opaque(classified, Instant::now()) { super::trace::record("cache"); self.resource_stats.dns_cache_hit(); return Ok(response); @@ -1630,10 +1628,10 @@ impl RuntimeDns { let response = self .exchange_opaque_upstreams(nameservers, &upstream_query, &upstream_classified, deadline) .await?; - self.opaque_cache + self.cache .lock() .await - .insert(&response, Instant::now()); + .insert_opaque(&response, Instant::now()); let mut response = response.into_wire(); restore_response_id(&mut response, 0); Ok(response) @@ -2366,7 +2364,7 @@ mod tests { config::{DnsNameserverPolicy, DnsRoute, IpCidr, RuleAction, RuleKind, RuleSpec}, dialer::Dialer, dispatch::{BoxStream, DatagramTransport}, - dns::{DnsQuestion, DnsRecord}, + dns::{DnsQuestion, DnsRecord, MAX_CACHE_ENTRIES}, geodata::{DynamicGeoData, GEOSITE_FILE_NAME, GeoData}, outbound::DirectOutbound, routing::{EmptyGeoMatcher, RuleSet}, @@ -5131,6 +5129,74 @@ dns: assert_eq!(stats.dns_cache_hits, 1); } + #[tokio::test] + async fn dns_cache_configured_capacity_is_shared_by_address_and_opaque_questions() { + let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 103)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + (0..5) + .map(|_| MockReply::Response(ResponseSpec::answer(address))) + .collect(), + vec![], + )); + let mut configuration = config(false, vec![nameserver(1, DnsTransport::Udp)]); + configuration.cache_max_size = 2; + let resolver = RuntimeDns::new(&configuration, dispatcher.clone()); + for (index, name, qtype) in [ + (0, "first.example", 1), + (1, "opaque.example", 16), + (2, "first.example", 1), + (3, "second.example", 1), + (4, "opaque.example", 16), + (5, "first.example", 1), + ] { + let query = opaque_query(index, name, qtype); + resolver.exchange(&query).await.unwrap(); + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 5); + assert_eq!(resolver.cache.lock().await.len(), 2); + assert_eq!(resolver.singleflight.len(), 0); + } + + #[tokio::test] + async fn dns_cache_roles_keep_independent_results_and_eviction() { + let addresses = (1..=5) + .map(|last| IpAddr::V4(Ipv4Addr::new(203, 0, 113, last))) + .collect::>(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + addresses + .iter() + .map(|address| MockReply::Response(ResponseSpec::answer(*address))) + .collect(), + vec![], + )); + let mut configuration = config(false, vec![nameserver(1, DnsTransport::Udp)]); + configuration.cache_max_size = 1; + configuration.default_nameservers = configuration.nameservers.clone(); + configuration.proxy_nameservers = configuration.nameservers.clone(); + configuration.direct_nameservers = configuration.nameservers.clone(); + let resolver = RuntimeDns::new(&configuration, dispatcher.clone()); + let roles = [ + &resolver, + resolver.bootstrap.as_deref().unwrap(), + resolver.proxy_resolver.as_deref().unwrap(), + resolver.direct_resolver.as_deref().unwrap(), + ]; + for _ in 0..2 { + for (role, address) in roles.iter().zip(&addresses) { + assert_eq!(role.resolve("same.example").await.unwrap(), vec![*address]); + } + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 4); + assert_eq!( + resolver.resolve("new.example").await.unwrap(), + vec![addresses[4]] + ); + for (role, address) in roles.iter().zip(&addresses).skip(1) { + assert_eq!(role.resolve("same.example").await.unwrap(), vec![*address]); + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 5); + } + #[tokio::test] async fn concurrent_queries_above_old_threshold_reach_upstream_without_local_rejection() { const REQUESTS: usize = 160; diff --git a/src/limits.rs b/src/limits.rs index cbbec42..693b1f2 100644 --- a/src/limits.rs +++ b/src/limits.rs @@ -100,7 +100,8 @@ pub struct ResourceLimits { /// Maximum raw IP packet accepted by a TUN netstack. pub tun_max_datagram_size: usize, pub tcp_buffer_per_direction: usize, - /// Retained A/AAAA cache entries. A and AAAA share this one capacity. + /// Explicit low-level DNS constructor override. Production sessions use + /// dns.cache-max-size for the shared typed/opaque result capacity instead. pub dns_address_cache_entries: usize, /// Retained address-to-domain hints used by redir-host routing. pub dns_redir_host_entries: usize, diff --git a/src/runtime.rs b/src/runtime.rs index 8cfafaf..d8bf497 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -695,7 +695,7 @@ impl GraphBuilder { direct.clone(), rules.clone(), geo_matcher.clone(), - self.limits.dns_address_cache_entries, + dns_config.cache_max_size, redir_host_entries, ) .with_mapping_from(self.previous_dns.as_deref()) diff --git a/tests/README.md b/tests/README.md index b17a852..dc7c258 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,6 +31,9 @@ cargo test --locked --all-features --lib tcp_sniffer::tests:: cargo test --locked --all-features --lib dns::fallback::tests:: cargo test --locked --all-features --lib dns::runtime::tests::dns_fallback_ cargo test --locked --all-features --lib dns::runtime::tests::resolve_ipv6_ +cargo test --locked --lib dns::tests::cache_ +cargo test --locked --lib dns::tests::opaque_cache_ +cargo test --locked --lib dns::runtime::tests::dns_cache_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 994ad3f472ec697ddc63b62089d7d15efd1648c7 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 10:33:48 +0800 Subject: [PATCH 028/445] Add bounded adaptive DNS ARC cache --- docs/config.yaml | 3 +- docs/tun-icmp-dns.md | 13 +- src/config/dns.rs | 5 + src/config/mod.rs | 27 ++- src/dns/cache.rs | 491 ++++++++++++++++++++++++++++++++++++++----- src/dns/mod.rs | 14 +- src/dns/runtime.rs | 35 ++- tests/README.md | 1 + 8 files changed, 525 insertions(+), 64 deletions(-) diff --git a/docs/config.yaml b/docs/config.yaml index b197ce3..3b8c513 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -554,7 +554,8 @@ dns: enable: true ipv6: true ipv6-timeout: 100 # 毫秒;内部 A/AAAA 并发,A 完成后最多等待 AAAA;0 同缺省 100 - cache-max-size: 4096 # 每个 DNS 角色的结果总条目数;0 同缺省 4096,A/AAAA 和 wire 共用 LRU + cache-max-size: 4096 # 每个 DNS 角色的结果总条目数;0 同缺省 4096,A/AAAA 和 wire 共用 + cache-algorithm: lru # 精确 arc 使用 ARC;缺省与其他字符串使用 LRU,与 Mihomo 一致 fallback: [] # A/AAAA 的备用解析组;不是 nameserver 内部的故障转移 fallback-lazy-query: false # false:与主组同时查询;true:确定需要时才查询 fallback-filter: diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 9e7f54a..3ff69eb 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -179,16 +179,23 @@ bootstrap、单独配置的节点/直连 resolver,以及非地址 qtype 不使 ### 结果缓存 `dns.cache-max-size` 控制每个 DNS 角色的结果总条目数,缺省或零值为 4,096。 -A/AAAA 的类型化结果和其他 qtype 的原始响应共享这份容量与 LRU 顺序。 -命中和替换将条目移到最近使用端;满容量时淘汰最久未使用的结果,命中不延长 TTL。 +A/AAAA 的类型化结果和其他 qtype 的原始响应共享这份容量与替换策略。 +`dns.cache-algorithm` 精确为 `arc` 时使用 ARC,缺省和其他字符串使用 LRU。 +LRU 命中和替换将条目移到最近使用端;满容量时淘汰最久未使用的结果,命中不延长 TTL。 哈希索引和安全槽位链表按需分配,查询时只检查命中条目的寿命,过期结果重新查询。 类型化结果仍最多保留 16 个地址,负缓存仍保留 30 秒。 TUN 域名提示独立保留最多 256 项并按 TTL 失效;fake-IP 映射不计入这份结果缓存。 +ARC 分别保留近期与常用结果,命中提升到常用组,使用两组淘汰历史自适应调整比例。 +历史项只保留 key 和过期时间,不持有地址或原始响应;有效结果最多为配置容量, +历史项最多为同样容量,所有索引和槽位最多为两倍容量。过期结果或历史项按 key 删除, +不把过期作为命中或新增淘汰历史。清空和解析器释放会回收结果、历史项及其存储。 + ### 原始响应缓存 - 在结果总容量内额外限制为最多 64 项、总保留内存 256 KiB,单响应最大 4,096 字节。 - 原始响应的 key、TTL 偏移及条目索引/链表元数据计入字节预算;此预算满时淘汰最久未使用的原始响应。 + 原始响应的 key、TTL 偏移及条目索引/链表元数据计入字节预算,ARC 的原始响应历史项也计入。 + 此预算满时先删除原始响应历史项,再淘汰最久未使用的原始响应,不改变 ARC 的自适应比例。 - Key 包含规范化 question、qclass、qtype 和移除 transaction ID 后的完整查询语义 SHA-256。 - 命中时恢复调用方 transaction ID 和 RD,并按剩余寿命更新所有可缓存 TTL。 - 正缓存寿命取可缓存记录的最小 TTL,并限制在 30–3,600 秒。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 35a15cd..75e9f7b 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -83,6 +83,11 @@ impl RawDnsConfig { } else { self.cache_max_size }, + cache_algorithm: if self.cache_algorithm == "arc" { + DnsCacheAlgorithm::Arc + } else { + DnsCacheAlgorithm::Lru + }, nameservers: parse(self.nameserver, main_route)?, nameserver_policies: normalize_dns_nameserver_policies( self.nameserver_policies, diff --git a/src/config/mod.rs b/src/config/mod.rs index 0dcaf95..79bc4ef 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -275,6 +275,7 @@ pub struct DnsConfig { pub ipv6: bool, pub ipv6_timeout: Duration, pub cache_max_size: usize, + pub cache_algorithm: DnsCacheAlgorithm, pub nameservers: Vec, pub nameserver_policies: Vec, pub default_nameservers: Vec, @@ -287,6 +288,13 @@ pub struct DnsConfig { pub hosts: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DnsCacheAlgorithm { + #[default] + Lru, + Arc, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct HostEntry { pub pattern: String, @@ -1252,6 +1260,8 @@ struct RawDnsConfig { ipv6_timeout: u64, #[serde(rename = "cache-max-size", default)] cache_max_size: usize, + #[serde(rename = "cache-algorithm", default)] + cache_algorithm: String, #[serde(default = "dns::default_nameservers")] nameserver: Vec, #[serde(rename = "default-nameserver", default = "dns::default_bootstrap")] @@ -5247,8 +5257,21 @@ authentication: assert_eq!(config.dns.cache_max_size, entries); } assert!(Config::parse_yaml(b"mixed-port: 1080\ndns:\n cache-max-size: -1\n").is_err()); - // ARC is not accepted until its actual replacement policy exists. - assert!(Config::parse_yaml(b"mixed-port: 1080\ndns:\n cache-algorithm: arc\n").is_err()); + } + + #[test] + fn dns_cache_algorithm_activates_arc_only_for_the_exact_name() { + for (field, algorithm) in [ + ("", DnsCacheAlgorithm::Lru), + (" cache-algorithm: lru\n", DnsCacheAlgorithm::Lru), + (" cache-algorithm: arc\n", DnsCacheAlgorithm::Arc), + (" cache-algorithm: ARC\n", DnsCacheAlgorithm::Lru), + (" cache-algorithm: other\n", DnsCacheAlgorithm::Lru), + ] { + let config = + Config::parse_yaml(format!("mixed-port: 1080\ndns:\n{field}").as_bytes()).unwrap(); + assert_eq!(config.dns.cache_algorithm, algorithm); + } } #[test] diff --git a/src/dns/cache.rs b/src/dns/cache.rs index 26e8a01..7b496d2 100644 --- a/src/dns/cache.rs +++ b/src/dns/cache.rs @@ -34,17 +34,55 @@ impl Value { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(usize)] +enum List { + Recent, + Frequent, + RecentGhost, + FrequentGhost, + Lru, +} + +impl List { + const fn is_ghost(self) -> bool { + matches!(self, Self::RecentGhost | Self::FrequentGhost) + } +} + +#[derive(Debug, Default)] +struct Chain { + least: Option, + most: Option, + len: usize, +} + #[derive(Debug)] struct Entry { key: Arc, - value: Value, + value: Option, expires_at: Instant, + list: List, previous: Option, next: Option, opaque_previous: Option, opaque_next: Option, } +impl Entry { + fn opaque_bytes(&self) -> Option { + match self.value.as_ref() { + Some(value) => value.opaque_bytes(), + None => self.key.opaque_metadata_bytes(), + } + } + + fn opaque_chain(&self) -> Option { + self.opaque_bytes() + .map(|_| usize::from(self.value.is_none())) + } +} + #[derive(Debug)] enum Slot { Occupied(Entry), @@ -59,55 +97,81 @@ pub(super) const fn opaque_metadata_bytes() -> usize { + 2 * std::mem::size_of::() // Arc reference counters } -/// One role's total result budget, shared by address and opaque questions. -/// Slots and the hash table start empty and grow only after an actual insert. -/// Separate opaque links permit byte-budget eviction without scanning typed -/// entries or evicting an unrelated address result. +impl Key { + fn opaque_metadata_bytes(&self) -> Option { + match self { + Self::Address(..) => None, + Self::Opaque(question, digest) => { + Some(question.name.len() + 4 + digest.len() + opaque_metadata_bytes()) + } + } + } +} + +/// One role's shared result store. Hash/slot storage starts empty. LRU retains +/// at most c slots; ARC retains at most c payloads and c payload-free ghosts. +/// Active and ghost opaque chains allow byte-budget eviction without scanning +/// address results. Ghost key metadata remains charged to the opaque budget. #[derive(Debug)] pub(super) struct Store { index: HashMap, usize>, slots: Vec, free: Option, - least_recent: Option, - most_recent: Option, - opaque_least_recent: Option, - opaque_most_recent: Option, + lists: [Chain; 5], + opaque: [Chain; 2], + algorithm: crate::config::DnsCacheAlgorithm, + recent_target: usize, max_entries: usize, + resident_entries: usize, opaque_entries: usize, opaque_bytes: usize, } impl Store { pub(super) fn new(max_entries: usize) -> Self { + Self::with_algorithm(max_entries, crate::config::DnsCacheAlgorithm::Lru) + } + + pub(super) fn with_algorithm( + max_entries: usize, + algorithm: crate::config::DnsCacheAlgorithm, + ) -> Self { assert!(max_entries != 0, "DNS cache capacity must be non-zero"); Self { index: HashMap::new(), slots: Vec::new(), free: None, - least_recent: None, - most_recent: None, - opaque_least_recent: None, - opaque_most_recent: None, + lists: std::array::from_fn(|_| Chain::default()), + opaque: std::array::from_fn(|_| Chain::default()), + algorithm, + recent_target: 0, max_entries, + resident_entries: 0, opaque_entries: 0, opaque_bytes: 0, } } - pub(super) fn len(&self) -> usize { - self.index.len() + pub(super) const fn len(&self) -> usize { + self.resident_entries } pub(super) const fn opaque_len(&self) -> usize { self.opaque_entries } - pub(super) const fn opaque_bytes(&self) -> usize { self.opaque_bytes } + fn metadata_limit(&self) -> usize { + match self.algorithm { + crate::config::DnsCacheAlgorithm::Lru => self.max_entries, + crate::config::DnsCacheAlgorithm::Arc => self.max_entries.saturating_mul(2), + } + } + pub(super) fn clear(&mut self) { - *self = Self::new(self.max_entries); + *self = Self::with_algorithm(self.max_entries, self.algorithm); } pub(super) fn get(&mut self, key: &Key, now: Instant) -> Option<(&Value, Instant)> { @@ -116,41 +180,95 @@ impl Store { self.remove(slot); return None; } + self.entry(slot).value.as_ref()?; + let list = match self.algorithm { + crate::config::DnsCacheAlgorithm::Lru => List::Lru, + crate::config::DnsCacheAlgorithm::Arc => List::Frequent, + }; self.unlink(slot); - self.append(slot); + self.append(slot, list); let entry = self.entry(slot); - Some((&entry.value, entry.expires_at)) + Some(( + entry.value.as_ref().expect("resident entry has a payload"), + entry.expires_at, + )) } - pub(super) fn insert(&mut self, key: Key, value: Value, expires_at: Instant) -> Result<()> { - let replaced = self.index.get(&key).copied(); - if replaced.is_none() && self.len() < self.max_entries { + pub(super) fn insert( + &mut self, + key: Key, + value: Value, + expires_at: Instant, + now: Instant, + ) -> Result<()> { + let mut replaced = self.index.get(&key).copied(); + if let Some(slot) = replaced + && self.entry(slot).expires_at <= now + { + self.remove(slot); + replaced = None; + } + let previous_list = replaced.map(|slot| self.entry(slot).list); + // Make allocation failure leave existing usable payloads untouched. + let metadata_limit = self.metadata_limit(); + if replaced.is_none() && self.index.len() < metadata_limit { self.index .try_reserve(1) .map_err(|_| DnsError::CacheAllocation)?; if self.free.is_none() { - super::reserve_entry_slot(&mut self.slots, self.max_entries)?; + super::reserve_entry_slot(&mut self.slots, metadata_limit)?; } } + if previous_list == Some(List::RecentGhost) { + let step = + (self.list_len(List::FrequentGhost) / self.list_len(List::RecentGhost)).max(1); + self.recent_target = self + .recent_target + .saturating_add(step) + .min(self.max_entries); + } else if previous_list == Some(List::FrequentGhost) { + let step = + (self.list_len(List::RecentGhost) / self.list_len(List::FrequentGhost)).max(1); + self.recent_target = self.recent_target.saturating_sub(step); + } if let Some(slot) = replaced { self.remove(slot); } + let destination = match self.algorithm { + crate::config::DnsCacheAlgorithm::Lru => { + if self.len() >= self.max_entries { + self.remove_lru(List::Lru); + } + List::Lru + } + crate::config::DnsCacheAlgorithm::Arc => match previous_list { + Some(List::Recent | List::Frequent) => List::Frequent, + Some(list) if list.is_ghost() => { + if self.len() >= self.max_entries { + self.replace(list == List::FrequentGhost); + } + List::Frequent + } + _ => { + self.prepare_recent(); + List::Recent + } + }, + }; if let Some(bytes) = value.opaque_bytes() { while self.opaque_entries >= MAX_OPAQUE_CACHE_ENTRIES || self.opaque_bytes.saturating_add(bytes) > MAX_OPAQUE_CACHE_BYTES { - let Some(slot) = self.opaque_least_recent else { - return Err(DnsError::CacheAllocation); - }; - self.remove(slot); + // Discard metadata first; a byte-budget eviction is not an ARC + // capacity miss and does not change the adaptive target. + let candidate = self.opaque[1] + .least + .or(self.opaque[0].least) + .ok_or(DnsError::CacheAllocation)?; + self.remove(candidate); } } - if self.len() >= self.max_entries { - self.remove( - self.least_recent - .expect("full cache has a least recent entry"), - ); - } + self.trim_ghosts(); let slot = if let Some(slot) = self.free { let Slot::Vacant(next) = self.slots[slot] else { unreachable!("free cache slot must be vacant"); @@ -166,31 +284,111 @@ impl Store { self.opaque_entries += 1; self.opaque_bytes += bytes; } + self.resident_entries += 1; self.index.insert(key.clone(), slot); self.slots[slot] = Slot::Occupied(Entry { key, - value, + value: Some(value), expires_at, + list: destination, previous: None, next: None, opaque_previous: None, opaque_next: None, }); - self.append(slot); + self.append(slot, destination); debug_assert!(self.len() <= self.max_entries); - debug_assert!(self.slots.len() <= self.max_entries); + debug_assert!(self.index.len() <= metadata_limit); + debug_assert!(self.slots.len() <= metadata_limit); debug_assert!(self.opaque_entries <= MAX_OPAQUE_CACHE_ENTRIES); debug_assert!(self.opaque_bytes <= MAX_OPAQUE_CACHE_BYTES); Ok(()) } + fn list_len(&self, list: List) -> usize { + self.lists[list as usize].len + } + + fn prepare_recent(&mut self) { + let recent_and_ghost = self.list_len(List::Recent) + self.list_len(List::RecentGhost); + if recent_and_ghost == self.max_entries { + if self.list_len(List::Recent) < self.max_entries { + self.remove_lru(List::RecentGhost); + if self.len() >= self.max_entries { + self.replace(false); + } + } else { + self.remove_lru(List::Recent); + } + } else if self.index.len() >= self.max_entries { + if self.index.len() >= self.metadata_limit() && !self.remove_lru(List::FrequentGhost) { + self.remove_lru(List::RecentGhost); + } + if self.len() >= self.max_entries { + self.replace(false); + } + } + } + + fn replace(&mut self, incoming_frequent_ghost: bool) { + let recent = self.list_len(List::Recent); + let from_recent = recent != 0 + && (recent > self.recent_target + || (incoming_frequent_ghost && recent == self.recent_target)); + let (from, to) = if from_recent { + (List::Recent, List::RecentGhost) + } else { + (List::Frequent, List::FrequentGhost) + }; + let slot = self.lists[from as usize] + .least + .expect("full ARC has a replacement entry"); + self.unlink(slot); + let value = self + .entry_mut(slot) + .value + .take() + .expect("ARC resident has a payload"); + self.resident_entries -= 1; + if let Some(bytes) = value.opaque_bytes() { + self.opaque_entries -= 1; + self.opaque_bytes -= bytes + - self + .entry(slot) + .key + .opaque_metadata_bytes() + .expect("opaque key metadata"); + } + drop(value); + self.append(slot, to); + } + + fn trim_ghosts(&mut self) { + while self.list_len(List::RecentGhost) + self.list_len(List::FrequentGhost) + > self.max_entries + || self.index.len() >= self.metadata_limit() + { + if !self.remove_lru(List::FrequentGhost) && !self.remove_lru(List::RecentGhost) { + break; + } + } + } + + fn remove_lru(&mut self, list: List) -> bool { + if let Some(slot) = self.lists[list as usize].least { + self.remove(slot); + true + } else { + false + } + } + fn entry(&self, slot: usize) -> &Entry { match &self.slots[slot] { Slot::Occupied(entry) => entry, Slot::Vacant(_) => unreachable!("linked cache slot must be occupied"), } } - fn entry_mut(&mut self, slot: usize) -> &mut Entry { match &mut self.slots[slot] { Slot::Occupied(entry) => entry, @@ -200,56 +398,62 @@ impl Store { fn unlink(&mut self, slot: usize) { let entry = self.entry(slot); + let list = entry.list as usize; let (previous, next) = (entry.previous, entry.next); - let opaque = entry.value.opaque_bytes().is_some(); + let opaque_chain = entry.opaque_chain(); let (opaque_previous, opaque_next) = (entry.opaque_previous, entry.opaque_next); if let Some(previous) = previous { self.entry_mut(previous).next = next; } else { - self.least_recent = next; + self.lists[list].least = next; } if let Some(next) = next { self.entry_mut(next).previous = previous; } else { - self.most_recent = previous; + self.lists[list].most = previous; } - if opaque { + self.lists[list].len -= 1; + if let Some(chain) = opaque_chain { if let Some(previous) = opaque_previous { self.entry_mut(previous).opaque_next = opaque_next; } else { - self.opaque_least_recent = opaque_next; + self.opaque[chain].least = opaque_next; } if let Some(next) = opaque_next { self.entry_mut(next).opaque_previous = opaque_previous; } else { - self.opaque_most_recent = opaque_previous; + self.opaque[chain].most = opaque_previous; } + self.opaque[chain].len -= 1; } } - fn append(&mut self, slot: usize) { - let previous = self.most_recent; - let opaque_previous = self.opaque_most_recent; + fn append(&mut self, slot: usize, list: List) { + let previous = self.lists[list as usize].most; + let opaque_chain = self.entry(slot).opaque_chain(); let entry = self.entry_mut(slot); + entry.list = list; entry.previous = previous; entry.next = None; - let opaque = entry.value.opaque_bytes().is_some(); if let Some(previous) = previous { self.entry_mut(previous).next = Some(slot); } else { - self.least_recent = Some(slot); + self.lists[list as usize].least = Some(slot); } - self.most_recent = Some(slot); - if opaque { + self.lists[list as usize].most = Some(slot); + self.lists[list as usize].len += 1; + if let Some(chain) = opaque_chain { + let previous = self.opaque[chain].most; let entry = self.entry_mut(slot); - entry.opaque_previous = opaque_previous; + entry.opaque_previous = previous; entry.opaque_next = None; - if let Some(previous) = opaque_previous { + if let Some(previous) = previous { self.entry_mut(previous).opaque_next = Some(slot); } else { - self.opaque_least_recent = Some(slot); + self.opaque[chain].least = Some(slot); } - self.opaque_most_recent = Some(slot); + self.opaque[chain].most = Some(slot); + self.opaque[chain].len += 1; } } @@ -262,8 +466,13 @@ impl Store { }; self.free = Some(slot); self.index.remove(entry.key.as_ref()); - if let Some(bytes) = entry.value.opaque_bytes() { - self.opaque_entries -= 1; + if entry.value.is_some() { + self.resident_entries -= 1; + } + if let Some(bytes) = entry.opaque_bytes() { + if entry.value.is_some() { + self.opaque_entries -= 1; + } self.opaque_bytes -= bytes; } } @@ -273,3 +482,173 @@ impl Store { self.slots.capacity() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::DnsCacheAlgorithm; + use std::{net::Ipv4Addr, time::Duration}; + + fn address(name: &str) -> Key { + Key::Address(name.into(), QueryType::A) + } + + fn put(store: &mut Store, name: &str, now: Instant) { + store + .insert( + address(name), + Value::Address(CacheValue::Positive(vec![Ipv4Addr::LOCALHOST.into()])), + now + Duration::from_secs(60), + now, + ) + .unwrap(); + } + + fn opaque_key(name: &str) -> Key { + Key::Opaque( + WireQuestion { + name: name.into(), + query_type: 16, + query_class: 1, + }, + [0; 32], + ) + } + + fn put_opaque(store: &mut Store, name: &str, now: Instant, ttl: u64) { + let key = opaque_key(name); + let accounted_bytes = key.opaque_metadata_bytes().unwrap() + 4096 + 2; + store + .insert( + key, + Value::Opaque { + wire: vec![0_u8; 4096].into_boxed_slice(), + ttl_offsets: vec![8].into_boxed_slice(), + accounted_bytes, + }, + now + Duration::from_secs(ttl), + now, + ) + .unwrap(); + } + + #[test] + fn arc_preserves_a_frequent_result_that_lru_evicts_during_a_scan() { + let now = Instant::now(); + for (algorithm, retained) in [ + (DnsCacheAlgorithm::Lru, false), + (DnsCacheAlgorithm::Arc, true), + ] { + let mut store = Store::with_algorithm(2, algorithm); + put(&mut store, "a", now); + assert!(store.get(&address("a"), now).is_some()); + put(&mut store, "b", now); + put(&mut store, "c", now); + assert_eq!(store.get(&address("a"), now).is_some(), retained); + assert_eq!(store.len(), 2); + } + } + + #[test] + fn arc_ghost_hits_adapt_in_both_directions_and_release_wire_payloads() { + let now = Instant::now(); + let mut store = Store::with_algorithm(2, DnsCacheAlgorithm::Arc); + put(&mut store, "a", now); + store.get(&address("a"), now).unwrap(); + put_opaque(&mut store, "b", now, 60); + put(&mut store, "c", now); + let ghost = store.entry(*store.index.get(&opaque_key("b")).unwrap()); + assert_eq!(ghost.list, List::RecentGhost); + assert!(ghost.value.is_none()); + assert_eq!(store.opaque_entries, 0); + assert_eq!( + store.opaque_bytes, + opaque_key("b").opaque_metadata_bytes().unwrap() + ); + put_opaque(&mut store, "b", now, 60); + assert_eq!(store.recent_target, 1); + let ghost = store.entry(*store.index.get(&address("a")).unwrap()); + assert_eq!(ghost.list, List::FrequentGhost); + assert!(ghost.value.is_none()); + put(&mut store, "a", now); + assert_eq!(store.recent_target, 0); + assert!(store.get(&address("a"), now).is_some()); + assert!(store.get(&opaque_key("b"), now).is_some()); + assert_eq!(store.len(), 2); + } + + #[test] + fn arc_expired_residents_and_ghosts_are_removed_without_learning_a_hit() { + let now = Instant::now(); + let mut store = Store::with_algorithm(2, DnsCacheAlgorithm::Arc); + put(&mut store, "a", now); + store.get(&address("a"), now).unwrap(); + put_opaque(&mut store, "b", now, 30); + store + .insert( + address("c"), + Value::Address(CacheValue::Negative { rcode: 3 }), + now + Duration::from_secs(30), + now, + ) + .unwrap(); + let expired = now + Duration::from_secs(31); + assert!(store.get(&opaque_key("b"), expired).is_none()); + assert_eq!(store.opaque_bytes, 0); + assert!(store.get(&address("c"), expired).is_none()); + assert_eq!(store.len(), 1); + put_opaque(&mut store, "b", expired, 60); + assert_eq!(store.recent_target, 0); + assert_eq!( + store + .entry(*store.index.get(&opaque_key("b")).unwrap()) + .list, + List::Recent + ); + } + + #[test] + fn arc_mixed_payloads_keep_resident_ghost_and_opaque_budgets_bounded() { + let now = Instant::now(); + let mut store = Store::with_algorithm(64, DnsCacheAlgorithm::Arc); + assert_eq!(store.allocated_slots(), 0); + for index in 0..600 { + if index % 6 == 0 { + put(&mut store, &format!("typed{index}"), now); + } else { + put_opaque(&mut store, &format!("wire{index}"), now, 60); + } + if index % 3 == 0 { + store.get(&opaque_key(&format!("wire{}", index - index % 6 + 1)), now); + } + assert!(store.len() <= 64); + assert!(store.index.len() <= 128); + assert!(store.slots.len() <= 128); + assert!(store.list_len(List::RecentGhost) + store.list_len(List::FrequentGhost) <= 64); + assert!(store.list_len(List::Recent) + store.list_len(List::RecentGhost) <= 64); + assert!(store.recent_target <= 64); + assert!(store.opaque_entries <= MAX_OPAQUE_CACHE_ENTRIES); + assert!(store.opaque_bytes <= MAX_OPAQUE_CACHE_BYTES); + let mut resident = 0; + let mut opaque = 0; + let mut bytes = 0; + for slot in store.index.values() { + let entry = store.entry(*slot); + assert_eq!(entry.value.is_none(), entry.list.is_ghost()); + resident += usize::from(entry.value.is_some()); + if let Some(retained) = entry.opaque_bytes() { + opaque += usize::from(entry.value.is_some()); + bytes += retained; + } + } + assert_eq!(store.len(), resident); + assert_eq!(store.opaque_entries, opaque); + assert_eq!(store.opaque_bytes, bytes); + } + store.clear(); + assert_eq!(store.len(), 0); + assert_eq!(store.opaque_bytes, 0); + assert_eq!(store.allocated_slots(), 0); + assert_eq!(store.index.capacity(), 0); + } +} diff --git a/src/dns/mod.rs b/src/dns/mod.rs index ca2768d..8d11a13 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -972,6 +972,16 @@ impl DnsCache { } } + #[must_use] + pub(crate) fn with_algorithm( + max_entries: usize, + algorithm: crate::config::DnsCacheAlgorithm, + ) -> Self { + Self { + store: cache::Store::with_algorithm(max_entries, algorithm), + } + } + #[must_use] pub fn len(&self) -> usize { self.store.len() @@ -1067,6 +1077,7 @@ impl DnsCache { cache::Key::Address(canonicalize_name(name)?, query_type), cache::Value::Address(value), expiry(now, ttl), + now, ) } @@ -1106,7 +1117,7 @@ impl DnsCache { Some(wire) } - /// Opaque payloads share the role's LRU count while retaining their own + /// Opaque payloads share the role's result count while retaining their own /// 64-response, 256 KiB, 4096-byte and 64-record safety boundaries. pub(crate) fn insert_opaque( &mut self, @@ -1134,6 +1145,7 @@ impl DnsCache { accounted_bytes, }, expiry(now, lifetime_secs), + now, ) .is_ok() } diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 8ed185a..30dd884 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -1024,7 +1024,10 @@ impl RuntimeDns { policy_index: NameserverPolicies::new(&config.nameserver_policies), geo_matcher, egress, - cache: Mutex::new(DnsCache::with_max_entries(address_cache_entries)), + cache: Mutex::new(DnsCache::with_algorithm( + address_cache_entries, + config.cache_algorithm, + )), hints: (redir_host_entries != 0).then(|| { Arc::new(StdMutex::new(RedirHostHints::with_max_entries( redir_host_entries, @@ -5197,6 +5200,36 @@ dns: assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 5); } + #[tokio::test] + async fn dns_cache_arc_configuration_changes_actual_upstream_reuse() { + for (algorithm, expected_calls) in [ + (crate::config::DnsCacheAlgorithm::Lru, 4), + (crate::config::DnsCacheAlgorithm::Arc, 3), + ] { + let address = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 104)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + (0..expected_calls) + .map(|_| MockReply::Response(ResponseSpec::answer(address))) + .collect(), + vec![], + )); + let mut configuration = config(false, vec![nameserver(1, DnsTransport::Udp)]); + configuration.cache_max_size = 2; + configuration.cache_algorithm = algorithm; + let resolver = RuntimeDns::new(&configuration, dispatcher.clone()); + for name in [ + "a.example", + "a.example", + "b.example", + "c.example", + "a.example", + ] { + assert_eq!(resolver.resolve(name).await.unwrap(), vec![address]); + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), expected_calls); + } + } + #[tokio::test] async fn concurrent_queries_above_old_threshold_reach_upstream_without_local_rejection() { const REQUESTS: usize = 160; diff --git a/tests/README.md b/tests/README.md index dc7c258..50557ae 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,6 +32,7 @@ cargo test --locked --all-features --lib dns::fallback::tests:: cargo test --locked --all-features --lib dns::runtime::tests::dns_fallback_ cargo test --locked --all-features --lib dns::runtime::tests::resolve_ipv6_ cargo test --locked --lib dns::tests::cache_ +cargo test --locked --lib dns::cache::tests:: cargo test --locked --lib dns::tests::opaque_cache_ cargo test --locked --lib dns::runtime::tests::dns_cache_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets From 6636df1191e9a64984feab0196b08e28a4d375fb Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 10:48:03 +0800 Subject: [PATCH 029/445] Add local DNS RCODE responses and epoch-safe cache flush --- docs/config.yaml | 3 + docs/controller-api.md | 6 +- docs/tun-icmp-dns.md | 16 +- src/config/dns.rs | 27 +- src/config/mod.rs | 37 +++ src/controller.rs | 14 + src/dns/runtime.rs | 567 ++++++++++++++++++++++++++++++++++++----- tests/README.md | 3 + 8 files changed, 603 insertions(+), 70 deletions(-) diff --git a/docs/config.yaml b/docs/config.yaml index 3b8c513..83c87d5 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -537,6 +537,8 @@ hosts: # nameserver 默认 [https://doh.pub/dns-query, tls://223.5.5.5:853],不能显式留空。 # 支持裸 IP、UDP/TCP、tls://、http(s)://,允许上游域名;DoH 使用 HTTP/1.1 POST, # DoT 与 HTTPS 校验证书与名称。default-nameserver 只接受 IP 上游,用于 bootstrap。 +# rcode://success/format_error/server_failure/name_error/not_implemented/refused 返回本地 RCODE 0–5, +# 可用于各 nameserver 列表与 policy(default-nameserver 除外);不建立网络连接或额外设置 RA。 # 无 fragment 时默认 DIRECT;respect-rules: true 时默认 RULES,且必须配置 # proxy-server-nameserver。显式 fragment 可选 DIRECT、RULES 或已配置的节点/组名; # 名称支持百分号编码。proxy-server-nameserver 专门解析节点,direct-nameserver @@ -586,6 +588,7 @@ dns: nameserver: - "tcp://1.1.1.1:53#main-select" nameserver-policy: + "blocked.example": "rcode://refused" "internal.example": "223.5.5.5" "+.example.com": ["tcp://1.1.1.1:53#DIRECT"] "geosite:private,cn,apple": diff --git a/docs/controller-api.md b/docs/controller-api.md index af83f40..b01bd56 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -200,12 +200,16 @@ policy 与 fallback 仍生效。DNS 关闭或查询失败返回 500。这里只 不会写入普通日志。`/connections` 是业务负载统计,`/traffic` 继续表示 TUN 原始 IP 包流量, `/proxies` 的节点健康数据继续表示探测结果,三者不可相互替代。 +`POST /cache/dns/flush` 清除当前资源代际所有 DNS 角色的普通地址、负响应、原始响应缓存和 +域名提示,成功返回 204;DNS 未启用时同样返回 204。旧在途查询可以完成,但不能回填 +清理后的缓存,新查询不会复用这些旧查询。fake-IP 映射和活动业务会话保留。 + ## HTTP 状态与协议边界 | 状态 | 含义 | | --- | --- | | `200 OK` | 查询成功,返回 `application/json` | -| `204 No Content` | 选择成功,正文为空 | +| `204 No Content` | 选择、刷新或清理成功,正文为空 | | `400 Bad Request` | 路径、JSON、长度格式或成员无效 | | `401 Unauthorized` | Bearer 鉴权失败 | | `403 Forbidden` | 请求来源不是回环地址 | diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 3ff69eb..1562a55 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -110,6 +110,8 @@ dns: - `dns.enable`、`dns.ipv6` 默认 false。主 nameserver 默认使用 DoH/DoT,可显式替换为非空列表。 - 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用共享 TLS 证书验证。DoH 采用 HTTP/1.1 POST。 +- `rcode://success`、`rcode://format_error`、`rcode://server_failure`、`rcode://name_error`、`rcode://not_implemented`、`rcode://refused` 分别返回本地 RCODE 0–5;可用于 nameserver、policy、fallback、节点和直连 resolver,不能用于 default-nameserver。 +- 同一个上游列表中存在本地 RCODE 时使用第一项本地响应,不建立网络连接,也不把 SERVFAIL/REFUSED 当作上游故障重试。响应保留原查询的 flags 和 additional,仅设置 QR 与 RCODE;不额外开启 RA,不写入结果缓存。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 - 上游域名由 `default-nameserver` 引导解析,后者只允许 IP 上游。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。显式 system/DHCP nameserver 尚未实现。 - 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 @@ -137,6 +139,10 @@ dns: - GeoSite 资产或分类不可用时该 policy 不命中,不能因反选变成全匹配; - 存在分类但属性筛选为空时仍可用,正选不命中、反选匹配合法非空名称。 +hosts 和 fake-IP 分配先于 policy;命中 fake-IP 过滤器、需要真实响应时才应用 policy 中的 RCODE。 +policy 的本地响应不进入 fallback;主 nameserver 的本地响应没有地址,已配置 fallback 时沿用 +本节的 fallback 选择规则。未配置 fallback 时直接返回本地响应。 + Policy 只决定当前 DNS exchange,不改写后续业务动作。分类、属性归一和语义边界见 [GeoData 规则](geodata.md#规则);所有平台均不按 GeoData 条数截断记录。 每次同步 policy 选择捕获一份不可变 GeoData 快照,所有 selector 与 policy 项共用, @@ -205,13 +211,21 @@ ARC 分别保留近期与常用结果,命中提升到常用组,使用两组 ### Singleflight -未命中缓存时,以规范化 question 和 wire 语义摘要作为 key: +未命中缓存时,以规范化 question、wire 语义摘要和缓存 epoch 作为 key: - Leader 在发起方任务内运行; - Leader 被取消或释放时,RAII 通知 follower 重新选举; - 每个调用方恢复自己的 transaction ID 和 endpoint,并独立应用失败策略; - 不同 key 可以并行。 +### 清理 + +Controller `POST /cache/dns/flush` 清理主 resolver、bootstrap、节点和直连角色的类型化与 +原始响应缓存、ARC 历史项和 redir-host hints。所有角色共享一次 epoch 边界;清理完成后 +的新查询不能加入旧 singleflight,旧在途查询仍可向原调用方返回结果,但不能回填缓存或 hints。 +资源替换保留这份 epoch,旧资源 generation 也不能回填共享 hints。 +fake-IP 映射、活动业务会话、在途查询和上游连接池继续保留。 + ## Hosts 与解析职责 `hosts` 支持 IP、IP 数组和域名别名,匹配规则沿用 Clash 域名通配;默认包含 localhost,别名循环在配置阶段拒绝。`use-hosts`、`use-system-hosts` 独立控制配置与系统 hosts,默认均为 true;系统 hosts 在运行时创建时读取。TCP、UDP 节点的 TLS SNI 和逻辑目的名称不因解析为 IP 而改变。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 75e9f7b..7e916ab 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -120,7 +120,8 @@ pub(super) fn parse_dns_nameserver( ) -> Result { let bad = || { VoleError::InvalidConfig( - "invalid DNS upstream; supported transports are UDP, TCP, TLS and HTTP(S)".into(), + "invalid DNS upstream; supported transports are UDP, TCP, TLS, HTTP(S) and RCODE" + .into(), ) }; let (endpoint, route) = match input.split_once('#') { @@ -154,6 +155,30 @@ pub(super) fn parse_dns_nameserver( format!("udp://{endpoint}") }; let url = Url::parse(&endpoint).map_err(|_| bad())?; + if url.scheme() == "rcode" { + let host = url.host_str().ok_or_else(bad)?; + let rcode = match host { + "success" => 0, + "format_error" => 1, + "server_failure" => 2, + "name_error" => 3, + "not_implemented" => 4, + "refused" => 5, + _ => return Err(bad()), + }; + // Mihomo selects the local client by URL host. Paths and query + // parameters do not modify its behavior; a port changes the host. + if url.port().is_some() { + return Err(bad()); + } + return Ok(DnsNameserver { + transport: DnsTransport::Rcode(rcode), + address: DnsAddress::Domain(host.to_owned()), + port: 0, + route, + path: None, + }); + } let (transport, default_port) = match url.scheme() { "udp" => (DnsTransport::Udp, 53), "tcp" => (DnsTransport::Tcp, 53), diff --git a/src/config/mod.rs b/src/config/mod.rs index 79bc4ef..27e9026 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -385,6 +385,8 @@ impl DnsNameserverPolicy { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum DnsTransport { + /// A local response with a Mihomo RCODE name, without network I/O. + Rcode(u8), Udp, Tcp, Tls, @@ -5949,6 +5951,41 @@ authentication: assert!(!config.dns.enable); } + #[test] + fn dns_rcode_nameserver_uses_the_mihomo_names_and_existing_policy_schema() { + for (name, rcode) in [ + ("success", 0), + ("format_error", 1), + ("server_failure", 2), + ("name_error", 3), + ("not_implemented", 4), + ("refused", 5), + ] { + let yaml = format!( + "mixed-port: 1080\ndns:\n enable: true\n nameserver: [rcode://{name}]\n nameserver-policy:\n example.com: rcode://{name}\n" + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!( + config.dns.nameservers[0].transport, + DnsTransport::Rcode(rcode) + ); + assert_eq!( + config.dns.nameserver_policies[0].nameservers[0].transport, + DnsTransport::Rcode(rcode) + ); + } + for endpoint in ["rcode://NXDOMAIN", "rcode://6", "rcode://success:53"] { + let yaml = format!("mixed-port: 1080\ndns:\n nameserver: [{endpoint}]\n"); + assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); + } + assert!( + Config::parse_yaml( + b"mixed-port: 1080\ndns:\n default-nameserver: [rcode://success]\n" + ) + .is_err() + ); + } + #[test] fn current_config_normalizes_dns_nameserver_routes() { let yaml = current_yaml( diff --git a/src/controller.rs b/src/controller.rs index 0a0433c..69524b9 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -181,6 +181,20 @@ async fn handle_connection( return write_error(&mut stream, 400, "Bad request", &[]).await; } + if request.target == "/cache/dns/flush" { + if request.method != "POST" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "POST")]).await; + } + if let Some(snapshot) = resources.as_ref().and_then(|resources| resources.dns()) { + tokio::select! { + biased; + () = cancellation.cancelled() => return Ok(()), + () = snapshot.dns.flush_cache() => {} + } + } + return write_response(&mut stream, 204, &[], &[]).await; + } + if matches!(request.target.as_str(), "/configs" | "/rules") { if request.method != "GET" { return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 30dd884..b79fb71 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -8,7 +8,7 @@ mod encrypted_tests; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, fmt, future::Future, io, @@ -23,7 +23,7 @@ use std::{ use bytes::Bytes; use tokio::{ io::{AsyncReadExt as _, AsyncWriteExt as _}, - sync::{Mutex, watch}, + sync::{Mutex, RwLock, watch}, time::{Instant as TokioInstant, sleep_until, timeout_at}, }; use tokio_util::sync::CancellationToken; @@ -106,6 +106,7 @@ pub struct RuntimeDns { geo_matcher: Arc, egress: DnsEgress, cache: Mutex, + cache_epoch: Arc>, // Hint lookup and insertion are bounded synchronous work. Keep this // shared TUN hot path free of asynchronous mutex scheduling. hints: Option>>, @@ -591,13 +592,15 @@ type SharedDnsResult = Result, RuntimeDnsError>; struct SingleflightKey { question: WireQuestion, semantics_digest: [u8; 32], + epoch: u64, } -impl From<&ClassifiedDnsQuery> for SingleflightKey { - fn from(query: &ClassifiedDnsQuery) -> Self { +impl SingleflightKey { + fn new(query: &ClassifiedDnsQuery, epoch: u64) -> Self { Self { question: query.question.clone(), semantics_digest: query.semantics_digest, + epoch, } } } @@ -808,6 +811,7 @@ impl RuntimeDns { pub(crate) fn with_mapping_from(mut self, previous: Option<&Self>) -> Self { if let Some(previous) = previous { self.hints = previous.hints.clone(); + self.share_cache_epoch(previous.cache_epoch.clone()); if self.fake_config.enabled == previous.fake_config.enabled && self.fake_config.range == previous.fake_config.range && self.fake_config.range6 == previous.fake_config.range6 @@ -818,6 +822,62 @@ impl RuntimeDns { } self } + // Resource replacement consumes a freshly assembled tree. Release the + // leaves' shared bootstrap references before mutating that owned node. + fn share_cache_epoch(&mut self, epoch: Arc>) { + let mut roles = [self.proxy_resolver.take(), self.direct_resolver.take()]; + for role in roles.iter_mut().flatten() { + Arc::get_mut(role) + .expect("fresh DNS role is exclusively owned") + .bootstrap = None; + } + if let Some(bootstrap) = &mut self.bootstrap { + Arc::get_mut(bootstrap) + .expect("fresh DNS bootstrap is exclusively owned") + .cache_epoch = epoch.clone(); + } + for role in roles.iter_mut().flatten() { + let role = Arc::get_mut(role).expect("fresh DNS role is exclusively owned"); + role.cache_epoch = epoch.clone(); + role.bootstrap = self.bootstrap.clone(); + } + [self.proxy_resolver, self.direct_resolver] = roles; + self.cache_epoch = epoch; + } + + /// Clears typed and wire results and redir-host hints for every DNS role. + /// Fake-IP mappings, active queries and upstream connections stay live. + /// Queries admitted before this boundary cannot refill cleared state. + pub async fn flush_cache(&self) { + let mut epoch = self.cache_epoch.write().await; + // Advance before clearing so cancellation during shutdown cannot + // allow an old leader to repopulate a partially cleared tree. + *epoch = epoch.wrapping_add(1); + let mut pending = vec![self]; + let mut seen = HashSet::new(); + while let Some(resolver) = pending.pop() { + if !seen.insert(std::ptr::from_ref(resolver) as usize) { + continue; + } + resolver.cache.lock().await.clear(); + if let Some(hints) = &resolver.hints { + hints + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } + pending.extend( + [ + resolver.bootstrap.as_deref(), + resolver.proxy_resolver.as_deref(), + resolver.direct_resolver.as_deref(), + ] + .into_iter() + .flatten(), + ); + } + } + pub(crate) fn with_fake_ip_store(mut self, root: Option<&std::path::Path>) -> io::Result { if self.enabled && self.fake_config.enabled @@ -948,6 +1008,7 @@ impl RuntimeDns { cache: usize, hints: usize, ) -> Self { + let cache_epoch = Arc::new(RwLock::new(0)); let mut bootstrap_config = config.clone(); bootstrap_config.enable = true; bootstrap_config.fallback = crate::config::DnsFallbackConfig::default(); @@ -959,8 +1020,16 @@ impl RuntimeDns { geo.clone(), cache, 0, + cache_epoch.clone(), )); - let mut root = Self::assemble_leaf(config, egress.clone(), geo.clone(), cache, hints); + let mut root = Self::assemble_leaf( + config, + egress.clone(), + geo.clone(), + cache, + hints, + cache_epoch.clone(), + ); root.bootstrap = Some(bootstrap.clone()); if config.enable && config.fake_ip.enabled { root.fake_pools = Some(Arc::new( @@ -973,8 +1042,14 @@ impl RuntimeDns { role_config.nameservers = nameservers.to_vec(); role_config.fallback = crate::config::DnsFallbackConfig::default(); role_config.nameserver_policies = policies.to_vec(); - let mut resolver = - Self::assemble_leaf(&role_config, egress.clone(), geo.clone(), cache, 0); + let mut resolver = Self::assemble_leaf( + &role_config, + egress.clone(), + geo.clone(), + cache, + 0, + cache_epoch.clone(), + ); resolver.bootstrap = Some(bootstrap.clone()); Arc::new(resolver) }; @@ -1000,6 +1075,7 @@ impl RuntimeDns { geo_matcher: Arc, address_cache_entries: usize, redir_host_entries: usize, + cache_epoch: Arc>, ) -> Self { let resource_stats = RuntimeResourceStats::new("runtime_dns"); Self { @@ -1028,6 +1104,7 @@ impl RuntimeDns { address_cache_entries, config.cache_algorithm, )), + cache_epoch, hints: (redir_host_entries != 0).then(|| { Arc::new(StdMutex::new(RedirHostHints::with_max_entries( redir_host_entries, @@ -1060,8 +1137,11 @@ impl RuntimeDns { ) } - fn accepts_primary(&self, result: &Result<(Vec, DnsMessage), RuntimeDnsError>) -> bool { - let Ok((_, response)) = result else { + fn accepts_primary( + &self, + result: &Result<(Vec, DnsMessage, bool), RuntimeDnsError>, + ) -> bool { + let Ok((_, response, _)) = result else { return false; }; let Ok(answers) = matching_answer_set(response) else { @@ -1473,8 +1553,8 @@ impl RuntimeDns { let parsed_query = parse_query(query).map_err(RuntimeDnsError::InvalidQuery)?; loop { - let key = SingleflightKey::from(classified); - let role = { + let (role, epoch) = { + let epoch = self.cache_epoch.read().await; // Keep the cache lock through registry selection. A completing // leader populates this cache before removing its registry // entry, so a caller cannot observe a miss and then create a @@ -1496,12 +1576,16 @@ impl RuntimeDns { failure_mode, ); } - self.singleflight.join(key) + ( + self.singleflight + .join(SingleflightKey::new(classified, *epoch)), + *epoch, + ) }; match role { FlightRole::Leader(leader) => { let result = self - .exchange_address_leader(query, classified, deadline) + .exchange_address_leader(query, classified, deadline, epoch) .await .map(Arc::<[u8]>::from); leader.complete(result.clone()); @@ -1523,52 +1607,56 @@ impl RuntimeDns { query: &[u8], classified: &ClassifiedDnsQuery, deadline: TokioInstant, + epoch: u64, ) -> Result, RuntimeDnsError> { let upstream_query = rewrite_query_id(query, self.next_upstream_id()); let upstream_parsed_query = parse_query(&upstream_query).map_err(RuntimeDnsError::InvalidQuery)?; - let (mut response, parsed_response) = if let Some(policy) = self.policy_for(classified) { - super::trace::record("nameserver-policy"); - self.exchange_upstreams(policy, &upstream_query, &upstream_parsed_query, deadline) - .await? - } else if self.fallback.nameservers.is_empty() { - super::trace::record("nameserver"); - self.exchange_upstreams( - &self.nameservers, - &upstream_query, - &upstream_parsed_query, - deadline, - ) - .await? - } else if self.fallback_domain(&classified.question.name) { - super::trace::record("fallback-domain"); - self.exchange_upstreams( - &self.fallback.nameservers, - &upstream_query, - &upstream_parsed_query, - deadline, - ) - .await? - } else { - super::fallback::choose( + let (mut response, parsed_response, cacheable) = + if let Some(policy) = self.policy_for(classified) { + super::trace::record("nameserver-policy"); + self.exchange_upstreams(policy, &upstream_query, &upstream_parsed_query, deadline) + .await? + } else if self.fallback.nameservers.is_empty() { + super::trace::record("nameserver"); self.exchange_upstreams( &self.nameservers, &upstream_query, &upstream_parsed_query, deadline, - ), + ) + .await? + } else if self.fallback_domain(&classified.question.name) { + super::trace::record("fallback-domain"); self.exchange_upstreams( &self.fallback.nameservers, &upstream_query, &upstream_parsed_query, deadline, - ), - self.fallback.lazy, - |answer| self.accepts_primary(answer), - ) - .await? - }; - self.cache_response(&parsed_response).await?; + ) + .await? + } else { + super::fallback::choose( + self.exchange_upstreams( + &self.nameservers, + &upstream_query, + &upstream_parsed_query, + deadline, + ), + self.exchange_upstreams( + &self.fallback.nameservers, + &upstream_query, + &upstream_parsed_query, + deadline, + ), + self.fallback.lazy, + |answer| self.accepts_primary(answer), + ) + .await? + }; + if cacheable { + self.cache_response(&parsed_response, epoch).await?; + } restore_response_id(&mut response, 0); Ok(response) } @@ -1581,8 +1669,8 @@ impl RuntimeDns { failure_mode: FailureMode, ) -> Result, RuntimeDnsError> { loop { - let key = SingleflightKey::from(classified); - let role = { + let (role, epoch) = { + let epoch = self.cache_epoch.read().await; let mut cache = self.cache.lock().await; if let Some(response) = cache.get_opaque(classified, Instant::now()) { super::trace::record("cache"); @@ -1596,12 +1684,16 @@ impl RuntimeDns { failure_mode, ); } - self.singleflight.join(key) + ( + self.singleflight + .join(SingleflightKey::new(classified, *epoch)), + *epoch, + ) }; match role { FlightRole::Leader(leader) => { let result = self - .exchange_opaque_leader(query, classified, deadline) + .exchange_opaque_leader(query, classified, deadline, epoch) .await .map(Arc::<[u8]>::from); leader.complete(result.clone()); @@ -1623,6 +1715,7 @@ impl RuntimeDns { query: &[u8], classified: &ClassifiedDnsQuery, deadline: TokioInstant, + epoch: u64, ) -> Result, RuntimeDnsError> { let upstream_query = rewrite_query_id(query, self.next_upstream_id()); let upstream_classified = @@ -1631,10 +1724,15 @@ impl RuntimeDns { let response = self .exchange_opaque_upstreams(nameservers, &upstream_query, &upstream_classified, deadline) .await?; - self.cache - .lock() - .await - .insert_opaque(&response, Instant::now()); + { + let current_epoch = self.cache_epoch.read().await; + if *current_epoch == epoch { + self.cache + .lock() + .await + .insert_opaque(&response, Instant::now()); + } + } let mut response = response.into_wire(); restore_response_id(&mut response, 0); Ok(response) @@ -1700,8 +1798,11 @@ impl RuntimeDns { query: &[u8], parsed_query: &DnsMessage, deadline: TokioInstant, - ) -> Result<(Vec, DnsMessage), RuntimeDnsError> { - exchange_nameservers( + ) -> Result<(Vec, DnsMessage, bool), RuntimeDnsError> { + let cacheable = !nameservers + .iter() + .any(|server| matches!(server.transport, DnsTransport::Rcode(_))); + let (wire, response) = exchange_nameservers( nameservers, deadline, |nameserver| self.exchange_nameserver(nameserver, query, parsed_query), @@ -1710,7 +1811,8 @@ impl RuntimeDns { .then_some(u16::from(response.rcode)) }, ) - .await + .await?; + Ok((wire, response, cacheable)) } async fn exchange_opaque_upstreams( @@ -1757,6 +1859,7 @@ impl RuntimeDns { query: &[u8], parsed: &ClassifiedDnsQuery, ) -> Result { + let local = matches!(nameserver.transport, DnsTransport::Rcode(_)); self.exchange_validated( nameserver, query, @@ -1764,7 +1867,12 @@ impl RuntimeDns { |wire| validate_opaque_response(parsed, wire).map_err(AttemptError::from), ) .await - .map(|(_, parsed)| parsed) + .map(|(_, mut parsed)| { + if local { + parsed.cache_policy = super::OpaqueCachePolicy::NotCacheable; + } + parsed + }) } async fn exchange_validated( @@ -1778,7 +1886,17 @@ impl RuntimeDns { M: Fn(&[u8]) -> Result, V: Fn(&[u8]) -> Result, { + if let DnsTransport::Rcode(rcode) = nameserver.transport { + super::trace::record("rcode"); + let mut response = query.to_vec(); + let flags = u16::from_be_bytes([response[2], response[3]]); + let flags = (flags | DNS_FLAG_RESPONSE) & !0x000f | u16::from(rcode); + response[2..4].copy_from_slice(&flags.to_be_bytes()); + let parsed = validate(&response)?; + return Ok((response, parsed)); + } super::trace::record(match nameserver.transport { + DnsTransport::Rcode(_) => unreachable!("local response handled above"), DnsTransport::Udp => "udp", DnsTransport::Tcp => "tcp", DnsTransport::Tls => "tls", @@ -1938,14 +2056,21 @@ impl RuntimeDns { Ok((response, validated)) } - async fn cache_response(&self, response: &DnsMessage) -> Result<(), RuntimeDnsError> { + async fn cache_response( + &self, + response: &DnsMessage, + epoch: u64, + ) -> Result<(), RuntimeDnsError> { + let current_epoch = self.cache_epoch.read().await; + if *current_epoch != epoch { + return Ok(()); + } + let mut cache = self.cache.lock().await; let now = Instant::now(); let name = &response.question.name; let query_type = response.question.query_type; if response.rcode == RCODE_NXDOMAIN { - self.cache - .lock() - .await + cache .insert_negative(name, query_type, RCODE_NXDOMAIN, now) .map_err(RuntimeDnsError::Cache)?; return Ok(()); @@ -1956,18 +2081,14 @@ impl RuntimeDns { let answers = matching_answer_set(response).map_err(RuntimeDnsError::InvalidResponse)?; if answers.addresses.is_empty() { - self.cache - .lock() - .await + cache .insert_negative(name, query_type, RCODE_NOERROR, now) .map_err(RuntimeDnsError::Cache)?; return Ok(()); } let ttl = answers.ttl.unwrap_or(MIN_TTL_SECS); - self.cache - .lock() - .await + cache .insert_positive(name, query_type, &answers.addresses, ttl, now) .map_err(RuntimeDnsError::Cache)?; if let Some(hints) = &self.hints { @@ -2072,6 +2193,20 @@ where RetryableResponse: Fn(&T) -> Option, { use futures_util::{StreamExt, stream}; + if let Some(server) = nameservers + .iter() + .find(|server| matches!(server.transport, DnsTransport::Rcode(_))) + { + return match timeout_at(deadline, Box::pin(attempt(server.clone()))).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(error)) => Err(RuntimeDnsError::UpstreamsExhausted { + last_error: error.summary(0), + }), + Err(_) => Err(RuntimeDnsError::UpstreamsExhausted { + last_error: "local DNS response timed out".into(), + }), + }; + } let mut last_error: Box = "no nameserver attempted".into(); let mut attempts = stream::iter(nameservers.iter().cloned().enumerate()) .map(|(index, server)| { @@ -5200,6 +5335,304 @@ dns: assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 5); } + #[tokio::test] + async fn dns_rcode_preserves_query_flags_and_wire_without_network_or_result_caching() { + for rcode in 0..=5 { + let dispatcher = Arc::new(MockDispatcher::default()); + let resolver = RuntimeDns::new( + &config( + false, + vec![ + nameserver(1, DnsTransport::Udp), + nameserver(2, DnsTransport::Rcode(rcode)), + ], + ), + dispatcher.clone(), + ); + for query_type in [1, 16] { + for flags in [0x0100_u16, 0x0180, 0x0010] { + for id in [800, 801] { + let mut query = opaque_query(id, "local.example", query_type); + query[2..4].copy_from_slice(&flags.to_be_bytes()); + // Preserve a legal empty EDNS OPT record as well. + query[10..12].copy_from_slice(&1_u16.to_be_bytes()); + query.extend_from_slice(&[0, 0, 41, 16, 0, 0, 0, 0, 0, 0, 0]); + let mut expected = query.clone(); + expected[2..4].copy_from_slice( + &(flags | DNS_FLAG_RESPONSE | u16::from(rcode)).to_be_bytes(), + ); + assert_eq!(resolver.exchange(&query).await.unwrap(), expected); + } + } + } + assert!(resolver.cache.lock().await.is_empty()); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); + assert_eq!(dispatcher.tcp_calls.load(Ordering::Acquire), 0); + } + } + + #[tokio::test] + async fn dns_rcode_policy_runs_after_fake_ip_allocation_and_flush_preserves_mapping() { + let config = crate::config::Config::parse_yaml(b"mixed-port: 1080\ndns:\n enable: true\n enhanced-mode: fake-ip\n fake-ip-filter: [real.example]\n nameserver: [rcode://success]\n nameserver-policy:\n '+.example': rcode://refused\n").unwrap(); + let dispatcher = Arc::new(MockDispatcher::default()); + let resolver = RuntimeDns::new(&config.dns, dispatcher.clone()); + let fake_query = build_query(810, "fake.example", QueryType::A).unwrap(); + let fake = parse_response(&resolver.exchange(&fake_query).await.unwrap()).unwrap(); + let RecordData::A(address) = fake.answers[0].data else { + panic!("fake A answer") + }; + assert_eq!(fake.rcode, 0); + let real_query = build_query(811, "real.example", QueryType::A).unwrap(); + let real = parse_response(&resolver.exchange(&real_query).await.unwrap()).unwrap(); + assert_eq!(real.rcode, RCODE_REFUSED); + assert!(!real.recursion_available); + resolver.flush_cache().await; + assert_eq!( + resolver.recover_fake_ip(address.into()).unwrap().as_deref(), + Some("fake.example") + ); + let after = parse_response(&resolver.exchange(&fake_query).await.unwrap()).unwrap(); + assert_eq!(after.answers[0].data, RecordData::A(address)); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); + } + + #[tokio::test] + async fn dns_flush_clears_typed_wire_and_hints_in_every_role() { + let old = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 110)); + let new = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 111)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + [old, new] + .into_iter() + .flat_map(|address| { + (0..8).map(move |_| MockReply::Response(ResponseSpec::answer(address))) + }) + .collect(), + vec![], + )); + let mut configuration = config(false, vec![nameserver(1, DnsTransport::Udp)]); + configuration.default_nameservers = configuration.nameservers.clone(); + configuration.proxy_nameservers = configuration.nameservers.clone(); + configuration.direct_nameservers = configuration.nameservers.clone(); + let resolver = RuntimeDns::new(&configuration, dispatcher.clone()); + let roles = [ + &resolver, + resolver.bootstrap.as_deref().unwrap(), + resolver.proxy_resolver.as_deref().unwrap(), + resolver.direct_resolver.as_deref().unwrap(), + ]; + for (round, address) in [old, new].into_iter().enumerate() { + for role in roles { + for query_type in [1, 16] { + let query = opaque_query(820, "clear.example", query_type); + assert_eq!( + role.exchange(&query).await.unwrap(), + response_for_query(&query, &ResponseSpec::answer(address)) + ); + // Both result classes are retained without another query. + role.exchange(&query).await.unwrap(); + } + } + assert_eq!( + dispatcher.udp_calls.load(Ordering::Acquire), + (round + 1) * 8 + ); + assert_eq!( + resolver.domain_hint(address).await.as_deref(), + Some("clear.example") + ); + resolver.flush_cache().await; + for role in roles { + assert!(role.cache.lock().await.is_empty()); + } + assert!(resolver.domain_hint(address).await.is_none()); + } + } + + #[tokio::test] + async fn dns_flush_separates_old_flights_and_blocks_typed_wire_and_hint_refill() { + for role_index in 0..4 { + for query_type in [1, 16] { + let release = Arc::new(Notify::new()); + let old = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 112)); + let new = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 113)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Gated { + response: ResponseSpec::answer(old), + release: release.clone(), + }, + MockReply::Response(ResponseSpec::answer(new)), + ], + vec![], + )); + let mut configuration = config(false, vec![nameserver(1, DnsTransport::Udp)]); + configuration.default_nameservers = configuration.nameservers.clone(); + configuration.proxy_nameservers = configuration.nameservers.clone(); + configuration.direct_nameservers = configuration.nameservers.clone(); + let root = Arc::new(RuntimeDns::new(&configuration, dispatcher.clone())); + let role = match role_index { + 0 => root.clone(), + 1 => root.bootstrap.as_ref().unwrap().clone(), + 2 => root.proxy_resolver.as_ref().unwrap().clone(), + _ => root.direct_resolver.as_ref().unwrap().clone(), + }; + let query = opaque_query(830, "pending.example", query_type); + let old_query = query.clone(); + let old_role = role.clone(); + let leader = + tokio::spawn(async move { old_role.exchange(&old_query).await.unwrap() }); + tokio::time::timeout(Duration::from_secs(1), async { + while dispatcher.udp_calls.load(Ordering::Acquire) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let mut follower_query = query.clone(); + restore_response_id(&mut follower_query, 831); + let old_follower_query = follower_query.clone(); + let old_role = role.clone(); + let follower = + tokio::spawn( + async move { old_role.exchange(&old_follower_query).await.unwrap() }, + ); + tokio::time::timeout(Duration::from_secs(1), async { + while role.singleflight.follower_count() != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + root.flush_cache().await; + let fresh = role.exchange(&query).await.unwrap(); + assert_eq!( + fresh, + response_for_query(&query, &ResponseSpec::answer(new)) + ); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); + release.notify_one(); + assert_eq!( + leader.await.unwrap(), + response_for_query(&query, &ResponseSpec::answer(old)) + ); + assert_eq!( + follower.await.unwrap(), + response_for_query(&follower_query, &ResponseSpec::answer(old)) + ); + let mut cached = role.exchange(&query).await.unwrap(); + let ttl_offset = cached.len() - 10; + let ttl = + u32::from_be_bytes(cached[ttl_offset..ttl_offset + 4].try_into().unwrap()); + assert!((1..=60).contains(&ttl)); + // Cache replay decrements TTL while retaining the fresh result. + cached[ttl_offset..ttl_offset + 4].copy_from_slice(&60_u32.to_be_bytes()); + assert_eq!(cached, fresh); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); + assert!(root.domain_hint(old).await.is_none()); + assert_eq!(role.singleflight.len(), 0); + } + } + } + + #[tokio::test] + async fn dns_flush_holds_one_admission_boundary_until_every_role_is_cleared() { + let old = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 116)); + let new = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 117)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Response(ResponseSpec::answer(old)), + MockReply::Response(ResponseSpec::answer(new)), + ], + vec![], + )); + let resolver = Arc::new(RuntimeDns::new( + &config(false, vec![nameserver(1, DnsTransport::Udp)]), + dispatcher.clone(), + )); + assert_eq!( + resolver.resolve("barrier.example").await.unwrap(), + vec![old] + ); + let bootstrap_cache = resolver.bootstrap.as_ref().unwrap().cache.lock().await; + let flushing = resolver.clone(); + let flush = tokio::spawn(async move { flushing.flush_cache().await }); + tokio::time::timeout(Duration::from_secs(1), async { + while resolver.cache_epoch.try_read().is_ok() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let querying = resolver.clone(); + let query = tokio::spawn(async move { querying.resolve("barrier.example").await.unwrap() }); + tokio::time::timeout(Duration::from_secs(1), async { + while resolver.resource_stats.snapshot().dns_current == 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + assert!(!query.is_finished()); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 1); + drop(bootstrap_cache); + flush.await.unwrap(); + assert_eq!(query.await.unwrap(), vec![new]); + } + + #[tokio::test] + async fn dns_flush_blocks_old_resource_generation_from_refilling_shared_hints() { + let release = Arc::new(Notify::new()); + let old = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 114)); + let new = IpAddr::V4(Ipv4Addr::new(203, 0, 113, 115)); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Gated { + response: ResponseSpec::answer(old), + release: release.clone(), + }, + MockReply::Response(ResponseSpec::answer(new)), + ], + vec![], + )); + let mut configuration = config(false, vec![nameserver(1, DnsTransport::Udp)]); + configuration.proxy_nameservers = configuration.nameservers.clone(); + configuration.direct_nameservers = configuration.nameservers.clone(); + let previous = Arc::new(RuntimeDns::new(&configuration, dispatcher.clone())); + let query = build_query(840, "generation.example", QueryType::A).unwrap(); + let old_query = query.clone(); + let old_role = previous.clone(); + let leader = tokio::spawn(async move { old_role.exchange(&old_query).await.unwrap() }); + tokio::time::timeout(Duration::from_secs(1), async { + while dispatcher.udp_calls.load(Ordering::Acquire) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let current = + RuntimeDns::new(&configuration, dispatcher.clone()).with_mapping_from(Some(&previous)); + for role in [ + current.bootstrap.as_deref().unwrap(), + current.proxy_resolver.as_deref().unwrap(), + current.direct_resolver.as_deref().unwrap(), + ] { + assert!(Arc::ptr_eq(¤t.cache_epoch, &role.cache_epoch)); + } + current.flush_cache().await; + release.notify_one(); + leader.await.unwrap(); + assert!(current.domain_hint(old).await.is_none()); + assert!(previous.cache.lock().await.is_empty()); + assert_eq!( + current.resolve("generation.example").await.unwrap(), + vec![new] + ); + assert_eq!( + current.domain_hint(new).await.as_deref(), + Some("generation.example") + ); + } + #[tokio::test] async fn dns_cache_arc_configuration_changes_actual_upstream_reuse() { for (algorithm, expected_calls) in [ diff --git a/tests/README.md b/tests/README.md index 50557ae..801511e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -35,6 +35,9 @@ cargo test --locked --lib dns::tests::cache_ cargo test --locked --lib dns::cache::tests:: cargo test --locked --lib dns::tests::opaque_cache_ cargo test --locked --lib dns::runtime::tests::dns_cache_ +cargo test --locked --lib config::tests::dns_rcode_ +cargo test --locked --lib dns::runtime::tests::dns_rcode_ +cargo test --locked --lib dns::runtime::tests::dns_flush_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 8a8ca1ed9aa776158f413a719efab2314e016626 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 11:07:51 +0800 Subject: [PATCH 030/445] Unify client TLS policy and add SOCKS5 TLS --- docs/config.yaml | 10 +- docs/outbounds.md | 11 +- docs/tls-client-fingerprint.md | 3 +- src/config/mod.rs | 159 ++++++++++++++++++++++----- src/config/tls_policy_tests.rs | 88 +++++++++++++++ src/config/trojan.rs | 30 ++++- src/config/vless.rs | 2 +- src/config/vmess.rs | 29 ++++- src/outbound/socks5.rs | 54 ++++++++- src/outbound/trojan/outbound.rs | 15 +-- src/outbound/vless/outbound.rs | 1 + src/outbound/vmess/outbound.rs | 15 +-- src/runtime.rs | 25 ++++- src/security/tls.rs | 55 ++++----- src/security/tls_named_wire_tests.rs | 2 +- src/security/tls_tests.rs | 125 +++++++++++++-------- tests/README.md | 3 + tests/datagram_foundations.rs | 1 + tests/trojan_config.rs | 8 +- 19 files changed, 483 insertions(+), 153 deletions(-) create mode 100644 src/config/tls_policy_tests.rs diff --git a/docs/config.yaml b/docs/config.yaml index 83c87d5..5b44442 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -244,6 +244,8 @@ proxies: # network 默认 tcp,支持 tcp/ws/grpc/http/h2。tls 默认 false;关闭时 # 禁止 servername/alpn/fingerprint/skip-cert-verify 等 TLS 专属字段。 # servername 优先显式值,其次 WS Host 去端口,否则 server。 + # TLS 支持 name-cert-verify 和成对的内联 PEM certificate/private-key; + # 验证名不改变 servername,客户端身份不增加服务端信任根。 # ws-opts/grpc-opts 与下面 Trojan 的结构相同;HTTP/H2 选项见示例注释。 # UDP 默认 false;packet-encoding 默认为空(raw),或 xudp/packetaddr。 # raw transport 固定首包目标;packetaddr 域名由受控 Vole DNS 解析为 IP。 @@ -281,6 +283,7 @@ proxies: # Stop / 取消仍关闭全部会话及自有 driver,不更改 XHTTP 的全关闭约定。 # skip-cert-verify 默认 false;fingerprint 沿用 AnyTLS 的证书 SHA-256 # pin 语义,pin 与 skip 同时存在仍必须匹配 pin。udp 默认 false。 + # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 客户端身份。 # UDP 在 TLS 流内传输;单包负载最多 8192 字节并与入口预算取较小值, # 不拆包或截断。域名地址保持域名交由服务端处理;Mihomo v1.19.31 的 # Trojan listener 不接收域名 UDP 帧,使用该对端时需要 IP 业务目标。 @@ -381,7 +384,9 @@ proxies: # config: "" # SOCKS5 出站支持 CONNECT 和 UDP ASSOCIATE。username/password 必须同时省略 - # 或同时出现;tls 只能省略或为 false。udp 控制 UDP ASSOCIATE。 + # 或同时出现;tls 默认 false,true 使用 server 作为 TLS 名称,不接受 sni/alpn/client-fingerprint。 + # TLS 支持 skip-cert-verify/name-cert-verify/fingerprint 和成对内联 PEM certificate/private-key。 + # TLS 只保护 TCP 控制连接,UDP relay 数据不经 TLS;udp 控制 UDP ASSOCIATE。 - name: socks-hop type: socks5 server: socks.example.com @@ -394,7 +399,8 @@ proxies: # sni 省略时使用 server。TLS 1.3/1.2 默认 WebPKI,alpn 省略/[] 时不发送。 # skip-cert-verify 不能覆盖 fingerprint;pin 是证书 SHA-256,不是客户端伪造指纹。 # 叶 pin 直接决定信任;非叶 pin 作为根继续验证链和名称。所有策略均验证握手签名。 - # 不支持 REALITY、fp、ECH、mTLS 或 session/padding 调参字段,详见 outbounds.md#anytls。 + # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 客户端身份。 + # 不支持 REALITY、fp、ECH 或 session/padding 调参字段,详见 outbounds.md#anytls。 # TCP 使用 AnyTLS session;udp: true 使用 UoT v2 datagram mode。 - name: anytls-edge type: anytls diff --git a/docs/outbounds.md b/docs/outbounds.md index 6f8b4e6..20c69fa 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -35,13 +35,17 @@ HTTP CONNECT 不提供 UDP,也不接受 `udp` 字段。握手沿用原期限 仅 SOCKS5 CONNECT / UDP ASSOCIATE;用户名密码成对配置,不支持 SOCKS4。 TCP 保留域名交由上游解析;UDP 由控制连接拥有,校验 relay 来源并保留实际回包目标。 上游为 select 组时,控制连接和后续 UDP 数据报使用同次建链快照。 -SOCKS5 不提供 TLS、证书 pin 或 ClientHello 模板;它的预算独立于 TUN MTU。 +`tls: true` 使用共享 TLS、`skip-cert-verify`、`name-cert-verify`、`fingerprint` 和 +成对的内联 `certificate/private-key`。TLS 名称固定使用 server;不接受 sni、alpn +或 ClientHello 模板。TLS 保护 CONNECT / UDP ASSOCIATE 控制连接,普通 UDP relay +数据不经过 TLS;预算独立于 TUN MTU。TLS 关闭时不能配置证书字段。 ## AnyTLS 支持 v2、兼容 v1 的会话、内置填充与 UoT v2。password 为 1–1024 UTF-8 字节; SNI 默认 server;ALPN 省略/空列表不发送,非空保持顺序,不强制选中某协议。 -不开放 TLS 版本、ECH、mTLS、REALITY、填充和会话参数;命名指纹沿用共享 TLS。 +支持独立的 `name-cert-verify` 和成对内联 PEM `certificate/private-key` 客户端身份; +不开放 TLS 版本、ECH、REALITY、填充和会话参数。命名指纹沿用共享 TLS。 连接先发送 SHA-256 密码摘要、认证填充和首个会话批次。Settings 固定声明 v=2 和 填充方案 MD5,不附产品/版本标识;合法 ServerSettings v>=2 启用 v2,否则兼容 v1。 @@ -73,6 +77,8 @@ begin_shutdown 同步禁止新流、清空空闲并取消所有会话;shutdown SNI 默认 server;WS Host 只覆盖 HTTP authority,不作为 SNI 回退。ALPN 默认 TCP 空、 WS http/1.1、gRPC h2,显式列表及实际协商均须满足传输,不能静默降级。 +`name-cert-verify` 只覆盖证书验证名;成对的内联 PEM `certificate/private-key` +用于客户端 mTLS 身份,共用标准 TLS 的验证、指纹与恢复策略。 WS path 默认 /,可带 query;头字段不得覆盖握手保留头或大小写重复。early-data 0–2048 字节,启用时默认 Sec-WebSocket-Protocol,可自定义;空头名表示路径后缀, @@ -106,6 +112,7 @@ none 只关闭 body 加密,仍验证响应身份;不能启用 global-padding network 默认 tcp,另支持 ws/grpc/http/h2;均可明文或标准 TLS。tls 默认 false, 关闭时禁止 TLS 字段,即便空串、空列表或 false。认证名依次为 servername、WS Host 去端口、server;HTTP 伪装 Host/H2 authority 不替代认证名。WS 需 http/1.1,gRPC/H2 需 h2。 +TLS 支持独立的 `name-cert-verify` 和成对内联 PEM `certificate/private-key` 客户端身份。 - WS ED/路径/头及 HTTPUpgrade 普通/fast-open 约束同 Trojan。 - gRPC service-name 必填,普通名/完整路径规则同 Trojan,无额外池调度器。 diff --git a/docs/tls-client-fingerprint.md b/docs/tls-client-fingerprint.md index 43e117d..a3dbd38 100644 --- a/docs/tls-client-fingerprint.md +++ b/docs/tls-client-fingerprint.md @@ -46,7 +46,8 @@ client-fingerprint: chrome - 命名指纹的标准 TLS,以及所有 REALITY/JLS,使用自有 boring fork;开发及 PR/发布的来源规则见 [TLS 依赖](tls-dependencies.md)。 - 普通 TLS 两条路径共用同一个 WebPKI 证书验证器和发布信任根;叶 pin、非叶信任锚、 独立验证名、skip 优先级不变。TLS 握手签名仍由各自后端强制验证。 -- VLESS mTLS 继续使用配对的内联 PEM,密钥匹配在使用 IO 前检查;profile 不修改身份。 +- HTTP、SOCKS5、AnyTLS、Trojan、VMess、VLESS 与 Hysteria2 的 mTLS 使用配对内联 PEM, + 密钥匹配在使用 IO 前检查;客户端证书不增加服务端信任根,profile 不修改身份。 - REALITY 仅允许自身临时证书认证,不能混用 pin/skip/mTLS 或降级到普通站点证书。 - [JLS](vless.md#jls) 使用共享凭据身份和完整原生 TLS1.3,不启用恢复或 PKI 回退;profile 不改变身份。 - 连接器只包装 Dialer/上游交付的 IO,不解析 DNS、不创建 socket、不绕过平台 protect。 diff --git a/src/config/mod.rs b/src/config/mod.rs index 27e9026..6d5bd6b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -60,6 +60,16 @@ pub use xhttp::{XHttpHeaders, XHttpRequestOptions, XHttpReuseConfig, XHttpVersio mod vmess; pub use vmess::StreamTransport as VmessTransport; pub use vmess::{StreamTransport, VmessCipher, VmessOutboundConfig, VmessPacketEncoding}; +#[cfg(all( + test, + feature = "outbound-anytls", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-socks5", + feature = "inbound-http", + feature = "inbound-socks5" +))] +mod tls_policy_tests; #[cfg(feature = "invoke")] mod measure; @@ -660,6 +670,7 @@ pub struct Socks5OutboundConfig { pub port: u16, pub username: Option, pub password: Option, + pub tls: Option, } #[derive(Clone, PartialEq, Eq)] @@ -668,15 +679,15 @@ pub struct AnyTlsOutboundConfig { pub port: u16, pub password: String, pub server_name: String, - pub tls: AnyTlsCertificatePolicy, + pub tls: TlsClientPolicy, } #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AnyTlsCertificatePolicy { +pub struct TlsClientPolicy { pub client_fingerprint: Option, pub alpn: Vec>, - pub skip_cert_verify: bool, - pub fingerprint: Option<[u8; 32]>, + pub certificate: TlsCertificatePolicy, + pub identity: Option, } /// A version-pinned ClientHello profile, independent of certificate pins. @@ -1441,6 +1452,28 @@ enum RawProtocol { udp: bool, #[serde(default)] tls: bool, + #[serde( + rename = "skip-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + skip_cert_verify: Option, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + fingerprint: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, #[serde(default, deserialize_with = "deserialize_present_option")] username: Option, #[serde(default, deserialize_with = "deserialize_present_option")] @@ -1468,6 +1501,20 @@ enum RawProtocol { skip_cert_verify: bool, #[serde(default, deserialize_with = "deserialize_present_option")] fingerprint: Option, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, #[serde( rename = "client-fingerprint", default, @@ -2559,15 +2606,41 @@ impl RawOutbound { port, udp, tls, + skip_cert_verify, + name_cert_verify, + fingerprint, + certificate, + private_key, username, password, dialer_proxy, - } => ( - name, - dialer_proxy, - udp, - ProxyProtocol::Socks5(normalize_socks5(server, port, tls, username, password)?), - ), + } => { + let mut config = normalize_socks5(server, port, username, password)?; + if !tls + && (skip_cert_verify.is_some() + || name_cert_verify.is_some() + || fingerprint.is_some() + || certificate.is_some() + || private_key.is_some()) + { + return invalid("SOCKS5 TLS options require tls: true"); + } + if tls { + config.tls = Some(TlsClientPolicy { + certificate: TlsCertificatePolicy { + skip_cert_verify: skip_cert_verify.unwrap_or(false), + fingerprint: fingerprint + .as_deref() + .map(vless::parse_pin) + .transpose()?, + verification_name: normalize_verification_name(name_cert_verify)?, + }, + identity: normalize_client_identity(certificate, private_key)?, + ..Default::default() + }); + } + (name, dialer_proxy, udp, ProxyProtocol::Socks5(config)) + } RawProtocol::AnyTls { name, server, @@ -2578,6 +2651,9 @@ impl RawOutbound { alpn, skip_cert_verify, fingerprint, + name_cert_verify, + certificate, + private_key, client_fingerprint, dialer_proxy, } => { @@ -2590,6 +2666,9 @@ impl RawOutbound { skip_cert_verify, fingerprint, )?; + config.tls.certificate.verification_name = + normalize_verification_name(name_cert_verify)?; + config.tls.identity = normalize_client_identity(certificate, private_key)?; config.tls.client_fingerprint = parse_client_fingerprint(client_fingerprint.as_deref())?; (name, dialer_proxy, udp, ProxyProtocol::AnyTls(config)) @@ -2646,15 +2725,11 @@ impl RawRealitySettings { fn normalize_socks5( server: String, port: u16, - tls: bool, username: Option, password: Option, ) -> Result { validate_host(&server, "SOCKS5 server")?; validate_port(port, "SOCKS5")?; - if tls { - return invalid("SOCKS5 tls must be false"); - } match (&username, &password) { (None, None) => {} (Some(username), Some(password)) => { @@ -2675,6 +2750,7 @@ fn normalize_socks5( port, username, password, + tls: None, }) } @@ -2730,15 +2806,50 @@ fn normalize_anytls( port, password, server_name, - tls: AnyTlsCertificatePolicy { + tls: TlsClientPolicy { client_fingerprint: None, alpn, - skip_cert_verify, - fingerprint, + certificate: TlsCertificatePolicy { + skip_cert_verify, + fingerprint, + ..Default::default() + }, + ..Default::default() }, }) } +fn normalize_verification_name(name: Option) -> Result> { + let name = name.filter(|name| !name.is_empty()); + if let Some(name) = &name { + validate_host(name, "TLS certificate verification name")?; + } + Ok(name) +} + +fn normalize_client_identity( + certificate: Option, + private_key: Option, +) -> Result> { + match (certificate, private_key) { + (None, None) => Ok(None), + (Some(certificate), Some(private_key)) + if certificate.is_empty() && private_key.is_empty() => + { + Ok(None) + } + (Some(certificate), Some(private_key)) => { + crate::security::TlsClientIdentity::from_pem(&certificate, &private_key) + .map_err(|_| VoleError::InvalidConfig("invalid TLS client identity".into()))?; + Ok(Some(TlsIdentityPem { + certificate, + private_key, + })) + } + _ => invalid("TLS certificate and private-key must be provided together"), + } +} + impl RawXHttpSettings { fn normalize( self, @@ -4747,8 +4858,6 @@ authentication: for field in [ "tls: true", "ech-opts: {}", - "certificate: client.crt", - "private-key: client.key", "idle-session-check-interval: 30", "idle-session-timeout: 30", "min-idle-session: 1", @@ -4779,7 +4888,7 @@ authentication: let ProxyProtocol::AnyTls(anytls) = &config.proxies[0].protocol else { panic!() }; - assert_eq!(anytls.tls, AnyTlsCertificatePolicy::default()); + assert_eq!(anytls.tls, TlsClientPolicy::default()); } let plain = "aB".repeat(32); let colon = std::iter::repeat_n("AB", 32).collect::>().join(":"); @@ -4798,8 +4907,8 @@ authentication: anytls.tls.alpn, [b"h2".to_vec(), b"http/1.1".to_vec(), b"h2".to_vec()] ); - assert!(anytls.tls.skip_cert_verify); - assert_eq!(anytls.tls.fingerprint, Some([0xab; 32])); + assert!(anytls.tls.certificate.skip_cert_verify); + assert_eq!(anytls.tls.certificate.fingerprint, Some([0xab; 32])); } for fields in [ "alpn: null", @@ -5450,12 +5559,6 @@ authentication: " server: socks.example.com", " streamSettings: {}\n server: socks.example.com", ), - two_proxy_yaml( - "mixed-port: 1080 -authentication: - - measure:secret", - ) - .replace(" type: socks5", " type: socks5\n tls: true"), ] { assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); } diff --git a/src/config/tls_policy_tests.rs b/src/config/tls_policy_tests.rs new file mode 100644 index 0000000..5808156 --- /dev/null +++ b/src/config/tls_policy_tests.rs @@ -0,0 +1,88 @@ +use super::*; + +fn pem(label: &str, der: &[u8]) -> String { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + format!( + "-----BEGIN {label}-----\n{}\n-----END {label}-----\n", + STANDARD.encode(der) + ) +} + +fn node(protocol: &str) -> serde_json::Value { + let mut node = serde_json::json!({ + "name": "edge", "type": protocol, "server": "cover.invalid", "port": 443, + "name-cert-verify": "verified.invalid", "fingerprint": "ab".repeat(32), + }); + match protocol { + "anytls" | "trojan" => node["password"] = "synthetic-secret".into(), + "vmess" => { + node["uuid"] = "00000000-0000-4000-8000-000000000001".into(); + node["tls"] = true.into(); + } + "socks5" => node["tls"] = true.into(), + _ => unreachable!(), + } + node +} + +fn parse(node: serde_json::Value) -> Result { + Config::parse_yaml( + serde_yaml_ng::to_string(&serde_json::json!({"mixed-port": 1080, "proxies": [node]})) + .unwrap() + .as_bytes(), + ) +} + +fn policy(config: &Config) -> &TlsClientPolicy { + match &config.proxies[0].protocol { + ProxyProtocol::AnyTls(config) => &config.tls, + ProxyProtocol::Trojan(config) => &config.tls, + ProxyProtocol::Vmess(config) => config.tls.as_ref().unwrap(), + ProxyProtocol::Socks5(config) => config.tls.as_ref().unwrap(), + _ => unreachable!(), + } +} + +#[test] +fn common_tls_policy_keeps_verified_identity_separate_from_server_name() { + let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); + for protocol in ["anytls", "trojan", "vmess", "socks5"] { + let mut node = node(protocol); + node["certificate"] = pem("CERTIFICATE", identity.cert.der()).into(); + node["private-key"] = pem("PRIVATE KEY", &identity.signing_key.serialize_der()).into(); + let config = parse(node).unwrap(); + let policy = policy(&config); + assert_eq!( + policy.certificate.verification_name.as_deref(), + Some("verified.invalid") + ); + assert_eq!(policy.certificate.fingerprint, Some([0xab; 32])); + assert!( + crate::security::TlsClientOptions::from_policy(policy, None) + .unwrap() + .identity + .is_some() + ); + let debug = format!("{policy:?}"); + assert!(!debug.contains("PRIVATE KEY") && !debug.contains("verified.invalid")); + assert_eq!( + (config.proxies[0].address(), config.proxies[0].port()), + ("cover.invalid", 443) + ); + } +} + +#[test] +fn common_tls_identity_rejects_unpaired_or_mismatched_keys() { + let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); + let other = rcgen::generate_simple_self_signed(vec!["other.invalid".into()]).unwrap(); + for protocol in ["anytls", "trojan", "vmess", "socks5"] { + let mut node = node(protocol); + node["certificate"] = pem("CERTIFICATE", identity.cert.der()).into(); + assert!(parse(node.clone()).is_err()); + node["private-key"] = pem("PRIVATE KEY", &other.signing_key.serialize_der()).into(); + let error = parse(node).unwrap_err().to_string(); + assert!(error.contains("invalid TLS client identity")); + assert!(!error.contains("PRIVATE KEY")); + } +} diff --git a/src/config/trojan.rs b/src/config/trojan.rs index bcabaf7..8da8b41 100644 --- a/src/config/trojan.rs +++ b/src/config/trojan.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use std::collections::BTreeMap; use super::{ - AnyTlsCertificatePolicy, ProxyProtocol, deserialize_present_option, invalid, validate_host, + ProxyProtocol, TlsClientPolicy, deserialize_present_option, invalid, validate_host, validate_port, }; use crate::Result; @@ -13,7 +13,7 @@ pub struct TrojanOutboundConfig { pub port: u16, pub password: String, pub server_name: String, - pub tls: AnyTlsCertificatePolicy, + pub tls: TlsClientPolicy, pub transport: TrojanTransport, } @@ -233,6 +233,20 @@ pub(super) struct RawTrojan { skip_cert_verify: bool, #[serde(default, deserialize_with = "deserialize_present_option")] fingerprint: Option, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, #[serde( rename = "client-fingerprint", default, @@ -363,13 +377,19 @@ impl RawTrojan { password: self.password, server_name, transport, - tls: AnyTlsCertificatePolicy { + tls: TlsClientPolicy { client_fingerprint: super::parse_client_fingerprint( self.client_fingerprint.as_deref(), )?, alpn, - skip_cert_verify: self.skip_cert_verify, - fingerprint, + certificate: super::TlsCertificatePolicy { + skip_cert_verify: self.skip_cert_verify, + fingerprint, + verification_name: super::normalize_verification_name( + self.name_cert_verify, + )?, + }, + identity: super::normalize_client_identity(self.certificate, self.private_key)?, }, }), )) diff --git a/src/config/vless.rs b/src/config/vless.rs index 3b4293e..1d6d05f 100644 --- a/src/config/vless.rs +++ b/src/config/vless.rs @@ -514,7 +514,7 @@ pub(super) fn parse_pin(value: &str) -> Result<[u8; 32]> { .filter(|byte| *byte != b':') .collect::>(); if hex.len() != 64 || hex.iter().any(|byte| !byte.is_ascii_hexdigit()) { - return invalid("VLESS fingerprint must be a SHA-256 certificate digest"); + return invalid("fingerprint must be a SHA-256 certificate digest"); } let mut pin = [0; 32]; for (index, pair) in hex.as_chunks::<2>().0.iter().enumerate() { diff --git a/src/config/vmess.rs b/src/config/vmess.rs index 01508e7..87b4ef8 100644 --- a/src/config/vmess.rs +++ b/src/config/vmess.rs @@ -217,7 +217,7 @@ pub struct VmessOutboundConfig { pub authenticated_length: bool, pub packet_encoding: VmessPacketEncoding, pub server_name: String, - pub tls: Option, + pub tls: Option, pub transport: StreamTransport, } impl std::fmt::Debug for VmessOutboundConfig { @@ -256,6 +256,20 @@ pub(super) struct RawVmess { skip_cert_verify: Option, #[serde(default, deserialize_with = "deserialize_present_option")] fingerprint: Option, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, + #[serde(default, deserialize_with = "deserialize_present_option")] + certificate: Option, + #[serde( + rename = "private-key", + default, + deserialize_with = "deserialize_present_option" + )] + private_key: Option, #[serde( rename = "client-fingerprint", default, @@ -322,6 +336,9 @@ impl RawVmess { || self.alpn.is_some() || self.skip_cert_verify.is_some() || self.fingerprint.is_some() + || self.name_cert_verify.is_some() + || self.certificate.is_some() + || self.private_key.is_some() || self.client_fingerprint.is_some()) { return invalid("VMess TLS options require tls=true"); @@ -374,13 +391,17 @@ impl RawVmess { Ok(pin) }) .transpose()?; - Some(super::AnyTlsCertificatePolicy { + Some(super::TlsClientPolicy { client_fingerprint: super::parse_client_fingerprint( self.client_fingerprint.as_deref(), )?, alpn, - fingerprint, - skip_cert_verify: self.skip_cert_verify.unwrap_or(false), + certificate: super::TlsCertificatePolicy { + fingerprint, + skip_cert_verify: self.skip_cert_verify.unwrap_or(false), + verification_name: super::normalize_verification_name(self.name_cert_verify)?, + }, + identity: super::normalize_client_identity(self.certificate, self.private_key)?, }) } else { None diff --git a/src/outbound/socks5.rs b/src/outbound/socks5.rs index 41c0faa..1e2ae8a 100644 --- a/src/outbound/socks5.rs +++ b/src/outbound/socks5.rs @@ -8,6 +8,9 @@ use crate::{ config::Socks5OutboundConfig, dialer::{Dialer, ResolvedEndpoint}, dispatch::{BoxStream, DatagramTransport, DispatchError, Dispatcher}, + security::{ + SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsClientOptions, + }, session::{Datagram, DatagramSession, Destination, StreamSession}, socks5::{ COMMAND_CONNECT, COMMAND_UDP_ASSOCIATE, MAX_UDP_HEADER_SIZE, VERSION, decode_udp_packet, @@ -62,6 +65,7 @@ pub struct Socks5Outbound { server: Destination, auth: Option, upstream: UpstreamPath, + tls: Option, } impl std::fmt::Debug for Socks5Outbound { @@ -70,6 +74,7 @@ impl std::fmt::Debug for Socks5Outbound { .debug_struct("Socks5Outbound") .field("server", &self.server) .field("has_auth", &self.auth.is_some()) + .field("tls", &self.tls.is_some()) .field("upstream", &self.upstream) .finish() } @@ -95,6 +100,23 @@ impl Socks5Outbound { pub fn new_with_path( config: &Socks5OutboundConfig, upstream: UpstreamPath, + ) -> io::Result { + let security = config.tls.as_ref().map(|_| SecurityContext::new()); + Self::with_shared_security( + config, + upstream, + security.as_ref(), + TLS_RESUMPTION_SESSION_BUDGET, + 64 * 1024, + ) + } + + pub(crate) fn with_shared_security( + config: &Socks5OutboundConfig, + upstream: UpstreamPath, + security: Option<&SecurityContext>, + sessions: usize, + buffer_limit: usize, ) -> io::Result { let auth = match (&config.username, &config.password) { (None, None) => None, @@ -112,6 +134,24 @@ impl Socks5Outbound { server: server_destination(&config.address, config.port)?, auth, upstream, + tls: config + .tls + .as_ref() + .map(|policy| { + StandardTlsClient::with_options( + security.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "SOCKS5 TLS requires security context", + ) + })?, + &config.address, + TlsClientOptions::from_policy(policy, None)?, + sessions, + buffer_limit, + ) + }) + .transpose()?, }) } @@ -120,9 +160,16 @@ impl Socks5Outbound { session: StreamSession, context: &EstablishContext, ) -> Result { - self.upstream + let mut control = self + .upstream .connect_server(session, &self.server, context) - .await + .await?; + if let Some(tls) = &self.tls { + control.io = context + .run_io("SOCKS5 TLS handshake", tls.connect(control.io)) + .await?; + } + Ok(control) } async fn establish_tcp( @@ -754,6 +801,7 @@ mod tests { fn config(auth: Option<(&str, &str)>) -> Socks5OutboundConfig { Socks5OutboundConfig { + tls: None, address: "socks.example".to_owned(), port: 1080, username: auth.map(|(username, _)| username.to_owned()), @@ -1000,6 +1048,7 @@ mod tests { async fn socks_over_socks_udp_nests_control_relay_and_wire_budgets() { let physical = Arc::new(NestedSocksUpstream::new()); let outer_config = Socks5OutboundConfig { + tls: None, address: "outer.example".to_owned(), port: 1080, username: None, @@ -1008,6 +1057,7 @@ mod tests { let outer = Arc::new(Socks5Outbound::new_with_upstream(&outer_config, physical.clone()).unwrap()); let inner_config = Socks5OutboundConfig { + tls: None, address: "inner.example".to_owned(), port: 1080, username: None, diff --git a/src/outbound/trojan/outbound.rs b/src/outbound/trojan/outbound.rs index 6b9bf40..650f90a 100644 --- a/src/outbound/trojan/outbound.rs +++ b/src/outbound/trojan/outbound.rs @@ -14,8 +14,7 @@ use crate::{ }, resources::observation::{self, ResourceKind}, security::{ - SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsCertificatePolicy, - TlsClientOptions, + SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsClientOptions, }, session::{Destination, StreamSession}, transport::{WebSocketOptions, connect_websocket, grpc_duplex}, @@ -84,17 +83,7 @@ impl TrojanOutbound { tls: StandardTlsClient::with_options( security, &config.server_name, - TlsClientOptions { - client_fingerprint: config.tls.client_fingerprint, - alpn: config.tls.alpn.clone(), - required_alpn: config.transport.required_alpn().map(<[u8]>::to_vec), - certificate: TlsCertificatePolicy { - skip_cert_verify: config.tls.skip_cert_verify, - fingerprint: config.tls.fingerprint, - ..Default::default() - }, - ..Default::default() - }, + TlsClientOptions::from_policy(&config.tls, config.transport.required_alpn())?, resumption_sessions, buffer_limit, )?, diff --git a/src/outbound/vless/outbound.rs b/src/outbound/vless/outbound.rs index 732c422..8bb3e03 100644 --- a/src/outbound/vless/outbound.rs +++ b/src/outbound/vless/outbound.rs @@ -1016,6 +1016,7 @@ mod connector_composition_tests { fn socks_config(address: &str) -> Socks5OutboundConfig { Socks5OutboundConfig { + tls: None, address: address.to_owned(), port: 1080, username: None, diff --git a/src/outbound/vmess/outbound.rs b/src/outbound/vmess/outbound.rs index a124833..f3e9c30 100644 --- a/src/outbound/vmess/outbound.rs +++ b/src/outbound/vmess/outbound.rs @@ -11,8 +11,7 @@ use crate::{ }, resources::observation::{self, ResourceKind}, security::{ - SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsCertificatePolicy, - TlsClientOptions, + SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsClientOptions, }, session::{Datagram, Destination, StreamSession}, transport::{connect_websocket, grpc, http_obfs, legacy_h2}, @@ -74,17 +73,7 @@ impl VmessOutbound { StandardTlsClient::with_options( security, &config.server_name, - TlsClientOptions { - client_fingerprint: tls.client_fingerprint, - alpn: tls.alpn.clone(), - required_alpn: config.transport.required_alpn().map(<[u8]>::to_vec), - certificate: TlsCertificatePolicy { - skip_cert_verify: tls.skip_cert_verify, - fingerprint: tls.fingerprint, - ..Default::default() - }, - ..Default::default() - }, + TlsClientOptions::from_policy(tls, config.transport.required_alpn())?, sessions, buffer, ) diff --git a/src/runtime.rs b/src/runtime.rs index d8bf497..9687209 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -85,6 +85,7 @@ use crate::outbound::{VlessOutbound, VlessResourceLimits}; use crate::security::StandardTlsClient; #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -860,6 +861,7 @@ fn build_proxy_graph( .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -871,6 +873,7 @@ fn build_proxy_graph( let (security_client_count, standard_tls_count) = security_counts(proxies); #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -882,6 +885,7 @@ fn build_proxy_graph( let security_context = (security_client_count != 0).then(SecurityContext::new); #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -1130,7 +1134,13 @@ fn build_proxy_graph( ProxyProtocol::Socks5(config) => { #[cfg(feature = "outbound-socks5")] { - Arc::new(Socks5Outbound::new_with_path(config, upstream)?) + Arc::new(Socks5Outbound::with_shared_security( + config, + upstream, + security_context.as_ref(), + resumption_sessions, + limits.tls_buffer_limit, + )?) } #[cfg(not(feature = "outbound-socks5"))] { @@ -1144,12 +1154,12 @@ fn build_proxy_graph( ProxyProtocol::AnyTls(config) => { #[cfg(feature = "outbound-anytls")] { - let tls = StandardTlsClient::for_anytls( + let tls = StandardTlsClient::with_options( security_context .as_ref() .expect("AnyTLS graph has shared security material"), &config.server_name, - &config.tls, + crate::security::TlsClientOptions::from_policy(&config.tls, None)?, resumption_sessions, limits.tls_buffer_limit, )?; @@ -1326,6 +1336,7 @@ fn restrict_endpoint_addresses( #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -1370,7 +1381,10 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { client_count + 1, standard_count + usize::from(config.tls.is_some()), ), - ProxyProtocol::Socks5(_) => (client_count, standard_count), + ProxyProtocol::Socks5(config) => ( + client_count + usize::from(config.tls.is_some()), + standard_count + usize::from(config.tls.is_some()), + ), ProxyProtocol::Tuic(_) => (client_count + 1, standard_count), }, ) @@ -1378,6 +1392,7 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless", feature = "outbound-trojan", @@ -2147,6 +2162,7 @@ proxy-groups: #[test] #[cfg(any( feature = "outbound-http", + feature = "outbound-socks5", feature = "outbound-anytls", feature = "outbound-vless" ))] @@ -2487,6 +2503,7 @@ rules: dialer_proxy: None, udp: false, protocol: ProxyProtocol::Socks5(crate::config::Socks5OutboundConfig { + tls: None, address: "127.0.0.1".to_owned(), port: 1080, username: None, diff --git a/src/security/tls.rs b/src/security/tls.rs index 8bad1c8..2a485fc 100644 --- a/src/security/tls.rs +++ b/src/security/tls.rs @@ -54,6 +54,35 @@ impl std::fmt::Debug for TlsClientOptions { } } +impl TlsClientOptions { + #[cfg(any( + test, + feature = "outbound-anytls", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-socks5" + ))] + pub(crate) fn from_policy( + policy: &crate::config::TlsClientPolicy, + required_alpn: Option<&[u8]>, + ) -> io::Result { + Ok(Self { + client_fingerprint: policy.client_fingerprint, + alpn: policy.alpn.clone(), + required_alpn: required_alpn.map(<[u8]>::to_vec), + certificate: policy.certificate.clone(), + identity: policy + .identity + .as_ref() + .map(|identity| { + TlsClientIdentity::from_pem(&identity.certificate, &identity.private_key) + }) + .transpose()?, + ..Default::default() + }) + } +} + /// A node-owned certificate chain and matching key. The provider validates the /// key pair while constructing the client, before any supplied stream is used. /// No file loading, environment key logging or dynamic identity reload occurs. @@ -190,32 +219,6 @@ impl StandardTlsClient { ) } - #[cfg(feature = "outbound-anytls")] - pub(crate) fn for_anytls( - context: &SecurityContext, - server_name: impl Into, - policy: &crate::config::AnyTlsCertificatePolicy, - resumption_sessions: usize, - buffer_limit: usize, - ) -> io::Result { - Self::with_options( - context, - server_name, - TlsClientOptions { - alpn: policy.alpn.clone(), - client_fingerprint: policy.client_fingerprint, - certificate: TlsCertificatePolicy { - verification_name: None, - skip_cert_verify: policy.skip_cert_verify, - fingerprint: policy.fingerprint, - }, - ..Default::default() - }, - resumption_sessions, - buffer_limit, - ) - } - /// Wraps caller-supplied streams only: no socket, resolver, background task /// or unbounded shared cache is created here. The graph allocates ticket /// capacity from its existing aggregate budget; zero disables resumption. diff --git a/src/security/tls_named_wire_tests.rs b/src/security/tls_named_wire_tests.rs index edae613..540d961 100644 --- a/src/security/tls_named_wire_tests.rs +++ b/src/security/tls_named_wire_tests.rs @@ -175,7 +175,7 @@ async fn named_reality_retains_browser_offer_but_rejects_tls12_server_hello() { let ordinary = client( &trusted(&chain), "fixture.invalid", - AnyTlsCertificatePolicy { + TlsClientPolicy { client_fingerprint: profile, ..Default::default() }, diff --git a/src/security/tls_tests.rs b/src/security/tls_tests.rs index 2032769..fa76d1e 100644 --- a/src/security/tls_tests.rs +++ b/src/security/tls_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::config::AnyTlsCertificatePolicy; +use crate::config::TlsClientPolicy; use rcgen::{ BasicConstraints, CertificateParams, CertifiedIssuer, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, date_time_ymd, @@ -27,6 +27,28 @@ struct Chain { key: PrivateKeyDer<'static>, } +impl Chain { + fn identity_pem(&self) -> crate::config::TlsIdentityPem { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + crate::config::TlsIdentityPem { + certificate: self + .certificates + .iter() + .map(|cert| { + format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + STANDARD.encode(cert.as_ref()) + ) + }) + .collect(), + private_key: format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", + STANDARD.encode(self.key.secret_der()) + ), + } + } +} + fn ca(name: &str) -> CertificateParams { let mut params = CertificateParams::default(); params.distinguished_name.push(DnType::CommonName, name); @@ -81,24 +103,11 @@ fn trusted(chain: &Chain) -> SecurityContext { } } -fn client( - context: &SecurityContext, - name: &str, - policy: AnyTlsCertificatePolicy, -) -> StandardTlsClient { +fn client(context: &SecurityContext, name: &str, policy: TlsClientPolicy) -> StandardTlsClient { StandardTlsClient::with_options( context, name, - TlsClientOptions { - client_fingerprint: policy.client_fingerprint, - certificate: TlsCertificatePolicy { - verification_name: None, - skip_cert_verify: policy.skip_cert_verify, - fingerprint: policy.fingerprint, - }, - alpn: policy.alpn, - ..Default::default() - }, + TlsClientOptions::from_policy(&policy, None).unwrap(), 4, DEFAULT_TLS_BUFFER_LIMIT, ) @@ -370,7 +379,7 @@ async fn named_fingerprint_close_notify_has_a_five_second_bound() { let client = client( &trusted(&chain), "fixture.invalid", - AnyTlsCertificatePolicy { + TlsClientPolicy { client_fingerprint: Some(profile), ..Default::default() }, @@ -414,7 +423,7 @@ async fn webpki_rejects_untrusted_wrong_name_and_expired_unless_explicitly_skipp &client( &context, name, - AnyTlsCertificatePolicy { + TlsClientPolicy { client_fingerprint: profile, ..Default::default() } @@ -425,9 +434,12 @@ async fn webpki_rejects_untrusted_wrong_name_and_expired_unless_explicitly_skipp .0, expected ); - let insecure = AnyTlsCertificatePolicy { + let insecure = TlsClientPolicy { + certificate: TlsCertificatePolicy { + skip_cert_verify: true, + ..Default::default() + }, client_fingerprint: profile, - skip_cert_verify: true, ..Default::default() }; assert!( @@ -454,10 +466,13 @@ async fn leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry() { for index in 0..3 { for name in ["fixture.invalid", "wrong.invalid"] { for skip in [false, true] { - let policy = AnyTlsCertificatePolicy { + let policy = TlsClientPolicy { + certificate: TlsCertificatePolicy { + fingerprint: Some(pin(&chain, index)), + skip_cert_verify: skip, + ..Default::default() + }, client_fingerprint: profile, - fingerprint: Some(pin(&chain, index)), - skip_cert_verify: skip, ..Default::default() }; let expected = index == 0 || (!expired && name == "fixture.invalid"); @@ -474,10 +489,13 @@ async fn leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry() { } } } - let policy = AnyTlsCertificatePolicy { + let policy = TlsClientPolicy { + certificate: TlsCertificatePolicy { + fingerprint: Some([0; 32]), + skip_cert_verify: true, + ..Default::default() + }, client_fingerprint: profile, - fingerprint: Some([0; 32]), - skip_cert_verify: true, ..Default::default() }; assert!( @@ -496,10 +514,13 @@ async fn leaf_pin_is_trust_but_nonleaf_pin_checks_chain_name_and_expiry() { key: chain.key.clone_key(), }; unrelated.certificates.push(other.certificates[2].clone()); - let policy = AnyTlsCertificatePolicy { + let policy = TlsClientPolicy { + certificate: TlsCertificatePolicy { + fingerprint: Some(pin(&other, 2)), + skip_cert_verify: true, + ..Default::default() + }, client_fingerprint: profile, - fingerprint: Some(pin(&other, 2)), - skip_cert_verify: true, ..Default::default() }; assert!( @@ -584,10 +605,13 @@ async fn every_certificate_policy_verifies_tls12_and_tls13_handshake_signatures( let chain = chain(false); for version in [&TLS12, &TLS13] { for fingerprint in [None, Some(pin(&chain, 0)), Some(pin(&chain, 1))] { - let policy = AnyTlsCertificatePolicy { + let policy = TlsClientPolicy { + certificate: TlsCertificatePolicy { + skip_cert_verify: true, + fingerprint, + ..Default::default() + }, client_fingerprint: profile, - skip_cert_verify: true, - fingerprint, ..Default::default() }; let client = client(&SecurityContext::new(), "fixture.invalid", policy); @@ -611,8 +635,11 @@ async fn vless_keeps_webpki_tls13_and_required_h2_after_anytls_connections() { let insecure = client( &SecurityContext::new(), "fixture.invalid", - AnyTlsCertificatePolicy { - skip_cert_verify: true, + TlsClientPolicy { + certificate: TlsCertificatePolicy { + skip_cert_verify: true, + ..Default::default() + }, alpn: vec![b"h2".to_vec()], ..Default::default() }, @@ -652,9 +679,12 @@ async fn alpn_and_tls_resumption_are_isolated_between_node_policies() { let server = server(&chain, &TLS13, false); let context = trusted(&chain); let alpn = vec![b"custom".to_vec(), b"h2".to_vec(), b"http/1.1".to_vec()]; - let policy = AnyTlsCertificatePolicy { + let policy = TlsClientPolicy { + certificate: TlsCertificatePolicy { + fingerprint: Some(pin(&chain, 2)), + ..Default::default() + }, alpn: alpn.clone(), - fingerprint: Some(pin(&chain, 2)), ..Default::default() }; let first = client(&context, "fixture.invalid", policy.clone()); @@ -682,9 +712,12 @@ async fn alpn_and_tls_resumption_are_isolated_between_node_policies() { .await .0 ); - let wrong = AnyTlsCertificatePolicy { - fingerprint: Some([0; 32]), - skip_cert_verify: true, + let wrong = TlsClientPolicy { + certificate: TlsCertificatePolicy { + fingerprint: Some([0; 32]), + skip_cert_verify: true, + ..Default::default() + }, ..policy }; assert!( @@ -724,7 +757,7 @@ async fn explicit_verification_name_does_not_change_sni_or_allow_skip_to_overrid ("wrong.invalid", true, Some(pin(&chain, 1)), false), ("fixture.invalid", true, Some([0; 32]), false), ] { - let options = TlsClientOptions { + let policy = TlsClientPolicy { client_fingerprint: profile, certificate: TlsCertificatePolicy { verification_name: Some(name.into()), @@ -736,7 +769,7 @@ async fn explicit_verification_name_does_not_change_sni_or_allow_skip_to_overrid let client = StandardTlsClient::with_options( &context, "sni.invalid", - options, + TlsClientOptions::from_policy(&policy, None).unwrap(), 1, DEFAULT_TLS_BUFFER_LIMIT, ) @@ -782,17 +815,15 @@ async fn mutual_tls_identity_is_required_verified_and_not_shared_between_clients (Some(&unknown), false), (Some(&identity), true), ] { - let options = TlsClientOptions { + let policy = TlsClientPolicy { client_fingerprint: profile, - identity: cert.map(|cert| { - TlsClientIdentity::from_der(cert.certificates.clone(), cert.key.clone_key()) - }), + identity: cert.map(Chain::identity_pem), ..Default::default() }; let client = StandardTlsClient::with_options( &trusted(&chain), "fixture.invalid", - options, + TlsClientOptions::from_policy(&policy, None).unwrap(), 1, DEFAULT_TLS_BUFFER_LIMIT, ) @@ -1029,7 +1060,7 @@ async fn cancelling_tls_handshake_releases_the_caller_supplied_stream() { let client = client( &trusted(&chain), "fixture.invalid", - AnyTlsCertificatePolicy { + TlsClientPolicy { client_fingerprint: profile, ..Default::default() }, diff --git a/tests/README.md b/tests/README.md index 801511e..e0a520d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,6 +12,9 @@ cargo test --locked --all-features --all-targets --no-run cargo test --locked --no-default-features --features cli --bin vole cli::tests:: cargo test --locked --no-default-features --features cli --lib invoke::foreground::tests:: cargo test --locked --lib config::tests:: +cargo test --locked --lib config::tls_policy_tests:: +cargo test --locked --lib explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it +cargo test --locked --lib mutual_tls_identity_is_required_verified_and_not_shared_between_clients cargo test --locked --lib geodata::tests:: cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: diff --git a/tests/datagram_foundations.rs b/tests/datagram_foundations.rs index 82f5871..33cac7a 100644 --- a/tests/datagram_foundations.rs +++ b/tests/datagram_foundations.rs @@ -126,6 +126,7 @@ async fn socks5_budget_retains_distinct_payload_and_envelope_limits() { }); let outbound = Socks5Outbound::new( &Socks5OutboundConfig { + tls: None, address: "127.0.0.1".into(), port: endpoint.port(), username: None, diff --git a/tests/trojan_config.rs b/tests/trojan_config.rs index c537224..0538af7 100644 --- a/tests/trojan_config.rs +++ b/tests/trojan_config.rs @@ -41,8 +41,8 @@ fn trojan_tcp_defaults_preserve_credentials_and_address_policy() { assert_eq!(node.server_name, "localhost"); assert!(!parsed.proxies[0].udp); assert!(node.tls.alpn.is_empty()); - assert!(!node.tls.skip_cert_verify); - assert_eq!(node.tls.fingerprint, None); + assert!(!node.tls.certificate.skip_cert_verify); + assert_eq!(node.tls.certificate.fingerprint, None); for server in ["127.0.0.1", "::1", "example.com"] { assert!(Config::parse_yaml(&document(json!({"server":server, "network":"tcp"}))).is_ok()); } @@ -51,8 +51,8 @@ fn trojan_tcp_defaults_preserve_credentials_and_address_policy() { unreachable!() }; assert_eq!(node.tls.alpn, [b"second".to_vec(), b"first".to_vec()]); - assert!(node.tls.skip_cert_verify); - assert_eq!(node.tls.fingerprint, Some([0xab; 32])); + assert!(node.tls.certificate.skip_cert_verify); + assert_eq!(node.tls.certificate.fingerprint, Some([0xab; 32])); } #[test] From 7cae801652f7304d5f3cef61385fd820fc5c55b3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 11:29:21 +0800 Subject: [PATCH 031/445] Add transactional provider subscription metadata and overrides --- docs/config.yaml | 7 +- docs/controller-api.md | 7 + docs/mihomo-compatibility.md | 5 +- docs/outbounds.md | 3 +- src/config/hysteria2.rs | 10 +- src/config/providers.rs | 125 ++++++++++++++- src/geodata/service.rs | 2 +- src/resource/cache.rs | 104 ++++++++++--- src/resource/cache_tests.rs | 291 +++++++++++++++++++++++++++++++++++ src/resource/http.rs | 49 +++++- src/resource/mod.rs | 1 + src/resource/subscription.rs | 78 ++++++++++ tests/README.md | 1 + 13 files changed, 651 insertions(+), 32 deletions(-) create mode 100644 src/resource/cache_tests.rs create mode 100644 src/resource/subscription.rs diff --git a/docs/config.yaml b/docs/config.yaml index 5b44442..9c51b4a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -226,6 +226,7 @@ proxies: sni: hysteria2.example.com alpn: [h3] # 缺省或 [] 都归一为 h3 skip-cert-verify: false + # name-cert-verify: verified.example.com # 独立证书验证名,不改变 SNI # fingerprint: "" # certificate/private-key: 成对内联 PEM 客户端身份 udp: true @@ -499,13 +500,17 @@ proxy-groups: # proxy 指定当前运行配置中的出口;缺省按规则选路。不会等待新资源才能建立下载连接。 # header 值为字符串数组;size-limit 单位字节,0 不设下载上限,解析仍有容量边界。 # proxy provider 文件为 {proxies: [...]};可用 filter/exclude-filter/exclude-type。 -# override 支持 udp、udp-over-tcp、up/down、dialer-proxy、skip-cert-verify、 +# provider 的 dialer-proxy 覆盖每个节点的同名字段,override 最后应用; +# 它只控制节点上游,资源下载仍使用独立的 proxy 字段。 +# override 支持 udp、udp-over-tcp、up/down、dialer-proxy、skip-cert-verify、name-cert-verify、 # additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]; # 协议专属覆盖只用于支持该字段的节点。未实现的覆盖字段严格拒绝。 # proxy providers 的 health-check 接受 enable/url/interval/timeout/lazy/expected-status; # enable 默认 false,开启后 interval 缺省/0 为 300 秒。空 URL 不发起探测。 # 相同节点/URL 的检查共享结果,每批最多 10 个并发,超时与停止均取消 I/O。 # 更新先解析并校验整个依赖图,成功才替换;失败保留旧配置与 HTTP 缓存。 +# HTTP proxy provider 的订阅用量来自 Subscription-Userinfo 响应头,成功更新和 304 后 +# 随缓存保存;缺少该头保留旧值,失败候选不更新。Controller 不用本地流量填充订阅用量。 # 新连接使用新资源,旧 TCP/UDP 会话保留原资源;停止等待下载、解析和资源释放。 # rule providers 支持 domain/ipcidr/classical,format 为 yaml/text/mrs;MRS 仅 domain/ipcidr。 # YAML 规则文件为 {payload: [...]},text 每行一条,支持空行和 # 注释。 diff --git a/docs/controller-api.md b/docs/controller-api.md index b01bd56..090c2ae 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -151,6 +151,13 @@ Hysteria2 已认证 QUIC 会话也保留其 `dialer-proxy` 上游组快照,包 `count` 与脱敏的 `error`。`updatedAt` 为 Unix 秒,未成功获取时为空;inline 或 bundled payload 可以在未下载时可用。响应不包含订阅 URL、认证头或节点凭据。 +HTTP proxy provider 收到 `Subscription-Userinfo` 后另含 `subscriptionInfo` 对象, +字段为 `Upload`、`Download`、`Total`、`Expire`,均为有符号整数;前三项为订阅字节数, +Expire 为 Unix 秒。未知时省略该对象,缺失子项为 0;不以本地业务流量代替订阅信息。 +数值接受十进制整数、有限小数和科学计数法,小数向零截断;非法或超范围子项忽略。 +成功候选或合法 304 才更新元信息;未带该头时保留旧值,失败更新保留已发布对象。 +缓存恢复只接受与配置 URL 和内容 hash 一致的元信息。 + 更新将节点、组和规则作为完整候选检查,发布时保留仍存在的当前选择。 既有会话持有旧资源,最后一个使用者释放后回收;下载通过当前可用的运行配置建立出口, 不等待候选资源。空动态组使用配置的 `empty-fallback`,默认 COMPATIBLE(直连)。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 31301c3..9b0ff4d 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -7,7 +7,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 | 范围 | 当前支持 | | --- | --- | | 配置与选路 | YAML anchor/merge;rule/global/direct;内置 DIRECT/REJECT;域名、地址、端口、网络、GeoData、RULE-SET 和 AND/OR/NOT | -| 资源 | HTTP/file/inline proxy/rule providers;动态成员、筛选、覆盖、健康检查;YAML/Text 与 domain/ipcidr MRS;失败保留旧代 | +| 资源 | HTTP/file/inline proxy/rule providers;动态成员、筛选、覆盖、健康检查;Provider dialer-proxy 与 HTTP 订阅信息;YAML/Text 与 domain/ipcidr MRS;失败保留旧代 | | 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | | DNS | hosts、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | | mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | @@ -23,6 +23,9 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - DNS 不提供独立 listen、DoQ、DoH/H3、ECS、system/DHCP nameserver 或可配置 ARC 缓存。 DoH 使用 H1 POST;详细解析角色、缓存与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 - 正则使用 Rust regex 子集,明确拒绝 lookaround/backreference。规则与 provider 的协议覆盖仅限已实现字段。 +- 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; + 与 Mihomo 在读取响应头时更新的时点不同。数值仅接受有限且可表示的十进制数, + 不保留非有限/溢出浮点转换的参考行为。 - 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 - 未配置入站的配置不能启动。Controller 沿用回环地址、secret 及运行配置准入,见 [Controller API](controller-api.md)。 diff --git a/docs/outbounds.md b/docs/outbounds.md index 20c69fa..4a40733 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -146,7 +146,8 @@ gRPC/H2 driver 由节点跟踪并同步退出,无全局会话额度或历史 始终 QUIC/TLS 1.3,使用官方 rustls;不接受 tls/network/client-fingerprint/REALITY。 port 为 1–65535,仅提供 ports 时可省略;显式非法 port 仍拒绝。password 默认空、 保留原值,须为最长 8192 字节的合法 HTTP 头值。SNI 默认 server;ALPN 省略/空为 h3。 -支持共享证书 pin/skip 和成对内联 PEM mTLS;udp 只控制业务 UDP,不关闭 QUIC 载体。 +支持共享证书 pin/skip、独立 `name-cert-verify` 和成对内联 PEM mTLS; +验证名不改变 SNI。udp 只控制业务 UDP,不关闭 QUIC 载体。 - up/down 独立非负整数或十进制整数字符串,裸值 Mbit/s;单位 [KMGT]?[bB]ps 使用 十进制倍率,bit 向下取整为 byte/s。省略/归一零为自动;拒绝小数、符号、溢出。 diff --git a/src/config/hysteria2.rs b/src/config/hysteria2.rs index 05bc496..42d16d7 100644 --- a/src/config/hysteria2.rs +++ b/src/config/hysteria2.rs @@ -2,7 +2,7 @@ use serde::Deserialize; use super::{ ProxyProtocol, TlsCertificatePolicy, TlsConfig, TlsIdentityPem, deserialize_present_option, - invalid, validate_host, validate_port, + invalid, normalize_verification_name, validate_host, validate_port, }; use crate::Result; @@ -50,6 +50,12 @@ pub(super) struct RawHysteria2 { alpn: Option>, #[serde(rename = "skip-cert-verify", default)] skip_cert_verify: bool, + #[serde( + rename = "name-cert-verify", + default, + deserialize_with = "deserialize_present_option" + )] + name_cert_verify: Option, #[serde(default, deserialize_with = "deserialize_present_option")] fingerprint: Option, #[serde( @@ -210,7 +216,7 @@ impl RawHysteria2 { certificate: TlsCertificatePolicy { skip_cert_verify: self.skip_cert_verify, fingerprint, - verification_name: None, + verification_name: normalize_verification_name(self.name_cert_verify)?, }, identity, }, diff --git a/src/config/providers.rs b/src/config/providers.rs index b7c4ec4..c0727bb 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -44,6 +44,8 @@ pub struct ProviderSpec { #[serde(default)] pub proxy: String, #[serde(default)] + pub dialer_proxy: String, + #[serde(default)] pub header: BTreeMap>, #[serde(default)] pub size_limit: u64, @@ -88,10 +90,14 @@ impl ProviderSpec { && (!self.filter.is_empty() || !self.exclude_filter.is_empty() || !self.exclude_type.is_empty() + || !self.dialer_proxy.is_empty() || !self.overrides.is_empty()) { return invalid("rule providers do not accept proxy filters or overrides"); } + if !self.dialer_proxy.is_empty() { + validate_route_target_name(&self.dialer_proxy, "provider dialer-proxy")?; + } match self.kind { ProviderType::Http => { let options = crate::resource::http::HttpOptions { @@ -124,6 +130,7 @@ impl ProviderSpec { "down", "dialer-proxy", "skip-cert-verify", + "name-cert-verify", "additional-prefix", "additional-suffix", "proxy-name", @@ -277,6 +284,13 @@ impl ProviderTemplate { let nodes = filter.select(nodes.clone(), false)?; let mut names = Vec::new(); for mut node in nodes { + if !spec.dialer_proxy.is_empty() { + node.as_mapping_mut() + .ok_or_else(|| { + VoleError::InvalidConfig("provider node must be a mapping".into()) + })? + .insert("dialer-proxy".into(), spec.dialer_proxy.clone().into()); + } apply_overrides(&mut node, &spec.overrides)?; names.push(node_name(&node)?); proxies.push(node); @@ -623,10 +637,18 @@ fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Res "udp" => kind != "http", "up" | "down" => kind == "hysteria2", "udp-over-tcp" => kind == "ss", - "skip-cert-verify" => matches!( - kind, - "vless" | "vmess" | "trojan" | "anytls" | "hysteria2" | "tuic" | "http" - ), + "skip-cert-verify" | "name-cert-verify" => match kind { + "trojan" | "anytls" | "hysteria2" | "tuic" => true, + "http" | "socks5" | "vmess" => { + map.get("tls").and_then(Value::as_bool) == Some(true) + } + "vless" => { + map.get("tls").and_then(Value::as_bool) == Some(true) + && !map.contains_key("reality-opts") + && !map.contains_key("jls-opts") + } + _ => false, + }, _ => true, }; if applicable { @@ -671,6 +693,101 @@ fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Res #[cfg(test)] mod tests { use super::*; + #[test] + fn provider_dialer_and_certificate_overrides_materialize_in_reference_order() { + for (kind, source) in [ + ("inline", ""), + ("file", "path: nodes.yaml"), + ("http", "url: https://subscription.invalid/nodes.yaml"), + ] { + for (provider, override_dialer, expected) in [ + ("", "", "node-path"), + ("provider-path", "", "provider-path"), + ("provider-path", "override-path", "override-path"), + ] { + let yaml = format!( + r#" +mixed-port: 1080 +proxy-providers: + remote: + type: {kind} + {source} + proxy: node-path + dialer-proxy: '{provider}' + override: + name-cert-verify: verified.invalid + skip-cert-verify: true + {override_entry} + payload: + - {{name: edge, type: socks5, tls: true, server: cover.invalid, port: 443, dialer-proxy: node-path}} + - {{name: plain-socks, type: socks5, server: 192.0.2.1, port: 1080}} + - {{name: plain-http, type: http, server: 192.0.2.2, port: 8080}} + - {{name: plain-vmess, type: vmess, server: 192.0.2.3, port: 1080, uuid: 00000000-0000-4000-8000-000000000001}} + - {{name: plain-vless, type: vless, server: 192.0.2.4, port: 1080, uuid: 00000000-0000-4000-8000-000000000001}} +proxy-groups: + - {{name: node-path, type: select, proxies: [DIRECT]}} + - {{name: provider-path, type: select, proxies: [DIRECT]}} + - {{name: override-path, type: select, proxies: [DIRECT]}} +"#, + override_entry = if override_dialer.is_empty() { + String::new() + } else { + format!("dialer-proxy: {override_dialer}") + }, + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + let group = config + .proxy_groups + .iter() + .position(|group| group.name == expected) + .unwrap(); + assert_eq!( + config.proxies[0].dialer_proxy, + Some(RouteTargetId::Group(ProxyGroupId::from_index(group))) + ); + let ProxyProtocol::Socks5(node) = &config.proxies[0].protocol else { + panic!("SOCKS provider node") + }; + let certificate = &node.tls.as_ref().unwrap().certificate; + assert_eq!( + certificate.verification_name.as_deref(), + Some("verified.invalid") + ); + assert!(certificate.skip_cert_verify); + assert_eq!(node.address, "cover.invalid"); + assert_eq!( + config.providers.as_ref().unwrap().proxies[0].1.proxy, + "node-path" + ); + } + } + } + + #[test] + fn quic_provider_certificate_name_override_uses_existing_certificate_policy() { + for (kind, credentials) in [ + ("hysteria2", "password: fixture"), + ( + "tuic", + "uuid: 00000000-0000-4000-8000-000000000001, password: fixture", + ), + ] { + let config = Config::parse_yaml(format!( + "mixed-port: 1080\nproxy-providers:\n nodes:\n type: inline\n override: {{name-cert-verify: verified.invalid}}\n payload:\n - {{name: edge, type: {kind}, server: cover.invalid, port: 443, {credentials}}}\n" + ).as_bytes()).unwrap(); + let tls = match &config.proxies[0].protocol { + ProxyProtocol::Hysteria2(node) => &node.tls, + ProxyProtocol::Tuic(node) => &node.tls, + _ => unreachable!(), + }; + assert_eq!( + tls.certificate.verification_name.as_deref(), + Some("verified.invalid") + ); + assert_eq!(tls.server_name, "cover.invalid"); + } + } + #[test] fn inline_provider_groups_and_rule_policies_share_materialized_content() { let config = Config::parse_yaml( diff --git a/src/geodata/service.rs b/src/geodata/service.rs index 38a791e..6bffe2b 100644 --- a/src/geodata/service.rs +++ b/src/geodata/service.rs @@ -204,7 +204,7 @@ impl GeoDataUpdateService { options: self.options.clone(), }; match download(request).await { - Ok(DownloadOutcome::NotModified) => { + Ok(DownloadOutcome::NotModified { .. }) => { blocking_operation(move || { session.not_modified_with_cancellation(etag, &cancellation) }) diff --git a/src/resource/cache.rs b/src/resource/cache.rs index 727ffbc..fc142c3 100644 --- a/src/resource/cache.rs +++ b/src/resource/cache.rs @@ -1,5 +1,6 @@ //! Provider files and candidate updates. Publication belongs to the runtime graph owner. use super::http::{self, DownloadOutcome, DownloadRequest, HttpOptions}; +use super::subscription::SubscriptionInfo; use crate::{ config::providers::{ ProviderContent, ProviderContents, ProviderSpec, ProviderTemplate, ProviderType, @@ -28,6 +29,8 @@ struct Metadata { etag: Option, hash: String, checked: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + subscription_info: Option, } #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -41,6 +44,8 @@ pub(crate) struct ProviderState { pub updated_at: Option, pub error: Option<&'static str>, pub count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub subscription_info: Option, } pub(crate) struct ResourceEntry { pub key: String, @@ -97,6 +102,7 @@ impl ProviderCache { updated_at: None, error: None, count: spec.payload.len(), + subscription_info: None, }, path: None, metadata: Metadata::default(), @@ -139,6 +145,12 @@ impl ProviderCache { && metadata.hash == hex(&Sha256::digest(&bytes)) { entry.metadata = metadata; + if !rule && spec.kind == ProviderType::Http { + entry.state.subscription_info = + entry.metadata.subscription_info; + } else { + entry.metadata.subscription_info = None; + } } contents.insert(key, content); if spec.kind == ProviderType::Http { @@ -232,12 +244,29 @@ impl ProviderCache { .await .map_err(|_| io::Error::other("provider download failed"))?; match outcome { - DownloadOutcome::NotModified => { - entry.metadata.checked = unix_time(SystemTime::now()); - write_metadata(&path, &entry.metadata)?; + DownloadOutcome::NotModified { subscription_info } => { + if cancellation.is_cancelled() { + return Err(io::ErrorKind::Interrupted.into()); + } + let mut metadata = entry.metadata.clone(); + metadata.checked = unix_time(SystemTime::now()); + if !entry.state.rule { + metadata.subscription_info = + subscription_info.or(metadata.subscription_info); + } else { + metadata.subscription_info = None; + } + write_metadata(&path, &metadata)?; + entry.state.subscription_info = metadata.subscription_info; + entry.metadata = metadata; Ok(None) } - DownloadOutcome::Downloaded { etag, sha256, .. } => { + DownloadOutcome::Downloaded { + etag, + sha256, + subscription_info, + .. + } => { let mut cleanup = TemporaryFile(Some(temporary.clone())); let spec = entry.spec.clone(); let template = self.template.clone(); @@ -261,6 +290,11 @@ impl ProviderCache { etag, hash: sha256.iter().map(|byte| format!("{byte:02x}")).collect(), checked: unix_time(SystemTime::now()), + subscription_info: if entry.state.rule { + None + } else { + subscription_info.or(entry.metadata.subscription_info) + }, }, })) } @@ -301,11 +335,11 @@ impl ProviderCache { let entry = &mut self.entries[index]; if let Some(temporary) = &candidate.temporary { let path = entry.path.as_ref().expect("cache path"); - write_metadata(path, &candidate.metadata)?; - fs::rename(temporary, path)?; + commit_download(path, temporary, &candidate.metadata)?; candidate.temporary = None; entry.metadata = candidate.metadata.clone(); entry.state.downloaded = true; + entry.state.subscription_info = entry.metadata.subscription_info; } entry.modified = entry .path @@ -393,19 +427,49 @@ fn temporary_path(path: &Path) -> PathBuf { PathBuf::from(name) } fn write_metadata(path: &Path, metadata: &Metadata) -> io::Result<()> { - let path = metadata_path(path); - let temporary = temporary_path(&path); - let result = (|| { - fs::write( - &temporary, - serde_json::to_vec(metadata).map_err(io::Error::other)?, - )?; - fs::rename(&temporary, &path) - })(); - if result.is_err() { - let _ = fs::remove_file(temporary); + let staged = stage_metadata(path, metadata)?; + fs::rename(staged.0.as_ref().unwrap(), metadata_path(path)) +} + +fn stage_metadata(path: &Path, metadata: &Metadata) -> io::Result { + let staged = TemporaryFile(Some(temporary_path(&metadata_path(path)))); + fs::write( + staged.0.as_ref().unwrap(), + serde_json::to_vec(metadata).map_err(io::Error::other)?, + )?; + Ok(staged) +} + +fn commit_download(path: &Path, temporary: &Path, metadata: &Metadata) -> io::Result<()> { + let staged = stage_metadata(path, metadata)?; + let mut previous = TemporaryFile(None); + match fs::metadata(path) { + Ok(file) if file.is_file() => { + let backup = temporary_path(path); + fs::rename(path, &backup)?; + previous.0 = Some(backup); + } + Ok(_) => return Err(io::Error::other("provider cache must be a regular file")), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error), } - result + let mut installed = false; + let result = fs::rename(temporary, path).and_then(|()| { + installed = true; + fs::rename(staged.0.as_ref().unwrap(), metadata_path(path)) + }); + if let Err(error) = result { + // Keep the old body and sidecar paired if either publication fails. + // A rollback failure retains its backup for recovery instead of deleting it. + if let Some(backup) = previous.0.take() { + fs::rename(backup, path) + .map_err(|_| io::Error::other("provider cache rollback failed"))?; + } else if installed { + fs::remove_file(path)?; + } + return Err(error); + } + Ok(()) } pub(crate) fn resource_path(root: &Path, path: &str, create: bool) -> io::Result { let root = fs::canonicalize(root)?; @@ -474,3 +538,7 @@ impl Drop for TemporaryFile { } } } + +#[cfg(test)] +#[path = "cache_tests.rs"] +mod tests; diff --git a/src/resource/cache_tests.rs b/src/resource/cache_tests.rs new file mode 100644 index 0000000..3028786 --- /dev/null +++ b/src/resource/cache_tests.rs @@ -0,0 +1,291 @@ +//! Provider state transactions using in-memory HTTP streams and temporary files. +use super::*; +use crate::{ + config::Config, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + session::{DatagramSession, StreamSession}, +}; +use async_trait::async_trait; +use std::{collections::VecDeque, sync::Mutex}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +struct Responses(Mutex>>); + +#[async_trait] +impl Dispatcher for Responses { + async fn connect_tcp(&self, _: StreamSession) -> Result { + let response = self + .0 + .lock() + .unwrap() + .pop_front() + .expect("scripted response"); + let (client, mut peer) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + let mut request = Vec::new(); + let mut byte = [0]; + while peer.read_exact(&mut byte).await.is_ok() { + request.push(byte[0]); + if request.ends_with(b"\r\n\r\n") { + break; + } + } + let _ = peer.write_all(&response).await; + let _ = peer.shutdown().await; + }); + Ok(Box::new(client)) + } + + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} + +fn response(status: u16, header: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status} fixture\r\nETag: \"fixture\"\r\n{header}Content-Length: {}\r\n\r\n{body}", + body.len() + ) + .into_bytes() +} + +fn template() -> Arc { + Config::parse_yaml( + br#" +mixed-port: 1080 +proxy-providers: + remote: {type: http, url: 'http://subscription.invalid/nodes.yaml', path: nodes.yaml} +proxy-groups: + - {name: choice, type: select, use: [remote]} +"#, + ) + .unwrap() + .providers + .unwrap() +} + +async fn fetch(cache: &mut ProviderCache, response: Vec) -> io::Result> { + cache + .fetch( + 0, + Arc::new(Responses(Mutex::new([response].into()))), + "fixture".into(), + true, + CancellationToken::new(), + ) + .await +} + +fn commit_valid(cache: &mut ProviderCache, candidate: Candidate) { + let mut contents = cache.contents.clone(); + contents.insert(cache.entries[0].key.clone(), candidate.content.clone()); + cache.template.materialize(&contents).unwrap(); + cache + .commit(0, candidate, &CancellationToken::new()) + .unwrap(); +} + +#[tokio::test] +async fn subscription_metadata_follows_successful_candidates_304_and_verified_disk_cache() { + let root = tempfile::tempdir().unwrap(); + let template = template(); + let mut cache = ProviderCache::open(root.path(), template.clone()).unwrap(); + assert!( + serde_json::to_value(&cache.entries[0].state) + .unwrap() + .get("subscriptionInfo") + .is_none() + ); + let old = "proxies: [{name: old, type: socks5, server: 192.0.2.1, port: 1080}]"; + let new = "proxies: [{name: new, type: socks5, server: 192.0.2.2, port: 1080}]"; + let info = |upload| SubscriptionInfo { + upload, + ..Default::default() + }; + let candidate = fetch( + &mut cache, + response(200, "Subscription-Userinfo: upload=1\r\n", old), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(cache.entries[0].state.subscription_info, None); + commit_valid(&mut cache, candidate); + assert_eq!(cache.entries[0].state.subscription_info, Some(info(1))); + assert_eq!( + serde_json::to_value(&cache.entries[0].state).unwrap()["subscriptionInfo"], + serde_json::json!({"Upload":1,"Download":0,"Total":0,"Expire":0}) + ); + + let previous_content = cache.contents[&cache.entries[0].key].clone(); + assert!( + fetch( + &mut cache, + response(304, "Subscription-Userinfo: upload=2\r\n", "") + ) + .await + .unwrap() + .is_none() + ); + assert_eq!(cache.entries[0].state.subscription_info, Some(info(2))); + assert_eq!( + fs::read_to_string(root.path().join("nodes.yaml")).unwrap(), + old + ); + assert_eq!(content_len(&previous_content), cache.entries[0].state.count); + let restored = ProviderCache::open(root.path(), template.clone()).unwrap(); + assert_eq!(restored.entries[0].state.subscription_info, Some(info(2))); + + assert!( + fetch(&mut cache, response(304, "", "")) + .await + .unwrap() + .is_none() + ); + let candidate = fetch(&mut cache, response(200, "", new)) + .await + .unwrap() + .unwrap(); + commit_valid(&mut cache, candidate); + assert_eq!(cache.entries[0].state.subscription_info, Some(info(2))); + let saved_metadata = fs::read(metadata_path(&root.path().join("nodes.yaml"))).unwrap(); + + let candidate = fetch( + &mut cache, + response(200, "Subscription-Userinfo: upload=77\r\n", old), + ) + .await + .unwrap() + .unwrap(); + fs::remove_file(candidate.temporary.as_ref().unwrap()).unwrap(); + assert!( + cache + .commit(0, candidate, &CancellationToken::new()) + .is_err() + ); + assert_eq!( + fs::read(metadata_path(&root.path().join("nodes.yaml"))).unwrap(), + saved_metadata + ); + assert_eq!( + fs::read_to_string(root.path().join("nodes.yaml")).unwrap(), + new + ); + assert!( + fetch( + &mut cache, + response(500, "Subscription-Userinfo: upload=66\r\n", "") + ) + .await + .is_err() + ); + + assert!( + fetch( + &mut cache, + response( + 200, + "Subscription-Userinfo: upload=99\r\n", + "not a provider" + ) + ) + .await + .is_err() + ); + let candidate = fetch(&mut cache, response(200, "Subscription-Userinfo: upload=88\r\n", "proxies: [{name: cycle, type: socks5, server: 192.0.2.1, port: 1080, dialer-proxy: cycle}]")).await.unwrap().unwrap(); + let mut proposed = cache.contents.clone(); + proposed.insert(cache.entries[0].key.clone(), candidate.content.clone()); + assert!(template.materialize(&proposed).is_err()); + cache.reject(0); + drop(candidate); + assert_eq!(cache.entries[0].state.subscription_info, Some(info(2))); + assert_eq!( + fs::read(metadata_path(&root.path().join("nodes.yaml"))).unwrap(), + saved_metadata + ); + assert_eq!( + fs::read_to_string(root.path().join("nodes.yaml")).unwrap(), + new + ); + assert_eq!( + ProviderCache::open(root.path(), template.clone()) + .unwrap() + .entries[0] + .state + .subscription_info, + Some(info(2)) + ); + + // A valid file with a stale sidecar is usable, but its subscription data is untrusted. + fs::write(root.path().join("nodes.yaml"), old).unwrap(); + let restored = ProviderCache::open(root.path(), template.clone()).unwrap(); + assert!(restored.entries[0].state.available); + assert_eq!(restored.entries[0].state.subscription_info, None); + + fs::write(root.path().join("nodes.yaml"), new).unwrap(); + let mut legacy: serde_json::Value = serde_json::from_slice(&saved_metadata).unwrap(); + legacy.as_object_mut().unwrap().remove("subscription_info"); + fs::write( + metadata_path(&root.path().join("nodes.yaml")), + serde_json::to_vec(&legacy).unwrap(), + ) + .unwrap(); + let legacy = ProviderCache::open(root.path(), template).unwrap(); + assert!(legacy.entries[0].state.available); + assert_eq!(legacy.entries[0].state.subscription_info, None); +} + +#[tokio::test] +async fn failed_304_sidecar_write_does_not_publish_subscription_metadata() { + let root = tempfile::tempdir().unwrap(); + let mut cache = ProviderCache::open(root.path(), template()).unwrap(); + let body = "proxies: [{name: edge, type: socks5, server: 192.0.2.1, port: 1080}]"; + let candidate = fetch( + &mut cache, + response(200, "Subscription-Userinfo: upload=1\r\n", body), + ) + .await + .unwrap() + .unwrap(); + commit_valid(&mut cache, candidate); + let path = metadata_path(&root.path().join("nodes.yaml")); + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert!( + fetch( + &mut cache, + response(304, "Subscription-Userinfo: upload=99\r\n", "") + ) + .await + .is_err() + ); + assert_eq!( + cache.entries[0].metadata.subscription_info.unwrap().upload, + 1 + ); + assert_eq!(cache.entries[0].state.subscription_info.unwrap().upload, 1); + let candidate = fetch( + &mut cache, + response( + 200, + "Subscription-Userinfo: upload=88\r\n", + "proxies: [{name: replacement, type: socks5, server: 192.0.2.2, port: 1080}]", + ), + ) + .await + .unwrap() + .unwrap(); + assert!( + cache + .commit(0, candidate, &CancellationToken::new()) + .is_err() + ); + assert_eq!(cache.entries[0].state.subscription_info.unwrap().upload, 1); + assert_eq!( + fs::read_to_string(root.path().join("nodes.yaml")).unwrap(), + body + ); +} diff --git a/src/resource/http.rs b/src/resource/http.rs index 92e1ea7..832a888 100644 --- a/src/resource/http.rs +++ b/src/resource/http.rs @@ -20,6 +20,7 @@ use tokio_rustls::TlsConnector; use tokio_util::sync::CancellationToken; use url::{Host, Url}; +use super::subscription::SubscriptionInfo; use crate::{ dispatch::{BoxStream, DispatchError, Dispatcher}, session::{Destination, InboundKind, StreamSession}, @@ -72,12 +73,15 @@ impl Default for HttpOptions { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum DownloadOutcome { - NotModified, + NotModified { + subscription_info: Option, + }, Downloaded { etag: Option, sha256: [u8; 32], size: u64, final_url: String, + subscription_info: Option, }, } @@ -281,7 +285,11 @@ async fn download_inner( } match head.status { - 304 if request.etag.is_some() => return Ok(DownloadOutcome::NotModified), + 304 if request.etag.is_some() => { + return Ok(DownloadOutcome::NotModified { + subscription_info: head.subscription_info, + }); + } 304 => { return Err(DownloadError::Protocol( "received 304 without sending If-None-Match".to_owned(), @@ -323,6 +331,7 @@ async fn download_inner( sha256, size, final_url: current_url.to_string(), + subscription_info: head.subscription_info, }); } @@ -535,6 +544,7 @@ struct ResponseHead { content_encoding: Option, etag: Option, location: Option, + subscription_info: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -840,6 +850,8 @@ fn parse_response_head(block: &[u8]) -> Result { let mut content_encoding = None; let mut etag = None; let mut location = None; + let mut subscription_info = None; + let mut subscription_seen = false; let mut header_count = 0_usize; for line in lines { @@ -883,6 +895,9 @@ fn parse_response_head(block: &[u8]) -> Result { set_string_once(&mut etag, value, "ETag")?; } else if name.eq_ignore_ascii_case("location") { set_string_once(&mut location, value, "Location")?; + } else if name.eq_ignore_ascii_case("subscription-userinfo") && !subscription_seen { + subscription_seen = true; + subscription_info = SubscriptionInfo::parse(value); } } @@ -893,6 +908,7 @@ fn parse_response_head(block: &[u8]) -> Result { content_encoding, etag, location, + subscription_info, }) } @@ -1056,6 +1072,20 @@ mod tests { session::{DatagramSession, Destination}, }; + #[test] + fn subscription_header_uses_first_value_including_an_empty_one() { + let head = parse_response_head( + b"HTTP/1.1 200 OK\r\nSubscription-Userinfo: upload=7\r\nsubscription-userinfo: upload=99", + ) + .unwrap(); + assert_eq!(head.subscription_info.unwrap().upload, 7); + let head = parse_response_head( + b"HTTP/1.1 304 Not Modified\r\nSubscription-Userinfo: \r\nsubscription-userinfo: upload=99", + ) + .unwrap(); + assert_eq!(head.subscription_info, None); + } + struct PlainHttpsConnector; #[async_trait] @@ -1220,7 +1250,12 @@ mod tests { assert_eq!(fs::read(&path).unwrap(), b"data"); } "not-modified" => { - assert_eq!(outcome.unwrap(), DownloadOutcome::NotModified); + assert_eq!( + outcome.unwrap(), + DownloadOutcome::NotModified { + subscription_info: None + } + ); assert!(!path.exists()); } "truncated" => { @@ -1334,6 +1369,7 @@ mod tests { sha256: expected_sha256, size: 8, final_url: "https://cdn.example.test/final.dat?version=2".to_owned(), + subscription_info: None, } ); assert_eq!( @@ -1390,7 +1426,12 @@ mod tests { .await .expect("conditional request succeeds"); - assert_eq!(outcome, DownloadOutcome::NotModified); + assert_eq!( + outcome, + DownloadOutcome::NotModified { + subscription_info: None + } + ); assert!(!temporary_path.exists()); } diff --git a/src/resource/mod.rs b/src/resource/mod.rs index 84cbbf7..b41bc9a 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -4,3 +4,4 @@ pub(crate) mod control; pub(crate) mod http; pub(crate) mod probe; pub(crate) mod profile; +pub(crate) mod subscription; diff --git a/src/resource/subscription.rs b/src/resource/subscription.rs new file mode 100644 index 0000000..72818a9 --- /dev/null +++ b/src/resource/subscription.rs @@ -0,0 +1,78 @@ +//! Bounded numeric subscription metadata; raw headers are never retained. +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub(crate) struct SubscriptionInfo { + pub upload: i64, + pub download: i64, + pub total: i64, + pub expire: i64, +} + +impl SubscriptionInfo { + pub fn parse(header: &str) -> Option { + if header.is_empty() { + return None; + } + let normalized = header.to_ascii_lowercase().replace(' ', ""); + let mut info = Self::default(); + for field in normalized.split(';') { + let Some((name, value)) = field.split_once('=') else { + continue; + }; + let slot = match name { + "upload" => &mut info.upload, + "download" => &mut info.download, + "total" => &mut info.total, + "expire" => &mut info.expire, + _ => continue, + }; + if let Some(value) = parse_value(value) { + *slot = value; + } + } + Some(info) + } +} + +fn parse_value(value: &str) -> Option { + if let Ok(value) = value.parse::() { + return Some(value); + } + let value = value.parse::().ok()?; + // The upper bound is exclusive: i64::MAX rounds to 2^63 in f64. + (value.is_finite() + && (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0).contains(&value)) + .then_some(value as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn numeric_header_matches_subscription_fields_without_retaining_raw_values() { + let info = SubscriptionInfo::parse( + " Upload = 12 ; DOWNLOAD=3.9; total=1e4; expire=-4.8; upload=19; upload=invalid; other=55", + ) + .unwrap(); + assert_eq!( + serde_json::to_value(info).unwrap(), + serde_json::json!({"Upload":19,"Download":3,"Total":10000,"Expire":-4}) + ); + assert_eq!(SubscriptionInfo::parse(""), None); + assert_eq!( + SubscriptionInfo::parse("upload=NaN;download=Inf;total=9223372036854775808;expire=bad"), + Some(SubscriptionInfo::default()) + ); + assert_eq!( + SubscriptionInfo::parse("upload=9223372036854775807;download=-9223372036854775808"), + Some(SubscriptionInfo { + upload: i64::MAX, + download: i64::MIN, + ..Default::default() + }) + ); + } +} diff --git a/tests/README.md b/tests/README.md index e0a520d..f2547ca 100644 --- a/tests/README.md +++ b/tests/README.md @@ -95,5 +95,6 @@ container-benchmark compare --source vole=/path/to/Vole 维护,结果记录在对应运行或 PR 中;[验收边界](../docs/acceptance.md) 区分编译、互通与设备验证。 `runtime::providers::tests::` 使用临时文件与内存流验证候选发布、坏更新保留、旧会话资源和停止清理;`config::` 同时覆盖 provider、规则集合和 MRS 格式。 +`resource::cache::tests::` 使用内存 HTTP 流和临时缓存验证订阅元信息的候选提交、304、失败保留与磁盘恢复;`resource::subscription::tests::` 验证有界数值解析。 `routing::proxy_group::tests::` 覆盖自动选择、容差、恢复与选择缓存;`resource::probe::tests::` 和 `invoke::measure_delay::tests::` 使用内存流验证共享健康探测、HTTP 状态和取消。 From 74d4d67ebf9bb3810ae020cb77516ef95b24b285 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 11:42:15 +0800 Subject: [PATCH 032/445] Add resumable sub-rules and flow controls across routing and DNS --- .github/workflows/test.yml | 1 + docs/config.yaml | 23 ++- docs/geodata.md | 3 +- docs/tun-icmp-dns.md | 3 +- src/config/mod.rs | 159 +++++++++++++---- src/config/providers.rs | 83 ++++++++- src/config/rules.rs | 162 +++++++++++++++++ src/dns/runtime.rs | 227 ++++++++++++++++++------ src/geodata/mod.rs | 33 +++- src/outbound/connector.rs | 5 +- src/routing/balance.rs | 17 +- src/routing/diagnostics.rs | 26 ++- src/routing/dispatcher.rs | 282 +++++++++++++++++++++++------- src/routing/mod.rs | 163 +++++++++-------- src/routing/program.rs | 346 +++++++++++++++++++++++++++++++++++++ src/routing/proxy_group.rs | 82 +++++++++ src/routing/selection.rs | 31 +++- src/runtime.rs | 1 + src/runtime/providers.rs | 10 +- tests/README.md | 1 + 20 files changed, 1410 insertions(+), 248 deletions(-) create mode 100644 src/routing/program.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08a10f8..32fd898 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -213,6 +213,7 @@ jobs: invoke::tests::runtime_thread_cannot_reenter_invoke invoke::tests::engine_workers_preserve_invoke_admission_and_shutdown invoke::tests::engine_completion_notifies_after_panic_and_join_reports_failure + dns::runtime::tests::subrule_dns_rules_keep_round_robin_for_new_physical_connections dns::runtime::tests::runtime_dns_separates_bootstrap_proxy_direct_and_hosts dns::runtime::tests::udp_truncation_retries_the_same_upstream_over_tcp dns::runtime::tests::concurrent_redir_host_hint_reads_and_dns_updates_complete diff --git a/docs/config.yaml b/docs/config.yaml index 9c51b4a..c3c8a7d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -474,7 +474,9 @@ proxies: # load-balance 没有 now/手动选择;UDP 按目标会话固定叶节点,健康变化不迁移旧会话。 # 自动或手动切换只影响新连接;既有连接、认证会话及其中复用的流仍保留原路径。 # 组作为 dialer-proxy 时,与路由共用当前选择,但不重新执行业务规则;DIRECT 连接 -# 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。单次建链对每个组 +# 当前节点的服务器而不是业务目标,REJECT 在建链时失败且不回退。成员也可为 PASS/ +# PASS-RULE;仅 rule 模式消费控制,global、dialer-proxy 或显式 DNS 出口选中时拒绝。 +# 单次建链对每个组 # 只读一次选择,SOCKS5 UDP 控制流与 relay 路径共用该快照和期限;不同组没有原子 # 快照保证。复用旧 AnyTLS 会话的新业务流仍沿用该会话路径,只有新建底层 transport # 才读取新选择。Hysteria2/TUIC 已建立会话的新逻辑流、Hysteria2 跳端口 socket @@ -607,14 +609,20 @@ dns: - "tcp://1.1.1.1:53#main-select" # rules 可省略或为空;rule 模式按顺序首条命中,未命中 DIRECT。MATCH 可放在任意 -# 位置,命中后其后规则不再执行。DIRECT、REJECT 是内置动作,其他动作精确引用节点或组。 +# 位置,普通目标命中后其后规则不再执行。DIRECT、REJECT 是内置出口,其他出口精确引用节点或组。 # 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 # GEOSITE、GEOIP、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 -# RULE-SET、AND/OR/NOT 和 MATCH。SRC-IP-CIDR 支持 IPv4/IPv6,来源信息来自实际入站会话。 +# RULE-SET、SUB-RULE、AND/OR/NOT 和 MATCH。SRC-IP-CIDR 支持 IPv4/IPv6,来源信息来自实际入站会话。 # 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 忽略大小写,支持 regex crate # 子集,不支持 lookaround/backreference,无法编译的模式明确报错。 # 逻辑规则例如 AND,((DOMAIN-SUFFIX,example.com),(SRC-PORT,1000-2000)),DIRECT; -# NOT 恰好一个子规则,最多嵌套 32 层。规则上限 65536 条、单条 16 KiB、合计 8 MiB。 +# NOT 恰好一个子规则,逻辑最多嵌套 32 层。主列表与所有 sub-rules 共用 65536 条、 +# 单条 16 KiB、文本合计 8 MiB 的预算;子规则名计入文本。子规则调用最多 32 层, +# 名称必须存在且全图无环;定义共享存储,不按调用次数复制。 +# SUB-RULE,(纯条件),名称 在条件匹配时进入指定列表;子列表耗尽则继续调用者。 +# PASS(含组选择为 PASS)退出所有子调用并继续主列表下一条;PASS-RULE 仅继续 +# 当前子列表,出现在主列表时为 REJECT。两者不能出现在逻辑谓词或 classical 集合中。 +# 组的控制目标先于 disable-udp/节点 UDP 检查;跳过不推进轮询。实际出口只选择一次。 # GEOSITE 支持 [!][@...],GEOIP 支持 [!] 和可选 no-resolve。 # 例如 GEOSITE,google@ads,REJECT / GEOSITE,!cn@ads,main-select / GEOIP,!CN,DIRECT。 # geolocation-!cn 为字面分类名;GeoIP DAT reverse_match 接受但忽略,取反由 ! 控制。 @@ -624,9 +632,14 @@ rules: - GEOSITE,cn,DIRECT - GEOIP,CN,DIRECT,no-resolve - DOMAIN-SUFFIX,anytls.example,anytls-edge - - NETWORK,UDP,anytls-edge + - SUB-RULE,(NETWORK,UDP),udp-policy - MATCH,main-select +sub-rules: + udp-policy: + - DOMAIN-SUFFIX,example.com,PASS-RULE + - NETWORK,UDP,anytls-edge + # measureDelay 使用独立的 node-only 配置:顶层只能包含 proxies,不能包含 # proxy-groups。节点字段和代理图校验与上文一致,但不能出现 mixed-port/authentication、 # tun、sniffer、Controller、DNS、rules 或 GeoData;此接口的 dialer-proxy 只能引用具体 diff --git a/docs/geodata.md b/docs/geodata.md index 86da137..bdf11c0 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -1,7 +1,8 @@ # GeoData 规则与资产 Vole 管理 `dataDir/geodata` 下的 `geosite.dat` 和 `geoip.dat`,只为当前配置实际引用的 -分类与属性交集构建匹配器。所有平台均不设 GeoData 条数上限,不按数量截断记录, +分类与属性交集构建匹配器。主规则和所有 `sub-rules` 定义中的谓词、逻辑子项及 +classical provider 内容共同收集引用;每个定义只遍历一次,不沿调用边展开。所有平台均不设 GeoData 条数上限,不按数量截断记录, 也不设总内存预算。资产缺失或加载失败时,对应种类不可用,跳过相应 Geo 规则并继续 普通选路;卸载前的下载或结构校验失败保留当前快照。配置、格式、整数表示和 可恢复分配错误仍然失败关闭。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 1562a55..fa00f21 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -118,9 +118,10 @@ dns: - Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或支持的代理组名。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 - 规则模式下,UDP 跳过不支持 UDP 的节点或组,继续匹配后续规则;显式出口和 global 模式不做此跳过。 +- `RULES` 与业务 TCP/UDP 使用同一子规则求值器:`SUB-RULE`、`PASS`、`PASS-RULE` 的作用域一致;规则控制和组控制先于 UDP 能力检查。 - 规则结果为 `REJECT` 时,当前尝试失败并继续当前组下一项。 -代理组成员可以是具体节点、嵌套组、`DIRECT` 或 `REJECT`。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。 +代理组成员可以是具体节点、嵌套组、`DIRECT`、`REJECT`、`PASS` 或 `PASS-RULE`。后两者仅在 rule 模式的 `RULES` 中参与规则流程,global 模式、显式命名组或 dialer-proxy 选中控制目标时拒绝连接。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。 ### Policy diff --git a/src/config/mod.rs b/src/config/mod.rs index 6d5bd6b..2d98bd3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,7 +33,7 @@ use crate::{Result, VoleError}; mod dns; pub mod providers; mod rule_provider; -mod rules; +pub(crate) mod rules; pub use rule_provider::RuleCollection; mod yaml; pub use rules::DomainRegex; @@ -107,6 +107,7 @@ pub struct Config { pub sniffer: SnifferConfig, pub dns: DnsConfig, pub rules: Vec, + pub sub_rules: Vec, pub provider_members: HashMap>, pub providers: Option>, } @@ -418,8 +419,61 @@ pub struct RuleSpec { pub no_resolve: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleControl { + Pass, + PassRule, +} + +impl RuleControl { + pub(crate) fn name(self) -> &'static str { + match self { + Self::Pass => "PASS", + Self::PassRule => "PASS-RULE", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct SubRuleId(usize); + +impl SubRuleId { + pub const fn index(self) -> usize { + self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamedRuleProgram { + pub name: String, + pub rules: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeRuleProgram { + pub main: Vec, + pub sub_rules: Vec, +} + +impl From> for RuntimeRuleProgram { + fn from(main: Vec) -> Self { + Self { + main, + sub_rules: Vec::new(), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleKind { + Flow { + predicate: Box, + control: RuleControl, + }, + SubRule { + predicate: Box, + target: SubRuleId, + }, RuleSet { name: String, content: Option>, @@ -502,6 +556,7 @@ pub enum ProxyGroupMemberTarget { Route(RouteTargetId), Direct, Reject, + Control(RuleControl), } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1039,6 +1094,8 @@ struct RawVoleConfig { dns: Option, #[serde(default)] rules: Vec, + #[serde(rename = "sub-rules", default)] + sub_rules: std::collections::BTreeMap>, } #[derive(Debug, Deserialize)] @@ -1886,9 +1943,12 @@ impl Config { template.materialize(&providers::ProviderContents::new()) } #[cfg(any(feature = "invoke", test))] - pub(crate) fn take_runtime_rules(&mut self) -> Vec { + pub(crate) fn take_runtime_rules(&mut self) -> RuntimeRuleProgram { match self.mode { - Mode::Rule => std::mem::take(&mut self.rules), + Mode::Rule => RuntimeRuleProgram { + main: std::mem::take(&mut self.rules), + sub_rules: std::mem::take(&mut self.sub_rules), + }, Mode::Direct | Mode::Global => { let action = if self.mode == Mode::Direct { RuleAction::Direct @@ -1900,11 +1960,11 @@ impl Config { .expect("GLOBAL group exists"), ))) }; - vec![RuleSpec { + RuntimeRuleProgram::from(vec![RuleSpec { kind: RuleKind::Match, action, no_resolve: false, - }] + }]) } } } @@ -2014,7 +2074,8 @@ impl RawVoleConfig { .map_or_else(DnsConfig::disabled, |dns| dns.normalize(&route_targets))?; dns.ipv6 &= self.ipv6; dns.hosts = dns::normalize_hosts(self.hosts)?; - let rules = normalize_rules(self.rules, &route_targets)?; + let (rules, sub_rules) = + normalize_rule_program(self.rules, self.sub_rules, &route_targets)?; drop(proxy_ids); drop(route_targets); @@ -2063,6 +2124,7 @@ impl RawVoleConfig { sniffer, dns, rules, + sub_rules, providers: None, provider_members: HashMap::new(), }) @@ -2453,13 +2515,15 @@ fn normalize_proxy_groups( let target = match name.as_str() { "DIRECT" | "COMPATIBLE" => ProxyGroupMemberTarget::Direct, "REJECT" => ProxyGroupMemberTarget::Reject, + "PASS" => ProxyGroupMemberTarget::Control(RuleControl::Pass), + "PASS-RULE" => ProxyGroupMemberTarget::Control(RuleControl::PassRule), _ => route_targets .get(&name) .copied() .map(ProxyGroupMemberTarget::Route) .ok_or_else(|| { VoleError::InvalidConfig(format!( - "proxy group `{}` member `{name}` does not reference a configured proxy, proxy group, DIRECT, or REJECT", + "proxy group `{}` member `{name}` does not reference a configured proxy, proxy group, DIRECT, REJECT, PASS, or PASS-RULE", group.name )) })?, @@ -3278,34 +3342,67 @@ fn normalize_dns_policy_selector(input: &str) -> Result { use dns::parse_dns_nameserver; -fn normalize_rules( - rules: Vec, +fn normalize_rule_program( + main: Vec, + definitions: std::collections::BTreeMap>, route_targets: &RouteTargetsByName, -) -> Result> { - if rules.len() > MAX_RULES { +) -> Result<(Vec, Vec)> { + let mut count = main.len(); + let mut bytes = 0_usize; + let mut ids = HashMap::with_capacity(definitions.len()); + for (index, (name, rules)) in definitions.iter().enumerate() { + if name.is_empty() { + return invalid("sub-rule name must not be empty"); + } + count = count + .checked_add(rules.len()) + .ok_or_else(|| VoleError::InvalidConfig("rules count overflowed".into()))?; + bytes = bytes + .checked_add(name.len()) + .ok_or_else(|| VoleError::InvalidConfig("rules byte length overflowed".into()))?; + ids.insert(name.clone(), SubRuleId(index)); + } + if count > MAX_RULES { return invalid(format!("rules exceeds the {MAX_RULES}-entry limit")); } - - let mut total_bytes = 0_usize; - let mut normalized = Vec::with_capacity(rules.len()); - for rule in rules { - if rule.len() > MAX_RULE_BYTES { - return invalid(format!( - "rule exceeds the {MAX_RULE_BYTES}-byte per-entry limit" - )); - } - total_bytes = total_bytes - .checked_add(rule.len()) - .ok_or_else(|| VoleError::InvalidConfig("rules byte length overflowed".to_owned()))?; - if total_bytes > MAX_RULES_TOTAL_BYTES { - return invalid(format!( - "rules exceeds the {MAX_RULES_TOTAL_BYTES}-byte cumulative limit" - )); - } - normalized.push(parse_rule(&rule, route_targets)?); + if bytes > MAX_RULES_TOTAL_BYTES { + return invalid(format!( + "rules exceeds the {MAX_RULES_TOTAL_BYTES}-byte cumulative limit" + )); } - - Ok(normalized) + let mut normalize = |rules: Vec| -> Result> { + rules + .into_iter() + .map(|rule| { + if rule.len() > MAX_RULE_BYTES { + return invalid(format!( + "rule exceeds the {MAX_RULE_BYTES}-byte per-entry limit" + )); + } + bytes = bytes.checked_add(rule.len()).ok_or_else(|| { + VoleError::InvalidConfig("rules byte length overflowed".into()) + })?; + if bytes > MAX_RULES_TOTAL_BYTES { + return invalid(format!( + "rules exceeds the {MAX_RULES_TOTAL_BYTES}-byte cumulative limit" + )); + } + rules::parse_program_rule(&rule, route_targets, &ids) + }) + .collect() + }; + let main = normalize(main)?; + let sub_rules = definitions + .into_iter() + .map(|(name, rules)| { + Ok(NamedRuleProgram { + name, + rules: normalize(rules)?, + }) + }) + .collect::>>()?; + rules::validate_subrule_calls(&main, &sub_rules)?; + Ok((main, sub_rules)) } fn trim_ascii_whitespace(input: &str) -> &str { diff --git a/src/config/providers.rs b/src/config/providers.rs index c0727bb..acec72c 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -410,6 +410,9 @@ impl ProviderTemplate { sets.insert(name.clone(), rules.clone()); } bind_rules(&mut config.rules, &sets)?; + for sub in &mut config.sub_rules { + bind_rules(&mut sub.rules, &sets)?; + } bind_rules(&mut config.sniffer.skip_src_address, &sets)?; bind_rules(&mut config.sniffer.skip_dst_address, &sets)?; for rule in config @@ -492,7 +495,13 @@ fn bind_rules(rules: &mut [RuleSpec], sets: &HashMap ) } RuleKind::And(children) | RuleKind::Or(children) => bind_rules(children, sets)?, - RuleKind::Not(child) => bind_rules(std::slice::from_mut(child), sets)?, + RuleKind::Not(child) + | RuleKind::Flow { + predicate: child, .. + } + | RuleKind::SubRule { + predicate: child, .. + } => bind_rules(std::slice::from_mut(child), sets)?, _ => {} } } @@ -788,6 +797,78 @@ proxy-groups: } } + #[test] + fn subrule_provider_candidate_rebinds_without_replacing_old_snapshot() { + use crate::{ + routing::{EmptyGeoMatcher, RoutingContext, RuleEvaluation, RuleMatch, RuleSet}, + session::Destination, + }; + let mut config = Config::parse_yaml( + br#" +mixed-port: 1080 +rule-providers: + domains: {type: inline, behavior: classical, payload: ['DOMAIN,old.example','GEOSITE,cn']} +rules: ['SUB-RULE,(NETWORK,TCP),child', 'MATCH,DIRECT'] +sub-rules: + child: ['RULE-SET,domains,REJECT'] + unused: ['GEOSITE,us,DIRECT'] +"#, + ) + .unwrap(); + let template = config.providers.clone().unwrap(); + let requirements = crate::geodata::GeoRequirements::for_config(&config).unwrap(); + assert_eq!(requirements.total_codes(), 2); + let RuleKind::RuleSet { + content: Some(set), .. + } = &config + .sub_rules + .iter() + .find(|program| program.name == "child") + .unwrap() + .rules[0] + .kind + else { + panic!("unbound child provider"); + }; + let old_set = Arc::downgrade(set); + let old = RuleSet::compile_config(&mut config).unwrap(); + drop(config); + let mut contents = ProviderContents::new(); + contents.insert( + ProviderTemplate::key(true, "domains"), + template + .parse_content( + &template.rules[0].1, + b"payload: ['DOMAIN,new.example','GEOSITE,cn']\n", + ) + .unwrap(), + ); + let mut candidate = template.materialize(&contents).unwrap(); + let new = RuleSet::compile_config(&mut candidate).unwrap(); + let action = |rules: &RuleSet, host| { + let context = + RoutingContext::new(Network::Tcp, &Destination::domain(host, 443).unwrap()) + .unwrap(); + rules.evaluate_with_geo(&context, &EmptyGeoMatcher) + }; + let rejected = RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Reject, + rule_index: 0, + }); + assert_eq!(action(&old, "old.example"), rejected); + assert_eq!(action(&new, "new.example"), rejected); + assert_eq!( + action(&new, "old.example"), + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Direct, + rule_index: 1 + }) + ); + assert!(old_set.upgrade().is_some()); + drop(old); + assert!(old_set.upgrade().is_none()); + } + #[test] fn inline_provider_groups_and_rule_policies_share_materialized_content() { let config = Config::parse_yaml( diff --git a/src/config/rules.rs b/src/config/rules.rs index b8b78cc..ed55ec1 100644 --- a/src/config/rules.rs +++ b/src/config/rules.rs @@ -32,6 +32,168 @@ pub(super) fn parse_rule(input: &str, targets: &RouteTargetsByName) -> Result, +) -> Result { + let (kind, rest) = input + .split_once(',') + .ok_or_else(|| VoleError::InvalidConfig("rule requires a type and payload".into()))?; + if trim_ascii_whitespace(kind).eq_ignore_ascii_case("SUB-RULE") { + let (condition, name) = rest.rsplit_once(',').ok_or_else(|| { + VoleError::InvalidConfig("SUB-RULE requires a condition and named program".into()) + })?; + let condition = trim_ascii_whitespace(condition) + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .ok_or_else(|| { + VoleError::InvalidConfig("SUB-RULE condition requires parentheses".into()) + })?; + let target = sub_rules + .get(trim_ascii_whitespace(name)) + .copied() + .ok_or_else(|| { + VoleError::InvalidConfig("SUB-RULE references an unknown program".into()) + })?; + return Ok(RuleSpec { + kind: RuleKind::SubRule { + predicate: Box::new(parse_predicate(condition)?), + target, + }, + action: RuleAction::Direct, + no_resolve: false, + }); + } + let logical = matches!( + trim_ascii_whitespace(kind).to_ascii_uppercase().as_str(), + "AND" | "OR" | "NOT" | "DOMAIN-REGEX" + ); + let target_range = if logical { + input.rfind(',').map(|start| start + 1..input.len()) + } else { + let start = input.find(',').unwrap() + 1; + if trim_ascii_whitespace(kind).eq_ignore_ascii_case("MATCH") { + Some(start..input.len()) + } else { + input[start..].find(',').map(|offset| { + let start = start + offset + 1; + let end = input[start..] + .find(',') + .map_or(input.len(), |offset| start + offset); + start..end + }) + } + }; + if let Some(range) = target_range { + let control = match trim_ascii_whitespace(&input[range.clone()]) { + "PASS" => Some(RuleControl::Pass), + "PASS-RULE" => Some(RuleControl::PassRule), + _ => None, + }; + if let Some(control) = control { + let mut terminal = input.to_owned(); + terminal.replace_range(range, "DIRECT"); + let predicate = parse(&terminal, Some(targets), 0)?; + return Ok(RuleSpec { + kind: RuleKind::Flow { + predicate: Box::new(predicate), + control, + }, + action: RuleAction::Direct, + no_resolve: false, + }); + } + } + parse(input, Some(targets), 0) +} + +pub(crate) fn validate_subrule_calls( + main: &[RuleSpec], + sub_rules: &[NamedRuleProgram], +) -> Result<()> { + fn visit( + id: usize, + depth: usize, + sub_rules: &[NamedRuleProgram], + states: &mut [u8], + lengths: &mut [usize], + ) -> Result { + if depth > 32 { + return invalid("sub-rules exceed 32 call levels"); + } + let Some(program) = sub_rules.get(id) else { + return invalid("SUB-RULE references an unknown program"); + }; + if states[id] == 1 { + return invalid("sub-rules contain a reference cycle"); + } + if states[id] == 2 { + return Ok(lengths[id]); + } + states[id] = 1; + let mut length = 1; + for rule in &program.rules { + if let RuleKind::SubRule { target, .. } = &rule.kind { + length = + length.max(1 + visit(target.index(), depth + 1, sub_rules, states, lengths)?); + } + } + if length > 32 { + return invalid("sub-rules exceed 32 call levels"); + } + states[id] = 2; + lengths[id] = length; + Ok(length) + } + fn pure(rule: &RuleSpec, depth: usize) -> Result<()> { + if depth > 32 { + return invalid("logical rules exceed 32 nesting levels"); + } + match &rule.kind { + RuleKind::Flow { .. } | RuleKind::SubRule { .. } => { + invalid("flow controls are not predicates") + } + RuleKind::And(children) | RuleKind::Or(children) => { + for child in children { + pure(child, depth + 1)?; + } + Ok(()) + } + RuleKind::Not(child) => pure(child, depth + 1), + _ => Ok(()), + } + } + let mut names = std::collections::HashSet::new(); + for program in sub_rules { + if program.name.is_empty() || !names.insert(&program.name) { + return invalid("sub-rules require unique nonempty names"); + } + } + for rule in main + .iter() + .chain(sub_rules.iter().flat_map(|program| &program.rules)) + { + match &rule.kind { + RuleKind::Flow { predicate, .. } | RuleKind::SubRule { predicate, .. } => { + pure(predicate, 0)? + } + _ => pure(rule, 0)?, + } + } + let mut states = vec![0; sub_rules.len()]; + let mut lengths = vec![0; sub_rules.len()]; + for id in 0..sub_rules.len() { + visit(id, 1, sub_rules, &mut states, &mut lengths)?; + } + for rule in main { + if let RuleKind::SubRule { target, .. } = &rule.kind { + visit(target.index(), 1, sub_rules, &mut states, &mut lengths)?; + } + } + Ok(()) +} + pub(super) fn parse_predicate(input: &str) -> Result { parse(input, None, 0) } diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index b79fb71..49d252a 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -44,7 +44,8 @@ use crate::{ dispatch::{BoxStream, DispatchError, Dispatcher}, resources::{ResourceActivity, ResourceActivityGuard, RuntimeResourceStats}, routing::{ - EmptyGeoMatcher, GeoMatcher, ProxyDispatchers, RoutingContext, RuleEvaluation, RuleSet, + EmptyGeoMatcher, GeoMatcher, ProgramEvaluation, ProxyDispatchers, RoutingContext, RuleSet, + RuleTargetEffect, }, session::{Datagram, DatagramSession, Destination, InboundKind, StreamSession}, }; @@ -234,15 +235,28 @@ impl TcpConnectionPool { }) } - async fn checkout( + async fn checkout_for( self: &Arc, - key: TcpPoolKey, nameserver: &ResolvedNameserver, - egress: &Arc, + resolve: impl FnOnce(&TcpPoolState) -> Result<(DnsEgressKey, Arc), AttemptError>, ) -> Result { - let (connection, expired) = { + let (key, egress, connection, expired) = { let mut state = self.state.lock().await; let expired = self.take_expired_locked(&mut state, TokioInstant::now()); + // Resolve and remove an idle connection in one transaction. The + // callback commits a group only when this stable route key is absent. + let (egress_key, egress) = match resolve(&state) { + Ok(route) => route, + Err(error) => { + self.record_idle_locked(&state); + return Err(error); + } + }; + let key = TcpPoolKey { + upstream: nameserver.config.clone(), + endpoint: nameserver.endpoint(), + egress: egress_key, + }; let index = state .idle .iter() @@ -252,10 +266,9 @@ impl TcpConnectionPool { .map(|(index, _)| index); let connection = index.map(|index| state.idle.swap_remove(index).connection); self.record_idle_locked(&state); - (connection, expired) + (key, egress, connection, expired) }; drop(expired); - if let Some(connection) = connection { self.stats.reuse_hits.fetch_add(1, Ordering::Relaxed); return Ok(TcpLease { @@ -266,8 +279,7 @@ impl TcpConnectionPool { stats: Arc::clone(&self.stats), }); } - - let connection = self.open_connection(nameserver, egress).await?; + let connection = self.open_connection(nameserver, &egress).await?; Ok(TcpLease { key, connection: Some(connection), @@ -1160,7 +1172,9 @@ impl RuntimeDns { &self, nameserver: &ResolvedNameserver, network: Network, - ) -> Result<(DnsEgressKey, &Arc), AttemptError> { + reuse: impl Fn(DnsEgressKey) -> bool, + ) -> Result<(DnsEgressKey, Arc), AttemptError> { + use crate::routing::proxy_group::InspectedRuleTarget; let DnsEgress::Routed { proxies, direct, @@ -1170,45 +1184,84 @@ impl RuntimeDns { let DnsEgress::Single(dispatcher) = &self.egress else { unreachable!() }; - return Ok((DnsEgressKey::Single, dispatcher)); + return Ok((DnsEgressKey::Single, dispatcher.clone())); + }; + let destination = match &nameserver.address { + DnsAddress::Ip(_) => Destination::Ip(nameserver.endpoint()), + DnsAddress::Domain(host) => { + Destination::domain(host, nameserver.port).map_err(AttemptError::Io)? + } + }; + let inspect = |id| { + proxies.inspect_rule_target( + id, + network == Network::Udp, + !reuse(DnsEgressKey::Route(id)), + Some(internal_source(nameserver.endpoint.ip())), + Some(&destination), + ) }; - match nameserver.route { - DnsRoute::Direct => Ok((DnsEgressKey::Direct, direct)), - DnsRoute::Route(id) => proxies - .get_route(id) - .map(|dispatcher| (DnsEgressKey::Route(id), dispatcher)) - .map_err(AttemptError::Dispatch), + DnsRoute::Direct => Ok((DnsEgressKey::Direct, direct.clone())), + DnsRoute::Route(id) => match inspect(id).map_err(AttemptError::Dispatch)? { + InspectedRuleTarget::Egress(leaf) => Ok((DnsEgressKey::Route(id), leaf.dispatcher)), + InspectedRuleTarget::Control(_) | InspectedRuleTarget::Unavailable => { + Err(AttemptError::Dispatch(DispatchError::NotAllowed)) + } + }, DnsRoute::Rules => { - let destination = match &nameserver.address { - DnsAddress::Ip(_) => Destination::Ip(nameserver.endpoint()), - DnsAddress::Domain(host) => { - Destination::domain(host, nameserver.port).map_err(AttemptError::Io)? - } - }; - let mut context = RoutingContext::new(network, &destination).map_err(|error| { - AttemptError::Dispatch(DispatchError::Other(format!( - "DNS nameserver routing context is invalid: {error}" - ))) - })?; + let mut context = RoutingContext::new(network, &destination) + .map_err(|_| AttemptError::Dispatch(DispatchError::HostUnreachable))?; context.pin_ip(nameserver.endpoint.ip()); - match rules.evaluate_with_egress(&context, self.geo_matcher.as_ref(), |action| { - proxies.supports_udp(action) - }) { - RuleEvaluation::Matched(rule_match) => match rule_match.action { - RuleAction::Route(id) => proxies - .get_route(id) - .map(|dispatcher| (DnsEgressKey::Route(id), dispatcher)) - .map_err(AttemptError::Dispatch), - RuleAction::Direct => Ok((DnsEgressKey::Direct, direct)), + let snapshot = self.geo_matcher.routing_snapshot(); + let geo = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); + let mut selected = None; + let mut error = None; + let evaluation = + rules.evaluate_program(&mut context, geo, None, None, |action, _| { + let RuleAction::Route(id) = action else { + return RuleTargetEffect::Accept; + }; + match inspect(id) { + Ok(InspectedRuleTarget::Control(_)) if !rules.filters_udp() => { + error = Some(DispatchError::NotAllowed); + RuleTargetEffect::Accept + } + Ok(InspectedRuleTarget::Control(control)) => { + RuleTargetEffect::Control(control) + } + Ok(InspectedRuleTarget::Egress(leaf)) => { + selected = Some((DnsEgressKey::Route(id), leaf.dispatcher)); + RuleTargetEffect::Accept + } + Ok(InspectedRuleTarget::Unavailable) if rules.filters_udp() => { + RuleTargetEffect::Skip + } + Ok(InspectedRuleTarget::Unavailable) => { + error = Some(DispatchError::NotAllowed); + RuleTargetEffect::Accept + } + Err(failure) => { + error = Some(failure); + RuleTargetEffect::Accept + } + } + }); + if let Some(error) = error { + return Err(AttemptError::Dispatch(error)); + } + match evaluation { + ProgramEvaluation::Matched(rule_match) => match rule_match.action { + RuleAction::Route(_) => { + Ok(selected.expect("accepted DNS route owns a leaf")) + } + RuleAction::Direct => Ok((DnsEgressKey::Direct, direct.clone())), RuleAction::Reject => { Err(AttemptError::Dispatch(DispatchError::NotAllowed)) } }, - RuleEvaluation::NeedsIpResolution { .. } | RuleEvaluation::NoMatch => { - Err(AttemptError::Dispatch(DispatchError::Other( - "DNS nameserver rules produced no final action".to_owned(), - ))) + ProgramEvaluation::NeedsIpResolution(_) => { + Err(AttemptError::Dispatch(DispatchError::HostUnreachable)) } } } @@ -1905,8 +1958,8 @@ impl RuntimeDns { }); let mut server = self.resolve_nameserver(&nameserver).await?; if server.transport == DnsTransport::Udp { - let (_, egress) = self.egress_for(&server, Network::Udp)?; - let response = self.exchange_udp(&server, egress, query, matches).await?; + let (_, egress) = self.egress_for(&server, Network::Udp, |_| false)?; + let response = self.exchange_udp(&server, &egress, query, matches).await?; if response.get(2).is_some_and(|flags| flags & 0x02 != 0) { server.config.transport = DnsTransport::Tcp; } else { @@ -1914,9 +1967,7 @@ impl RuntimeDns { return Ok((response, parsed)); } } - let (key, egress) = self.egress_for(&server, Network::Tcp)?; - self.exchange_tcp(&server, key, egress, query, validate) - .await + self.exchange_tcp(&server, query, validate).await } async fn exchange_udp( @@ -2001,20 +2052,24 @@ impl RuntimeDns { async fn exchange_tcp( &self, nameserver: &ResolvedNameserver, - egress_key: DnsEgressKey, - egress: &Arc, query: &[u8], validate: F, ) -> Result<(Vec, R), AttemptError> where F: Fn(&[u8]) -> Result, { - let key = TcpPoolKey { - upstream: nameserver.config.clone(), - endpoint: nameserver.endpoint(), - egress: egress_key, - }; - let mut lease = self.tcp_pool.checkout(key, nameserver, egress).await?; + let mut lease = self + .tcp_pool + .checkout_for(nameserver, |state| { + self.egress_for(nameserver, Network::Tcp, |egress| { + state.idle.iter().any(|idle| { + idle.key.upstream == nameserver.config + && idle.key.endpoint == nameserver.endpoint() + && idle.key.egress == egress + }) + }) + }) + .await?; let first_was_reused = lease.reused; let mut result = exchange_stream_validated( @@ -2033,8 +2088,10 @@ impl RuntimeDns { event = "runtime_dns_tcp_pool_stale_retry", "runtime DNS is rebuilding one stale reused TCP connection" ); + let (key, egress) = self.egress_for(nameserver, Network::Tcp, |_| false)?; + lease.key.egress = key; self.tcp_pool - .replace_stale(&mut lease, nameserver, egress) + .replace_stale(&mut lease, nameserver, &egress) .await?; result = exchange_stream_validated( &mut lease @@ -3393,6 +3450,66 @@ dns: assert_eq!(resolver.tcp_pool.snapshot().current_idle, 2); } + #[tokio::test] + async fn subrule_dns_rules_keep_round_robin_for_new_physical_connections() { + use crate::{ + config::Config, + routing::{ProxyGroups, RouteTargetDispatchers}, + }; + let mut configured = Config::parse_yaml(br#" +mixed-port: 1080 +proxies: + - {name: a, type: socks5, server: 192.0.2.10, port: 1080} + - {name: b, type: socks5, server: 192.0.2.11, port: 1080} +proxy-groups: + - {name: gate, type: load-balance, proxies: [PASS-RULE,a], strategy: round-robin, disable-udp: true} + - {name: pool, type: load-balance, proxies: [a,b], strategy: round-robin} +rules: ['SUB-RULE,(DST-PORT,53),child', 'MATCH,REJECT'] +sub-rules: {child: ['MATCH,gate', 'MATCH,pool']} +"#).unwrap(); + let address = "203.0.113.65".parse().unwrap(); + let a = Arc::new(ScriptedTcpDispatcher::new(vec![vec![ + TcpScriptReply::Response(ResponseSpec::answer(address)), + TcpScriptReply::Response(ResponseSpec::answer(address)), + ]])); + let b = Arc::new(ScriptedTcpDispatcher::new(vec![vec![ + TcpScriptReply::Response(ResponseSpec::answer(address)), + ]])); + let direct: Arc = Arc::new(MockDispatcher::default()); + let nodes: Vec> = vec![a.clone(), b.clone()]; + let groups = + ProxyGroups::new(&configured.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let targets = RouteTargetDispatchers::with_proxy_groups(nodes, &groups).unwrap(); + let mut upstream = nameserver(1, DnsTransport::Tcp); + upstream.route = DnsRoute::Rules; + let resolver = RuntimeDns::new_routed_proxies( + &config(false, vec![upstream.clone()]), + targets, + direct, + RuleSet::compile_config(&mut configured).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + assert_eq!(resolver.resolve("first.example").await.unwrap(), [address]); + assert_eq!(resolver.resolve("pooled.example").await.unwrap(), [address]); + assert_eq!(a.tcp_calls.load(Ordering::Acquire), 1); + assert_eq!(b.tcp_calls.load(Ordering::Acquire), 0); + tokio::task::yield_now().await; + assert_eq!( + resolver.resolve("rebuilt.example").await.unwrap(), + [address] + ); + assert_eq!(b.tcp_calls.load(Ordering::Acquire), 1); + upstream.route = DnsRoute::Route(crate::config::RouteTargetId::Group( + crate::config::ProxyGroupId::new(0).unwrap(), + )); + let server = resolver.resolve_nameserver(&upstream).await.unwrap(); + assert!(matches!( + resolver.egress_for(&server, Network::Tcp, |_| false), + Err(AttemptError::Dispatch(DispatchError::NotAllowed)) + )); + assert_eq!(groups.selection(0).choose(None, None), 0); + } + #[tokio::test] async fn group_switch_keeps_dns_cache_and_tcp_pool_until_a_new_connection() { use crate::config::{ diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 7496a4d..3bc6ddb 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -386,15 +386,15 @@ struct RequestedCodes { } impl RequestedCodes { - fn collect( - rules: &[RuleSpec], + fn collect<'a>( + rules: impl IntoIterator, dns_policies: &[DnsNameserverPolicy], ) -> Result { let mut requested = Self { sites: CodeSet::EMPTY, ips: CodeSet::EMPTY, }; - let mut pending: Vec<_> = rules.iter().collect(); + let mut pending: Vec<_> = rules.into_iter().collect(); while let Some(rule) = pending.pop() { match &rule.kind { RuleKind::And(children) | RuleKind::Or(children) => pending.extend(children), @@ -402,7 +402,13 @@ impl RequestedCodes { content: Some(content), .. } => pending.extend(&content.classical), - RuleKind::Not(child) => pending.push(child), + RuleKind::Not(child) + | RuleKind::Flow { + predicate: child, .. + } + | RuleKind::SubRule { + predicate: child, .. + } => pending.push(child), RuleKind::GeoSite(code) => requested.sites.insert(GeoDataKind::GeoSite, code)?, RuleKind::GeoIp(code) => requested.ips.insert(GeoDataKind::GeoIp, code)?, _ => (), @@ -449,6 +455,19 @@ pub struct GeoRequirements { impl GeoRequirements { pub(crate) fn for_config(config: &crate::config::Config) -> Result { + Self::for_rules( + config, + config + .rules + .iter() + .chain(config.sub_rules.iter().flat_map(|sub| &sub.rules)), + ) + } + + pub(crate) fn for_rules<'a>( + config: &crate::config::Config, + rules: impl IntoIterator, + ) -> Result { let mut policies: Vec<_> = config .dns .nameserver_policies @@ -489,7 +508,11 @@ impl GeoRequirements { }), ); } - let mut result = Self::collect(&config.rules, &policies)?; + let requested = RequestedCodes::collect(rules, &policies)?; + let mut result = Self { + sites: requested.sites, + ips: requested.ips, + }; if config.sniffer.enable { result.extend(&Self::collect(&config.sniffer.skip_src_address, &[])?)?; result.extend(&Self::collect(&config.sniffer.skip_dst_address, &[])?)?; diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index e8e46dc..f3a4c08 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -455,6 +455,7 @@ pub(crate) enum SelectUpstreamMember { Group(Arc), Direct, Reject, + Control, } impl SelectUpstream { @@ -590,7 +591,9 @@ impl UpstreamPath { .as_ref() .map(|endpoint| ResolvedUpstream::Direct(endpoint, dialer)) .map_or_else(|| Ok(ResolvedUpstream::LazyDirect(dialer)), Ok), - SelectUpstreamMember::Reject => Err(DispatchError::NotAllowed), + SelectUpstreamMember::Reject | SelectUpstreamMember::Control => { + Err(DispatchError::NotAllowed) + } SelectUpstreamMember::Group(_) => { unreachable!("upstream resolution returns a leaf") } diff --git a/src/routing/balance.rs b/src/routing/balance.rs index c945751..579c186 100644 --- a/src/routing/balance.rs +++ b/src/routing/balance.rs @@ -45,6 +45,14 @@ impl Balance { &mut self, source: Option, destination: Option<&Destination>, + ) -> usize { + self.inspect(source, destination, true) + } + pub fn inspect( + &mut self, + source: Option, + destination: Option<&Destination>, + commit: bool, ) -> usize { let count = self.healthy.len(); if count == 0 { @@ -55,7 +63,9 @@ impl Balance { .map(|offset| (self.cursor + offset) % count) .find(|&i| self.healthy[i]); if let Some(index) = selected { - self.cursor = (index + 1) % count; + if commit { + self.cursor = (index + 1) % count; + } return index; } return 0; @@ -110,6 +120,11 @@ impl Balance { ); index } + pub fn commit(&mut self, index: usize) { + if self.strategy == BalanceStrategy::RoundRobin && !self.healthy.is_empty() { + self.cursor = (index + 1) % self.healthy.len(); + } + } } fn destination_key(destination: Option<&Destination>) -> String { match destination { diff --git a/src/routing/diagnostics.rs b/src/routing/diagnostics.rs index e7b1539..75432fc 100644 --- a/src/routing/diagnostics.rs +++ b/src/routing/diagnostics.rs @@ -38,7 +38,7 @@ impl Diagnostics { // pinning a Config or an entire retired resource generation. let mut texts = BTreeSet::new(); let mut names = BTreeSet::new(); - for name in ["", "DIRECT", "REJECT"] + for name in ["", "DIRECT", "REJECT", "PASS", "PASS-RULE"] .into_iter() .chain(config.proxies.iter().map(|p| p.tag.as_str())) .chain(config.proxy_groups.iter().map(|g| g.name.as_str())) @@ -68,13 +68,19 @@ impl Diagnostics { .filter_map(|i| rules.rule(i)) .map(|spec| { let (kind, payload) = rule_description(&spec.kind); - let target = match spec.action { - RuleAction::Direct => "DIRECT", - RuleAction::Reject => "REJECT", - RuleAction::Route(RouteTargetId::Proxy(id)) => &config.proxies[id.index()].tag, - RuleAction::Route(RouteTargetId::Group(id)) => { - &config.proxy_groups[id.index()].name - } + let target = match &spec.kind { + RuleKind::Flow { control, .. } => control.name(), + RuleKind::SubRule { target, .. } => rules.subrule_name(*target), + _ => match spec.action { + RuleAction::Direct => "DIRECT", + RuleAction::Reject => "REJECT", + RuleAction::Route(RouteTargetId::Proxy(id)) => { + &config.proxies[id.index()].tag + } + RuleAction::Route(RouteTargetId::Group(id)) => { + &config.proxy_groups[id.index()].name + } + }, }; Rule { kind, @@ -214,6 +220,10 @@ fn rule_description(rule: &RuleKind) -> (&'static str, String) { logical(rules.iter().map(|r| &r.kind)), ), RuleKind::Not(rule) => ("NOT", logical(std::iter::once(&rule.kind))), + RuleKind::Flow { predicate, .. } => rule_description(&predicate.kind), + RuleKind::SubRule { predicate, .. } => { + ("SubRule", logical(std::iter::once(&predicate.kind))) + } } } fn logical<'a>(rules: impl Iterator) -> String { diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index da1e609..7235b46 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -29,8 +29,10 @@ use crate::{ }; use super::{ - GeoMatcher, ProxyGroups, ResolvedProxyGroupLeaf, RoutingContext, RuleEvaluation, RuleSet, + GeoMatcher, ProgramEvaluation, ProxyGroups, ResolvedProxyGroupLeaf, RoutingContext, RuleSet, + RuleTargetEffect, datagram_ready::{ReadyFlow, ReadyQueue}, + proxy_group::InspectedRuleTarget, }; const TCP_DNS_DUPLEX_CAPACITY: usize = 8 * 1024; @@ -109,13 +111,6 @@ impl std::fmt::Debug for ProxyDispatchers { } impl ProxyDispatchers { - pub(crate) fn supports_udp(&self, action: RuleAction) -> bool { - match action { - RuleAction::Route(id) => self.get_route(id).is_ok_and(|d| d.supports_udp()), - RuleAction::Direct | RuleAction::Reject => true, - } - } - pub fn new(proxies: Vec>) -> Result { Ok(Self { proxies, @@ -157,6 +152,7 @@ impl ProxyDispatchers { .ok_or_else(|| DispatchError::Other(format!("unknown proxy id {}", id.index()))) } + #[cfg(test)] pub(crate) fn get_route( &self, id: RouteTargetId, @@ -169,6 +165,35 @@ impl ProxyDispatchers { } } + pub(crate) fn inspect_rule_target( + &self, + id: RouteTargetId, + udp: bool, + commit: bool, + source: Option, + destination: Option<&Destination>, + ) -> Result { + match id { + RouteTargetId::Proxy(id) => { + let dispatcher = self.get(id)?.clone(); + if udp && !dispatcher.supports_udp() { + return Ok(InspectedRuleTarget::Unavailable); + } + Ok(InspectedRuleTarget::Egress(ResolvedProxyGroupLeaf { + chain: Vec::new(), + selections: Vec::new(), + dispatcher, + direct: false, + })) + } + RouteTargetId::Group(id) => self + .proxy_groups + .as_ref() + .ok_or(DispatchError::NotAllowed)? + .inspect_rule_target(id, udp, commit, source, destination), + } + } + fn per_flow(&self, id: RouteTargetId) -> bool { matches!(id, RouteTargetId::Group(id) if self.proxy_groups.as_ref().is_some_and(|groups| groups.contains_balance(id))) } @@ -331,6 +356,9 @@ impl RoutingDispatcher { source: SocketAddr, destination: &Destination, sniffed_domain: Option<&str>, + mut cached: Option< + &mut HashMap<(RouteTargetId, Option), CachedRouteTransport>, + >, ) -> Result { self.ensure_destination_allowed(destination)?; let recovered = if matches!( @@ -395,31 +423,83 @@ impl RoutingDispatcher { let geo_matcher = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); let mut resolved = None; - let supports_udp = |action| self.route_targets.supports_udp(action); - let evaluation = match self - .rules - .evaluate_with_egress(&context, geo_matcher, supports_udp) - { - RuleEvaluation::NeedsIpResolution { rule_index } => { - let addresses = match (destination, &self.dns) { - (Destination::Domain { host, .. }, Some(dns)) => { - self.resolve_addresses(dns, host).await - } - _ => Vec::new(), - }; - resolved = Some(addresses); - self.rules.evaluate_with_resolved_egress( - &mut context, - geo_matcher, - rule_index, - resolved.as_deref().unwrap_or_default(), - supports_udp, - ) + let mut selected = None; + let mut selection_error = None; + let mut select = |action, candidate: &RoutingContext| { + let RuleAction::Route(id) = action else { + return RuleTargetEffect::Accept; + }; + let metadata = candidate.domain().map_or_else( + || destination.clone(), + |host| Destination::Domain { + host: host.to_owned(), + port: destination.port(), + }, + ); + let key = ( + id, + self.route_targets.per_flow(id).then(|| destination.clone()), + ); + let cached = cached + .as_deref_mut() + .and_then(|cached| cached.get(&key)) + .map(|cached| cached.leaf.clone()); + match self.route_targets.inspect_rule_target( + id, + network == Network::Udp, + cached.is_none(), + Some(source), + Some(&metadata), + ) { + Ok(InspectedRuleTarget::Control(_)) if !self.rules.filters_udp() => { + selected = Some(Err(DispatchError::NotAllowed)); + RuleTargetEffect::Accept + } + Ok(InspectedRuleTarget::Control(control)) => RuleTargetEffect::Control(control), + Ok(InspectedRuleTarget::Egress(leaf)) => { + selected = Some(Ok(cached.unwrap_or(leaf))); + RuleTargetEffect::Accept + } + Ok(InspectedRuleTarget::Unavailable) if self.rules.filters_udp() => { + RuleTargetEffect::Skip + } + Ok(InspectedRuleTarget::Unavailable) => { + selected = Some(Err(DispatchError::NotAllowed)); + RuleTargetEffect::Accept + } + Err(error) => { + selection_error = Some(error); + RuleTargetEffect::Accept + } } - evaluation => evaluation, }; - - let RuleEvaluation::Matched(rule_match) = evaluation else { + let evaluation = + match self + .rules + .evaluate_program(&mut context, geo_matcher, None, None, &mut select) + { + ProgramEvaluation::NeedsIpResolution(cursor) => { + let addresses = match (destination, &self.dns) { + (Destination::Domain { host, .. }, Some(dns)) => { + self.resolve_addresses(dns, host).await + } + _ => Vec::new(), + }; + resolved = Some(addresses); + self.rules.evaluate_program( + &mut context, + geo_matcher, + Some(cursor), + resolved.as_deref(), + &mut select, + ) + } + evaluation => evaluation, + }; + if let Some(error) = selection_error { + return Err(error); + } + let ProgramEvaluation::Matched(rule_match) = evaluation else { return Err(DispatchError::Other( "routing rules produced no final action".to_owned(), )); @@ -462,6 +542,7 @@ impl RoutingDispatcher { Destination::Ip(std::net::SocketAddr::new(address, destination.port())) }), resolved_addresses: resolved, + selected, }; if decision.action == RuleAction::Direct { self.resolve_direct_destination(&mut decision).await?; @@ -588,6 +669,7 @@ struct RouteDecision { action: RuleAction, destination: Destination, resolved_addresses: Option>, + selected: Option>, } fn log_route_decision(network: Network, decision: &RouteDecision) { @@ -718,17 +800,10 @@ impl RoutingDispatcher { session.source, &session.destination, session.sniffed_domain.as_deref(), + None, ) .await?; - let resolved_target = match decision.action { - RuleAction::Route(id) => Some(self.route_targets.resolve_for( - id, - false, - Some(session.source), - Some(&decision.metadata), - )?), - RuleAction::Direct | RuleAction::Reject => None, - }; + let resolved_target = decision.selected.take().transpose()?; if resolved_target.as_ref().is_some_and(|target| target.direct) { self.resolve_direct_destination(&mut decision).await?; } @@ -981,6 +1056,7 @@ impl Drop for ManagedDatagramFlow { } struct CachedRouteTransport { + leaf: ResolvedProxyGroupLeaf, last_used: Instant, transport: Box, direct: bool, @@ -1119,6 +1195,7 @@ impl RoutedDatagramTransport { self.session.source, &key, datagram.sniffed_domain.as_deref(), + None, ) .await?; if decision.action == RuleAction::Reject { @@ -1126,13 +1203,11 @@ impl RoutedDatagramTransport { } let (dispatcher, direct, chain) = match decision.action { RuleAction::Direct => (self.router.direct.clone(), true, Vec::new()), - RuleAction::Route(id) => { - let leaf = self.router.route_targets.resolve_for( - id, - true, - Some(self.session.source), - Some(&decision.metadata), - )?; + RuleAction::Route(_) => { + let leaf = decision + .selected + .take() + .expect("accepted route owns its selected leaf")?; (leaf.dispatcher, leaf.direct, leaf.chain) } RuleAction::Reject => unreachable!(), @@ -1397,18 +1472,17 @@ impl RoutedDatagramTransport { self.session.source, &datagram.remote, datagram.sniffed_domain.as_deref(), + None, ) .await?; let (dispatcher, direct) = match decision.action { RuleAction::Reject => return Ok(()), RuleAction::Direct => (self.router.direct.clone(), true), - RuleAction::Route(id) => { - let target = self.router.route_targets.resolve_for( - id, - true, - Some(self.session.source), - Some(&decision.metadata), - )?; + RuleAction::Route(_) => { + let target = decision + .selected + .take() + .expect("accepted route owns its selected leaf")?; (target.dispatcher, target.direct) } }; @@ -1475,12 +1549,15 @@ impl RoutedDatagramTransport { let direct = match direct { Some(direct) => direct, None => { - let target = self.router.route_targets.resolve_for( - id, - true, - Some(self.session.source), - Some(&decision.metadata), - )?; + let target = match decision.selected.take() { + Some(target) => target?, + None => self.router.route_targets.resolve_for( + id, + true, + Some(self.session.source), + Some(&decision.metadata), + )?, + }; let direct = target.direct; if direct { self.router @@ -1496,6 +1573,7 @@ impl RoutedDatagramTransport { CachedRouteTransport { transport, direct, + leaf: target, last_used: Instant::now(), }, ); @@ -1648,6 +1726,7 @@ impl DatagramTransport for RoutedDatagramTransport { action, destination: datagram.remote.clone(), resolved_addresses: None, + selected: None, } } else { let decision = self @@ -1658,6 +1737,7 @@ impl DatagramTransport for RoutedDatagramTransport { self.session.source, &datagram.remote, datagram.sniffed_domain.as_deref(), + Some(&mut self.route_transports), ) .await?; if let Some(destination) = flow { @@ -2340,6 +2420,7 @@ rules: ["DST-PORT,443,{target}", "MATCH,DIRECT"] "192.0.2.2:1234".parse().unwrap(), &Destination::Ip("203.0.113.1:443".parse().unwrap()), None, + None, ) .await .unwrap(); @@ -2472,6 +2553,87 @@ rules: ["MATCH,balance"] transport.close().await.unwrap(); } + #[tokio::test] + async fn subrule_routes_resume_dns_and_commit_one_tcp_udp_leaf() { + let mut config = crate::config::Config::parse_yaml(br#" +mixed-port: 1080 +proxies: + - {name: a, type: socks5, server: 192.0.2.10, port: 1080} + - {name: b, type: socks5, server: 192.0.2.11, port: 1080} +proxy-groups: + - {name: gate, type: load-balance, proxies: [PASS-RULE,a], strategy: round-robin, disable-udp: true} + - {name: pool, type: load-balance, proxies: [a,b], strategy: round-robin} + - {name: GLOBAL, type: select, proxies: [PASS,pool]} +rules: ['SUB-RULE,(DST-PORT,443),child', 'MATCH,REJECT'] +sub-rules: + child: ['DST-PORT,443,gate', 'IP-CIDR,192.0.2.0/24,pool', 'MATCH,pool'] +"#).unwrap(); + let mut global_config = config.clone(); + global_config.mode = crate::config::Mode::Global; + let a = Arc::new(RecordingDispatcher::default()); + let b = Arc::new(RecordingDispatcher::default()); + let nodes: Vec> = vec![a.clone(), b.clone()]; + let direct = Arc::new(RecordingDispatcher::default()); + let groups = ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let dns = Arc::new(MockDns::new(vec!["192.0.2.7".parse().unwrap()])); + let mut router = RoutingDispatcher::new( + ProxyDispatchers::with_proxy_groups(nodes, &groups).unwrap(), + direct, + None, + RuleSet::compile_config(&mut config).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + router.dns = Some(dns.clone()); + let source = "192.0.2.1:5555".parse().unwrap(); + for _ in 0..2 { + router + .connect_tcp(StreamSession { + inbound: InboundKind::Socks5, + source, + destination: Destination::domain("example.com", 443).unwrap(), + sniffed_domain: None, + }) + .await + .unwrap(); + } + assert_eq!(dns.resolve_calls.load(Ordering::Relaxed), 2); + assert_eq!(a.tcp_sessions.lock().unwrap().len(), 1); + assert_eq!(b.tcp_sessions.lock().unwrap().len(), 1); + let mut transport = router + .open_datagram(DatagramSession::new(InboundKind::Socks5, source)) + .await + .unwrap(); + let first = datagram("198.51.100.1:443".parse().unwrap(), b"first"); + transport.send(first.clone()).await.unwrap(); + transport.send(first).await.unwrap(); + transport + .send(datagram("198.51.100.2:443".parse().unwrap(), b"next")) + .await + .unwrap(); + assert_eq!(a.datagrams.lock().unwrap().len(), 2); + assert_eq!(b.datagrams.lock().unwrap().len(), 1); + assert_eq!(groups.selection(0).choose(None, None), 0); + transport.close().await.unwrap(); + let global = RoutingDispatcher::new( + router.route_targets.clone(), + router.direct.clone(), + None, + RuleSet::compile_config(&mut global_config).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + assert!(matches!( + global + .connect_tcp(StreamSession { + inbound: InboundKind::Socks5, + source, + destination: Destination::Ip("198.51.100.1:443".parse().unwrap()), + sniffed_domain: None + }) + .await, + Err(DispatchError::NotAllowed) + )); + } + #[test] fn proxy_registry_accepts_ids_beyond_u8() { let proxies = (0..300) diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 74adf81..c796d68 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -12,10 +12,13 @@ use std::{ }; use crate::{ - config::{IpCidr, Network, RuleAction, RuleKind, RuleSpec}, + config::{IpCidr, Network, RuleAction, RuleKind, RuleSpec, RuntimeRuleProgram}, session::Destination, }; +mod program; +use program::CompiledProgram; +pub(crate) use program::{ProgramEvaluation, RuleTargetEffect}; mod balance; pub(super) mod datagram_ready; mod selection; @@ -23,7 +26,7 @@ pub(crate) use selection::GroupSelection; pub(crate) mod diagnostics; mod dispatcher; -mod proxy_group; +pub(crate) mod proxy_group; pub(crate) use dispatcher::RouteTargetDispatchers; pub use dispatcher::{ProxyDispatchers, RoutingDispatcher}; @@ -43,7 +46,7 @@ pub struct RoutingContext { network: Network, destination_port: u16, source: Option, - normalized_domain: Option>, + normalized_domain: Option>, destination_ip: Option, pinned_ip: Option, ip_resolution_unavailable: bool, @@ -52,7 +55,7 @@ pub struct RoutingContext { impl RoutingContext { pub(crate) fn set_sniffed_domain(&mut self, host: &str) { if let Ok(host) = normalize_domain_name(host) { - self.normalized_domain = Some(host.into_boxed_str()); + self.normalized_domain = Some(Arc::from(host)); } } @@ -72,7 +75,7 @@ impl RoutingContext { ) -> Result { let (normalized_domain, destination_ip) = match destination { Destination::Domain { host, .. } => { - (Some(normalize_domain_name(host)?.into_boxed_str()), None) + (Some(Arc::from(normalize_domain_name(host)?)), None) } Destination::Ip(address) => ( domain_hint @@ -82,7 +85,7 @@ impl RoutingContext { // contain `_`), so an unusable hint must not turn the // literal IP itself into an unreachable destination. .and_then(|hint| normalize_domain_name(hint).ok()) - .map(String::into_boxed_str), + .map(Arc::from), Some(address.ip()), ), }; @@ -279,6 +282,7 @@ pub enum RuleEvaluation { #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleSetError { + InvalidProgram, TooManyRules { count: usize, maximum: usize, @@ -308,6 +312,7 @@ pub enum RuleSetError { impl fmt::Display for RuleSetError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::InvalidProgram => formatter.write_str("invalid sub-rule program graph"), Self::TooManyRules { count, maximum } => { write!(formatter, "routing rule count {count} exceeds {maximum}") } @@ -353,30 +358,48 @@ impl std::error::Error for RuleSetError {} /// An immutable ordered rule set. #[derive(Debug, Clone)] pub struct RuleSet { - rules: Box<[RuleSpec]>, - uses_domain_routing: bool, + graph: Arc, filter_udp: bool, } impl RuleSet { /// Validates and normalizes the ordered rules. This is the only rule-set /// construction path that allocates. - pub fn compile(mut rules: Vec) -> Result { - if rules.len() > MAX_ROUTING_RULES { + pub fn compile(program: impl Into) -> Result { + let mut program = program.into(); + let count = program + .sub_rules + .iter() + .fold(program.main.len(), |count, sub| { + count.saturating_add(sub.rules.len()) + }); + if count > MAX_ROUTING_RULES { return Err(RuleSetError::TooManyRules { - count: rules.len(), + count, maximum: MAX_ROUTING_RULES, }); } let mut text_bytes = 0; let mut uses_domain_routing = false; - for (index, rule) in rules.iter_mut().enumerate() { + for sub in &program.sub_rules { + text_bytes = checked_text_bytes(text_bytes, sub.name.len())?; + } + for (index, rule) in program + .main + .iter_mut() + .chain(program.sub_rules.iter_mut().flat_map(|sub| &mut sub.rules)) + .enumerate() + { normalize_rule(rule, index, 0, &mut text_bytes, &mut uses_domain_routing)?; } - + crate::config::rules::validate_subrule_calls(&program.main, &program.sub_rules) + .map_err(|_| RuleSetError::InvalidProgram)?; Ok(Self { - rules: rules.into_boxed_slice(), - uses_domain_routing, + graph: Arc::new(CompiledProgram { + main: program.main.into_boxed_slice(), + sub_rules: program.sub_rules.into_boxed_slice(), + uses_domain_routing, + }), filter_udp: true, }) } @@ -390,19 +413,23 @@ impl RuleSet { #[must_use] pub fn len(&self) -> usize { - self.rules.len() + self.graph.main.len() } #[must_use] pub fn is_empty(&self) -> bool { - self.rules.is_empty() + self.graph.main.is_empty() + } + + pub(crate) const fn filters_udp(&self) -> bool { + self.filter_udp } /// Whether an IP-based TUN flow can benefit from a sniffed or DNS-derived /// domain routing hint. #[must_use] - pub const fn uses_domain_routing(&self) -> bool { - self.uses_domain_routing + pub fn uses_domain_routing(&self) -> bool { + self.graph.uses_domain_routing } /// Exposes one immutable compiled rule so the asynchronous router can @@ -410,7 +437,7 @@ impl RuleSet { /// [`RuleEvaluation::NeedsIpResolution`]. #[must_use] pub fn rule(&self, index: usize) -> Option<&RuleSpec> { - self.rules.get(index) + self.graph.main.get(index) } /// Evaluates with no loaded GeoData. GEOSITE and GEOIP never match. @@ -433,26 +460,33 @@ impl RuleSet { pub(crate) fn evaluate_with_egress( &self, context: &RoutingContext, - geo_matcher: &dyn GeoMatcher, + geo: &dyn GeoMatcher, supports_udp: impl Fn(RuleAction) -> bool, ) -> RuleEvaluation { - for (index, rule) in self.rules.iter().enumerate() { - let matched = match matches_rule(rule, context, geo_matcher) { - Ok(matched) => matched, - Err(()) => return RuleEvaluation::NeedsIpResolution { rule_index: index }, - }; + self.evaluate_program( + &mut context.clone(), + geo, + None, + None, + |action, candidate| { + if self.accepts_egress(candidate, action, &supports_udp) { + RuleTargetEffect::Accept + } else { + RuleTargetEffect::Skip + } + }, + ) + .into_public() + } - if matched && self.accepts_egress(context, rule.action, &supports_udp) { - return RuleEvaluation::Matched(RuleMatch { - action: rule.action, - rule_index: index, - }); - } - } - RuleEvaluation::Matched(RuleMatch { - action: RuleAction::Direct, - rule_index: self.rules.len(), - }) + pub(crate) fn all_rules(&self) -> impl Iterator { + self.graph + .main + .iter() + .chain(self.graph.sub_rules.iter().flat_map(|sub| &sub.rules)) + } + pub(crate) fn subrule_name(&self, id: crate::config::SubRuleId) -> &str { + &self.graph.sub_rules[id.index()].name } /// Continues evaluation at the IP rule which requested a bounded @@ -490,42 +524,17 @@ impl RuleSet { pub(crate) fn evaluate_with_resolved_egress( &self, context: &mut RoutingContext, - geo_matcher: &dyn GeoMatcher, - resolution_rule_index: usize, + geo: &dyn GeoMatcher, + index: usize, addresses: &[IpAddr], supports_udp: impl Fn(RuleAction) -> bool, ) -> RuleEvaluation { - for (index, rule) in self.rules.iter().enumerate().skip(resolution_rule_index) { - let mut selected_address = None; - let matched = if context.effective_ip().is_none() - && uses_destination_ip(rule) - && !addresses.is_empty() - { - selected_address = preferred_addresses(addresses).find(|address| { - let mut candidate = context.clone(); - candidate.pin_ip(*address); - matches_rule(rule, &candidate, geo_matcher).unwrap_or(false) - }); - selected_address.is_some() + self.flat_resolved(context, geo, index, addresses, |action, candidate| { + if self.accepts_egress(candidate, action, &supports_udp) { + RuleTargetEffect::Accept } else { - let mut candidate = context.clone(); - candidate.mark_ip_resolution_unavailable(); - matches_rule(rule, &candidate, geo_matcher).unwrap_or(false) - }; - - if matched && self.accepts_egress(context, rule.action, &supports_udp) { - if let Some(address) = selected_address { - context.pin_ip(address); - } - return RuleEvaluation::Matched(RuleMatch { - action: rule.action, - rule_index: index, - }); + RuleTargetEffect::Skip } - } - RuleEvaluation::Matched(RuleMatch { - action: RuleAction::Direct, - rule_index: self.rules.len(), }) } @@ -605,6 +614,9 @@ fn normalize_rule( } } RuleKind::Not(child) => normalize_rule(child, index, depth + 1, bytes, domain)?, + RuleKind::Flow { predicate, .. } | RuleKind::SubRule { predicate, .. } => { + normalize_rule(predicate, index, depth, bytes, domain)? + } RuleKind::Network(_) | RuleKind::Match => {} } Ok(()) @@ -617,7 +629,13 @@ pub(crate) fn uses_destination_ip(rule: &RuleSpec) -> bool { RuleKind::And(children) | RuleKind::Or(children) => { children.iter().any(uses_destination_ip) } - RuleKind::Not(child) => uses_destination_ip(child), + RuleKind::Not(child) + | RuleKind::Flow { + predicate: child, .. + } + | RuleKind::SubRule { + predicate: child, .. + } => uses_destination_ip(child), _ => false, } } @@ -704,6 +722,9 @@ pub(crate) fn matches_rule( } RuleKind::Not(child) => !matches_rule(child, context, geo)?, RuleKind::Match => true, + RuleKind::Flow { predicate, .. } | RuleKind::SubRule { predicate, .. } => { + matches_rule(predicate, context, geo)? + } }) } @@ -842,10 +863,10 @@ mod tests { ]) .unwrap(); assert_eq!( - rules.rules[0].kind, + rules.graph.main[0].kind, RuleKind::GeoSite("!google@ads@cn".to_owned()) ); - assert_eq!(rules.rules[1].kind, RuleKind::GeoIp("!cn".to_owned())); + assert_eq!(rules.graph.main[1].kind, RuleKind::GeoIp("!cn".to_owned())); assert!( RuleSet::compile(vec![ spec(RuleKind::GeoIp("cn@ads".to_owned()), RuleAction::Direct), diff --git a/src/routing/program.rs b/src/routing/program.rs new file mode 100644 index 0000000..a090901 --- /dev/null +++ b/src/routing/program.rs @@ -0,0 +1,346 @@ +//! Immutable programs and bounded resumable rule traversal. +use std::net::IpAddr; + +use super::{ + GeoMatcher, RoutingContext, RuleEvaluation, RuleMatch, RuleSet, matches_rule, + preferred_addresses, uses_destination_ip, +}; +use crate::config::{NamedRuleProgram, RuleAction, RuleControl, RuleKind, RuleSpec}; + +#[derive(Debug)] +pub(super) struct CompiledProgram { + pub main: Box<[RuleSpec]>, + pub sub_rules: Box<[NamedRuleProgram]>, + pub uses_domain_routing: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Frame { + program: usize, + index: usize, + address: Option, +} + +/// Pauses only the current predicate; completed controls and groups are not replayed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RuleCursor { + frames: [Frame; 33], + depth: usize, +} + +impl RuleCursor { + fn new(index: usize) -> Self { + let frame = Frame { + program: 0, + index: 0, + address: None, + }; + let mut frames = [frame; 33]; + frames[0].index = index; + Self { frames, depth: 0 } + } + pub(crate) fn root_index(&self) -> usize { + self.frames[0].index + } +} + +// The fixed cursor is inline; ordinary decisions do not allocate a call stack. +#[allow(clippy::large_enum_variant)] +pub(crate) enum ProgramEvaluation { + Matched(RuleMatch), + NeedsIpResolution(RuleCursor), +} + +impl ProgramEvaluation { + pub(crate) fn into_public(self) -> RuleEvaluation { + match self { + Self::Matched(matched) => RuleEvaluation::Matched(matched), + Self::NeedsIpResolution(cursor) => RuleEvaluation::NeedsIpResolution { + rule_index: cursor.root_index(), + }, + } + } +} + +pub(crate) enum RuleTargetEffect { + Accept, + Skip, + Control(RuleControl), +} + +impl RuleSet { + pub(crate) fn evaluate_program( + &self, + context: &mut RoutingContext, + geo: &dyn GeoMatcher, + cursor: Option, + addresses: Option<&[IpAddr]>, + mut effect: impl FnMut(RuleAction, &RoutingContext) -> RuleTargetEffect, + ) -> ProgramEvaluation { + let mut cursor = cursor.unwrap_or_else(|| RuleCursor::new(0)); + loop { + let frame = cursor.frames[cursor.depth]; + let rules = if frame.program == 0 { + &self.graph.main[..] + } else { + &self.graph.sub_rules[frame.program - 1].rules[..] + }; + let Some(rule) = rules.get(frame.index) else { + if cursor.depth == 0 { + return ProgramEvaluation::Matched(RuleMatch { + action: RuleAction::Direct, + rule_index: self.len(), + }); + } + cursor.depth -= 1; + cursor.frames[cursor.depth].index += 1; + continue; + }; + let predicate = match &rule.kind { + RuleKind::Flow { predicate, .. } | RuleKind::SubRule { predicate, .. } => predicate, + _ => rule, + }; + let mut candidate = context.clone(); + if let Some(address) = frame.address { + candidate.pin_ip(address); + } + let matched = if candidate.effective_ip().is_none() + && uses_destination_ip(predicate) + && let Some(addresses) = addresses + && !addresses.is_empty() + { + let found = preferred_addresses(addresses).find(|address| { + candidate.pin_ip(*address); + matches_rule(predicate, &candidate, geo).unwrap_or(false) + }); + if let Some(address) = found { + candidate.pin_ip(address); + true + } else { + false + } + } else { + if addresses.is_some() { + candidate.mark_ip_resolution_unavailable(); + } + match matches_rule(predicate, &candidate, geo) { + Ok(matched) => matched, + Err(()) => return ProgramEvaluation::NeedsIpResolution(cursor), + } + }; + if !matched { + cursor.frames[cursor.depth].index += 1; + continue; + } + let effect = match &rule.kind { + RuleKind::Flow { control, .. } => RuleTargetEffect::Control(*control), + RuleKind::SubRule { target, .. } => { + cursor.depth += 1; + cursor.frames[cursor.depth] = Frame { + program: target.index() + 1, + index: 0, + address: candidate.pinned_ip(), + }; + continue; + } + _ => effect(rule.action, &candidate), + }; + match effect { + RuleTargetEffect::Control(RuleControl::Pass) => { + cursor.depth = 0; + cursor.frames[0].index += 1; + } + RuleTargetEffect::Control(RuleControl::PassRule) if cursor.depth == 0 => { + return ProgramEvaluation::Matched(RuleMatch { + action: RuleAction::Reject, + rule_index: cursor.root_index(), + }); + } + RuleTargetEffect::Skip | RuleTargetEffect::Control(RuleControl::PassRule) => { + cursor.frames[cursor.depth].index += 1 + } + RuleTargetEffect::Accept => { + if let Some(address) = candidate.pinned_ip() { + context.pin_ip(address); + } + return ProgramEvaluation::Matched(RuleMatch { + action: rule.action, + rule_index: cursor.root_index(), + }); + } + } + } + } + + pub(super) fn flat_resolved( + &self, + context: &mut RoutingContext, + geo: &dyn GeoMatcher, + index: usize, + addresses: &[IpAddr], + effect: impl FnMut(RuleAction, &RoutingContext) -> RuleTargetEffect, + ) -> RuleEvaluation { + self.evaluate_program( + context, + geo, + Some(RuleCursor::new(index)), + Some(addresses), + effect, + ) + .into_public() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::{Config, Network, RuntimeRuleProgram}, + routing::EmptyGeoMatcher, + session::Destination, + }; + + fn compile(yaml: &str) -> RuleSet { + let mut config = + Config::parse_yaml(format!("mixed-port: 1080\n{yaml}").as_bytes()).unwrap(); + RuleSet::compile_config(&mut config).unwrap() + } + fn context() -> RoutingContext { + RoutingContext::new( + Network::Tcp, + &Destination::domain("example.com", 443).unwrap(), + ) + .unwrap() + } + + #[test] + fn subrule_nested_calls_and_flow_controls_preserve_root_position() { + for (inner, outer_tail, expected, index) in [ + ("['DOMAIN,example.com,REJECT']", "", RuleAction::Reject, 0), + ("[]", ", 'MATCH,REJECT'", RuleAction::Reject, 0), + ("['MATCH,PASS']", ", 'MATCH,REJECT'", RuleAction::Direct, 1), + ( + "['MATCH,PASS-RULE','MATCH,REJECT']", + "", + RuleAction::Reject, + 0, + ), + ] { + let rules = compile(&format!( + "rules: ['SUB-RULE,(NETWORK,TCP),outer', 'MATCH,DIRECT']\nsub-rules:\n outer: ['SUB-RULE,(DOMAIN,example.com),inner'{outer_tail}]\n inner: {inner}\n" + )); + assert_eq!( + rules.evaluate(&context()), + RuleEvaluation::Matched(RuleMatch { + action: expected, + rule_index: index + }) + ); + assert!(std::sync::Arc::ptr_eq(&rules.graph, &rules.clone().graph)); + } + assert_eq!( + compile("rules: ['MATCH,PASS-RULE']\n").evaluate(&context()), + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Reject, + rule_index: 0 + }) + ); + } + + #[test] + fn subrule_graph_validation_covers_references_and_combined_budget() { + for yaml in [ + "rules: ['SUB-RULE,(NETWORK,TCP),missing']\n", + "sub-rules: {a: ['SUB-RULE,(NETWORK,TCP),b'], b: ['SUB-RULE,(NETWORK,TCP),a']}\n", + ] { + assert!(Config::parse_yaml(format!("mixed-port: 1080\n{yaml}").as_bytes()).is_err()); + } + for depth in [32, 33] { + let definitions = (0..depth) + .map(|index| { + let rule = if index + 1 == depth { + "MATCH,DIRECT".to_owned() + } else { + format!("SUB-RULE,(NETWORK,TCP),p{}", index + 1) + }; + format!(" p{index}: ['{rule}']\n") + }) + .collect::(); + let parsed = Config::parse_yaml(format!("mixed-port: 1080\nrules: ['SUB-RULE,(NETWORK,TCP),p0']\nsub-rules:\n{definitions}").as_bytes()); + assert_eq!(parsed.is_ok(), depth == 32); + if let Ok(mut config) = parsed { + assert_eq!( + RuleSet::compile_config(&mut config) + .unwrap() + .evaluate(&context()), + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Direct, + rule_index: 0 + }) + ); + } + } + let rules = compile("rules: ['MATCH,DIRECT']\nsub-rules: {one: ['MATCH,DIRECT']}\n"); + let program = RuntimeRuleProgram { + main: vec![rules.graph.main[0].clone(); crate::config::MAX_RULES], + sub_rules: rules.graph.sub_rules.to_vec(), + }; + assert!(matches!( + RuleSet::compile(program), + Err(super::super::RuleSetError::TooManyRules { .. }) + )); + } + + #[test] + fn subrule_dns_cursor_does_not_replay_controls_or_mix_candidate_ips() { + let rules = compile( + "rules: ['SUB-RULE,(NETWORK,TCP),child', 'MATCH,REJECT']\nsub-rules: {child: ['NETWORK,TCP,DIRECT', 'IP-CIDR,192.0.2.0/24,DIRECT']}\n", + ); + let mut context = context(); + let mut controls = 0; + let cursor = + match rules.evaluate_program(&mut context, &EmptyGeoMatcher, None, None, |_, _| { + controls += 1; + RuleTargetEffect::Control(RuleControl::PassRule) + }) { + ProgramEvaluation::NeedsIpResolution(cursor) => cursor, + _ => panic!("expected a child DNS pause"), + }; + let addresses = ["192.0.2.7".parse().unwrap()]; + let result = rules.evaluate_program( + &mut context, + &EmptyGeoMatcher, + Some(cursor), + Some(&addresses), + |_, _| RuleTargetEffect::Accept, + ); + assert_eq!(controls, 1); + assert_eq!( + result.into_public(), + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Direct, + rule_index: 0 + }) + ); + assert_eq!(context.pinned_ip(), Some(addresses[0])); + let rules = compile( + "rules: ['SUB-RULE,(IP-CIDR,192.0.2.0/24),child', 'MATCH,REJECT']\nsub-rules: {child: ['IP-CIDR,198.51.100.0/24,DIRECT']}\n", + ); + let mut context = self::context(); + let result = rules.evaluate_program( + &mut context, + &EmptyGeoMatcher, + None, + Some(&[addresses[0], "198.51.100.7".parse().unwrap()]), + |_, _| RuleTargetEffect::Accept, + ); + assert_eq!( + result.into_public(), + RuleEvaluation::Matched(RuleMatch { + action: RuleAction::Reject, + rule_index: 1 + }) + ); + assert_eq!(context.pinned_ip(), None); + } +} diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index 69f5565..50d80f4 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -59,6 +59,7 @@ struct ProxyGroupMember { target: ProxyGroupMemberTarget, } +#[derive(Clone)] pub(crate) struct ResolvedProxyGroupLeaf { pub(crate) chain: Vec, pub(crate) selections: Vec>, @@ -66,6 +67,12 @@ pub(crate) struct ResolvedProxyGroupLeaf { pub(crate) direct: bool, } +pub(crate) enum InspectedRuleTarget { + Control(crate::config::RuleControl), + Egress(ResolvedProxyGroupLeaf), + Unavailable, +} + impl ResolvedProxyGroupLeaf { pub(crate) fn report(&self, result: &Result) { if !self.direct && !matches!(result, Err(DispatchError::NotAllowed)) { @@ -306,6 +313,7 @@ impl ProxyGroups { direct: true, }); } + ProxyGroupMemberTarget::Control(_) => return Err(DispatchError::NotAllowed), ProxyGroupMemberTarget::Reject => { chain.push("REJECT".into()); chain.reverse(); @@ -323,6 +331,79 @@ impl ProxyGroups { )) } + /// The validated DAG gives group locks one traversal order. No lock is held + /// across IO; controls and ineligible UDP leaves never advance round robin. + pub(crate) fn inspect_rule_target( + &self, + mut group_id: ProxyGroupId, + udp: bool, + commit: bool, + source: Option, + destination: Option<&crate::session::Destination>, + ) -> Result { + let mut inspected = Vec::new(); + let mut chain = Vec::new(); + let mut selections = Vec::new(); + let mut disabled = false; + for _ in 0..=self.groups.len() { + let group = self + .groups + .get(group_id.index()) + .ok_or(DispatchError::NotAllowed)?; + // Defend the public constructor as well as the validated config path. + if selections + .iter() + .any(|selection| Arc::ptr_eq(selection, &group.selected)) + { + return Err(DispatchError::NotAllowed); + } + let selected = group.selected.inspect(source, destination); + let member = group + .members + .get(selected.index) + .ok_or(DispatchError::NotAllowed)?; + inspected.push(selected); + selections.push(group.selected.clone()); + chain.push(group.name.clone()); + disabled |= group.disable_udp; + let (dispatcher, direct) = match member.target { + ProxyGroupMemberTarget::Control(control) => { + return Ok(InspectedRuleTarget::Control(control)); + } + ProxyGroupMemberTarget::Route(RouteTargetId::Group(id)) => { + group_id = id; + continue; + } + ProxyGroupMemberTarget::Route(RouteTargetId::Proxy(id)) => ( + self.proxies + .get(id.index()) + .ok_or(DispatchError::NotAllowed)? + .clone(), + false, + ), + ProxyGroupMemberTarget::Direct => (self.direct.clone(), true), + ProxyGroupMemberTarget::Reject => (self.reject.clone(), false), + }; + if udp && (disabled || !dispatcher.supports_udp()) { + return Ok(InspectedRuleTarget::Unavailable); + } + if commit { + for selected in inspected { + selected.commit(); + } + } + chain.push(member.name.clone()); + chain.reverse(); + return Ok(InspectedRuleTarget::Egress(ResolvedProxyGroupLeaf { + chain, + selections, + dispatcher, + direct, + })); + } + Err(DispatchError::NotAllowed) + } + pub(crate) fn has_balance(&self) -> bool { self.groups .iter() @@ -455,6 +536,7 @@ impl Dispatcher for SelectProxyGroupDispatcher { } ProxyGroupMemberTarget::Route(RouteTargetId::Group(nested)) => id = nested, ProxyGroupMemberTarget::Direct | ProxyGroupMemberTarget::Reject => return true, + ProxyGroupMemberTarget::Control(_) => return false, } } false diff --git a/src/routing/selection.rs b/src/routing/selection.rs index 2d9f546..35eb801 100644 --- a/src/routing/selection.rs +++ b/src/routing/selection.rs @@ -1,6 +1,6 @@ //! Selection and probe activity shared by route and upstream groups. use std::sync::{ - Mutex, + Mutex, MutexGuard, atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}, }; use std::time::Duration; @@ -16,6 +16,19 @@ pub(crate) struct GroupSelection { maximum: AtomicU32, window_ms: AtomicU64, } +pub(super) struct InspectedSelection<'a> { + owner: &'a GroupSelection, + balance: MutexGuard<'a, Option>, + pub index: usize, +} +impl InspectedSelection<'_> { + pub fn commit(mut self) { + if let Some(balance) = self.balance.as_mut() { + balance.commit(self.index); + } + self.owner.touch(); + } +} impl GroupSelection { pub fn new(index: usize) -> Self { let now = Instant::now(); @@ -52,6 +65,22 @@ impl GroupSelection { |s| s.choose(source, destination), ) } + pub(super) fn inspect( + &self, + source: Option, + destination: Option<&crate::session::Destination>, + ) -> InspectedSelection<'_> { + let mut balance = self.balance.lock().unwrap(); + let index = balance.as_mut().map_or_else( + || self.load(Ordering::Acquire), + |state| state.inspect(source, destination, false), + ); + InspectedSelection { + owner: self, + balance, + index, + } + } pub fn balance_health(&self, samples: &[Option]) { if let Some(balance) = self.balance.lock().unwrap().as_mut() { balance.health(samples); diff --git a/src/runtime.rs b/src/runtime.rs index 9687209..248f93d 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -941,6 +941,7 @@ fn build_proxy_graph( } ProxyGroupMemberTarget::Direct => SelectUpstreamMember::Direct, ProxyGroupMemberTarget::Reject => SelectUpstreamMember::Reject, + ProxyGroupMemberTarget::Control(_) => SelectUpstreamMember::Control, }) .collect(); let group = Arc::new( diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 5e99c30..6bfcfa8 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -88,11 +88,8 @@ impl ProviderRuntime { dialer: Dialer, ) -> Arc { let (requests, receiver) = mpsc::channel(16); - let mut configured = prepared.config.clone(); - configured.rules = (0..prepared.rules.len()) - .filter_map(|i| prepared.rules.rule(i).cloned()) - .collect(); - let requirements = GeoRequirements::for_config(&configured).expect("prepared requirements"); + let requirements = GeoRequirements::for_rules(&prepared.config, prepared.rules.all_rules()) + .expect("prepared requirements"); let root = prepared .geodata_manager .store_dir() @@ -283,8 +280,7 @@ impl ProviderRuntime { let mut config = template.materialize(&contents).map_err(io::Error::other)?; let requirements = GeoRequirements::for_config(&config).map_err(io::Error::other)?; - let rules = - RuleSet::compile(config.take_runtime_rules()).map_err(io::Error::other)?; + let rules = RuleSet::compile_config(&mut config).map_err(io::Error::other)?; let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; let parts = GraphBuilder { connections, diff --git a/tests/README.md b/tests/README.md index f2547ca..3addeb2 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,6 +25,7 @@ cargo test --locked --all-features --lib routing::datagram_ready::tests:: cargo test --locked --all-features --lib polled_datagram_ cargo test --locked --all-features --lib managed_udp_connections_ cargo test --locked --all-features --lib runtime::tests::controller_diagnostics_are_opt_in_for_business_routes +cargo test --locked --all-features --lib subrule_ cargo test --locked --all-features --lib routing::balance::tests:: cargo test --locked --all-features --lib load_balance_routes_ cargo test --locked --all-features --lib dialer::connections::tests:: From 50dad49d5bdedab20d8e129da4e991accf33af1a Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 12:07:34 +0800 Subject: [PATCH 033/445] Add authenticated inbound and source-aware routing rules --- .github/workflows/test.yml | 1 + docs/config.yaml | 13 ++- docs/geodata.md | 9 +- docs/inbounds.md | 15 +++ docs/tun-icmp-dns.md | 9 ++ src/config/credentials.rs | 35 ++++--- src/config/groups.rs | 9 ++ src/config/mod.rs | 137 +++++++++++++++++++------- src/config/providers.rs | 2 +- src/config/rule_provider.rs | 57 ++++++++++- src/config/rules.rs | 154 ++++++++++++++++++++++++++++-- src/controller.rs | 1 + src/dispatch.rs | 1 + src/dns/runtime.rs | 4 + src/geodata/mod.rs | 42 +++++++- src/geodata/tests.rs | 67 +++++++++++++ src/inbound/http/server.rs | 43 ++++++--- src/inbound/mixed.rs | 1 + src/inbound/mixed/tests.rs | 123 +++++++++++++++++++++++- src/inbound/socks5/association.rs | 50 +++++++++- src/inbound/socks5/handshake.rs | 23 ++++- src/inbound/socks5/server.rs | 21 +++- src/outbound/anytls/client.rs | 1 + src/outbound/anytls/mod.rs | 1 + src/outbound/connector.rs | 114 +++++++++++++++++++++- src/outbound/direct.rs | 1 + src/outbound/shadowsocks/tests.rs | 2 + src/outbound/shadowsocks/uot.rs | 1 + src/outbound/socks5.rs | 2 + src/outbound/trojan/outbound.rs | 1 + src/outbound/vless/outbound.rs | 2 + src/outbound/vmess/outbound.rs | 1 + src/resource/http.rs | 1 + src/resource/probe.rs | 1 + src/routing/balance.rs | 96 +++++++++++++++---- src/routing/diagnostics.rs | 14 ++- src/routing/dispatcher.rs | 122 +++++++++++++++++++++-- src/routing/mod.rs | 109 +++++++++++++++++---- src/routing/proxy_group.rs | 20 +++- src/routing/selection.rs | 22 +++-- src/runtime.rs | 1 + src/runtime_group_tests.rs | 1 + src/session.rs | 38 +++++++- src/sniffing.rs | 1 + src/tun_runtime.rs | 1 + src/tun_runtime/tests.rs | 2 + tests/README.md | 1 + tests/resolution_foundations.rs | 1 + tests/shadowsocks_backpressure.rs | 2 + tests/sing_mux.rs | 1 + tests/tuic_memory.rs | 1 + tests/vless_codec.rs | 1 + tests/vless_lifecycle.rs | 4 +- tests/vless_transports.rs | 1 + tests/vmess_lifecycle.rs | 2 + tests/xhttp_budget.rs | 1 + tests/xhttp_h3_shutdown.rs | 1 + 57 files changed, 1227 insertions(+), 161 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 32fd898..b2b6224 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -206,6 +206,7 @@ jobs: dialer::tests::socket_initialization_cancellation_joins_started_and_queued_work dialer::tests::socket_initialization_shared_pool_contention_reclaims_cancelled_work outbound::connector::tests::authenticated_continuation_keeps_group_choice_but_has_a_new_io_deadline + outbound::connector::tests::ingress_connector_dispatcher_preserves_user_for_dialer_proxy_and_continuations runtime::tests::integration_feature_admission_is_explicit_without_opening_sockets invoke::tests::version_and_state_use_the_fixed_response_envelope invoke::tests::initialize_is_idempotent_only_for_the_same_data_directory diff --git a/docs/config.yaml b/docs/config.yaml index c3c8a7d..b85394b 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -471,6 +471,8 @@ proxies: # load-balance 的 strategy 为 consistent-hashing(缺省)、round-robin、sticky-sessions。 # 一致性哈希按目标可注册主域名或 IP 分桶;轮询跳过不健康成员;粘滞会话按来源 IP # 与目标主域名/IP 缓存 10 分钟,最多 1000 项。哈希种子属于当前进程,不承诺重启后相同。 +# hash-key 仅接受空字符串(缺省)或 in-user;in-user 仅用于一致性哈希/粘滞策略, +# 使用已认证用户名单独分桶,无用户名时回退地址策略。业务路由和 dialer-proxy 都保留此身份。 # load-balance 没有 now/手动选择;UDP 按目标会话固定叶节点,健康变化不迁移旧会话。 # 自动或手动切换只影响新连接;既有连接、认证会话及其中复用的流仍保留原路径。 # 组作为 dialer-proxy 时,与路由共用当前选择,但不重新执行业务规则;DIRECT 连接 @@ -611,8 +613,14 @@ dns: # rules 可省略或为空;rule 模式按顺序首条命中,未命中 DIRECT。MATCH 可放在任意 # 位置,普通目标命中后其后规则不再执行。DIRECT、REJECT 是内置出口,其他出口精确引用节点或组。 # 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 -# GEOSITE、GEOIP、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 -# RULE-SET、SUB-RULE、AND/OR/NOT 和 MATCH。SRC-IP-CIDR 支持 IPv4/IPv6,来源信息来自实际入站会话。 +# GEOSITE、GEOIP、SRC-GEOIP、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 +# IN-TYPE、IN-USER、IN-PORT、RULE-SET、SUB-RULE、AND/OR/NOT 和 MATCH。来源信息来自实际入站会话。 +# CIDR 两种别名都按 payload 实际地址族解析;GEOIP/CIDR/RULE-SET 可追加 src,自动禁止 DNS。 +# RULE-SET 的 src 在局部视图交换源/目标 IP 与端口,保留域名、入口类型和身份。 +# IN-TYPE 为 / 分隔的 Mihomo 类型名,大小写无关,SOCKS 展开为 SOCKS4/SOCKS5; +# IN-USER 为 / 分隔、区分大小写的已认证用户名。两种列表逐项 trim,空项报错。 +# IN-PORT/DST-PORT/SRC-PORT 为 uint16 端口或范围,/ 分隔,含 0,逆序范围归一, +# 最多 28 段;分隔中的空段跳过。真实入口和服务器端口仍须非零。 # 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 忽略大小写,支持 regex crate # 子集,不支持 lookaround/backreference,无法编译的模式明确报错。 # 逻辑规则例如 AND,((DOMAIN-SUFFIX,example.com),(SRC-PORT,1000-2000)),DIRECT; @@ -624,6 +632,7 @@ dns: # 当前子列表,出现在主列表时为 REJECT。两者不能出现在逻辑谓词或 classical 集合中。 # 组的控制目标先于 disable-udp/节点 UDP 检查;跳过不推进轮询。实际出口只选择一次。 # GEOSITE 支持 [!][@...],GEOIP 支持 [!] 和可选 no-resolve。 +# SRC-GEOIP 与 GEOIP,...,src 等价,来源规则不触发 DNS;lan 为无需资产的内置 GeoIP。 # 例如 GEOSITE,google@ads,REJECT / GEOSITE,!cn@ads,main-select / GEOIP,!CN,DIRECT。 # geolocation-!cn 为字面分类名;GeoIP DAT reverse_match 接受但忽略,取反由 ! 控制。 # GEOSITE 没有域名不匹配;分类缺失不匹配,而有效空属性交集的反选匹配非空域名。 diff --git a/docs/geodata.md b/docs/geodata.md index bdf11c0..c9a5a11 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -22,20 +22,23 @@ rules: ```text GEOSITE,[!][@...], -GEOIP,[!],[,no-resolve] +GEOIP,[!],[,no-resolve][,src] +SRC-GEOIP,[!], ``` - 规则类型和 `code` 不区分 ASCII 大小写,route target 名称大小写敏感并精确匹配。 - `target` 可以是 `DIRECT`、`REJECT`、当前资源中的代理节点或已支持的代理组。 - 基础 `code` 必须匹配 `[A-Za-z0-9][A-Za-z0-9._+!-]{0,63}`,按 ASCII 大小写折叠后定位。 -- `GEOSITE` 恰好三段;`GEOIP` 只允许可选且大小写敏感的 `no-resolve`。 +- `GEOSITE` 恰好三段;`GEOIP` 接受大小写敏感的 `no-resolve`、`src`,顺序无关。 + `src` 和 `SRC-GEOIP` 使用真实来源 IP,不触发 DNS。 - `GEOSITE,cn@ads@cn,DIRECT` 只保留同时存在 `ads` 和 `cn` 属性 key 的记录,多个属性为 AND。 属性只检查 key 存在,不解释 bool/int 值;`false`、`0` 或无 typed value 均不代表 key 缺失。 - 属性名按 Mihomo 的先小写、后 Unicode simple-fold 比较语义归一;交集顺序与重复 key 不改变 结果。空 `@` 项忽略,非空属性首尾空白移除,但内部空白、控制字符和逗号不允许。 - 前置 `!` 对整个正向匹配结果取反:`!cn@ads` 是 NOT(匹配 `cn@ads`),不是“没有 ads 的记录”。 `cn@!ads` 查找字面 key `!ads`;`geolocation-!cn` 是字面分类名。 -- GeoIP 支持前置 `!`,不支持属性。业务规则不支持单条多个 code、源地址 GeoIP 或隐式局域网分类。 +- GeoIP 支持前置 `!`,不支持属性或单条多个 code。`lan` 是内置分类:私有、未指定、 + 回环、多播与链路本地单播地址;来源与目标均可使用,不读取 GeoIP 资产。 - rule 模式按顺序首条命中,未命中 DIRECT;可选的 `MATCH` 使用 `DIRECT`、`REJECT`、实际代理节点或代理组。 DNS `nameserver-policy` 中的 `geosite:[,...]` 接受相同的属性与反选语义, diff --git a/docs/inbounds.md b/docs/inbounds.md index 458aef4..e4177a7 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -29,6 +29,21 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 - 不设置全局业务连接/关联准入数。Stop 取消并等待全部连接、relay 和接收任务, 返回后端口可重新绑定。 +## 选路元数据 + +`IN-TYPE` 使用实际入口类型:普通 HTTP/Upgrade 为 HTTP,CONNECT 为 HTTPS, +SOCKS5 为 SOCKS5;HTTPS 类型只表示 CONNECT,不检查目标 TLS。`IN-PORT` 读取 +接收连接或授权 UDP socket 的真实本地端口。 + +`IN-USER` 与 load-balance 的 `hash-key: in-user` 只使用验证成功的配置用户名。 +HTTP 每个请求重新验证,SOCKS TCP 和 UDP 授权关联保留握手身份。无认证或 +`skip-auth-prefixes` 旁路时身份为空,即使请求声称一个 Proxy-Authorization 用户。 +身份随请求/关联保存,只持有用户名,不保存密码、请求头或完整配置代际;Debug 不输出用户名。 +业务路由和 dialer-proxy 共用该身份,内部 DNS、资源下载及主动测速没有入口用户。 + +Mihomo 当前实现不把 SOCKS TCP 身份传给独立 UDP 接收;Vole 的授权 UDP 保留 +真实握手身份。Mihomo HTTP 在无认证时也可能记录请求声称的用户,Vole 保持为空。 + ## HTTP 每个请求独立认证,缺失、错误、重复或非法 Proxy-Authorization 返回 407 并关闭, diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index fa00f21..3ca4c41 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -300,3 +300,12 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 自动化和物理 TUN 覆盖范围见 [验收矩阵](acceptance.md)。 + +## 入站规则元数据 + +TUN TCP 的 `IN-TYPE` 为 TUN,`IN-PORT` 为虚拟连接的原始目标端口;TUN UDP +没有本地监听端口,`IN-PORT` 为 0。两者没有认证用户,来源规则读取原始来源 IP/端口。 +这与 Mihomo 默认 MIPS 栈一致;其他栈的内部 TCP listener 端口可能不同。 + +Core 的 DNS、GeoData 下载及测速入口均映射为 INNER,端口为 0、用户为空。 +DNS `respect-rules` 使用同一个规则求值器,但不继承触发解析的业务用户。 diff --git a/src/config/credentials.rs b/src/config/credentials.rs index dc6b97a..ab77efc 100644 --- a/src/config/credentials.rs +++ b/src/config/credentials.rs @@ -1,11 +1,11 @@ -use std::{io, net::IpAddr}; +use std::{io, net::IpAddr, sync::Arc}; use base64::{Engine as _, engine::general_purpose::STANDARD}; /// Client-proxy credentials shared by the HTTP and SOCKS5 frontends. #[derive(Clone, PartialEq, Eq)] pub struct ProxyCredentials { - entries: Vec<(String, String)>, + entries: Vec<(Arc, String)>, } impl std::fmt::Debug for ProxyCredentials { @@ -35,13 +35,17 @@ impl ProxyCredentials { )); } Ok(Self { - entries: vec![(username, password)], + entries: vec![(Arc::from(username), password)], }) } #[must_use] pub fn matches(&self, username: &[u8], password: &[u8]) -> bool { - let mut matched = false; + self.authenticate(username, password).is_some() + } + + pub(crate) fn authenticate(&self, username: &[u8], password: &[u8]) -> Option> { + let mut matched = None; for (expected_user, expected_password) in &self.entries { let mut difference = username.len() ^ expected_user.len(); difference |= password.len() ^ expected_password.len(); @@ -59,7 +63,9 @@ impl ProxyCredentials { .unwrap_or(0), ); } - matched |= difference == 0; + if difference == 0 { + matched = Some(expected_user.clone()); + } } matched } @@ -81,28 +87,31 @@ impl ProxyCredentials { ) } + #[cfg(test)] pub(crate) fn verifies(&self, headers: &[(String, String)]) -> bool { + self.authenticate_headers(headers).is_some() + } + + pub(crate) fn authenticate_headers(&self, headers: &[(String, String)]) -> Option> { let mut values = headers .iter() .filter(|(name, _)| name.eq_ignore_ascii_case("proxy-authorization")) .map(|(_, value)| value.as_str()); let (Some(value), None) = (values.next(), values.next()) else { - return false; + return None; }; let mut parts = value.split_ascii_whitespace(); let (Some(scheme), Some(encoded), None) = (parts.next(), parts.next(), parts.next()) else { - return false; + return None; }; if !scheme.eq_ignore_ascii_case("basic") || encoded.len() > 684 { - return false; + return None; } let Ok(decoded) = STANDARD.decode(encoded) else { - return false; - }; - let Some(separator) = decoded.iter().position(|&byte| byte == b':') else { - return false; + return None; }; - self.matches(&decoded[..separator], &decoded[separator + 1..]) + let separator = decoded.iter().position(|&byte| byte == b':')?; + self.authenticate(&decoded[..separator], &decoded[separator + 1..]) } } diff --git a/src/config/groups.rs b/src/config/groups.rs index 7fa6a20..dd67cb7 100644 --- a/src/config/groups.rs +++ b/src/config/groups.rs @@ -98,3 +98,12 @@ pub enum BalanceStrategy { RoundRobin, StickySessions, } + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +pub enum BalanceHashKey { + #[default] + #[serde(rename = "")] + Default, + #[serde(rename = "in-user")] + InUser, +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 2d98bd3..1ebca78 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,7 +11,7 @@ pub use ip_version::IpVersion; mod groups; pub(crate) use groups::status_ranges; -pub use groups::{BalanceStrategy, GroupKind, HealthCheckConfig, ProfileConfig}; +pub use groups::{BalanceHashKey, BalanceStrategy, GroupKind, HealthCheckConfig, ProfileConfig}; use std::{ collections::{HashMap, VecDeque}, @@ -36,8 +36,8 @@ mod rule_provider; pub(crate) mod rules; pub use rule_provider::RuleCollection; mod yaml; -pub use rules::DomainRegex; use rules::parse_rule; +pub use rules::{DomainRegex, RuleInboundType}; mod credentials; pub use credentials::{ProxyAccess, ProxyAccessRules, ProxyCredentials}; @@ -477,6 +477,7 @@ pub enum RuleKind { RuleSet { name: String, content: Option>, + source: bool, }, Domain(String), DomainSuffix(String), @@ -490,6 +491,10 @@ pub enum RuleKind { SrcPorts(Vec), GeoSite(String), GeoIp(String), + SrcGeoIp(String), + InTypes(Vec), + InUsers(Vec), + InPorts(Vec), IpCidr(IpCidr), DstPorts(Vec), Network(Network), @@ -569,6 +574,7 @@ pub struct ProxyGroupMemberConfig { pub struct SelectProxyGroupConfig { pub kind: GroupKind, pub strategy: BalanceStrategy, + pub hash_key: BalanceHashKey, pub health: HealthCheckConfig, pub tolerance: u16, pub max_failed_times: u32, @@ -1103,6 +1109,8 @@ struct RawVoleConfig { struct RawProxyGroup { #[serde(default)] strategy: BalanceStrategy, + #[serde(rename = "hash-key", default)] + hash_key: BalanceHashKey, #[serde(default)] url: String, #[serde(default)] @@ -2027,6 +2035,7 @@ impl RawVoleConfig { if generated_global { raw_groups.push(RawProxyGroup { strategy: BalanceStrategy::default(), + hash_key: BalanceHashKey::default(), disable_udp: false, hidden: false, icon: String::new(), @@ -2507,6 +2516,11 @@ fn normalize_proxy_groups( let groups = raw_groups .into_iter() .map(|group| { + if group.hash_key != BalanceHashKey::Default + && (group.kind != GroupKind::LoadBalance || group.strategy == BalanceStrategy::RoundRobin) + { + return invalid("hash-key requires consistent-hashing or sticky-sessions load-balance"); + } let members = group .proxies .into_iter() @@ -2553,7 +2567,7 @@ fn normalize_proxy_groups( }; health.validate()?; Ok(SelectProxyGroupConfig { - kind: group.kind, strategy: group.strategy, health, tolerance: group.tolerance, + kind: group.kind, strategy: group.strategy, hash_key: group.hash_key, health, tolerance: group.tolerance, max_failed_times: if group.max_failed_times == 0 { 5 } else { group.max_failed_times }, name: group.name, members, @@ -3495,37 +3509,37 @@ fn parse_ip_cidr(input: &str, require_v6: bool) -> Result { } fn parse_port_ranges(input: &str) -> Result> { - if input.is_empty() { - return invalid("DST-PORT value must not be empty"); - } - input - .split('/') - .map(|item| { - if item.is_empty() { - return invalid("DST-PORT contains an empty port range"); - } - let (start, end) = item.split_once('-').unwrap_or((item, item)); - if end.contains('-') { - return invalid("DST-PORT range must contain at most one `-`"); - } - let start = parse_destination_port(start)?; - let end = parse_destination_port(end)?; - if start > end { - return invalid("DST-PORT range start must not exceed its end"); + let input = input.trim(); + if input.split('/').count() > 28 { + return invalid("port rule exceeds 28 ranges"); + } + let mut ranges = Vec::new(); + for item in input.split('/').filter(|item| !item.is_empty()) { + let item = item.trim(); + if item.is_empty() { + ranges.push(PortRange { start: 0, end: 0 }); + continue; + } + let (start, end) = item.split_once('-').unwrap_or((item, item)); + let parse = |value: &str| -> Result { + let value = value.trim_matches(['[', ']', ' ']); + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return invalid("invalid rule port"); } - Ok(PortRange { start, end }) - }) - .collect() -} - -fn parse_destination_port(input: &str) -> Result { - let port = input - .parse::() - .map_err(|_| VoleError::InvalidConfig(format!("invalid destination port `{input}`")))?; - if port == 0 { - return invalid("destination port must be between 1 and 65535"); + value + .parse() + .map_err(|_| VoleError::InvalidConfig("invalid rule port".into())) + }; + let (start, end) = (parse(start)?, parse(end)?); + ranges.push(PortRange { + start: start.min(end), + end: start.max(end), + }); + } + if ranges.is_empty() { + return invalid("port rule requires at least one range"); } - Ok(port) + Ok(ranges) } fn parse_network(input: &str) -> Result { @@ -3634,6 +3648,61 @@ mod tests { assert_eq!(config.proxy_groups[0].members[0].name, "REJECT"); } + #[test] + fn ingress_source_params_types_users_ports_and_hash_schema() { + let mut config = Config::parse_yaml( + br#" +mixed-port: 1080 +proxy-groups: [{name: pool, type: load-balance, proxies: [DIRECT], hash-key: in-user}] +rules: + - SRC-GEOIP,CN,DIRECT + - GEOIP,CN,DIRECT,no-resolve,src + - GEOIP,CN,DIRECT,src,no-resolve + - IP-CIDR,2001:db8::/32,DIRECT,src + - IP-CIDR6,192.0.2.0/24,DIRECT,src + - IN-TYPE,HTTP / https / SOCKS / TUN / INNER,DIRECT + - IN-USER, Alice / Bob ,DIRECT + - IN-PORT,0/1081-1080//65535,DIRECT +"#, + ) + .unwrap(); + assert_eq!(config.proxy_groups[0].hash_key, BalanceHashKey::InUser); + assert!(config.rules[..5].iter().all(|rule| rule.no_resolve)); + assert_eq!(config.rules[0].kind, RuleKind::SrcGeoIp("cn".into())); + assert_eq!(config.rules[1], config.rules[2]); + assert!(matches!( + config.rules[3].kind, + RuleKind::SrcIpCidr(IpCidr { + network: IpAddr::V6(_), + .. + }) + )); + assert!(matches!( + config.rules[4].kind, + RuleKind::SrcIpCidr(IpCidr { + network: IpAddr::V4(_), + .. + }) + )); + assert_eq!( + config.rules[7].kind, + RuleKind::InPorts(vec![ + PortRange { start: 0, end: 0 }, + PortRange { + start: 1080, + end: 1081 + }, + PortRange { + start: 65535, + end: 65535 + } + ]) + ); + crate::routing::RuleSet::compile(config.take_runtime_rules()).unwrap(); + // Authentication hashing has no meaning for round robin. + assert!(Config::parse_yaml(b"mixed-port: 1080\nproxy-groups: [{name: pool, type: load-balance, strategy: round-robin, hash-key: in-user, proxies: [DIRECT]}]").is_err()); + } + #[test] fn yaml_templates_feed_logical_domain_and_source_rules() { use crate::routing::{RoutingContext, RuleEvaluation, RuleSet}; @@ -6522,11 +6591,7 @@ authentication: " - GEOIP,CN@ads,DIRECT\n - MATCH,proxy", " - GEOSITE,!,DIRECT\n - MATCH,proxy", " - GEOSITE,@ads,DIRECT\n - MATCH,proxy", - " - IP-CIDR,2001:db8::/32,DIRECT\n - MATCH,proxy", - " - IP-CIDR6,192.0.2.0/24,DIRECT\n - MATCH,proxy", " - IP-CIDR,192.0.2.0/33,DIRECT\n - MATCH,proxy", - " - DST-PORT,0,REJECT\n - MATCH,proxy", - " - DST-PORT,100-10,REJECT\n - MATCH,proxy", " - NETWORK,ICMP,REJECT\n - MATCH,proxy", " - MATCH,PROXY\n - DOMAIN,example.com,DIRECT", " - MATCH,PROXY\n - MATCH,DIRECT", diff --git a/src/config/providers.rs b/src/config/providers.rs index acec72c..e298466 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -485,7 +485,7 @@ fn bind_domain_selector( fn bind_rules(rules: &mut [RuleSpec], sets: &HashMap>) -> Result<()> { for rule in rules { match &mut rule.kind { - RuleKind::RuleSet { name, content } => { + RuleKind::RuleSet { name, content, .. } => { *content = Some( sets.get(name) .ok_or_else(|| { diff --git a/src/config/rule_provider.rs b/src/config/rule_provider.rs index 72b0a32..1f05f69 100644 --- a/src/config/rule_provider.rs +++ b/src/config/rule_provider.rs @@ -156,14 +156,20 @@ impl RuleCollection { context: &RoutingContext, geo: &dyn GeoMatcher, no_resolve: bool, + source: bool, ) -> std::result::Result { match self.behavior { RuleBehavior::Domain => Ok(context .domain() .is_some_and(|name| self.domains.find(0, name).is_some())), RuleBehavior::Ipcidr => { - let Some(ip) = context.effective_ip() else { - return if !no_resolve && context.should_request_ip_resolution() { + let ip = if source { + context.source_ip() + } else { + context.effective_ip() + }; + let Some(ip) = ip else { + return if !source && !no_resolve && context.should_request_ip_resolution() { Err(()) } else { Ok(false) @@ -176,6 +182,9 @@ impl RuleCollection { } RuleBehavior::Classical => { let mut context = context.clone(); + if source { + context.swap_source_destination(); + } if no_resolve { context.disable_ip_resolution(); } @@ -197,7 +206,7 @@ impl RuleCollection { && crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) }) } else { - self.matches(&context, geo, true).unwrap_or(false) + self.matches(&context, geo, true, false).unwrap_or(false) } } pub(crate) fn matches_domain(&self, domain: &str, geo: &dyn GeoMatcher) -> bool { @@ -221,7 +230,7 @@ impl RuleCollection { ) && crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) }) } else { - self.matches(&context, geo, true).unwrap_or(false) + self.matches(&context, geo, true, false).unwrap_or(false) } } } @@ -365,6 +374,46 @@ fn parse_mrs(bytes: &[u8], behavior: RuleBehavior) -> Result { #[cfg(test)] mod tests { use super::*; + #[test] + fn ingress_source_rule_set_swaps_address_ports_but_keeps_host_and_identity() { + use crate::session::{InboundKind, InboundMetadata}; + let destination = crate::session::Destination::domain("host.example", 443).unwrap(); + let mut context = RoutingContext::new(Network::Tcp, &destination).unwrap(); + context.set_source("192.0.2.1:1234".parse().unwrap()); + context.set_inbound( + InboundKind::Https, + &InboundMetadata { + local_port: 1080, + user: Some("Alice".into()), + }, + ); + let before = context.clone(); + let geo = crate::routing::EmptyGeoMatcher; + let classical = RuleCollection::from_payload(vec![ + "AND,((IP-CIDR,192.0.2.0/24),(DST-PORT,1234),(SRC-PORT,443),(DOMAIN,host.example),(IN-TYPE,HTTPS),(IN-PORT,1080),(IN-USER,Alice))".into() + ], RuleBehavior::Classical).unwrap(); + assert_eq!(classical.matches(&context, &geo, true, true), Ok(true)); + assert_eq!(classical.matches(&context, &geo, true, false), Ok(false)); + // Source port remains available while the domain target has no resolved IP. + let source_ip = RuleCollection::from_payload( + vec!["SRC-IP-CIDR,203.0.113.0/24".into()], + RuleBehavior::Classical, + ) + .unwrap(); + assert_eq!(source_ip.matches(&context, &geo, true, true), Ok(false)); + context.pin_ip("203.0.113.1".parse().unwrap()); + assert_eq!(source_ip.matches(&context, &geo, true, true), Ok(true)); + context = before.clone(); + for (behavior, payload) in [ + (RuleBehavior::Domain, "host.example"), + (RuleBehavior::Ipcidr, "192.0.2.0/24"), + ] { + let set = RuleCollection::from_payload(vec![payload.into()], behavior).unwrap(); + assert_eq!(set.matches(&context, &geo, true, true), Ok(true)); + } + assert_eq!(context, before); + } + #[test] fn mrs_domain_and_ip_ranges_match_yaml_and_text() { fn header(behavior: u8) -> Vec { diff --git a/src/config/rules.rs b/src/config/rules.rs index ed55ec1..49688d5 100644 --- a/src/config/rules.rs +++ b/src/config/rules.rs @@ -28,6 +28,117 @@ impl DomainRegex { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuleInboundType { + Http, + Https, + Socks4, + Socks5, + Shadowsocks, + Snell, + Vmess, + Vless, + Redir, + Tproxy, + Trojan, + Tunnel, + Tun, + Tuic, + Hysteria2, + Anytls, + Mieru, + Sudoku, + Trusttunnel, + Shadowquic, + Inner, +} +impl RuleInboundType { + pub(crate) fn for_inbound(kind: crate::session::InboundKind) -> Self { + use crate::session::InboundKind; + match kind { + InboundKind::Http => Self::Http, + InboundKind::Https => Self::Https, + InboundKind::Socks5 => Self::Socks5, + InboundKind::Tun => Self::Tun, + InboundKind::InternalDns + | InboundKind::InternalGeoData + | InboundKind::InternalMeasure => Self::Inner, + } + } + pub(crate) fn name(self) -> &'static str { + match self { + Self::Http => "HTTP", + Self::Https => "HTTPS", + Self::Socks4 => "SOCKS4", + Self::Socks5 => "SOCKS5", + Self::Shadowsocks => "SHADOWSOCKS", + Self::Snell => "SNELL", + Self::Vmess => "VMESS", + Self::Vless => "VLESS", + Self::Redir => "REDIR", + Self::Tproxy => "TPROXY", + Self::Trojan => "TROJAN", + Self::Tunnel => "TUNNEL", + Self::Tun => "TUN", + Self::Tuic => "TUIC", + Self::Hysteria2 => "HYSTERIA2", + Self::Anytls => "ANYTLS", + Self::Mieru => "MIERU", + Self::Sudoku => "SUDOKU", + Self::Trusttunnel => "TRUSTTUNNEL", + Self::Shadowquic => "SHADOWQUIC", + Self::Inner => "INNER", + } + } +} +fn parse_in_types(payload: &str) -> Result> { + let mut types = Vec::new(); + for value in payload.split('/') { + let value = value.trim().to_ascii_uppercase(); + if value == "SOCKS" { + types.extend([RuleInboundType::Socks4, RuleInboundType::Socks5]); + continue; + } + types.push(match value.as_str() { + "HTTP" => RuleInboundType::Http, + "HTTPS" => RuleInboundType::Https, + "SOCKS4" => RuleInboundType::Socks4, + "SOCKS5" => RuleInboundType::Socks5, + "SHADOWSOCKS" => RuleInboundType::Shadowsocks, + "SNELL" => RuleInboundType::Snell, + "VMESS" => RuleInboundType::Vmess, + "VLESS" => RuleInboundType::Vless, + "REDIR" => RuleInboundType::Redir, + "TPROXY" => RuleInboundType::Tproxy, + "TROJAN" => RuleInboundType::Trojan, + "TUNNEL" => RuleInboundType::Tunnel, + "TUN" => RuleInboundType::Tun, + "TUIC" => RuleInboundType::Tuic, + "HYSTERIA2" => RuleInboundType::Hysteria2, + "ANYTLS" => RuleInboundType::Anytls, + "MIERU" => RuleInboundType::Mieru, + "SUDOKU" => RuleInboundType::Sudoku, + "TRUSTTUNNEL" => RuleInboundType::Trusttunnel, + "SHADOWQUIC" => RuleInboundType::Shadowquic, + "INNER" => RuleInboundType::Inner, + _ => return invalid("IN-TYPE requires a known Mihomo inbound type"), + }); + } + Ok(types) +} +fn parse_in_users(payload: &str) -> Result> { + payload + .split('/') + .map(|value| { + let value = value.trim(); + if value.is_empty() { + return invalid("IN-USER requires nonempty users"); + } + Ok(value.to_owned()) + }) + .collect() +} + pub(super) fn parse_rule(input: &str, targets: &RouteTargetsByName) -> Result { parse(input, Some(targets), 0) } @@ -219,7 +330,7 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res no_resolve: false, }); } - let (payload, action, no_resolve) = + let (payload, action, no_resolve, source) = if matches!(kind.as_str(), "AND" | "OR" | "NOT" | "DOMAIN-REGEX") { match targets { Some(targets) => { @@ -230,9 +341,15 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res trim_ascii_whitespace(payload), parse_rule_action(trim_ascii_whitespace(action), targets)?, false, + false, ) } - None => (trim_ascii_whitespace(rest), RuleAction::Direct, false), + None => ( + trim_ascii_whitespace(rest), + RuleAction::Direct, + false, + false, + ), } } else { let mut fields = rest.split(',').map(trim_ascii_whitespace); @@ -242,24 +359,30 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res None => RuleAction::Direct, }; let mut no_resolve = false; + let mut source = kind == "SRC-GEOIP" || kind == "SRC-IP-CIDR"; for option in fields { if option == "no-resolve" && matches!( kind.as_str(), - "RULE-SET" | "GEOIP" | "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" + "RULE-SET" | "GEOIP" | "SRC-GEOIP" | "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" ) { no_resolve = true; + } else if option == "src" + && matches!(kind.as_str(), "RULE-SET" | "GEOIP" | "IP-CIDR" | "IP-CIDR6") + { + source = true; } else { return invalid("unsupported rule parameter"); } } - (payload, action, no_resolve) + (payload, action, no_resolve || source, source) }; let rule_kind = match kind.as_str() { "RULE-SET" => RuleKind::RuleSet { name: payload.to_owned(), content: None, + source, }, "DOMAIN" => RuleKind::Domain(normalize_rule_domain(payload, "DOMAIN")?), "DOMAIN-SUFFIX" => RuleKind::DomainSuffix(normalize_rule_domain(payload, "DOMAIN-SUFFIX")?), @@ -267,13 +390,28 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res "DOMAIN-WILDCARD" => RuleKind::DomainWildcard(payload.to_lowercase()), "DOMAIN-REGEX" => RuleKind::DomainRegex(DomainRegex::new(payload)?), "GEOSITE" => RuleKind::GeoSite(normalize_geo_code(payload, "GEOSITE")?), - "GEOIP" => RuleKind::GeoIp(normalize_geo_code(payload, "GEOIP")?), - "IP-CIDR" => RuleKind::IpCidr(parse_ip_cidr(payload, false)?), - "IP-CIDR6" => RuleKind::IpCidr(parse_ip_cidr(payload, true)?), - "SRC-IP-CIDR" => RuleKind::SrcIpCidr(parse_ip_cidr(payload, payload.contains(':'))?), + "GEOIP" | "SRC-GEOIP" => { + let code = normalize_geo_code(payload, "GEOIP")?; + if source { + RuleKind::SrcGeoIp(code) + } else { + RuleKind::GeoIp(code) + } + } + "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" => { + let cidr = parse_ip_cidr(payload, payload.contains(':'))?; + if source { + RuleKind::SrcIpCidr(cidr) + } else { + RuleKind::IpCidr(cidr) + } + } "DST-PORT" => RuleKind::DstPorts(parse_port_ranges(payload)?), "SRC-PORT" => RuleKind::SrcPorts(parse_port_ranges(payload)?), "NETWORK" => RuleKind::Network(parse_network(payload)?), + "IN-TYPE" => RuleKind::InTypes(parse_in_types(payload)?), + "IN-USER" => RuleKind::InUsers(parse_in_users(payload)?), + "IN-PORT" => RuleKind::InPorts(parse_port_ranges(payload)?), "AND" | "OR" | "NOT" => { let children = logical_children(payload)? .into_iter() diff --git a/src/controller.rs b/src/controller.rs index 69524b9..81552cc 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -765,6 +765,7 @@ mod tests { &[SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, strategy: crate::config::BalanceStrategy::default(), + hash_key: crate::config::BalanceHashKey::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, diff --git a/src/dispatch.rs b/src/dispatch.rs index 6039fee..16a5727 100644 --- a/src/dispatch.rs +++ b/src/dispatch.rs @@ -416,6 +416,7 @@ mod tests { fn stream_session(port: u16) -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::Tun, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::Ip(format!("192.0.2.1:{port}").parse().unwrap()), diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 49d252a..eaa451e 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -298,6 +298,7 @@ impl TcpConnectionPool { let connecting = TcpConnectingGuard::new(Arc::clone(&self.stats)); let stream = egress .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalDns, source: internal_source(nameserver.endpoint.ip()), destination: Destination::Ip(nameserver.endpoint()), @@ -1199,6 +1200,7 @@ impl RuntimeDns { !reuse(DnsEgressKey::Route(id)), Some(internal_source(nameserver.endpoint.ip())), Some(&destination), + None, ) }; match nameserver.route { @@ -1213,6 +1215,7 @@ impl RuntimeDns { let mut context = RoutingContext::new(network, &destination) .map_err(|_| AttemptError::Dispatch(DispatchError::HostUnreachable))?; context.pin_ip(nameserver.endpoint.ip()); + context.set_source(internal_source(nameserver.endpoint.ip())); let snapshot = self.geo_matcher.routing_snapshot(); let geo = snapshot.as_deref().unwrap_or(self.geo_matcher.as_ref()); let mut selected = None; @@ -3532,6 +3535,7 @@ sub-rules: {child: ['MATCH,gate', 'MATCH,pool']} &[SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, strategy: crate::config::BalanceStrategy::default(), + hash_key: crate::config::BalanceHashKey::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 3bc6ddb..6dc87da 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -254,7 +254,7 @@ impl GeoData { #[must_use] pub fn geoip_available(&self, code: &str) -> bool { - self.ip_category(code).is_some() + lan_selector(code) || self.ip_category(code).is_some() } fn site_category(&self, code: &str) -> Option<&SiteCategory> { @@ -274,6 +274,33 @@ impl GeoData { } } +pub(crate) fn lan_selector(code: &str) -> bool { + code.strip_prefix('!') + .unwrap_or(code) + .eq_ignore_ascii_case("lan") +} +pub(crate) fn matches_lan(code: &str, ip: IpAddr) -> Option { + if !lan_selector(code) { + return None; + } + // netip's LAN predicates unmap IPv4 except IsUnspecified. + let canonical = match ip { + IpAddr::V6(ip) => ip + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(ip)), + IpAddr::V4(_) => ip, + }; + let local = ip.is_unspecified() + || canonical.is_loopback() + || canonical.is_multicast() + || match canonical { + IpAddr::V4(ip) => ip.is_private() || ip.is_link_local(), + IpAddr::V6(ip) => ip.is_unique_local() || ip.is_unicast_link_local(), + }; + Some(local != code.starts_with('!')) +} + impl GeoMatcher for GeoData { fn geosite_available(&self, code: &str) -> bool { GeoData::geosite_available(self, code) @@ -289,8 +316,10 @@ impl GeoMatcher for GeoData { } fn matches_geoip(&self, code: &str, address: IpAddr) -> bool { - self.ip_category(code) - .is_some_and(|category| category.matches(address) != code.starts_with('!')) + matches_lan(code, address).unwrap_or_else(|| { + self.ip_category(code) + .is_some_and(|category| category.matches(address) != code.starts_with('!')) + }) } } @@ -352,6 +381,9 @@ impl CodeSet { fn insert(&mut self, kind: GeoDataKind, raw: &str) -> Result<(), GeoDataError> { let selector = normalize_selector(kind, raw)?; + if kind == GeoDataKind::GeoIp && lan_selector(&selector) { + return Ok(()); + } if self.selectors.contains(&selector) { return Ok(()); } @@ -410,7 +442,9 @@ impl RequestedCodes { predicate: child, .. } => pending.push(child), RuleKind::GeoSite(code) => requested.sites.insert(GeoDataKind::GeoSite, code)?, - RuleKind::GeoIp(code) => requested.ips.insert(GeoDataKind::GeoIp, code)?, + RuleKind::GeoIp(code) | RuleKind::SrcGeoIp(code) => { + requested.ips.insert(GeoDataKind::GeoIp, code)? + } _ => (), } } diff --git a/src/geodata/tests.rs b/src/geodata/tests.rs index 30eb2b9..b3ff849 100644 --- a/src/geodata/tests.rs +++ b/src/geodata/tests.rs @@ -1287,3 +1287,70 @@ fn real_xray_geodata_loads_common_codes_without_memory_quotas() { assert!(data.matches_geoip("private", "10.0.0.1".parse().unwrap())); assert!(data.matches_geoip("cn", "1.0.1.1".parse().unwrap())); } + +#[test] +fn ingress_source_geoip_and_builtin_lan_avoid_destination_resolution() { + use crate::{ + config::Network, + routing::{RoutingContext, RuleEvaluation, RuleSet}, + }; + let dir = tempdir().unwrap(); + write_asset( + dir.path(), + GEOIP_FILE_NAME, + &geoip_list(&[geoip("CN", &[cidr(&[192, 0, 2, 0], 24)], false)]), + ); + let mut config = crate::config::Config::parse_yaml( + b"mixed-port: 1080\nrules: ['SRC-GEOIP,CN,REJECT', 'GEOIP,lan,DIRECT,src', 'MATCH,DIRECT']", + ) + .unwrap(); + let requirements = GeoRequirements::for_config(&config).unwrap(); + assert_eq!( + requirements.codes(GeoDataKind::GeoIp).collect::>(), + ["cn"] + ); + let data = GeoData::load(dir.path(), &config.rules).unwrap(); + let rules = RuleSet::compile(config.take_runtime_rules()).unwrap(); + let mut context = RoutingContext::new( + Network::Tcp, + &crate::session::Destination::domain("unresolved.example", 443).unwrap(), + ) + .unwrap(); + context.set_source("192.0.2.1:1234".parse().unwrap()); + assert!( + matches!(rules.evaluate_with_geo(&context, &data), RuleEvaluation::Matched(result) if result.rule_index == 0) + ); + context.set_source( + "fe80::1" + .parse::() + .map(|ip| std::net::SocketAddr::new(ip, 1234)) + .unwrap(), + ); + assert!( + matches!(rules.evaluate(&context), RuleEvaluation::Matched(result) if result.rule_index == 1) + ); + assert!(context.effective_ip().is_none()); + for ip in [ + "127.0.0.1", + "10.0.0.1", + "0.0.0.0", + "224.0.0.1", + "::1", + "fc00::1", + "fe80::1", + "ff02::1", + "::ffff:10.0.0.1", + "::ffff:127.0.0.1", + ] { + assert_eq!(matches_lan("lan", ip.parse().unwrap()), Some(true)); + } + assert_eq!( + matches_lan("!lan", "203.0.113.1".parse().unwrap()), + Some(true) + ); + let only_lan = crate::config::Config::parse_yaml( + b"mixed-port: 1080\nrules: ['SRC-GEOIP,lan,DIRECT', 'GEOIP,!LAN,REJECT']", + ) + .unwrap(); + assert!(GeoRequirements::for_config(&only_lan).unwrap().is_empty()); +} diff --git a/src/inbound/http/server.rs b/src/inbound/http/server.rs index 9a88e6e..7dc3cfb 100644 --- a/src/inbound/http/server.rs +++ b/src/inbound/http/server.rs @@ -21,7 +21,7 @@ use crate::{ outbound::{ MAX_OUTBOUND_DIAGNOSTIC_MESSAGE_BYTES, OutboundDiagnostic, capture_outbound_diagnostic, }, - session::{InboundKind, StreamSession}, + session::{InboundKind, InboundMetadata, StreamSession}, }; use super::super::{ @@ -180,12 +180,23 @@ pub(crate) async fn handle_connection( cancellation: CancellationToken, ) -> io::Result<()> { let deadline = Instant::now() + config.header_timeout; - handle_connection_with_deadline(inbound, peer, dispatcher, config, cancellation, deadline).await + let local_port = inbound.local_addr()?.port(); + handle_connection_with_deadline( + inbound, + peer, + local_port, + dispatcher, + config, + cancellation, + deadline, + ) + .await } pub(crate) async fn handle_connection_with_deadline( inbound: S, peer: SocketAddr, + local_port: u16, dispatcher: Arc, config: HttpServerConfig, cancellation: CancellationToken, @@ -202,7 +213,7 @@ where result = async { let (read, mut write) = tokio::io::split(inbound); let mut reader = BufReader::with_capacity(framing::COPY_BUFFER, read); - serve_requests(&mut reader, &mut write, peer, &dispatcher, &config, deadline).await + serve_requests(&mut reader, &mut write, peer, local_port, &dispatcher, &config, deadline).await } => result, } } @@ -211,6 +222,7 @@ async fn serve_requests( reader: &mut R, writer: &mut W, peer: SocketAddr, + local_port: u16, dispatcher: &Arc, config: &HttpServerConfig, first_deadline: Instant, @@ -244,14 +256,16 @@ where return Err(error); } }; - if config - .auth - .as_ref() - .is_some_and(|auth| !auth.verifies(&request.headers)) - { - write_auth_required(writer).await?; - return Ok(()); - } + let user = match &config.auth { + Some(auth) => match auth.authenticate_headers(&request.headers) { + Some(user) => Some(user), + None => { + write_auth_required(writer).await?; + return Ok(()); + } + }, + None => None, + }; let diagnostic = request.is_connect() && config.auth.is_some() && config.listen.ip().is_loopback() @@ -271,7 +285,12 @@ where } }; let mut session = StreamSession { - inbound: InboundKind::Http, + metadata: InboundMetadata { local_port, user }, + inbound: if request.is_connect() { + InboundKind::Https + } else { + InboundKind::Http + }, source: peer, destination, sniffed_domain: None, diff --git a/src/inbound/mixed.rs b/src/inbound/mixed.rs index 23338f3..4acb8b4 100644 --- a/src/inbound/mixed.rs +++ b/src/inbound/mixed.rs @@ -200,6 +200,7 @@ impl MixedConnection { handle_connection_with_deadline( stream, self.peer, + self.relay.port(), self.dispatcher, self.http, self.cancellation, diff --git a/src/inbound/mixed/tests.rs b/src/inbound/mixed/tests.rs index 5fc59f4..781eafe 100644 --- a/src/inbound/mixed/tests.rs +++ b/src/inbound/mixed/tests.rs @@ -117,7 +117,7 @@ async fn memory_mixed_protocols_preserve_first_byte_and_pipelined_tunnel_bytes_w if socks { InboundKind::Socks5 } else { - InboundKind::Http + InboundKind::Https } ); assert_eq!( @@ -298,7 +298,7 @@ skip-auth-prefixes: [{}] let request = format!( "CONNECT 192.0.2.3:80 HTTP/1.1\r\nHost: 192.0.2.3:80\r\n{}\r\n", if skip { - String::new() + "Proxy-Authorization: Basic Y2xhaW1lZDp1bnRydXN0ZWQ=\r\n".into() } else { format!( "Proxy-Authorization: {}\r\n", @@ -319,6 +319,125 @@ skip-auth-prefixes: [{}] exact(&mut remote, b"body").await; cancellation.cancel(); task.await.unwrap().unwrap(); + let sessions = peers.sessions.lock().unwrap(); + assert_eq!(sessions[0].metadata.local_port, 1080); + assert_eq!(sessions[0].inbound, InboundKind::Https); + assert_eq!( + sessions[0].metadata.user.as_deref(), + if skip { None } else { Some("second") } + ); } } } + +#[tokio::test] +async fn ingress_memory_mixed_verified_http_socks_identity_and_unauthenticated_claims() { + for (socks, authenticate) in [(false, true), (true, true), (false, false)] { + let peers = Arc::new(Peers::default()); + let (upstream, mut remote) = tokio::io::duplex(1024); + peers.streams.lock().unwrap().push_back(upstream); + let credentials = ProxyCredentials::new("Alice", "pass").unwrap(); + let connection = connection(peers.clone(), authenticate.then(|| credentials.clone())); + let cancellation = connection.cancellation.clone(); + let (mut client, inbound) = tokio::io::duplex(1024); + let task = tokio::spawn(connection.serve(inbound)); + if socks { + client.write_all(&[5, 1, 2]).await.unwrap(); + exact(&mut client, &[5, 2]).await; + client.write_all(b"\x01\x05Alice\x04pass").await.unwrap(); + exact(&mut client, &[1, 0]).await; + client + .write_all(&[5, 1, 0, 1, 192, 0, 2, 3, 0, 80]) + .await + .unwrap(); + exact(&mut client, &[5, 0, 0, 1, 0, 0, 0, 0, 0, 0]).await; + } else { + client.write_all(format!("CONNECT 192.0.2.3:80 HTTP/1.1\r\nHost: 192.0.2.3:80\r\nProxy-Authorization: {}\r\n\r\n", credentials.authorization_header_value()).as_bytes()).await.unwrap(); + exact(&mut client, b"HTTP/1.1 200 Connection Established\r\n\r\n").await; + } + client.write_all(b"body").await.unwrap(); + exact(&mut remote, b"body").await; + cancellation.cancel(); + task.await.unwrap().unwrap(); + let sessions = peers.sessions.lock().unwrap(); + assert_eq!( + sessions[0].inbound, + if socks { + InboundKind::Socks5 + } else { + InboundKind::Https + } + ); + assert_eq!(sessions[0].metadata.local_port, 1080); + assert_eq!( + sessions[0].metadata.user.as_deref(), + authenticate.then_some("Alice") + ); + } +} + +#[tokio::test] +async fn ingress_memory_http_keepalive_authenticates_each_request() { + let peers = Arc::new(Peers::default()); + let credentials = ProxyCredentials::multiple(vec![ + ProxyCredentials::new("Alice", "pass").unwrap(), + ProxyCredentials::new("Bob", "other").unwrap(), + ]); + let connection = connection(peers.clone(), Some(credentials)); + let cancellation = connection.cancellation.clone(); + let (mut client, inbound) = tokio::io::duplex(2048); + let task = tokio::spawn(connection.serve(inbound)); + for (user, password) in [("Alice", "pass"), ("Bob", "other")] { + let (upstream, mut origin) = tokio::io::duplex(2048); + peers.streams.lock().unwrap().push_back(upstream); + let header = ProxyCredentials::new(user, password) + .unwrap() + .authorization_header_value(); + client.write_all(format!("GET http://192.0.2.3/test HTTP/1.1\r\nHost: 192.0.2.3\r\nProxy-Authorization: {header}\r\n\r\n").as_bytes()).await.unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + request.push( + tokio::time::timeout(Duration::from_secs(2), origin.read_u8()) + .await + .unwrap() + .unwrap(), + ); + } + assert!( + !String::from_utf8(request) + .unwrap() + .to_lowercase() + .contains("proxy-authorization") + ); + origin + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\nx") + .await + .unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\nx") { + response.push( + tokio::time::timeout(Duration::from_secs(2), client.read_u8()) + .await + .unwrap() + .unwrap(), + ); + } + } + cancellation.cancel(); + task.await.unwrap().unwrap(); + let sessions = peers.sessions.lock().unwrap(); + assert_eq!(sessions.len(), 2); + assert!( + sessions + .iter() + .all(|session| session.inbound == InboundKind::Http + && session.metadata.local_port == 1080) + ); + assert_eq!( + sessions + .iter() + .map(|session| session.metadata.user.as_deref()) + .collect::>(), + [Some("Alice"), Some("Bob")] + ); +} diff --git a/src/inbound/socks5/association.rs b/src/inbound/socks5/association.rs index cac1bd8..7fab165 100644 --- a/src/inbound/socks5/association.rs +++ b/src/inbound/socks5/association.rs @@ -11,7 +11,7 @@ use tokio::{net::UdpSocket, sync::mpsc, time::Instant}; use tokio_util::sync::CancellationToken; use crate::{ - session::{Datagram, SOCKS5_UDP_PACKET_LIMIT}, + session::{Datagram, DatagramSession, InboundKind, InboundMetadata, SOCKS5_UDP_PACKET_LIMIT}, socks5::decode_udp_packet, }; @@ -41,11 +41,19 @@ impl Entry { pub(super) struct Associations(Mutex>); pub(super) struct Lease { + pub(super) metadata: InboundMetadata, registry: Arc, pub(super) cancellation: CancellationToken, pub(super) source: Arc>, } +impl Lease { + pub(super) fn session(&self) -> DatagramSession { + DatagramSession::new(InboundKind::Socks5, *self.source.lock().unwrap()) + .with_metadata(self.metadata.clone()) + } +} + impl Drop for Lease { fn drop(&mut self) { // Serialize removal with UDP learning/replies. A stale generation must @@ -99,6 +107,7 @@ impl Associations { ); Ok(( Lease { + metadata: InboundMetadata::new(0), registry: self.clone(), cancellation, source: learned_source, @@ -200,6 +209,45 @@ mod tests { use super::*; use crate::{session::Destination, socks5::encode_udp_packet}; + #[tokio::test] + async fn ingress_udp_lease_retains_verified_user_and_learned_source() { + let registry = Arc::new(Associations::default()); + let credentials = crate::config::ProxyCredentials::new("Alice", "pass").unwrap(); + let user = credentials.authenticate(b"Alice", b"pass").unwrap(); + let weak = Arc::downgrade(&user); + let (mut lease, mut receiver) = registry + .register( + "192.0.2.1:1234".parse().unwrap(), + 0, + &CancellationToken::new(), + ) + .unwrap(); + lease.metadata = InboundMetadata { + local_port: 1080, + user: Some(user), + }; + drop(credentials); + let source = "192.0.2.1:4321".parse().unwrap(); + let packet = encode_udp_packet( + &Destination::Ip("203.0.113.1:53".parse().unwrap()), + b"data", + 65507, + ) + .unwrap(); + registry.enqueue(source, &packet, Instant::now()); + assert_eq!(receiver.recv().await.unwrap().payload.as_ref(), b"data"); + let session = lease.session(); + assert_eq!(session.source, source); + assert_eq!(session.metadata.local_port, 1080); + assert_eq!(session.metadata.user.as_deref(), Some("Alice")); + assert!(!format!("{:?}", session.metadata).contains("Alice")); + drop(lease); + assert!(registry.0.lock().unwrap().is_empty()); + assert!(weak.upgrade().is_some()); + drop(session); + assert!(weak.upgrade().is_none()); + } + #[tokio::test] async fn bounded_queue_idle_expiry_and_old_generation_cannot_remove_replacement() { let registry = Arc::new(Associations::default()); diff --git a/src/inbound/socks5/handshake.rs b/src/inbound/socks5/handshake.rs index 1bccb9b..0241fb0 100644 --- a/src/inbound/socks5/handshake.rs +++ b/src/inbound/socks5/handshake.rs @@ -1,4 +1,4 @@ -use std::{io, net::SocketAddr}; +use std::{io, net::SocketAddr, sync::Arc}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -13,11 +13,17 @@ fn invalid() -> io::Error { io::Error::new(io::ErrorKind::InvalidData, "invalid SOCKS5 handshake") } +pub(super) struct Negotiated { + pub command: u8, + pub destination: Destination, + pub user: Option>, +} + /// Fixed-size, exact reads leave pipelined application bytes in the socket. pub(super) async fn negotiate( stream: &mut S, auth: Option<&ProxyCredentials>, -) -> io::Result<(u8, Destination)> +) -> io::Result where S: AsyncRead + AsyncWrite + Unpin, { @@ -33,6 +39,7 @@ where return Err(invalid()); } stream.write_all(&[VERSION, method]).await?; + let mut authenticated_user = None; if let Some(auth) = auth { let version = stream.read_u8().await?; let user_length = stream.read_u8().await? as usize; @@ -41,8 +48,10 @@ where let password_length = stream.read_u8().await? as usize; let mut password = [0_u8; 255]; stream.read_exact(&mut password[..password_length]).await?; - let valid = - version == 1 && auth.matches(&user[..user_length], &password[..password_length]); + authenticated_user = (version == 1) + .then(|| auth.authenticate(&user[..user_length], &password[..password_length])) + .flatten(); + let valid = authenticated_user.is_some(); stream.write_all(&[1, u8::from(!valid)]).await?; if !valid { return Err(io::Error::new( @@ -62,7 +71,11 @@ where return Err(invalid()); } match socks5::read_destination(stream, header[1] == COMMAND_UDP_ASSOCIATE).await { - Ok(destination) => Ok((header[1], destination)), + Ok(destination) => Ok(Negotiated { + command: header[1], + destination, + user: authenticated_user, + }), Err(error) => { reply(stream, 8, None).await?; Err(error) diff --git a/src/inbound/socks5/server.rs b/src/inbound/socks5/server.rs index 86aef15..756c279 100644 --- a/src/inbound/socks5/server.rs +++ b/src/inbound/socks5/server.rs @@ -14,7 +14,7 @@ use crate::{ dispatch::{DispatchError, Dispatcher}, inbound::listen::{bind_proxy_tcp, bind_udp}, session::{ - Datagram, DatagramSession, Destination, InboundKind, SOCKS5_UDP_PACKET_LIMIT, StreamSession, + Datagram, Destination, InboundKind, InboundMetadata, SOCKS5_UDP_PACKET_LIMIT, StreamSession, }, socks5::{COMMAND_CONNECT, encode_udp_packet}, }; @@ -186,7 +186,11 @@ impl Socks5Handler { let config = &self.config; let dispatcher = self.dispatcher.clone(); let registry = self.registry.clone(); - let (command, destination) = timeout_at( + let handshake::Negotiated { + command, + destination, + user, + } = timeout_at( deadline, handshake::negotiate(&mut stream, config.auth.as_ref()), ) @@ -200,6 +204,10 @@ impl Socks5Handler { } if command == COMMAND_CONNECT { let mut session = StreamSession { + metadata: InboundMetadata { + local_port: relay.port(), + user, + }, inbound: InboundKind::Socks5, source: peer, destination, @@ -264,13 +272,17 @@ impl Socks5Handler { return Ok(()); } }; - let (lease, receiver) = match registry.register(peer, requested.port(), &cancellation) { + let (mut lease, receiver) = match registry.register(peer, requested.port(), &cancellation) { Ok(value) => value, Err(_) => { timed(handshake::reply(&mut stream, 2, None)).await?; return Ok(()); } }; + lease.metadata = InboundMetadata { + local_port: socket.local_addr()?.port(), + user, + }; // The accepted socket's concrete local IP is reachable even when the // listener was bound to a wildcard. TCP and UDP share this exact port. timed(handshake::reply(&mut stream, 0, Some(relay))).await?; @@ -301,8 +313,7 @@ async fn relay_datagrams( let Some(first) = receiver.recv().await else { return Ok(()); }; - let source = *lease.source.lock().unwrap(); - let session = DatagramSession::new(InboundKind::Socks5, source); + let session = lease.session(); let response_limit = usize::from(session.max_response_payload_size()); let mut transport = timeout(IO_TIMEOUT, dispatcher.open_datagram(session)) .await diff --git a/src/outbound/anytls/client.rs b/src/outbound/anytls/client.rs index f7f1f3d..b271307 100644 --- a/src/outbound/anytls/client.rs +++ b/src/outbound/anytls/client.rs @@ -567,6 +567,7 @@ mod tests { fn stream_session() -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:12000".parse().unwrap(), destination: Destination::domain("target.example", 443).unwrap(), diff --git a/src/outbound/anytls/mod.rs b/src/outbound/anytls/mod.rs index 689fcb5..5d3735f 100644 --- a/src/outbound/anytls/mod.rs +++ b/src/outbound/anytls/mod.rs @@ -157,6 +157,7 @@ impl OutboundConnector for AnyTlsOutbound { context: &EstablishContext, ) -> Result, DispatchError> { let stream_session = StreamSession { + metadata: request.session.metadata.clone(), inbound: request.session.inbound, source: request.session.source, destination: magic_destination(), diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index f3a4c08..2a07a40 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -210,6 +210,7 @@ pub struct EstablishContext { deadline: Instant, resolution: ResolutionContext, selections: Arc>, + inbound: crate::session::InboundMetadata, } type SelectionSnapshot = HashMap, usize)>; @@ -226,6 +227,7 @@ impl EstablishContext { deadline: inherited_deadline(Instant::now() + duration), resolution, selections: Arc::new(Mutex::new(HashMap::new())), + inbound: Default::default(), } } @@ -253,6 +255,7 @@ impl EstablishContext { Self { deadline: Instant::now() + DEFAULT_ESTABLISH_TIMEOUT, resolution: self.resolution.clone(), + inbound: self.inbound.clone(), selections: Arc::new(Mutex::new( self.selections .lock() @@ -274,7 +277,12 @@ impl EstablishContext { .unwrap_or_else(std::sync::PoisonError::into_inner); selections .entry(key) - .or_insert_with(|| (selection.clone(), selection.choose(None, Some(destination)))) + .or_insert_with(|| { + ( + selection.clone(), + selection.choose_for(None, Some(destination), self.inbound.user.as_deref()), + ) + }) .1 } @@ -806,8 +814,11 @@ impl ConnectorDispatcher { self } - fn context(&self) -> EstablishContext { - EstablishContext::with_resolution(DEFAULT_ESTABLISH_TIMEOUT, self.resolution.clone()) + fn context(&self, metadata: &crate::session::InboundMetadata) -> EstablishContext { + let mut context = + EstablishContext::with_resolution(DEFAULT_ESTABLISH_TIMEOUT, self.resolution.clone()); + context.inbound = metadata.clone(); + context } } @@ -818,7 +829,7 @@ impl Dispatcher for ConnectorDispatcher { } async fn connect_tcp(&self, session: StreamSession) -> Result { - let context = self.context(); + let context = self.context(&session.metadata); let result = self.inner.connect_stream(session, &context).await; if !matches!(result, Err(DispatchError::NotAllowed)) { context.report_group_outcome(result.is_ok()); @@ -833,8 +844,9 @@ impl Dispatcher for ConnectorDispatcher { if !self.allow_udp { return Err(DispatchError::NotAllowed); } + let context = self.context(&session.metadata); self.inner - .open_datagram(DatagramRequest::new(session), &self.context()) + .open_datagram(DatagramRequest::new(session), &context) .await } } @@ -977,6 +989,7 @@ mod tests { fn test_session() -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("business.invalid", 443).unwrap(), @@ -1004,6 +1017,97 @@ mod tests { } } + #[tokio::test] + async fn ingress_connector_dispatcher_preserves_user_for_dialer_proxy_and_continuations() { + struct Carrier { + path: UpstreamPath, + user: Arc, + } + #[async_trait] + impl OutboundConnector for Carrier { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + assert_eq!(context.inbound.user.as_deref(), Some(self.user.as_ref())); + assert_eq!(session.metadata, context.inbound); + self.path + .connect_server( + session, + &Destination::domain("server.example", 443).unwrap(), + context, + ) + .await + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + assert_eq!(context.inbound, request.session.metadata); + let continuation = context.authenticated_continuation(); + assert_eq!(continuation.inbound, context.inbound); + self.path.open_datagram(request, &continuation).await + } + } + let config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: + - {name: a, type: socks5, server: 192.0.2.10, port: 1080, udp: true} + - {name: b, type: socks5, server: 192.0.2.11, port: 1080, udp: true} +proxy-groups: + - {name: balance, type: load-balance, proxies: [a,b], hash-key: in-user} +"#, + ) + .unwrap(); + let selection = Arc::new(GroupSelection::new(0)); + selection.configure_balance(&config.proxy_groups[0]); + let server = Destination::domain("server.example", 443).unwrap(); + let fallback = selection.choose_for(None, Some(&server), None); + let user: Arc = Arc::from( + (0..256) + .map(|i| format!("verified-{i}")) + .find(|user| selection.choose_for(None, Some(&server), Some(user)) != fallback) + .unwrap(), + ); + let expected = selection.choose_for(None, Some(&server), Some(&user)) as u8 + 1; + let path = selected_path( + selection, + vec![ + SelectUpstreamMember::Proxy(Arc::new(MarkedConnector { + marker: 1, + ..Default::default() + })), + SelectUpstreamMember::Proxy(Arc::new(MarkedConnector { + marker: 2, + ..Default::default() + })), + ], + ); + let dispatcher = ConnectorDispatcher::new(Arc::new(Carrier { + path, + user: user.clone(), + })); + let metadata = crate::session::InboundMetadata { + local_port: 1080, + user: Some(user), + }; + let mut session = test_session(); + session.metadata = metadata.clone(); + let mut stream = dispatcher.connect_tcp(session).await.unwrap(); + assert_eq!(stream.read_u8().await.unwrap(), expected); + let mut udp = dispatcher + .open_datagram( + DatagramSession::new(InboundKind::Socks5, test_session().source) + .with_metadata(metadata), + ) + .await + .unwrap(); + assert_eq!(udp.receive().await.unwrap().payload[0], expected); + } + #[tokio::test] async fn group_upstream_pins_nested_choices_and_preserves_metadata_budget_and_deadline() { let first = Arc::new(MarkedConnector { diff --git a/src/outbound/direct.rs b/src/outbound/direct.rs index 30c5141..38e7c6a 100644 --- a/src/outbound/direct.rs +++ b/src/outbound/direct.rs @@ -284,6 +284,7 @@ mod tests { let destination = listener.local_addr().unwrap(); let outbound = DirectOutbound::new(Dialer::default()); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::Ip(destination), diff --git a/src/outbound/shadowsocks/tests.rs b/src/outbound/shadowsocks/tests.rs index b585d4f..0c40cc0 100644 --- a/src/outbound/shadowsocks/tests.rs +++ b/src/outbound/shadowsocks/tests.rs @@ -242,6 +242,7 @@ async fn ss_physical_tcp_uses_shared_dialer_and_logical_target() { .unwrap(); let target = Destination::domain("fixture.invalid", 443).unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: target.clone(), @@ -446,6 +447,7 @@ async fn physical_tcp_udp_protection_is_shared_and_fails_closed() { ) .unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("private-target.invalid", 443).unwrap(), diff --git a/src/outbound/shadowsocks/uot.rs b/src/outbound/shadowsocks/uot.rs index 70e0fb8..49016be 100644 --- a/src/outbound/shadowsocks/uot.rs +++ b/src/outbound/shadowsocks/uot.rs @@ -43,6 +43,7 @@ impl Owner { biased; () = self.cancellation.cancelled() => return Err(DispatchError::NotAllowed), result = outbound.connect_stream(StreamSession { + metadata: request.session.metadata.clone(), inbound: request.session.inbound, source: request.session.source, destination: magic_destination(), diff --git a/src/outbound/socks5.rs b/src/outbound/socks5.rs index 1e2ae8a..d3f2621 100644 --- a/src/outbound/socks5.rs +++ b/src/outbound/socks5.rs @@ -199,6 +199,7 @@ impl Socks5Outbound { context: &EstablishContext, ) -> Result, DispatchError> { let control_session = StreamSession { + metadata: request_parameters.session.metadata.clone(), inbound: request_parameters.session.inbound, source: request_parameters.session.source, destination: self.server.clone(), @@ -811,6 +812,7 @@ mod tests { fn stream_session() -> StreamSession { StreamSession { + metadata: Default::default(), inbound: crate::session::InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("target.example", 443).unwrap(), diff --git a/src/outbound/trojan/outbound.rs b/src/outbound/trojan/outbound.rs index 650f90a..6390938 100644 --- a/src/outbound/trojan/outbound.rs +++ b/src/outbound/trojan/outbound.rs @@ -161,6 +161,7 @@ impl OutboundConnector for TrojanOutbound { ) -> Result, DispatchError> { // UDP destinations live in individual frames, not this request header. let session = StreamSession { + metadata: request.session.metadata.clone(), inbound: request.session.inbound, source: request.session.source, destination: Destination::Ip(std::net::SocketAddr::from(([0, 0, 0, 0], 1))), diff --git a/src/outbound/vless/outbound.rs b/src/outbound/vless/outbound.rs index 8bb3e03..fc04276 100644 --- a/src/outbound/vless/outbound.rs +++ b/src/outbound/vless/outbound.rs @@ -843,6 +843,7 @@ impl OutboundConnector for VlessOutbound { { let target = crate::session::Destination::domain("sp.mux.sing-box.arpa", 444)?; let session = StreamSession { + metadata: request.session.metadata.clone(), inbound: request.session.inbound, source: request.session.source, destination: target.clone(), @@ -874,6 +875,7 @@ impl OutboundConnector for VlessOutbound { let prepared = self .prepare( StreamSession { + metadata: request.session.metadata.clone(), inbound: request.session.inbound, source: request.session.source, destination: self.upload.server.clone(), diff --git a/src/outbound/vmess/outbound.rs b/src/outbound/vmess/outbound.rs index f3e9c30..e78a4ec 100644 --- a/src/outbound/vmess/outbound.rs +++ b/src/outbound/vmess/outbound.rs @@ -233,6 +233,7 @@ impl OutboundConnector for VmessOutbound { context: &EstablishContext, ) -> Result, DispatchError> { let session = StreamSession { + metadata: request.session.metadata.clone(), inbound: request.session.inbound, source: request.session.source, destination: self.server.clone(), diff --git a/src/resource/http.rs b/src/resource/http.rs index 832a888..d6c1a7f 100644 --- a/src/resource/http.rs +++ b/src/resource/http.rs @@ -235,6 +235,7 @@ async fn download_inner( for redirects_followed in 0..=MAX_REDIRECTS { let endpoint = DownloadEndpoint::from_url(¤t_url)?; let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalGeoData, source: SocketAddr::from(([0, 0, 0, 0], 0)), destination: match endpoint.host.parse() { diff --git a/src/resource/probe.rs b/src/resource/probe.rs index 2cff801..b86a803 100644 --- a/src/resource/probe.rs +++ b/src/resource/probe.rs @@ -122,6 +122,7 @@ async fn connect( tls_connector: Option<&TlsConnector>, ) -> io::Result { let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), destination: target.destination.clone(), diff --git a/src/routing/balance.rs b/src/routing/balance.rs index 579c186..bd3b447 100644 --- a/src/routing/balance.rs +++ b/src/routing/balance.rs @@ -1,5 +1,8 @@ //! Per-group selection; transports retain the selected leaf, not this cursor. -use crate::{config::BalanceStrategy, session::Destination}; +use crate::{ + config::{BalanceHashKey, BalanceStrategy}, + session::Destination, +}; use std::{ collections::{HashMap, hash_map::RandomState}, hash::BuildHasher, @@ -12,6 +15,7 @@ use tokio::time::Instant; #[derive(Debug)] pub(super) struct Balance { strategy: BalanceStrategy, + hash_key: BalanceHashKey, healthy: Vec, cursor: usize, sticky: HashMap, @@ -28,12 +32,17 @@ impl Balance { pub fn new(strategy: BalanceStrategy, members: usize) -> Self { Self { strategy, + hash_key: BalanceHashKey::Default, healthy: vec![true; members], cursor: 0, sticky: HashMap::new(), clock: 0, } } + pub fn with_hash_key(mut self, hash_key: BalanceHashKey) -> Self { + self.hash_key = hash_key; + self + } pub fn health(&mut self, samples: &[Option]) { if samples.len() == self.healthy.len() { for (alive, sample) in self.healthy.iter_mut().zip(samples) { @@ -45,13 +54,15 @@ impl Balance { &mut self, source: Option, destination: Option<&Destination>, + user: Option<&str>, ) -> usize { - self.inspect(source, destination, true) + self.inspect(source, destination, user, true) } pub fn inspect( &mut self, source: Option, destination: Option<&Destination>, + user: Option<&str>, commit: bool, ) -> usize { let count = self.healthy.len(); @@ -70,15 +81,24 @@ impl Balance { } return 0; } - let mut key = destination_key(destination); - if self.strategy == BalanceStrategy::StickySessions { - key.insert_str( - 0, - &source.map_or_else(|| "invalid IP".into(), |s| s.ip().to_string()), - ); - } + let user = (self.hash_key == BalanceHashKey::InUser) + .then_some(user) + .flatten() + .filter(|user| !user.is_empty()); + let fallback = user.is_none().then(|| { + let mut key = destination_key(destination); + if self.strategy == BalanceStrategy::StickySessions { + key.insert_str( + 0, + &source.map_or_else(|| "invalid IP".into(), |s| s.ip().to_string()), + ); + } + key + }); static HASH: OnceLock = OnceLock::new(); - let key = HASH.get_or_init(RandomState::new).hash_one(key); + let key = HASH + .get_or_init(RandomState::new) + .hash_one(user.unwrap_or_else(|| fallback.as_deref().unwrap())); if self.strategy == BalanceStrategy::ConsistentHashing { return (0..5) .map(|offset| jump(key.wrapping_add(offset), count)) @@ -151,6 +171,41 @@ fn jump(mut key: u64, count: usize) -> usize { #[cfg(test)] mod tests { use super::*; + #[test] + fn ingress_hash_uses_only_authenticated_user_and_keeps_address_fallback() { + let a = Destination::domain("first.example.com", 443).unwrap(); + let b = Destination::domain("other.example.org", 80).unwrap(); + let first = Some("192.0.2.1:1234".parse().unwrap()); + let second = Some("203.0.113.2:4321".parse().unwrap()); + for strategy in [ + BalanceStrategy::ConsistentHashing, + BalanceStrategy::StickySessions, + ] { + let mut balance = Balance::new(strategy, 7).with_hash_key(BalanceHashKey::InUser); + let selected = balance.choose(first, Some(&a), Some("Alice")); + assert_eq!(balance.choose(second, Some(&b), Some("Alice")), selected); + assert_eq!(balance.choose(None, None, Some("Alice")), selected); + if strategy == BalanceStrategy::StickySessions { + assert_eq!(balance.sticky.len(), 1); + balance.choose(first, Some(&a), None); + assert_eq!(balance.sticky.len(), 2); + // An empty identity falls back to exactly the same address key. + balance.choose(first, Some(&a), Some("")); + assert_eq!(balance.sticky.len(), 2); + } else { + let mut fallback = Balance::new(strategy, 7); + assert_eq!( + balance.choose(first, Some(&a), None), + fallback.choose(first, Some(&a), None) + ); + assert_eq!( + balance.choose(second, Some(&b), Some("")), + fallback.choose(second, Some(&b), None) + ); + } + } + } + #[tokio::test(start_paused = true)] async fn balance_strategies_health_stickiness_and_membership() { let source = Some("192.0.2.1:1234".parse().unwrap()); @@ -159,31 +214,34 @@ mod tests { let mut round = Balance::new(BalanceStrategy::RoundRobin, 3); assert_eq!( (0..4) - .map(|_| round.choose(source, Some(&a))) + .map(|_| round.choose(source, Some(&a), None)) .collect::>(), [0, 1, 2, 0] ); round.health(&[None, Some(20), None]); - assert_eq!(round.choose(source, Some(&a)), 1); + assert_eq!(round.choose(source, Some(&a), None), 1); let mut hash = Balance::new(BalanceStrategy::ConsistentHashing, 3); - assert_eq!(hash.choose(source, Some(&a)), hash.choose(source, Some(&b))); + assert_eq!( + hash.choose(source, Some(&a), None), + hash.choose(source, Some(&b), None) + ); let mut sticky = Balance::new(BalanceStrategy::StickySessions, 3); - let index = sticky.choose(source, Some(&a)); + let index = sticky.choose(source, Some(&a), None); for _ in 0..20 { - assert_eq!(sticky.choose(source, Some(&b)), index); + assert_eq!(sticky.choose(source, Some(&b), None), index); } tokio::time::advance(Duration::from_secs(601)).await; - sticky.choose(source, Some(&a)); + sticky.choose(source, Some(&a), None); assert!(sticky.sticky.values().all(|e| e.expires > Instant::now())); // A new provider generation owns its own state; an old transport keeps its leaf. let mut updated = Balance::new(BalanceStrategy::RoundRobin, 2); assert_eq!( ( - updated.choose(source, Some(&a)), - updated.choose(source, Some(&b)) + updated.choose(source, Some(&a), None), + updated.choose(source, Some(&b), None) ), (0, 1) ); - assert_eq!(round.choose(source, Some(&a)), 1); + assert_eq!(round.choose(source, Some(&a), None), 1); } } diff --git a/src/routing/diagnostics.rs b/src/routing/diagnostics.rs index 75432fc..bfca78b 100644 --- a/src/routing/diagnostics.rs +++ b/src/routing/diagnostics.rs @@ -181,12 +181,24 @@ fn rule_description(rule: &RuleKind) -> (&'static str, String) { RuleKind::DomainRegex(r) => ("DomainRegex", r.as_str().into()), RuleKind::GeoSite(s) => ("GeoSite", s.clone()), RuleKind::GeoIp(s) => ("GeoIP", s.clone()), + RuleKind::SrcGeoIp(s) => ("SrcGeoIP", s.clone()), + RuleKind::InTypes(types) => ( + "InType", + types + .iter() + .map(|kind| kind.name()) + .collect::>() + .join("/"), + ), + RuleKind::InUsers(users) => ("InUser", users.join("/")), RuleKind::RuleSet { name, .. } => ("RuleSet", name.clone()), RuleKind::IpCidr(cidr) => ("IPCIDR", format!("{}/{}", cidr.network, cidr.prefix_len)), RuleKind::SrcIpCidr(cidr) => ("SrcIPCIDR", format!("{}/{}", cidr.network, cidr.prefix_len)), - RuleKind::SrcPorts(ports) | RuleKind::DstPorts(ports) => ( + RuleKind::SrcPorts(ports) | RuleKind::DstPorts(ports) | RuleKind::InPorts(ports) => ( if matches!(rule, RuleKind::SrcPorts(_)) { "SrcPort" + } else if matches!(rule, RuleKind::InPorts(_)) { + "InPort" } else { "DstPort" }, diff --git a/src/routing/dispatcher.rs b/src/routing/dispatcher.rs index 7235b46..fc937a7 100644 --- a/src/routing/dispatcher.rs +++ b/src/routing/dispatcher.rs @@ -172,6 +172,7 @@ impl ProxyDispatchers { commit: bool, source: Option, destination: Option<&Destination>, + user: Option<&str>, ) -> Result { match id { RouteTargetId::Proxy(id) => { @@ -190,7 +191,7 @@ impl ProxyDispatchers { .proxy_groups .as_ref() .ok_or(DispatchError::NotAllowed)? - .inspect_rule_target(id, udp, commit, source, destination), + .inspect_rule_target(id, udp, commit, source, destination, user), } } @@ -203,6 +204,7 @@ impl ProxyDispatchers { udp: bool, source: Option, destination: Option<&Destination>, + user: Option<&str>, ) -> Result { match id { RouteTargetId::Proxy(id) => self @@ -222,7 +224,7 @@ impl ProxyDispatchers { .ok_or_else(|| { DispatchError::Other(format!("unknown proxy group id {}", id.index())) })? - .resolve_leaf_for(id, udp, source, destination), + .resolve_leaf_for(id, udp, source, destination, user), } } } @@ -352,7 +354,7 @@ impl RoutingDispatcher { async fn route( &self, network: Network, - inbound: InboundKind, + inbound: (InboundKind, &crate::session::InboundMetadata), source: SocketAddr, destination: &Destination, sniffed_domain: Option<&str>, @@ -360,10 +362,11 @@ impl RoutingDispatcher { &mut HashMap<(RouteTargetId, Option), CachedRouteTransport>, >, ) -> Result { + let (inbound, inbound_metadata) = inbound; self.ensure_destination_allowed(destination)?; let recovered = if matches!( inbound, - InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 + InboundKind::Tun | InboundKind::Http | InboundKind::Https | InboundKind::Socks5 ) { match (destination, &self.dns) { (Destination::Ip(address), Some(dns)) => { @@ -417,6 +420,7 @@ impl RoutingDispatcher { } context.set_source(source); + context.set_inbound(inbound, inbound_metadata); // All GeoData rules in this decision see the same immutable view. let snapshot = self.geo_matcher.routing_snapshot(); @@ -450,6 +454,7 @@ impl RoutingDispatcher { cached.is_none(), Some(source), Some(&metadata), + candidate.authenticated_user(), ) { Ok(InspectedRuleTarget::Control(_)) if !self.rules.filters_udp() => { selected = Some(Err(DispatchError::NotAllowed)); @@ -796,7 +801,7 @@ impl RoutingDispatcher { let mut decision = self .route( Network::Tcp, - session.inbound, + (session.inbound, &session.metadata), session.source, &session.destination, session.sniffed_domain.as_deref(), @@ -930,6 +935,7 @@ impl RoutingDispatcher { inbound_type: match inbound { InboundKind::Tun => "Tun", InboundKind::Http => "HTTP", + InboundKind::Https => "HTTPS", _ => "Socks5", }, source, @@ -1191,7 +1197,7 @@ impl RoutedDatagramTransport { .router .route( Network::Udp, - self.session.inbound, + (self.session.inbound, &self.session.metadata), self.session.source, &key, datagram.sniffed_domain.as_deref(), @@ -1468,7 +1474,7 @@ impl RoutedDatagramTransport { .router .route( Network::Udp, - self.session.inbound, + (self.session.inbound, &self.session.metadata), self.session.source, &datagram.remote, datagram.sniffed_domain.as_deref(), @@ -1556,6 +1562,7 @@ impl RoutedDatagramTransport { true, Some(self.session.source), Some(&decision.metadata), + self.session.metadata.user.as_deref(), )?, }; let direct = target.direct; @@ -1733,7 +1740,7 @@ impl DatagramTransport for RoutedDatagramTransport { .router .route( Network::Udp, - self.session.inbound, + (self.session.inbound, &self.session.metadata), self.session.source, &datagram.remote, datagram.sniffed_domain.as_deref(), @@ -2159,6 +2166,7 @@ mod tests { SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, strategy: crate::config::BalanceStrategy::default(), + hash_key: crate::config::BalanceHashKey::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, @@ -2416,7 +2424,7 @@ rules: ["DST-PORT,443,{target}", "MATCH,DIRECT"] let decision = router .route( network, - InboundKind::Socks5, + (InboundKind::Socks5, &Default::default()), "192.0.2.2:1234".parse().unwrap(), &Destination::Ip("203.0.113.1:443".parse().unwrap()), None, @@ -2472,6 +2480,7 @@ rules: ["MATCH,outer"] for attempt in 0..2 { let result = router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source: "192.0.2.2:5555".parse().unwrap(), destination: Destination::Ip("198.51.100.1:443".parse().unwrap()), @@ -2516,6 +2525,7 @@ rules: ["MATCH,balance"] for _ in 0..2 { router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source, destination: dest.clone(), @@ -2553,6 +2563,87 @@ rules: ["MATCH,balance"] transport.close().await.unwrap(); } + #[tokio::test] + async fn ingress_rules_and_user_hash_reach_tcp_udp_with_one_leaf() { + let mut config = crate::config::Config::parse_yaml( + br#" +mixed-port: 1080 +proxies: + - {name: a, type: socks5, server: 192.0.2.10, port: 1080, udp: true} + - {name: b, type: socks5, server: 192.0.2.11, port: 1080, udp: true} +proxy-groups: + - {name: balance, type: load-balance, proxies: [a,b], hash-key: in-user} +rules: + - 'AND,((IN-TYPE,HTTPS/SOCKS5),(IN-USER,Alice),(IN-PORT,1080),(SRC-IP-CIDR,192.0.2.0/24)),balance' + - 'MATCH,REJECT' +"#, + ) + .unwrap(); + let a = Arc::new(RecordingDispatcher::default()); + let b = Arc::new(RecordingDispatcher::default()); + let direct = Arc::new(RecordingDispatcher::default()); + let nodes: Vec> = vec![a.clone(), b.clone()]; + let groups = ProxyGroups::new(&config.proxy_groups, nodes.clone(), direct.clone()).unwrap(); + let router = RoutingDispatcher::new( + ProxyDispatchers::with_proxy_groups(nodes, &groups).unwrap(), + direct, + None, + RuleSet::compile(config.take_runtime_rules()).unwrap(), + Arc::new(EmptyGeoMatcher), + ); + let metadata = crate::session::InboundMetadata { + local_port: 1080, + user: Some(Arc::from("Alice")), + }; + for (source, remote) in [ + ("192.0.2.1:5555", "198.51.100.1:443"), + ("192.0.2.2:6666", "203.0.113.2:80"), + ] { + router + .connect_tcp(StreamSession { + metadata: metadata.clone(), + inbound: InboundKind::Https, + source: source.parse().unwrap(), + destination: Destination::Ip(remote.parse().unwrap()), + sniffed_domain: None, + }) + .await + .unwrap(); + } + let counts = [ + a.tcp_sessions.lock().unwrap().len(), + b.tcp_sessions.lock().unwrap().len(), + ]; + assert!(counts == [2, 0] || counts == [0, 2]); + let selected = usize::from(counts[0] == 0); + let nodes = [a, b]; + let mut udp = router + .open_datagram( + DatagramSession::new(InboundKind::Socks5, "192.0.2.3:7777".parse().unwrap()) + .with_metadata(metadata), + ) + .await + .unwrap(); + let datagram = Datagram { + remote: Destination::Ip("198.51.100.3:53".parse().unwrap()), + payload: Bytes::from_static(b"x"), + sniffed_domain: None, + }; + udp.send(datagram.clone()).await.unwrap(); + groups.apply_health( + 0, + if selected == 0 { + &[None, Some(10)] + } else { + &[Some(10), None] + }, + ); + udp.send(datagram).await.unwrap(); + assert_eq!(nodes[selected].datagrams.lock().unwrap().len(), 2); + assert_eq!(nodes[1 - selected].datagrams.lock().unwrap().len(), 0); + udp.close().await.unwrap(); + } + #[tokio::test] async fn subrule_routes_resume_dns_and_commit_one_tcp_udp_leaf() { let mut config = crate::config::Config::parse_yaml(br#" @@ -2588,6 +2679,7 @@ sub-rules: for _ in 0..2 { router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source, destination: Destination::domain("example.com", 443).unwrap(), @@ -2624,6 +2716,7 @@ sub-rules: assert!(matches!( global .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source, destination: Destination::Ip("198.51.100.1:443".parse().unwrap()), @@ -2679,6 +2772,7 @@ sub-rules: router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("example.com", 443).unwrap(), @@ -2765,6 +2859,7 @@ sub-rules: fn stream(destination: SocketAddr, inbound: InboundKind) -> StreamSession { StreamSession { + metadata: Default::default(), inbound, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::Ip(destination), @@ -2938,6 +3033,7 @@ sub-rules: assert!(matches!( router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("example.com", 443).unwrap(), @@ -2977,6 +3073,7 @@ sub-rules: router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("example.com", 443).unwrap(), @@ -3016,6 +3113,7 @@ sub-rules: router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Tun, source: "192.0.2.10:10000".parse().unwrap(), destination: Destination::Ip(destination), @@ -3286,6 +3384,7 @@ sub-rules: ); router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("例子.中国", 443).unwrap(), @@ -3312,6 +3411,7 @@ sub-rules: ); router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10001".parse().unwrap(), destination: Destination::domain("例子.中国", 443).unwrap(), @@ -4247,6 +4347,7 @@ sub-rules: router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::domain("example.com", 443).unwrap(), @@ -4308,6 +4409,7 @@ sub-rules: router .connect_tcp(StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10001".parse().unwrap(), destination: Destination::domain("example.com", 443).unwrap(), @@ -4431,6 +4533,6 @@ sub-rules: fn business(inbound: InboundKind) -> bool { matches!( inbound, - InboundKind::Tun | InboundKind::Http | InboundKind::Socks5 + InboundKind::Tun | InboundKind::Http | InboundKind::Https | InboundKind::Socks5 ) } diff --git a/src/routing/mod.rs b/src/routing/mod.rs index c796d68..19587b3 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -13,7 +13,7 @@ use std::{ use crate::{ config::{IpCidr, Network, RuleAction, RuleKind, RuleSpec, RuntimeRuleProgram}, - session::Destination, + session::{Destination, InboundKind, InboundMetadata}, }; mod program; @@ -45,7 +45,10 @@ pub const MAX_RULE_TEXT_BYTES: usize = crate::config::MAX_RULES_TOTAL_BYTES; pub struct RoutingContext { network: Network, destination_port: u16, - source: Option, + source_ip: Option, + source_port: u16, + inbound: InboundKind, + metadata: InboundMetadata, normalized_domain: Option>, destination_ip: Option, pinned_ip: Option, @@ -93,7 +96,10 @@ impl RoutingContext { Ok(Self { network, destination_port: destination.port(), - source: None, + source_ip: None, + source_port: 0, + inbound: InboundKind::InternalDns, + metadata: InboundMetadata::new(0), normalized_domain, destination_ip, pinned_ip: None, @@ -106,7 +112,28 @@ impl RoutingContext { } pub fn set_source(&mut self, source: SocketAddr) { - self.source = Some(source); + self.source_ip = Some(source.ip()); + self.source_port = source.port(); + } + + pub(crate) fn set_inbound(&mut self, kind: InboundKind, metadata: &InboundMetadata) { + self.inbound = kind; + self.metadata = metadata.clone(); + } + pub(crate) fn source_ip(&self) -> Option { + self.source_ip + } + pub(crate) fn authenticated_user(&self) -> Option<&str> { + self.metadata.user.as_deref() + } + /// A local rule-provider view only. Host and ingress identity are unchanged. + pub(crate) fn swap_source_destination(&mut self) { + let source = self.source_ip; + self.source_ip = self.effective_ip(); + self.destination_ip = source; + self.pinned_ip = None; + std::mem::swap(&mut self.source_port, &mut self.destination_port); + self.disable_ip_resolution(); } #[must_use] @@ -283,6 +310,9 @@ pub enum RuleEvaluation { #[derive(Debug, Clone, PartialEq, Eq)] pub enum RuleSetError { InvalidProgram, + InvalidInboundRule { + rule_index: usize, + }, TooManyRules { count: usize, maximum: usize, @@ -313,6 +343,10 @@ impl fmt::Display for RuleSetError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidProgram => formatter.write_str("invalid sub-rule program graph"), + Self::InvalidInboundRule { rule_index } => write!( + formatter, + "routing rule {rule_index} has an invalid inbound predicate" + ), Self::TooManyRules { count, maximum } => { write!(formatter, "routing rule count {count} exceeds {maximum}") } @@ -593,7 +627,7 @@ fn normalize_rule( *bytes = checked_text_bytes(*bytes, value.len())?; *domain = true; } - RuleKind::GeoIp(value) => { + RuleKind::GeoIp(value) | RuleKind::SrcGeoIp(value) => { *value = crate::geodata::normalize_selector(crate::geodata::GeoDataKind::GeoIp, value) .map_err(|_| RuleSetError::InvalidGeoCode { rule_index: index })?; *bytes = checked_text_bytes(*bytes, value.len())?; @@ -603,11 +637,30 @@ fn normalize_rule( return Err(RuleSetError::InvalidCidr { rule_index: index }); } } - RuleKind::DstPorts(ranges) | RuleKind::SrcPorts(ranges) => { - if ranges.is_empty() || ranges.iter().any(|range| range.start > range.end) { + RuleKind::DstPorts(ranges) | RuleKind::SrcPorts(ranges) | RuleKind::InPorts(ranges) => { + if ranges.is_empty() + || ranges.len() > 28 + || ranges.iter().any(|range| range.start > range.end) + { return Err(RuleSetError::InvalidPortRange { rule_index: index }); } } + RuleKind::InUsers(users) => { + if users.is_empty() || users.iter().any(|user| user.is_empty()) { + return Err(RuleSetError::InvalidInboundRule { rule_index: index }); + } + for user in users { + *bytes = checked_text_bytes(*bytes, user.len())?; + } + } + RuleKind::InTypes(types) => { + if types.is_empty() { + return Err(RuleSetError::InvalidInboundRule { rule_index: index }); + } + for kind in types { + *bytes = checked_text_bytes(*bytes, kind.name().len())?; + } + } RuleKind::And(children) | RuleKind::Or(children) => { for child in children { normalize_rule(child, index, depth + 1, bytes, domain)?; @@ -624,7 +677,9 @@ fn normalize_rule( pub(crate) fn uses_destination_ip(rule: &RuleSpec) -> bool { match &rule.kind { - RuleKind::RuleSet { content, .. } => content.as_ref().is_some_and(|set| set.uses_ip()), + RuleKind::RuleSet { + content, source, .. + } => !source && content.as_ref().is_some_and(|set| set.uses_ip()), RuleKind::IpCidr(_) | RuleKind::GeoIp(_) => true, RuleKind::And(children) | RuleKind::Or(children) => { children.iter().any(uses_destination_ip) @@ -653,8 +708,10 @@ pub(crate) fn matches_rule( .any(|range| range.start <= port && port <= range.end) }; Ok(match &rule.kind { - RuleKind::RuleSet { content, .. } => match content { - Some(set) => set.matches(context, geo, rule.no_resolve)?, + RuleKind::RuleSet { + content, source, .. + } => match content { + Some(set) => set.matches(context, geo, rule.no_resolve, *source)?, None => false, }, RuleKind::Domain(value) => context.domain() == Some(value.as_str()), @@ -677,10 +734,10 @@ pub(crate) fn matches_rule( .is_some_and(|domain| geo.matches_geosite(code, domain)) } RuleKind::GeoIp(code) => { - if !geo.geoip_available(code) { + if !geoip_available(geo, code) { false } else if let Some(address) = context.effective_ip() { - geo.matches_geoip(code, address) + matches_geoip(geo, code, address) } else if !rule.no_resolve && context.should_request_ip_resolution() { return Err(()); } else { @@ -696,12 +753,21 @@ pub(crate) fn matches_rule( false } } - RuleKind::SrcIpCidr(cidr) => context - .source - .is_some_and(|source| cidr_contains(cidr, source.ip())), - RuleKind::SrcPorts(ranges) => context - .source - .is_some_and(|source| port_matches(ranges, source.port())), + RuleKind::SrcGeoIp(code) => { + geoip_available(geo, code) + && context + .source_ip + .is_some_and(|address| matches_geoip(geo, code, address)) + } + RuleKind::SrcIpCidr(cidr) => context.source_ip.is_some_and(|ip| cidr_contains(cidr, ip)), + RuleKind::SrcPorts(ranges) => port_matches(ranges, context.source_port), + RuleKind::InTypes(types) => types.contains(&crate::config::RuleInboundType::for_inbound( + context.inbound, + )), + RuleKind::InUsers(users) => context + .authenticated_user() + .is_some_and(|user| users.iter().any(|expected| expected == user)), + RuleKind::InPorts(ranges) => port_matches(ranges, context.metadata.local_port), RuleKind::DstPorts(ranges) => port_matches(ranges, context.destination_port), RuleKind::Network(network) => *network == context.network, RuleKind::And(children) => { @@ -728,6 +794,13 @@ pub(crate) fn matches_rule( }) } +fn geoip_available(geo: &dyn GeoMatcher, code: &str) -> bool { + crate::geodata::lan_selector(code) || geo.geoip_available(code) +} +fn matches_geoip(geo: &dyn GeoMatcher, code: &str, ip: IpAddr) -> bool { + crate::geodata::matches_lan(code, ip).unwrap_or_else(|| geo.matches_geoip(code, ip)) +} + fn wildcard_matches(pattern: &str, value: &str) -> bool { let (pattern, value) = (pattern.as_bytes(), value.as_bytes()); let (mut p, mut v) = (0, 0); diff --git a/src/routing/proxy_group.rs b/src/routing/proxy_group.rs index 50d80f4..4e0d559 100644 --- a/src/routing/proxy_group.rs +++ b/src/routing/proxy_group.rs @@ -256,7 +256,7 @@ impl ProxyGroups { &self, group_id: ProxyGroupId, ) -> Result { - self.resolve_leaf_for(group_id, false, None, None) + self.resolve_leaf_for(group_id, false, None, None, None) } pub(crate) fn resolve_leaf_for( @@ -265,6 +265,7 @@ impl ProxyGroups { udp: bool, source: Option, destination: Option<&crate::session::Destination>, + user: Option<&str>, ) -> Result { let mut chain = Vec::new(); let mut selections = Vec::new(); @@ -277,7 +278,7 @@ impl ProxyGroups { if udp && group.disable_udp { return Err(DispatchError::NotAllowed); } - let selected = group.selected.choose(source, destination); + let selected = group.selected.choose_for(source, destination, user); let member = group.members.get(selected).ok_or_else(|| { DispatchError::Other(format!( "proxy group {} has invalid selected index {selected}", @@ -340,6 +341,7 @@ impl ProxyGroups { commit: bool, source: Option, destination: Option<&crate::session::Destination>, + user: Option<&str>, ) -> Result { let mut inspected = Vec::new(); let mut chain = Vec::new(); @@ -357,7 +359,7 @@ impl ProxyGroups { { return Err(DispatchError::NotAllowed); } - let selected = group.selected.inspect(source, destination); + let selected = group.selected.inspect(source, destination, user); let member = group .members .get(selected.index) @@ -548,6 +550,7 @@ impl Dispatcher for SelectProxyGroupDispatcher { false, Some(session.source), Some(&session.destination), + session.metadata.user.as_deref(), )?; let result = leaf.dispatcher.connect_tcp(session).await; leaf.report(&result); @@ -567,7 +570,13 @@ impl Dispatcher for SelectProxyGroupDispatcher { })); } self.groups - .resolve_leaf_for(self.id, true, Some(session.source), None)? + .resolve_leaf_for( + self.id, + true, + Some(session.source), + None, + session.metadata.user.as_deref(), + )? .dispatcher .open_datagram(session) .await @@ -591,6 +600,7 @@ impl DatagramTransport for BalancedDatagrams { true, Some(self.session.source), Some(&datagram.remote), + self.session.metadata.user.as_deref(), )?; self.transport = Some(leaf.dispatcher.open_datagram(self.session.clone()).await?); } @@ -763,6 +773,7 @@ mod tests { SelectProxyGroupConfig { kind: crate::config::GroupKind::Select, strategy: crate::config::BalanceStrategy::default(), + hash_key: crate::config::BalanceHashKey::default(), health: crate::config::HealthCheckConfig::default(), tolerance: 0, max_failed_times: 5, @@ -777,6 +788,7 @@ mod tests { fn stream_session() -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:10000".parse().unwrap(), destination: Destination::Ip("192.0.2.1:443".parse().unwrap()), diff --git a/src/routing/selection.rs b/src/routing/selection.rs index 35eb801..84ff933 100644 --- a/src/routing/selection.rs +++ b/src/routing/selection.rs @@ -47,33 +47,43 @@ impl GroupSelection { if config.kind == crate::config::GroupKind::LoadBalance { let mut state = self.balance.lock().unwrap(); if state.is_none() { - *state = Some(super::balance::Balance::new( - config.strategy, - config.members.len(), - )); + *state = Some( + super::balance::Balance::new(config.strategy, config.members.len()) + .with_hash_key(config.hash_key), + ); } } } + #[cfg(test)] pub fn choose( &self, source: Option, destination: Option<&crate::session::Destination>, + ) -> usize { + self.choose_for(source, destination, None) + } + pub fn choose_for( + &self, + source: Option, + destination: Option<&crate::session::Destination>, + user: Option<&str>, ) -> usize { self.touch(); self.balance.lock().unwrap().as_mut().map_or_else( || self.load(Ordering::Acquire), - |s| s.choose(source, destination), + |s| s.choose(source, destination, user), ) } pub(super) fn inspect( &self, source: Option, destination: Option<&crate::session::Destination>, + user: Option<&str>, ) -> InspectedSelection<'_> { let mut balance = self.balance.lock().unwrap(); let index = balance.as_mut().map_or_else( || self.load(Ordering::Acquire), - |state| state.inspect(source, destination, false), + |state| state.inspect(source, destination, user, false), ); InspectedSelection { owner: self, diff --git a/src/runtime.rs b/src/runtime.rs index 248f93d..c65d427 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1996,6 +1996,7 @@ rules: let result = parts .dispatcher .connect_tcp(crate::session::StreamSession { + metadata: Default::default(), inbound: crate::session::InboundKind::Socks5, source: "127.0.0.1:12345".parse().unwrap(), destination: crate::session::Destination::Ip("192.0.2.1:443".parse().unwrap()), diff --git a/src/runtime_group_tests.rs b/src/runtime_group_tests.rs index 390fb83..14e864d 100644 --- a/src/runtime_group_tests.rs +++ b/src/runtime_group_tests.rs @@ -414,6 +414,7 @@ mod wire { fn session(host: &str) -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::Http, source: "127.0.0.1:18081".parse().unwrap(), destination: Destination::domain(host, 443).unwrap(), diff --git a/src/session.rs b/src/session.rs index fe736dc..ecce792 100644 --- a/src/session.rs +++ b/src/session.rs @@ -103,6 +103,7 @@ impl fmt::Display for Destination { pub enum InboundKind { Tun, Http, + Https, Socks5, /// Core-originated DNS traffic sent through the raw proxy dispatcher. /// This context must never be passed back through the routing dispatcher. @@ -114,8 +115,34 @@ pub enum InboundKind { InternalMeasure, } +/// Authenticated ingress identity owned by a request or authorized association. +/// Debug never exposes usernames; the Arc does not retain credential/config state. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct InboundMetadata { + pub local_port: u16, + pub user: Option>, +} +impl fmt::Debug for InboundMetadata { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("InboundMetadata") + .field("local_port", &self.local_port) + .field("authenticated", &self.user.is_some()) + .finish() + } +} +impl InboundMetadata { + pub const fn new(local_port: u16) -> Self { + Self { + local_port, + user: None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct StreamSession { + pub metadata: InboundMetadata, pub inbound: InboundKind, pub source: SocketAddr, pub destination: Destination, @@ -128,6 +155,7 @@ pub struct StreamSession { #[derive(Debug, Clone, PartialEq, Eq)] pub struct DatagramSession { + pub metadata: InboundMetadata, pub inbound: InboundKind, pub source: SocketAddr, max_response_payload_size: u16, @@ -147,10 +175,11 @@ impl DatagramSession { InboundKind::InternalDns => INTERNAL_DNS_XUDP_MAX_RESPONSE_PAYLOAD_SIZE, InboundKind::InternalGeoData => XUDP_MAX_RESPONSE_PAYLOAD_SIZE, InboundKind::InternalMeasure => XUDP_MAX_RESPONSE_PAYLOAD_SIZE, - InboundKind::Http => XUDP_MAX_RESPONSE_PAYLOAD_SIZE, + InboundKind::Http | InboundKind::Https => XUDP_MAX_RESPONSE_PAYLOAD_SIZE, InboundKind::Socks5 => SOCKS5_UDP_PAYLOAD_LIMIT, }; Self { + metadata: InboundMetadata::new(0), inbound, source, max_response_payload_size, @@ -162,6 +191,7 @@ impl DatagramSession { pub(crate) const fn for_tun(source: SocketAddr, mtu: usize) -> Self { let payload_size = mtu.saturating_sub(IPV6_UDP_OVERHEAD); Self { + metadata: InboundMetadata::new(0), inbound: InboundKind::Tun, source, max_response_payload_size: if payload_size > u16::MAX as usize { @@ -172,6 +202,12 @@ impl DatagramSession { } } + #[must_use] + pub fn with_metadata(mut self, metadata: InboundMetadata) -> Self { + self.metadata = metadata; + self + } + #[must_use] #[cfg_attr(not(feature = "outbound-vless"), allow(dead_code))] pub(crate) const fn max_response_payload_size(&self) -> u16 { diff --git a/src/sniffing.rs b/src/sniffing.rs index 91989f6..680d018 100644 --- a/src/sniffing.rs +++ b/src/sniffing.rs @@ -237,6 +237,7 @@ sniffer: input.extend_from_slice(b"business-bytes"); let mut reader = input.as_slice(); let mut session = StreamSession { + metadata: Default::default(), inbound: InboundKind::Tun, source, destination: destination.clone(), diff --git a/src/tun_runtime.rs b/src/tun_runtime.rs index b99c9ad..f028198 100644 --- a/src/tun_runtime.rs +++ b/src/tun_runtime.rs @@ -725,6 +725,7 @@ async fn relay_tcp( let destination_port = destination.port(); let ip_version = if destination.is_ipv4() { 4 } else { 6 }; let mut session = StreamSession { + metadata: crate::session::InboundMetadata::new(destination_port), inbound: InboundKind::Tun, source: inbound.source_addr(), destination: Destination::Ip(destination), diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index e55c188..a9871b3 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -2389,6 +2389,7 @@ async fn synthetic_fd_with_dns_disabled_dispatches_tcp_and_reuses_udp_associatio assert_eq!( dispatcher.tcp_sessions.lock().unwrap()[0], StreamSession { + metadata: Default::default(), inbound: InboundKind::Tun, source: tcp_source, destination: Destination::Ip(tcp_destination), @@ -2497,6 +2498,7 @@ async fn unconfigured_tls_port_dispatches_without_reading_a_prefix() { assert_eq!( dispatcher.tcp_sessions.lock().unwrap()[0], StreamSession { + metadata: Default::default(), inbound: InboundKind::Tun, source, destination: Destination::Ip(destination), diff --git a/tests/README.md b/tests/README.md index 3addeb2..79e206e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -26,6 +26,7 @@ cargo test --locked --all-features --lib polled_datagram_ cargo test --locked --all-features --lib managed_udp_connections_ cargo test --locked --all-features --lib runtime::tests::controller_diagnostics_are_opt_in_for_business_routes cargo test --locked --all-features --lib subrule_ +cargo test --locked --all-features --lib ingress_ cargo test --locked --all-features --lib routing::balance::tests:: cargo test --locked --all-features --lib load_balance_routes_ cargo test --locked --all-features --lib dialer::connections::tests:: diff --git a/tests/resolution_foundations.rs b/tests/resolution_foundations.rs index c50fea1..f21c59f 100644 --- a/tests/resolution_foundations.rs +++ b/tests/resolution_foundations.rs @@ -67,6 +67,7 @@ async fn measurement_domain_resolution_reaches_both_final_and_upstream_ip_bounda ConnectorDispatcher::new(connector.clone()).with_resolution(resolution.clone()); let target = Destination::domain("controlled.example", 443).unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:0".parse().unwrap(), destination: target.clone(), diff --git a/tests/shadowsocks_backpressure.rs b/tests/shadowsocks_backpressure.rs index 7aa59e9..6015142 100644 --- a/tests/shadowsocks_backpressure.rs +++ b/tests/shadowsocks_backpressure.rs @@ -80,6 +80,7 @@ async fn growing_caller_buffer_after_backpressure_never_loses_plaintext() { .unwrap(); let target = Destination::domain("target.invalid", 80).unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source: "127.0.0.1:1234".parse().unwrap(), destination: target, @@ -168,6 +169,7 @@ async fn read_first_flushes_the_official_header_over_a_buffered_upstream() { let mut stream = outbound .connect_stream( StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source: "127.0.0.1:1234".parse().unwrap(), destination: Destination::domain("target.invalid", 80).unwrap(), diff --git a/tests/sing_mux.rs b/tests/sing_mux.rs index feded63..8f64a68 100644 --- a/tests/sing_mux.rs +++ b/tests/sing_mux.rs @@ -179,6 +179,7 @@ fn outbound(peer: Arc, options: &str) -> VlessOutbound { async fn open(node: &VlessOutbound) -> BoxStream { node.connect_stream( StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: Destination::Ip("192.0.2.1:443".parse().unwrap()), diff --git a/tests/tuic_memory.rs b/tests/tuic_memory.rs index e58e97f..e7f409b 100644 --- a/tests/tuic_memory.rs +++ b/tests/tuic_memory.rs @@ -133,6 +133,7 @@ fn fixture_parts( fn request() -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::Socks5, source: "127.0.0.1:1".parse().unwrap(), destination: Destination::domain("x", 80).unwrap(), diff --git a/tests/vless_codec.rs b/tests/vless_codec.rs index 1315341..45bbd4f 100644 --- a/tests/vless_codec.rs +++ b/tests/vless_codec.rs @@ -224,6 +224,7 @@ async fn tcp_bad_response_header_poisoning_and_absolute_response_deadline() { let mut connected = outbound .connect_stream( StreamSession { + metadata: Default::default(), inbound: session().inbound, source: session().source, destination: packet().remote, diff --git a/tests/vless_lifecycle.rs b/tests/vless_lifecycle.rs index 37fdd01..62c3bdb 100644 --- a/tests/vless_lifecycle.rs +++ b/tests/vless_lifecycle.rs @@ -78,6 +78,7 @@ async fn vless_all_handshakes_keep_the_original_deadline_and_join_cancelled_io() ) .unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: "192.0.2.2:80" @@ -133,7 +134,7 @@ fn split_xhttp_construction_uses_default_stack_and_protect_failure_has_no_fallba let protect=Arc::new(RejectProtect(AtomicUsize::new(0))); let endpoint=ResolvedEndpoint {logical_host:"192.0.2.1".into(),port:443,addresses:vec!["192.0.2.1:443".parse().unwrap()]}; let outbound=VlessOutbound::new(config, endpoint, Dialer::default().with_protector(protect.clone())).unwrap(); - let session=StreamSession {inbound:InboundKind::InternalMeasure,source:"127.0.0.1:1".parse().unwrap(),destination:"192.0.2.2:80".parse::().unwrap().into(),sniffed_domain:None}; + let session=StreamSession {metadata: Default::default(),inbound:InboundKind::InternalMeasure,source:"127.0.0.1:1".parse().unwrap(),destination:"192.0.2.2:80".parse::().unwrap().into(),sniffed_domain:None}; assert!(outbound.connect_stream(session, &EstablishContext::default()).await.is_err()); assert!((1..=2).contains(&protect.0.load(Ordering::SeqCst))); outbound.shutdown().await; @@ -165,6 +166,7 @@ async fn vless_expired_deadline_and_protect_failure_never_fall_back() { ) .unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: "192.0.2.2:80" diff --git a/tests/vless_transports.rs b/tests/vless_transports.rs index 9b94cbe..4409419 100644 --- a/tests/vless_transports.rs +++ b/tests/vless_transports.rs @@ -57,6 +57,7 @@ fn node(io: BoxStream, extra: serde_json::Value) -> VlessOutbound { } fn session() -> StreamSession { StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: Destination::Ip("192.0.2.1:80".parse().unwrap()), diff --git a/tests/vmess_lifecycle.rs b/tests/vmess_lifecycle.rs index 932c824..932f4f3 100644 --- a/tests/vmess_lifecycle.rs +++ b/tests/vmess_lifecycle.rs @@ -94,6 +94,7 @@ async fn vmess_all_handshakes_keep_the_original_deadline_and_join_cancelled_io() ) .unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: "192.0.2.2:80" @@ -159,6 +160,7 @@ async fn vmess_expired_deadline_and_protect_failure_never_fall_back() { ) .unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: "192.0.2.2:80" diff --git a/tests/xhttp_budget.rs b/tests/xhttp_budget.rs index 45ec597..faa8026 100644 --- a/tests/xhttp_budget.rs +++ b/tests/xhttp_budget.rs @@ -86,6 +86,7 @@ async fn h3_rejects_incapable_or_small_budget_upstreams_before_any_datagram() { let result = outbound .connect_stream( StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: "127.0.0.1:1".parse().unwrap(), destination: "192.0.2.2:80" diff --git a/tests/xhttp_h3_shutdown.rs b/tests/xhttp_h3_shutdown.rs index 990d4ba..6500560 100644 --- a/tests/xhttp_h3_shutdown.rs +++ b/tests/xhttp_h3_shutdown.rs @@ -249,6 +249,7 @@ async fn run(mode: &str, closing: Closing, handshake_delay: Option) { ) .unwrap(); let session = StreamSession { + metadata: Default::default(), inbound: InboundKind::InternalMeasure, source: client_address, destination: Destination::domain("target.invalid", 443).unwrap(), From 85605a6a20e1c9353d6709032e65f2e2dc59e5d0 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 12:14:56 +0800 Subject: [PATCH 034/445] Add live Controller probes and process memory snapshots --- ...roxy-group-selection-through-controller.md | 4 +- docs/config.yaml | 9 +- docs/controller-api.md | 32 +- src/config/mod.rs | 19 +- src/connections.rs | 4 +- src/controller.rs | 152 ++++++ src/lib.rs | 1 + src/memory.rs | 105 ++++ src/resource/control.rs | 39 ++ src/resource/probe.rs | 37 +- src/runtime/health.rs | 469 +++++++++++++++--- src/runtime/providers.rs | 63 ++- tests/README.md | 2 + 13 files changed, 837 insertions(+), 99 deletions(-) create mode 100644 src/memory.rs diff --git a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md index 6d354bc..de0132b 100644 --- a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md +++ b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md @@ -6,8 +6,8 @@ status: accepted Vole 首版只通过 Controller 暴露代理组的实时选择,不新增 Invoke 方法,也不扩展 Windows 控制管道。Controller 采用 mihomo `select` 的核心调用形状:`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`;PUT body 为 `{"name":"member"}`,成功返回空的 `204`。组状态只承诺 `name`、`type`、`all`、`now`,不宣称兼容完整 mihomo Dashboard API。 -Windows WinRT VPN 的实际运行时位于独立 Session Host,桌面 Wintun、Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。 +Windows WinRT VPN 的实际运行时位于独立 Session Host,桌面 Wintun、Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。Controller 也可用于合法 mixed-only 静态节点或纯 DIRECT 配置,无需额外开启 TUN 或显式代理组;组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。 配置同时包含代理组和 Controller 时,必须为整个 Controller 配置 Bearer secret。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;Core 按 `profile.store-selected`(默认 true)保存 Controller 的显式选择;下次启动优先恢复仍存在的缓存成员,否则使用配置默认或首成员。持久化失败不回滚已经成功的运行时选择。 -Controller 同时展示动态 provider、自动组和节点健康状态;组选择与探测任务均位于实际运行会话。独立 measureDelay 保持 node-only Invoke 接口,复用内部 HTTP/TLS 探测能力。 +Controller 同时展示动态 provider、自动组和节点健康状态;组选择与探测任务均位于实际运行会话。Controller 的节点/组 delay 与 Provider healthcheck 在当前资源代际中共用自动健康探测器、DNS、Dialer 和停止屏障;每节点手动历史有界,实际内存仅在请求时采集。独立 measureDelay 保持 node-only Invoke 接口,复用内部 HTTP/TLS 探测能力。 diff --git a/docs/config.yaml b/docs/config.yaml index b85394b..cacca7d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -70,13 +70,14 @@ skip-auth-prefixes: [] # 仅跳过认证,仍执行来源访问控制 authentication: - "probe-user:probe-password" -# 可选的运行时 Controller,地址必须是回环 IP 和显式非零端口。它可服务于启用 -# TUN 的配置或含 proxy-groups 的配置;运行配置仍需开启 mixed-port 或 TUN。 -# 只要 Controller 管理 proxy-groups,secret 就必填;仅有 TUN 流量接口时可省略。 +# 可选的运行时 Controller,地址必须是回环 IP 和显式非零端口;合法 mixed-only +# 静态节点与纯 DIRECT 配置也可使用,运行配置仍需开启 mixed-port 或 TUN。 +# 同时显式配置 proxy-groups 时 secret 必填;没有显式代理组时可省略。 # secret 出现时必须为 1–255 UTF-8 字节,并作为全部 Controller 路由的 Bearer token。 # Controller 提供 GET /traffic、GET /group、GET /group/{name}、 # GET /proxies/{name} 和 PUT /proxies/{name};另含连接查询/关闭、有效配置、规则 -# 与 A/AAAA DNS 查询,完整接口见 controller-api.md;不属于 Invoke API。 +# 与 A/AAAA DNS 查询、节点/组 delay、Provider healthcheck、实际进程内存; +# 完整接口见 controller-api.md;不属于 Invoke API。 external-controller: 127.0.0.1:9090 secret: "vole-runtime-secret" diff --git a/docs/controller-api.md b/docs/controller-api.md index 090c2ae..a081d88 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -10,10 +10,10 @@ secret: "vole-runtime-secret" ``` - 省略 `external-controller` 表示不启动 Controller,也不采集业务连接、规则命中和建链诊断,此时不能单独配置 `secret`。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 -- `external-controller` 只接受带显式非零端口的回环 IP 地址。它要求启用 TUN 或至少显式定义一个 `proxy-groups`;非 TUN 配置须通过 `mixed-port` 满足运行配置的入站要求。 -- 只要同时显式配置代理组与 Controller,`secret` 就必填;仅有 TUN 流量接口时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 +- `external-controller` 只接受带显式非零端口的回环 IP 地址。合法的 mixed-only 静态节点和纯 DIRECT 配置均可使用;非 TUN 配置仍通过 `mixed-port` 满足入站要求。 +- 只要同时显式配置代理组与 Controller,`secret` 就必填;没有显式代理组时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 -- `validateConfig` 只校验字段;`prepare` 不监听端口;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 +- `validateConfig` 只校验字段;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 Controller 与公共运行时 session 同生共灭。`profile.store-selected` 默认 true,Core 将显式选择保存到 dataDir/selected.json,在下次启动时恢复有效成员;不写回 YAML。关闭该选项后只保留会话内选择。写入失败不回滚已生效的选择,`GET /proxies` 的 `selectionError` 给出脱敏原因。 @@ -96,6 +96,26 @@ load-balance 根据每条新连接的元数据选择成员,响应不含 `now` url-test/fallback 的手动选择在该成员健康时保持固定,失败后恢复自动选择。url-test 使用 tolerance 抑制小幅切换,fallback 按成员顺序选择与恢复。 +## 主动探测与内存 + +`GET /proxies/{name}/delay?url=https%3A%2F%2Fexample.com%2F&timeout=5000&expected=200-299` +通过当前节点或代理组探测 URL,成功返回 `{"delay":12}`,单位毫秒。 +`GET /group/{name}/delay` 使用相同 query,返回成功成员的名称到毫秒映射,并清除自动组的手动固定选择。 +url-test 组采用配置中的测试 URL,其他组采用请求 URL。 +`timeout` 必须传入:节点路由为有符号 16 位毫秒,组路由为有符号 32 位毫秒;非正数立即到期。 +`expected` 可省略,或使用现有状态范围语法。未知目标返回 404,无效请求 400,探测超时 504,节点失败或零 delay 503。 +HTTP 响应完成时即返回延迟,即使状态不符合 `expected`;该 URL 的健康记录会记为失败。 +节点的一般存活状态记录传输是否完成,与配置 URL 的健康状态分开。 + +`GET /providers/proxies/{name}/healthcheck` 使用 Provider 已配置的健康 URL、timeout 和 expected-status, +完成后返回 204;单个成员失败写入共享健康状态,不使整个响应失败。探测使用当前资源代际、DNS 和出口, +排除业务连接和流量统计,不触碰 lazy 活动时间。主动与自动探测共用每代际 10 个并发槽,停止取消所有等待。 +每个节点只保留一份最近手动结果;任意 URL 不增加无界历史。同 URL 的配置探测状态同步更新,供自动组选路使用。 + +`GET /memory` 返回一次 `{"inuse":123456,"oslimit":null}` 快照:Linux/Android 读取自身 `/proc/self/status` 的 VmRSS, +Apple 使用自身 Mach task resident size,Windows 使用当前进程 working set。单位均为字节;读取失败为 null。 +未获取进程内存上限,oslimit 为 null。该路由不提供 Mihomo 的周期流式响应,不启动后台采样;不同平台口径不能直接横比。 + ## `PUT /proxies/{name}` 切换组的当前直接成员: @@ -179,7 +199,7 @@ Expire 为 Unix 秒。未知时省略该对象,缺失子项为 0;不以本 扩展字段 `dnsPath` 是当前建链任务实际经过的有界 DNS 阶段标签,例如缓存、policy、 fallback、fake-IP 与解析角色;不包含上游地址、URL 或认证头。 - 扩展字段 `errors` 最多保留最近 32 次建链/数据报失败的连接信息与固定错误码, - 不返回底层错误原文。`memory` 当前为 0,表示未采集进程 RSS。 + 不返回底层错误原文。`memory` 是本次请求采集的当前进程 resident bytes,不可用时为 null。 连接元数据保存原生地址、端口和时间,重复的节点、组及规则文本共享存储。 查询只在短锁内取得连接引用,再固定计数和 DNS 路径并直接序列化;快照不保活 @@ -226,8 +246,10 @@ policy 与 fallback 仍生效。DNS 关闭或查询失败返回 500。这里只 | `411 Length Required` | PUT 缺少 `Content-Length` | | `413 Payload Too Large` | PUT 正文超过 1 KiB | | `415 Unsupported Media Type` | PUT 不是严格 `application/json` | +| `503 Service Unavailable` | 探测或资源更新失败 | +| `504 Gateway Timeout` | 主动探测超时 | -当前不提供 Controller 主动 delay 测试、配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的连接、规则、配置、DNS 和 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 +当前不提供配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的连接、规则、配置、DNS、主动探测、内存和 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; diff --git a/src/config/mod.rs b/src/config/mod.rs index 1ebca78..9484a1e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2074,7 +2074,6 @@ impl RawVoleConfig { let external_controller = normalize_external_controller( self.external_controller, self.secret, - tun.enable, has_explicit_groups, )?; @@ -2143,7 +2142,6 @@ impl RawVoleConfig { fn normalize_external_controller( listen: Option, secret: Option, - tun_enabled: bool, has_proxy_groups: bool, ) -> Result> { let Some(listen) = listen else { @@ -2152,9 +2150,6 @@ fn normalize_external_controller( } return Ok(None); }; - if !tun_enabled && !has_proxy_groups { - return invalid("external-controller requires tun.enable: true or proxy-groups"); - } if has_proxy_groups && secret.is_none() { return invalid("secret is required when external-controller manages proxy-groups"); } @@ -4662,6 +4657,16 @@ tun: assert!(!debug.contains("private-token")); } + #[test] + fn admits_mixed_only_direct_controller() { + let config = Config::parse_yaml( + b"mixed-port: 1080\nexternal-controller: 127.0.0.1:19090\nrules:\n - MATCH,DIRECT\n", + ) + .unwrap(); + assert!(config.external_controller.is_some()); + assert!(config.proxies.is_empty()); + } + #[test] fn rejects_unsafe_or_misplaced_traffic_controller_config() { for (fields, expected) in [ @@ -4669,10 +4674,6 @@ tun: "mixed-port: 1080\nauthentication:\n - measure:secret\nsecret: token", "secret requires external-controller", ), - ( - "mixed-port: 1080\nauthentication:\n - measure:secret\nexternal-controller: \"127.0.0.1:19090\"", - "external-controller requires tun.enable", - ), ( "external-controller: \"0.0.0.0:19090\"\ntun:\n enable: true", "loopback", diff --git a/src/connections.rs b/src/connections.rs index 4207f8d..2dbebe7 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -154,6 +154,7 @@ impl Serialize for Failure { pub(crate) struct Snapshot { connections: Vec, failures: Vec>, + memory: Option, upload: u64, download: u64, } @@ -163,7 +164,7 @@ impl Serialize for Snapshot { state.serialize_field("uploadTotal", &self.upload)?; state.serialize_field("downloadTotal", &self.download)?; state.serialize_field("connections", &self.connections)?; - state.serialize_field("memory", &0u8)?; + state.serialize_field("memory", &self.memory)?; state.serialize_field("errors", &SharedSlice(&self.failures))?; state.end() } @@ -228,6 +229,7 @@ impl Registry { Snapshot { connections, failures, + memory: crate::memory::resident_bytes(), upload: self.totals.upload.load(Ordering::Relaxed), download: self.totals.download.load(Ordering::Relaxed), } diff --git a/src/controller.rs b/src/controller.rs index 81552cc..a7b20d0 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -164,6 +164,59 @@ async fn handle_connection( .await; } + if request.target == "/memory" { + if request.method != "GET" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; + } + let value = serde_json::json!({"inuse":crate::memory::resident_bytes(),"oslimit":null}); + return write_json(&mut stream, 200, &value, &[]).await; + } + let route_path = request + .target + .split_once('?') + .map_or(request.target.as_str(), |(path, _)| path); + let probe_route = route_path.strip_suffix("/delay").and_then(|base| { + base.strip_prefix("/proxies/") + .map(|name| (false, name)) + .or_else(|| base.strip_prefix("/group/").map(|name| (true, name))) + }); + if let Some((group, raw_name)) = probe_route { + if request.method != "GET" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; + } + return delay_query( + &mut stream, + group, + raw_name, + &request.target, + resources.as_deref(), + &cancellation, + ) + .await; + } + if let Some(raw_name) = route_path + .strip_prefix("/providers/proxies/") + .and_then(|tail| tail.strip_suffix("/healthcheck")) + { + if request.method != "GET" { + return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; + } + let Ok(name) = decode_group_name(raw_name) else { + return write_error(&mut stream, 400, "Bad request", &[]).await; + }; + let Some(resources) = resources.as_deref() else { + return write_error(&mut stream, 404, "Resource not found", &[]).await; + }; + let result = tokio::select! { biased; + () = cancellation.cancelled() => return Ok(()), + result = resources.provider_healthcheck(&name, &cancellation) => result, + }; + return match result { + Ok(()) => write_response(&mut stream, 204, &[], &[]).await, + Err(error) => probe_error(&mut stream, error).await, + }; + } + if request.target == "/dns/query" || request.target.starts_with("/dns/query?") { if request.method != "GET" { return write_error(&mut stream, 405, "Method not allowed", &[("Allow", "GET")]).await; @@ -401,6 +454,86 @@ async fn handle_connection( write_error(&mut stream, 404, "Resource not found", &[]).await } +fn parse_delay(target: &str, group: bool) -> Result { + if target.contains('#') { + return Err(()); + } + let (_, raw) = target.split_once('?').ok_or(())?; + let mut values = std::collections::BTreeMap::new(); + for (key, value) in url::form_urlencoded::parse(raw.as_bytes()) { + values.entry(key).or_insert(value); + } + let url = values.get("url").ok_or(())?.to_string(); + crate::resource::probe::Target::parse(&url).map_err(|_| ())?; + let raw_timeout = values.get("timeout").ok_or(())?; + let millis = if group { + raw_timeout.parse::().map(i64::from) + } else { + raw_timeout.parse::().map(i64::from) + } + .map_err(|_| ())?; + let expected = + crate::config::status_ranges(values.get("expected").map_or("", |value| value.as_ref())) + .map_err(|_| ())?; + Ok(crate::resource::control::DelayRequest { + url, + timeout: Duration::from_millis(millis.max(0) as u64), + expected, + }) +} + +async fn probe_error( + stream: &mut TcpStream, + error: crate::resource::control::ProbeError, +) -> io::Result<()> { + use crate::resource::control::ProbeError; + match error { + ProbeError::NotFound => write_error(stream, 404, "Resource not found", &[]).await, + ProbeError::Timeout => write_error(stream, 504, "Delay test timed out", &[]).await, + ProbeError::Failed => write_error(stream, 503, "Delay test failed", &[]).await, + ProbeError::Stopped => Ok(()), + } +} + +async fn delay_query( + stream: &mut TcpStream, + group: bool, + raw_name: &str, + target: &str, + resources: Option<&dyn crate::resource::control::RuntimeResources>, + cancellation: &CancellationToken, +) -> io::Result<()> { + let (Ok(name), Ok(request)) = (decode_group_name(raw_name), parse_delay(target, group)) else { + return write_error(stream, 400, "Bad request", &[]).await; + }; + let Some(resources) = resources else { + return write_error(stream, 404, "Resource not found", &[]).await; + }; + let operation = async { + if group { + resources + .group_delay(&name, &request, cancellation) + .await + .map(|value| serde_json::json!(value)) + } else { + let delay = resources.node_delay(&name, &request, cancellation).await?; + if delay == 0 { + Err(crate::resource::control::ProbeError::Failed) + } else { + Ok(serde_json::json!({"delay":delay})) + } + } + }; + let result = tokio::select! { biased; + () = cancellation.cancelled() => return Ok(()), + result = operation => result, + }; + match result { + Ok(value) => write_json(stream, 200, &value, &[]).await, + Err(error) => probe_error(stream, error).await, + } +} + async fn handle_select( stream: &mut TcpStream, headers: &[(String, String)], @@ -710,6 +843,8 @@ async fn write_response( 413 => "Payload Too Large", 415 => "Unsupported Media Type", 500 => "Internal Server Error", + 503 => "Service Unavailable", + 504 => "Gateway Timeout", _ => "Error", }; let mut response = format!( @@ -807,6 +942,23 @@ mod tests { response.split_once("\r\n\r\n").unwrap().1 } + #[test] + fn delay_query_preserves_reference_timeout_width_and_expected_ranges() { + let base = "/proxies/edge/delay?url=http%3A%2F%2Fexample.test%2F&timeout=30000&expected=200-204%2F302"; + let request = parse_delay(base, false).unwrap(); + assert_eq!(request.timeout, Duration::from_secs(30)); + assert_eq!(request.expected, [(200, 204), (302, 302)]); + assert!(parse_delay(&base.replace("30000", "40000"), false).is_err()); + assert!(parse_delay(&base.replace("30000", "40000"), true).is_ok()); + assert_eq!( + parse_delay(&base.replace("30000", "-1"), false) + .unwrap() + .timeout, + Duration::ZERO + ); + assert!(parse_delay("/proxies/edge/delay?timeout=1", false).is_err()); + } + #[test] fn bearer_auth_is_strict_and_duplicate_safe() { let valid = vec![("Authorization".to_owned(), "Bearer token".to_owned())]; diff --git a/src/lib.rs b/src/lib.rs index 060ef17..d143356 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -103,3 +103,4 @@ pub const BUILD_IDENTITY: &str = concat!("Vole;engine=rust;coreVersion=", env!("CARGO_PKG_VERSION")); mod connections; +mod memory; diff --git a/src/memory.rs b/src/memory.rs new file mode 100644 index 0000000..d357859 --- /dev/null +++ b/src/memory.rs @@ -0,0 +1,105 @@ +//! Current-process memory only, sampled on an explicit Controller request. + +/// Resident bytes, or unavailable. No process enumeration or retained history. +pub(crate) fn resident_bytes() -> Option { + platform_resident_bytes() +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn platform_resident_bytes() -> Option { + use std::io::Read; + let mut data = String::new(); + std::fs::File::open("/proc/self/status") + .ok()? + .take(16 * 1024) + .read_to_string(&mut data) + .ok()?; + let line = data.lines().find_map(|line| line.strip_prefix("VmRSS:"))?; + let mut fields = line.split_whitespace(); + let bytes = fields.next()?.parse::().ok()?.checked_mul(1024)?; + (fields.next()? == "kB").then_some(bytes) +} + +#[cfg(target_vendor = "apple")] +fn platform_resident_bytes() -> Option { + // MACH_TASK_BASIC_INFO is the public 64-bit ABI on all Apple targets. + #[repr(C)] + struct BasicInfo { + virtual_size: u64, + resident_size: u64, + resident_size_max: u64, + user_time: [i32; 2], + system_time: [i32; 2], + policy: i32, + suspend_count: i32, + } + unsafe extern "C" { + static mach_task_self_: libc::mach_port_t; + } + let mut info = std::mem::MaybeUninit::::zeroed(); + let expected = (std::mem::size_of::() / std::mem::size_of::()) as u32; + let mut count = expected; + // SAFETY: our own task port, correctly sized/aligned writable ABI storage. + let result = + unsafe { libc::task_info(mach_task_self_, 20, info.as_mut_ptr().cast(), &mut count) }; + if result != 0 || count != expected { + return None; + } + // SAFETY: successful task_info initialized the full verified ABI structure. + Some(unsafe { info.assume_init() }.resident_size) +} + +#[cfg(windows)] +fn platform_resident_bytes() -> Option { + use std::ffi::c_void; + #[repr(C)] + struct Counters { + size: u32, + page_fault_count: u32, + peak_working_set: usize, + working_set: usize, + quota_peak_paged_pool: usize, + quota_paged_pool: usize, + quota_peak_non_paged_pool: usize, + quota_non_paged_pool: usize, + pagefile: usize, + peak_pagefile: usize, + } + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentProcess() -> *mut c_void; + fn K32GetProcessMemoryInfo(process: *mut c_void, counters: *mut Counters, size: u32) + -> i32; + } + let mut counters = std::mem::MaybeUninit::::zeroed(); + let size = std::mem::size_of::() as u32; + // SAFETY: own pseudo-handle; the API writes to the supplied sized structure. + unsafe { (*counters.as_mut_ptr()).size = size }; + // SAFETY: no external PID or handle lifetime; valid initialized ABI storage. + let result = + unsafe { K32GetProcessMemoryInfo(GetCurrentProcess(), counters.as_mut_ptr(), size) }; + if result == 0 { + return None; + } + // SAFETY: the successful API call populated this structure. + u64::try_from(unsafe { counters.assume_init() }.working_set).ok() +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_vendor = "apple", + windows +)))] +fn platform_resident_bytes() -> Option { + None +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(any(target_os = "linux", target_vendor = "apple"))] + fn current_process_has_resident_memory() { + assert!(super::resident_bytes().is_some_and(|bytes| bytes > 0)); + } +} diff --git a/src/resource/control.rs b/src/resource/control.rs index 3d78344..891b3b3 100644 --- a/src/resource/control.rs +++ b/src/resource/control.rs @@ -4,6 +4,22 @@ use crate::{ }; use async_trait::async_trait; use std::sync::Arc; +use std::time::Duration; +use tokio_util::sync::CancellationToken; + +pub(crate) struct DelayRequest { + pub url: String, + pub timeout: Duration, + pub expected: Vec<(u16, u16)>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ProbeError { + NotFound, + Timeout, + Failed, + Stopped, +} /// A query holds its resource generation through completion and output admission. pub(crate) struct DnsSnapshot { @@ -21,4 +37,27 @@ pub(crate) trait RuntimeResources: Send + Sync { fn providers(&self, rules: bool) -> serde_json::Value; fn dns(&self) -> Option; async fn refresh(&self, rules: bool, name: &str) -> Result<(), &'static str>; + async fn node_delay( + &self, + _name: &str, + _request: &DelayRequest, + _cancel: &CancellationToken, + ) -> Result { + Err(ProbeError::NotFound) + } + async fn group_delay( + &self, + _name: &str, + _request: &DelayRequest, + _cancel: &CancellationToken, + ) -> Result, ProbeError> { + Err(ProbeError::NotFound) + } + async fn provider_healthcheck( + &self, + _name: &str, + _cancel: &CancellationToken, + ) -> Result<(), ProbeError> { + Err(ProbeError::NotFound) + } } diff --git a/src/resource/probe.rs b/src/resource/probe.rs index b86a803..ea5085f 100644 --- a/src/resource/probe.rs +++ b/src/resource/probe.rs @@ -184,6 +184,12 @@ pub(crate) async fn probe_once( let mut stream = connect(target, dispatcher, tls).await?; head(&mut stream, target, false).await.map(drop) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Observation { + pub delay: u64, + pub expected_matches: bool, +} + pub(crate) async fn probe( target: &Target, dispatcher: Arc, @@ -192,6 +198,22 @@ pub(crate) async fn probe( expected: &[(u16, u16)], cancel: &CancellationToken, ) -> io::Result { + let observation = observe(target, dispatcher, timeout, unified, expected, cancel).await?; + if observation.expected_matches { + Ok(observation.delay) + } else { + Err(io::Error::other("unexpected health check status")) + } +} + +pub(crate) async fn observe( + target: &Target, + dispatcher: Arc, + timeout: Duration, + unified: bool, + expected: &[(u16, u16)], + cancel: &CancellationToken, +) -> io::Result { let operation = async { let tls = (target.scheme == TargetScheme::Https) .then(build_tls_connector) @@ -206,14 +228,13 @@ pub(crate) async fn probe( status = next; } } - if !expected.is_empty() - && !expected - .iter() - .any(|(start, end)| (*start..=*end).contains(&status)) - { - return Err(io::Error::other("unexpected health check status")); - } - Ok(started.elapsed().as_millis().min(u64::MAX as u128) as u64) + Ok(Observation { + delay: started.elapsed().as_millis().min(u64::MAX as u128) as u64, + expected_matches: expected.is_empty() + || expected + .iter() + .any(|(start, end)| (*start..=*end).contains(&status)), + }) }; tokio::select! {biased; ()=cancel.cancelled()=>Err(io::ErrorKind::Interrupted.into()), diff --git a/src/runtime/health.rs b/src/runtime/health.rs index e67427e..f630abf 100644 --- a/src/runtime/health.rs +++ b/src/runtime/health.rs @@ -3,7 +3,10 @@ use super::*; use crate::{ config::{HealthCheckConfig, status_ranges}, dispatch::{BoxStream, DatagramTransport, DispatchError}, - resource::probe::{self, Target}, + resource::{ + control::{DelayRequest, ProbeError}, + probe::{self, Target}, + }, session::{DatagramSession, StreamSession}, }; use futures_util::{StreamExt, stream}; @@ -52,16 +55,24 @@ pub(super) struct Sample { pub alive: bool, pub delay: u64, pub time: u64, + #[serde(skip)] + order: u64, + #[serde(skip)] + expected_matches: bool, } struct Job { spec: HealthCheckConfig, members: Vec, group: Option, last: Option, + provider: Option, } pub(super) struct HealthRuntime { jobs: Mutex>, samples: Mutex>, + manual_samples: Mutex>, + sequence: std::sync::atomic::AtomicU64, + probe_slots: tokio::sync::Semaphore, groups: Arc, targets: RouteTargetDispatchers, direct: Arc, @@ -91,6 +102,7 @@ impl HealthRuntime { .collect(), group: Some(i), last: None, + provider: None, }); } if let Some(template) = &config.providers { @@ -108,6 +120,7 @@ impl HealthRuntime { .unwrap_or_default(), group: None, last: None, + provider: Some(name.clone()), }); } } @@ -133,6 +146,9 @@ impl HealthRuntime { Arc::new(Self { jobs: Mutex::new(jobs), samples: Mutex::new(BTreeMap::new()), + manual_samples: Mutex::new(BTreeMap::new()), + sequence: std::sync::atomic::AtomicU64::new(0), + probe_slots: tokio::sync::Semaphore::new(10), groups, targets, direct, @@ -156,11 +172,271 @@ impl HealthRuntime { ) }) .collect::>(); - for ((name, _), sample) in samples.iter() { - nodes.insert(name.clone(),serde_json::json!({"name":name,"alive":sample.alive,"history":[{"time":sample.time,"delay":sample.delay}]})); + for (name, kind) in [ + ("DIRECT", "Direct"), + ("COMPATIBLE", "Compatible"), + ("REJECT", "Reject"), + ("REJECT-DROP", "RejectDrop"), + ("PASS", "Pass"), + ("PASS-RULE", "PassRule"), + ] { + nodes.insert( + name.to_owned(), + serde_json::json!({"name":name,"type":kind,"alive":true,"history":[]}), + ); + } + let manual = self.manual_samples.lock().unwrap(); + let mut latest = BTreeMap::<&str, &Sample>::new(); + for (name, sample) in samples + .iter() + .map(|((name, _), s)| (name.as_str(), s)) + .chain(manual.iter().map(|(name, s)| (name.as_str(), s))) + { + if latest.get(name).is_none_or(|old| old.order < sample.order) { + latest.insert(name, sample); + } + } + for (name, sample) in latest { + let node = nodes + .entry(name.to_owned()) + .or_insert_with(|| serde_json::json!({"name":name})); + node["alive"] = serde_json::json!(sample.alive); + node["history"] = serde_json::json!([{"time":sample.time,"delay":sample.delay}]); } serde_json::json!(nodes) } + fn sample(&self, result: &Result) -> Sample { + Sample { + alive: result.is_ok(), + delay: result.as_ref().map_or(0, |sample| sample.delay), + expected_matches: result.as_ref().is_ok_and(|sample| sample.expected_matches), + time: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + order: self + .sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + } + } + + async fn run_probe( + &self, + name: &str, + request: &DelayRequest, + cancel: &CancellationToken, + ) -> Result { + let action = match name { + "DIRECT" | "COMPATIBLE" => crate::config::RuleAction::Direct, + "REJECT" | "REJECT-DROP" | "PASS" | "PASS-RULE" => crate::config::RuleAction::Reject, + _ => { + crate::config::RuleAction::Route(*self.names.get(name).ok_or(ProbeError::NotFound)?) + } + }; + if request.timeout.is_zero() { + return Err(ProbeError::Timeout); + } + let operation = async { + let _slot = self + .probe_slots + .acquire() + .await + .map_err(|_| ProbeError::Stopped)?; + let target = Target::parse(&request.url).map_err(|_| ProbeError::Failed)?; + let rules = RuleSet::compile(vec![crate::config::RuleSpec { + kind: crate::config::RuleKind::Match, + action, + no_resolve: false, + }]) + .expect("health route"); + let dispatcher = Arc::new(RoutingDispatcher::new_with_ipv6( + self.targets.clone(), + self.direct.clone(), + self.dns.clone(), + self.ipv6, + rules, + Arc::new(crate::routing::EmptyGeoMatcher), + )); + probe::observe( + &target, + dispatcher, + request.timeout, + self.unified, + &request.expected, + cancel, + ) + .await + .map_err(|error| match error.kind() { + io::ErrorKind::TimedOut => ProbeError::Timeout, + io::ErrorKind::Interrupted => ProbeError::Stopped, + _ => ProbeError::Failed, + }) + }; + tokio::select! { biased; + () = cancel.cancelled() => Err(ProbeError::Stopped), + result = tokio::time::timeout(request.timeout, operation) => result.map_err(|_| ProbeError::Timeout)?, + } + } + + fn record_manual( + &self, + name: &str, + url: &str, + result: &Result, + ) { + let sample = self.sample(result); + self.manual_samples + .lock() + .unwrap() + .insert(name.to_owned(), sample.clone()); + let configured = self + .jobs + .lock() + .unwrap() + .iter() + .any(|job| job.spec.url == url && job.members.iter().any(|member| member == name)); + if configured { + self.samples + .lock() + .unwrap() + .insert((name.to_owned(), url.to_owned()), sample); + } + } + + pub async fn node_delay( + &self, + name: &str, + request: &DelayRequest, + cancel: &CancellationToken, + ) -> Result { + let result = self.run_probe(name, request, cancel).await; + if !cancel.is_cancelled() && result != Err(ProbeError::NotFound) { + self.record_manual(name, &request.url, &result); + self.apply_group_health(); + } + result.map(|sample| sample.delay) + } + + pub async fn group_delay( + &self, + name: &str, + request: &DelayRequest, + cancel: &CancellationToken, + ) -> Result, ProbeError> { + let state = self.groups.state(name).map_err(|_| ProbeError::NotFound)?; + let configured_url = if state.kind == crate::config::GroupKind::UrlTest { + self.names.get(name).and_then(|target| { + let RouteTargetId::Group(id) = target else { + return None; + }; + self.jobs + .lock() + .unwrap() + .iter() + .find(|job| job.group == Some(id.index())) + .map(|job| job.spec.url.clone()) + }) + } else { + None + }; + let request = DelayRequest { + url: configured_url.unwrap_or_else(|| request.url.clone()), + timeout: request.timeout, + expected: request.expected.clone(), + }; + let request = &request; + let work = state.all.into_iter().map(|name| async move { + let result = self.run_probe(&name, request, cancel).await; + if !cancel.is_cancelled() { + self.record_manual(&name, &request.url, &result); + } + (name, result) + }); + let operation = async { + let mut results = stream::iter(work).buffer_unordered(10); + let mut delays = BTreeMap::new(); + while let Some((name, result)) = results.next().await { + if let Ok(delay) = result { + delays.insert(name, delay.delay); + } + } + self.apply_group_health(); + if delays.is_empty() { + Err(ProbeError::Timeout) + } else { + Ok(delays) + } + }; + tokio::select! { biased; + () = cancel.cancelled() => Err(ProbeError::Stopped), + result = tokio::time::timeout(request.timeout, operation) => result.map_err(|_| ProbeError::Timeout)?, + } + } + + pub async fn provider_healthcheck( + &self, + name: &str, + cancel: &CancellationToken, + ) -> Result<(), ProbeError> { + let (spec, members) = self + .jobs + .lock() + .unwrap() + .iter() + .find(|job| job.provider.as_deref() == Some(name)) + .map(|job| (job.spec.clone(), job.members.clone())) + .ok_or(ProbeError::NotFound)?; + if spec.url.is_empty() { + return Ok(()); + } + let request = DelayRequest { + url: spec.url, + timeout: Duration::from_millis(if spec.timeout == 0 { + 5000 + } else { + spec.timeout + }), + expected: status_ranges(&spec.expected_status).expect("validated provider health"), + }; + let work = members.into_iter().map(|name| async { + let result = self.run_probe(&name, &request, cancel).await; + if !cancel.is_cancelled() { + self.samples + .lock() + .unwrap() + .insert((name, request.url.clone()), self.sample(&result)); + } + }); + let mut results = stream::iter(work).buffer_unordered(10); + while results.next().await.is_some() { + if cancel.is_cancelled() { + return Err(ProbeError::Stopped); + } + } + self.apply_group_health(); + Ok(()) + } + + fn apply_group_health(&self) { + let samples = self.samples.lock().unwrap(); + for job in self.jobs.lock().unwrap().iter() { + if let Some(index) = job.group { + let values = job + .members + .iter() + .map(|name| { + samples + .get(&(name.clone(), job.spec.url.clone())) + .map_or(Some(u16::MAX.into()), |s| { + (s.alive && s.expected_matches).then_some(s.delay) + }) + }) + .collect::>(); + self.groups.apply_health(index, &values); + } + } + } + pub async fn tick(&self, cancel: &CancellationToken) { let mut work = BTreeMap::new(); { @@ -209,49 +485,16 @@ impl HealthRuntime { } } let jobs = work.into_iter().map(|((name, url), spec)| async move { - let action = match name.as_str() { - "DIRECT" | "COMPATIBLE" => Some(crate::config::RuleAction::Direct), - "REJECT" => Some(crate::config::RuleAction::Reject), - _ => self - .names - .get(&name) - .copied() - .map(crate::config::RuleAction::Route), + let request = DelayRequest { + url: url.clone(), + timeout: Duration::from_millis(if spec.timeout == 0 { + 5000 + } else { + spec.timeout + }), + expected: status_ranges(&spec.expected_status).expect("validated health status"), }; - let dispatcher = action.map(|action| { - let rules = RuleSet::compile(vec![crate::config::RuleSpec { - kind: crate::config::RuleKind::Match, - action, - no_resolve: false, - }]) - .expect("health route"); - Arc::new(RoutingDispatcher::new_with_ipv6( - self.targets.clone(), - self.direct.clone(), - self.dns.clone(), - self.ipv6, - rules, - Arc::new(crate::routing::EmptyGeoMatcher), - )) as Arc - }); - let result = async { - let target = Target::parse(&url)?; - let expected = status_ranges(&spec.expected_status).map_err(io::Error::other)?; - probe::probe( - &target, - dispatcher.ok_or_else(|| io::Error::other("unavailable health target"))?, - Duration::from_millis(if spec.timeout == 0 { - 5000 - } else { - spec.timeout - }), - self.unified, - &expected, - cancel, - ) - .await - } - .await; + let result = self.run_probe(&name, &request, cancel).await; (name, url, result) }); let mut results = stream::iter(jobs).buffer_unordered(10); @@ -259,32 +502,120 @@ impl HealthRuntime { if cancel.is_cancelled() { return; } - self.samples.lock().unwrap().insert( - (name, url), - Sample { - alive: result.is_ok(), - delay: result.unwrap_or(0), - time: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - }, + self.samples + .lock() + .unwrap() + .insert((name, url), self.sample(&result)); + } + self.apply_group_health(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + struct MemoryProbe { + peers: Mutex>>, + } + #[async_trait::async_trait] + impl Dispatcher for MemoryProbe { + async fn connect_tcp(&self, session: StreamSession) -> Result { + assert_eq!( + session.inbound, + crate::session::InboundKind::InternalMeasure + ); + let (client, mut peer) = tokio::io::duplex(1024); + let task = tokio::spawn(async move { + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + bytes.push(peer.read_u8().await.unwrap()); + } + tokio::time::sleep(Duration::from_millis(2)).await; + peer.write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + }); + self.peers.lock().unwrap().push(task); + Ok(Box::new(client)) + } + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } + } + + #[tokio::test] + async fn manual_probes_keep_one_history_per_node_and_cancel_queued_work() { + let config = Config::parse_yaml(b"mixed-port: 1080\nrules:\n - MATCH,DIRECT\n").unwrap(); + let direct = Arc::new(MemoryProbe { + peers: Mutex::new(Vec::new()), + }); + let groups = ProxyGroups::new(&config.proxy_groups, vec![], direct.clone()).unwrap(); + let targets = RouteTargetDispatchers::with_proxy_groups(vec![], &groups).unwrap(); + let runtime = HealthRuntime::new(&config, groups, targets, direct.clone(), vec![], None); + let cancel = CancellationToken::new(); + for index in 0..8 { + let request = DelayRequest { + url: format!("http://192.0.2.1/{index}"), + timeout: Duration::from_secs(1), + expected: vec![(204, 204)], + }; + assert!( + runtime + .node_delay("DIRECT", &request, &cancel) + .await + .unwrap() + > 0 ); } - let samples = self.samples.lock().unwrap(); - for job in self.jobs.lock().unwrap().iter() { - if let Some(index) = job.group { - let values = job - .members - .iter() - .map(|name| { - samples - .get(&(name.clone(), job.spec.url.clone())) - .map_or(Some(u16::MAX.into()), |s| s.alive.then_some(s.delay)) - }) - .collect::>(); - self.groups.apply_health(index, &values); - } + assert_eq!(runtime.manual_samples.lock().unwrap().len(), 1); + assert!(runtime.samples.lock().unwrap().is_empty()); + let mismatch = DelayRequest { + url: "http://192.0.2.1/unexpected".into(), + timeout: Duration::from_secs(1), + expected: vec![(200, 200)], + }; + assert!( + runtime + .node_delay("DIRECT", &mismatch, &cancel) + .await + .unwrap() + > 0 + ); + { + let samples = runtime.manual_samples.lock().unwrap(); + assert!(samples["DIRECT"].alive); + assert!(!samples["DIRECT"].expected_matches); + } + assert_eq!( + runtime.states()["DIRECT"]["history"] + .as_array() + .unwrap() + .len(), + 1 + ); + let mut slots = Vec::new(); + for _ in 0..10 { + slots.push(runtime.probe_slots.acquire().await.unwrap()); + } + cancel.cancel(); + let request = DelayRequest { + url: "http://192.0.2.1/".into(), + timeout: Duration::from_secs(60), + expected: vec![], + }; + assert_eq!( + runtime.node_delay("DIRECT", &request, &cancel).await, + Err(ProbeError::Stopped) + ); + drop(slots); + let tasks = std::mem::take(&mut *direct.peers.lock().unwrap()); + for task in tasks { + task.await.unwrap(); } } } diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 6bfcfa8..acd745e 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -6,7 +6,7 @@ use crate::{ dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError}, resource::{ cache::{ProviderCache, ProviderState}, - control::{DnsSnapshot, RuntimeResources}, + control::{DelayRequest, DnsSnapshot, ProbeError, RuntimeResources}, }, routing::ProxyGroupError, session::{Datagram, DatagramSession, Destination, StreamSession}, @@ -492,6 +492,67 @@ impl RuntimeResources for ProviderRuntime { _generation: Some(generation), }) } + async fn node_delay( + &self, + name: &str, + request: &DelayRequest, + cancel: &CancellationToken, + ) -> Result { + let generation = self.current.load_full(); + tokio::select! { biased; + () = self.cancellation.cancelled() => Err(ProbeError::Stopped), + result = generation.health.node_delay(name, request, cancel) => result, + } + } + async fn group_delay( + &self, + name: &str, + request: &DelayRequest, + cancel: &CancellationToken, + ) -> Result, ProbeError> { + let generation = { + // Reset and persist under the same publication lock as manual selection. + // Release it before any probe waits or network I/O. + let _publication = self.publication.lock().unwrap(); + let generation = self.current.load_full(); + if let Ok(state) = generation.groups.state(name) + && matches!( + state.kind, + crate::config::GroupKind::UrlTest | crate::config::GroupKind::Fallback + ) + { + if let Some(index) = generation + .config + .proxy_groups + .iter() + .position(|group| group.name == name) + { + generation.groups.selection(index).clear_forced(); + } + *self.persistence_error.write().unwrap() = self + .store + .save(name, "") + .err() + .map(|_| "selection cache write failed"); + } + generation + }; + tokio::select! { biased; + () = self.cancellation.cancelled() => Err(ProbeError::Stopped), + result = generation.health.group_delay(name, request, cancel) => result, + } + } + async fn provider_healthcheck( + &self, + name: &str, + cancel: &CancellationToken, + ) -> Result<(), ProbeError> { + let generation = self.current.load_full(); + tokio::select! { biased; + () = self.cancellation.cancelled() => Err(ProbeError::Stopped), + result = generation.health.provider_healthcheck(name, cancel) => result, + } + } async fn refresh(&self, rules: bool, name: &str) -> Result<(), &'static str> { if self.cancellation.is_cancelled() { return Err("runtime stopped"); diff --git a/tests/README.md b/tests/README.md index 79e206e..96d4190 100644 --- a/tests/README.md +++ b/tests/README.md @@ -100,3 +100,5 @@ container-benchmark compare --source vole=/path/to/Vole `resource::cache::tests::` 使用内存 HTTP 流和临时缓存验证订阅元信息的候选提交、304、失败保留与磁盘恢复;`resource::subscription::tests::` 验证有界数值解析。 `routing::proxy_group::tests::` 覆盖自动选择、容差、恢复与选择缓存;`resource::probe::tests::` 和 `invoke::measure_delay::tests::` 使用内存流验证共享健康探测、HTTP 状态和取消。 + +Controller 主动探测与内存使用纯内存过滤:`manual_probes_keep_one_history`、`delay_query_preserves_reference`、`admits_mixed_only_direct_controller`、`current_process_has_resident_memory`。真实 HTTP 对端由 benchmark 隔离运行。 From 2f2e3cfa4c56e1ae0784986405fbfae9454cd118 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 12:39:04 +0800 Subject: [PATCH 035/445] Add lazy ASN rules and verified MMDB updates --- Cargo.lock | 19 + Cargo.toml | 1 + docs/config.yaml | 9 +- docs/geodata.md | 51 ++- docs/invoke-api.md | 14 +- src/config/mod.rs | 10 + src/config/rule_provider.rs | 6 +- src/config/rules.rs | 23 +- src/geodata/asn.rs | 123 +++++ src/geodata/asn_tests.rs | 130 ++++++ src/geodata/manager.rs | 433 ++++++++++++++++-- src/geodata/mod.rs | 74 ++- src/geodata/service.rs | 23 +- src/geodata/tests.rs | 2 + src/invoke/mod.rs | 1 + src/routing/diagnostics.rs | 2 + src/routing/mod.rs | 29 +- tests/README.md | 1 + tests/fixtures/geodata/GeoLite2-ASN-Test.mmdb | Bin 0 -> 12653 bytes tests/fixtures/geodata/LICENSE-MIT | 17 + tests/fixtures/geodata/README.md | 10 + 21 files changed, 915 insertions(+), 63 deletions(-) create mode 100644 src/geodata/asn.rs create mode 100644 src/geodata/asn_tests.rs create mode 100644 tests/fixtures/geodata/GeoLite2-ASN-Test.mmdb create mode 100644 tests/fixtures/geodata/LICENSE-MIT create mode 100644 tests/fixtures/geodata/README.md diff --git a/Cargo.lock b/Cargo.lock index 81fe41a..4b15228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1212,6 +1212,12 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" + [[package]] name = "itertools" version = "0.13.0" @@ -1335,6 +1341,18 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" +[[package]] +name = "maxminddb" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6" +dependencies = [ + "ipnetwork", + "memchr", + "serde", + "thiserror", +] + [[package]] name = "md-5" version = "0.11.0" @@ -2680,6 +2698,7 @@ dependencies = [ "idna", "libc", "log", + "maxminddb", "md-5", "oslog", "psl", diff --git a/Cargo.toml b/Cargo.toml index 47a66ed..adfe650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,6 +109,7 @@ libc = "0.2" # Keep the log facade compiled out when SS is linked; Vole uses tracing. log = { version = "0.4", optional = true, features = ["max_level_off", "release_max_level_off"] } md-5 = { version = "0.11", optional = true } +maxminddb = { version = "=0.32.0", default-features = false } quinn = { version = "=0.11.12", default-features = false, features = ["runtime-tokio", "rustls-ring"], optional = true } quinn-proto = { version = "=0.11.18", default-features = false, features = ["rustls-ring"], optional = true } rand = "0.10" diff --git a/docs/config.yaml b/docs/config.yaml index cacca7d..b004223 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -38,7 +38,7 @@ profile: store-fake-ip: false # true:dataDir/fake-ip.redb 持久化双向映射与分配位置 # 资源下载使用 global-ua(缺省 Vole/ 加 Core 版本);etag-support 缺省 true。 -# GeoData 字段独立可选;URL 缺省 MetaCubeX meta-rules-dat latest DAT。 +# GeoData 字段独立可选;URL 缺省 MetaCubeX meta-rules-dat latest DAT / ASN MMDB。 # URL 以域名为主机、无 userinfo/fragment,生产构建使用 HTTPS。 # 自动更新默认 false;启动后仍获取缺失的必需资产。周期单位小时,默认 24,0 关闭周期检查。 global-ua: "Vole/0.1.0" @@ -46,6 +46,7 @@ etag-support: true geox-url: geoip: https://assets.example.com/geoip.dat geosite: https://assets.example.com/geosite.dat + asn: https://assets.example.com/ASN.mmdb geo-auto-update: true geo-update-interval: 24 @@ -614,9 +615,9 @@ dns: # rules 可省略或为空;rule 模式按顺序首条命中,未命中 DIRECT。MATCH 可放在任意 # 位置,普通目标命中后其后规则不再执行。DIRECT、REJECT 是内置出口,其他出口精确引用节点或组。 # 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 -# GEOSITE、GEOIP、SRC-GEOIP、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 +# GEOSITE、GEOIP、SRC-GEOIP、IP-ASN、SRC-IP-ASN、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 # IN-TYPE、IN-USER、IN-PORT、RULE-SET、SUB-RULE、AND/OR/NOT 和 MATCH。来源信息来自实际入站会话。 -# CIDR 两种别名都按 payload 实际地址族解析;GEOIP/CIDR/RULE-SET 可追加 src,自动禁止 DNS。 +# CIDR 两种别名都按 payload 实际地址族解析;GEOIP/IP-ASN/CIDR/RULE-SET 可追加 src,自动禁止 DNS。 # RULE-SET 的 src 在局部视图交换源/目标 IP 与端口,保留域名、入口类型和身份。 # IN-TYPE 为 / 分隔的 Mihomo 类型名,大小写无关,SOCKS 展开为 SOCKS4/SOCKS5; # IN-USER 为 / 分隔、区分大小写的已认证用户名。两种列表逐项 trim,空项报错。 @@ -634,6 +635,8 @@ dns: # 组的控制目标先于 disable-udp/节点 UDP 检查;跳过不推进轮询。实际出口只选择一次。 # GEOSITE 支持 [!][@...],GEOIP 支持 [!] 和可选 no-resolve。 # SRC-GEOIP 与 GEOIP,...,src 等价,来源规则不触发 DNS;lan 为无需资产的内置 GeoIP。 +# IP-ASN 按实际目标 IP 查询 ASN.mmdb,可带 no-resolve;SRC-IP-ASN 使用实际来源 IP。 +# ASN payload 逐字匹配,不转换 AS 前缀或补零;仅引用时加载,数值型数据库无记录匹配 0。 # 例如 GEOSITE,google@ads,REJECT / GEOSITE,!cn@ads,main-select / GEOIP,!CN,DIRECT。 # geolocation-!cn 为字面分类名;GeoIP DAT reverse_match 接受但忽略,取反由 ! 控制。 # GEOSITE 没有域名不匹配;分类缺失不匹配,而有效空属性交集的反选匹配非空域名。 diff --git a/docs/geodata.md b/docs/geodata.md index c9a5a11..8597954 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -1,6 +1,6 @@ # GeoData 规则与资产 -Vole 管理 `dataDir/geodata` 下的 `geosite.dat` 和 `geoip.dat`,只为当前配置实际引用的 +Vole 管理 `dataDir/geodata` 下的 `geosite.dat`、`geoip.dat` 和 `ASN.mmdb`,只为当前配置实际引用的 分类与属性交集构建匹配器。主规则和所有 `sub-rules` 定义中的谓词、逻辑子项及 classical provider 内容共同收集引用;每个定义只遍历一次,不沿调用边展开。所有平台均不设 GeoData 条数上限,不按数量截断记录, 也不设总内存预算。资产缺失或加载失败时,对应种类不可用,跳过相应 Geo 规则并继续 @@ -24,6 +24,8 @@ rules: GEOSITE,[!][@...], GEOIP,[!],[,no-resolve][,src] SRC-GEOIP,[!], +IP-ASN,,[,no-resolve][,src] +SRC-IP-ASN,, ``` - 规则类型和 `code` 不区分 ASCII 大小写,route target 名称大小写敏感并精确匹配。 @@ -39,6 +41,8 @@ SRC-GEOIP,[!], `cn@!ads` 查找字面 key `!ads`;`geolocation-!cn` 是字面分类名。 - GeoIP 支持前置 `!`,不支持属性或单条多个 code。`lan` 是内置分类:私有、未指定、 回环、多播与链路本地单播地址;来源与目标均可使用,不读取 GeoIP 资产。 +- `IP-ASN` 的 payload 逐字匹配 ASN 十进制值,不转换 `AS` 前缀或补零; + `src` 和 `SRC-IP-ASN` 使用实际来源 IP,不触发 DNS。不可用的 ASN 不触发惰性 DNS。 - rule 模式按顺序首条命中,未命中 DIRECT;可选的 `MATCH` 使用 `DIRECT`、`REJECT`、实际代理节点或代理组。 DNS `nameserver-policy` 中的 `geosite:[,...]` 接受相同的属性与反选语义, @@ -57,6 +61,7 @@ Unicode 版本的 Mihomo 在所有新字符上完全等价。 | --- | --- | --- | --- | | `GEOSITE` | `geosite.dat` | `GeoSiteList` | code 与 Domain 记录 | | `GEOIP` | `geoip.dat` | `GeoIPList` | code 与 IPv4/IPv6 CIDR | +| `IP-ASN` / `SRC-IP-ASN` | `ASN.mmdb` | MaxMind DB | IPv4/IPv6 ASN 索引 | - 文件路径固定,YAML 不能指定本地路径或环境变量。 - 文件必须是普通文件;校验外层帧、code、分类引用、实际选中的 CIDR / Domain 记录。 @@ -64,25 +69,50 @@ Unicode 版本的 Mihomo 在所有新字符上完全等价。 - 只引用属性交集时,先流式检查属性;未命中任何交集的记录按 wire/length 边界跳过, 不校验 value 或编译 Regex。引用无属性 selector 时则包含该分类的全部记录。 - 目标 code 缺失、重复或损坏时,整份对应种类的快照不可用,不能当作空分类。 -- GeoSite 和 GeoIP 分别校验和报告可用性,一类不可用不影响基础规则。 +- GeoSite、GeoIP 和 ASN 分别校验和报告可用性,一类不可用不影响其他种类或基础规则。 + +## ASN MMDB + +仅在规则或 classical 内容引用 ASN 时读取 `ASN.mmdb`。支持数据库类型 +`GeoLite2-ASN`、`DBIP-ASN-Lite (compat=GeoLite2-ASN)` 和 +`ipinfo generic_asn_free.mmdb`;前两者读取 `autonomous_system_number`,后者读取 +`asn` 并移除其 `AS` 前缀。合法的数值型数据库无记录时按 ASN `0` 匹配;数据库 +不可用、索引错误或记录字段无效不会被当作 ASN `0`。IPinfo 无记录不匹配。 + +使用一个拥有文件字节的 MMDB Reader,直接查询 IPv4/IPv6 索引,不展开 CIDR,也不 +建立按地址增长的缓存。查询只解码所需字段,不复制完整记录。ASN 与目标 GEOIP 共用 +既有惰性 DNS 路径;`no-resolve` 不主动解析,但可使用先前规则已解析的地址。 + +更新先以唯一候选字节缓冲构造借用 Reader,完整校验 metadata、树、separator 和 +数据图;校验失败时保留当前内存与磁盘代次。校验期间旧文件字节与候选字节必然重叠, +验证还会临时分配第三方 metadata 和图检查状态。借用 Reader 释放后,暂停新快照读取、 +等待旧读者结束;超时或取消时恢复旧快照。旧存储析构后才将同一候选缓冲移入 owned +Reader,重建 metadata,避免另一份全量候选复制。 + +ASN 提交保留旧磁盘文件的 hard link(不可用时复制);提交失败先丢弃候选内存,再 +恢复旧稳定文件和调度状态并重新加载旧匹配器。恢复磁盘或状态遇真实 I/O 错误时报告 +该错误;不能恢复旧文件时 ASN 降级,其他健康种类继续可用。停止仍等待管理任务完成。 +普通 DAT 更新与外部重载保留后文的独立降级契约。 ## 自动更新 HTTP 传输由共享资源模块维护,所有跳转经过当前 Core dispatcher。下载、管理阻塞任务和解析均在停止屏障内;取消及下载 future 被丢弃时清理未发布的暂存文件。 -三个字段独立可选。缺省使用 MetaCubeX meta-rules-dat 的 latest GeoIP/GeoSite DAT URL;自动更新默认关闭,周期默认 24 小时: +三个字段独立可选。缺省使用 MetaCubeX meta-rules-dat 的 latest GeoIP/GeoSite DAT 与 +`GeoLite2-ASN.mmdb` URL;自动更新默认关闭,周期默认 24 小时: ```yaml geox-url: geoip: https://assets.example.com/geoip.dat geosite: https://assets.example.com/geosite.dat + asn: https://assets.example.com/ASN.mmdb geo-auto-update: true geo-update-interval: 24 ``` 约束: -- `geox-url` 只能包含 `geoip` 和 `geosite`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 +- `geox-url` 只能包含 `geoip`、`geosite` 和 `asn`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 - `geo-update-interval` 单位为小时,支持非负整数;0 停用周期更新。 - `validateConfig` 和准备阶段不下载。运行后仅为当前引用的资产启动资源任务;缺失或不可用时获取默认或配置的 URL,自动更新控制可用资产的周期检查。 - 下载按当前 mode 与业务规则选路,未命中使用 DIRECT;可选目标包含内置动作、节点和代理组。选中代理组时,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 @@ -174,6 +204,7 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 - 更新在下载和 staging 检查期间继续使用当前快照。构建新匹配器前,原子发布空匹配器、 释放 Manager 的旧快照引用,并等待最后一位旧选路读者完成、旧匹配器存储析构结束。 此后只构建一次候选;磁盘与调度状态提交成功后直接原子发布,不从最终文件重复加载。 + ASN 使用前文的完整预校验与失败恢复流程。 - 注册、重载与更新提交通过 Manager 管理锁串行,避免旧重载覆盖新发布;业务和 DNS 的 ArcSwap 读取不获取该锁,也不等待排空。旧读者继续完成本次求值;排空、加载期间 新请求复用首次资产缺失的语义,仅跳过不可用的 Geo 规则,继续普通规则与既有 DNS @@ -182,7 +213,7 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 取消不构建或发布新候选。后台状态观测、开始更新、提交和 304 恢复均移出业务 worker, Stop 取消后仍等待管理任务结束,不遗留后台发布。已开始的 CPU 加载不能中途强制 中断;结束后检查取消并丢弃候选,因此 25 ms 不是完整 Stop 的耗时保证。 -- 重载按已注册的 selector 重建属性索引与匹配器,两类可用性独立。卸载后的所选记录 +- 重载按已注册的 selector 与 ASN 需求重建匹配器,三类可用性独立。卸载后的所选记录 加载失败使对应种类保持不可用,仍发布另一类健康数据;不重新安装旧整体快照。 更新候选无效时保留旧磁盘文件并记录失败,后续继续按现有调度重试。 - 重载首次取消检查前先记录待重载状态。卸载前取消保留旧匹配器;即使新磁盘代次 @@ -190,7 +221,8 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 排空超时或卸载后取消保持空匹配器及待重载状态,自动更新关闭时亦可恢复。 已发布的独立降级结果不标记为待重载,失败种类保持不可用,直到下一次明确重载或更新。 - 新流使用当时的可用快照;已经完成的 TCP 选路和 TUN UDP 固定 action 不回溯。 - 当前完整重建所需两类数据,不跨代复用分类存储;排空防止两代 matcher 存储同时存活, + 当前完整重建所需的各类数据,不跨代复用分类存储;排空防止两代 owned matcher 同时存活, + ASN 校验期的字节重叠见上文, 不保证 allocator 立即归还 RSS,也不限制单代加载峰值。 - 分类表在加载时按 code 排序,运行时大小写无关二分定位;规则仍按配置顺序首命中。 每次业务选路捕获一份当前不可变快照,整次求值复用,不在每条 Geo 规则上重复读取 ArcSwap。 @@ -228,7 +260,8 @@ fallible Vec 扩容、合法 protobuf 帧/长度、code、选中匹配值的 UTF 容量账本仅作诊断:统计 Vole 自有 matcher/index/value/临时 Vec 的可见容量,扩容时 记录新旧缓冲可能重叠的容量;没有最大值、不控制准入。`regex::bytes::Regex` 不提供 -完整内部状态的准确 `memory_usage`,所以账本**不包含正则内部编译状态、搜索缓存、 +完整内部状态的准确 `memory_usage`;ASN 文件缓冲容量计入快照容量,借用候选 +验证期的新旧字节重叠与 MMDB metadata/图校验状态未计入。因此账本**不包含正则内部编译状态、搜索缓存、 动态 scratch、配置需求集合、allocator 开销或整个进程 footprint**。这些未计入的 内存并不是零;accounted peak 不能作为加载或运行期峰值上界。 50,000,000 bytes 是限定输入与联合负载下的进程实测验收目标,不是加载器保证。 @@ -249,8 +282,8 @@ Vole 自有脚本仅负责编译。压力与横向比较固定只引用完整 `g wire 结构或 staging 检查失败发生在卸载之前,当前快照继续可用。 - 首次加载、外部资产重载或卸载后的所选记录加载失败,使对应种类不可用;健康种类 独立发布。排空超时或卸载后取消也保持已卸载状态,不能通过反选绕过不可用语义。 - 失败更新保留旧磁盘资产,但不恢复旧内存快照;后续成功加载再恢复分流。 -- 不可用的 GeoIP 规则不得触发惰性 DNS。 + DAT 失败更新保留旧磁盘资产,不恢复旧内存快照;ASN 更新按前文恢复旧代。 +- 不可用的 GeoIP 或 ASN 规则不得触发惰性 DNS。 - 调度状态损坏时在更新锁内重置;异常退出留下的更新状态在锁释放后恢复。 自动化与实机覆盖范围见 [验收矩阵](acceptance.md)。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 44dcd34..eb79211 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -127,7 +127,7 @@ stopped -> preparing -> prepared -> starting -> running {"method":"getGeoDataState","payload":{}} ``` -返回 `geosite` 和 `geoip` 两项: +返回 `geosite`、`geoip` 和 `asn` 三项;三种资产分别报告需求与可用性: ```json { @@ -150,10 +150,22 @@ stopped -> preparing -> prepared -> starting -> running "lastError": null, "etag": null, "hash": null + }, + "asn": { + "required": false, + "available": false, + "updating": false, + "lastSuccess": null, + "nextCheck": null, + "lastError": null, + "etag": null, + "hash": null } } ``` +`asn` 使用相同的 `required`、`available`、`updating`、`lastSuccess`、`nextCheck`、 +`lastError`、`etag` 和 `hash` 字段。 时间使用 Unix 秒,hash 为小写 SHA-256。调用不会创建实例、联网或改变更新调度。 ### `createInstance` diff --git a/src/config/mod.rs b/src/config/mod.rs index 9484a1e..d89cae1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -185,6 +185,7 @@ pub struct GeoDataUpdateConfig { pub struct GeoDataUrls { pub geoip: String, pub geosite: String, + pub asn: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -492,6 +493,8 @@ pub enum RuleKind { GeoSite(String), GeoIp(String), SrcGeoIp(String), + IpAsn(String), + SrcIpAsn(String), InTypes(Vec), InUsers(Vec), InPorts(Vec), @@ -1151,6 +1154,8 @@ struct RawGeoDataUrls { geoip: String, #[serde(default = "default_geosite_url")] geosite: String, + #[serde(default = "default_asn_url")] + asn: String, } fn default_global_ua() -> String { concat!("Vole/", env!("CARGO_PKG_VERSION")).into() @@ -1161,11 +1166,15 @@ fn default_geoip_url() -> String { fn default_geosite_url() -> String { "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat".into() } +fn default_asn_url() -> String { + "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/GeoLite2-ASN.mmdb".into() +} impl Default for RawGeoDataUrls { fn default() -> Self { Self { geoip: default_geoip_url(), geosite: default_geosite_url(), + asn: default_asn_url(), } } } @@ -2188,6 +2197,7 @@ fn normalize_geodata_update( urls: GeoDataUrls { geoip: normalize_geox_url(urls.geoip, "geox-url.geoip")?, geosite: normalize_geox_url(urls.geosite, "geox-url.geosite")?, + asn: normalize_geox_url(urls.asn, "geox-url.asn")?, }, auto_update: auto_update.unwrap_or(false), interval_hours, diff --git a/src/config/rule_provider.rs b/src/config/rule_provider.rs index 1f05f69..67b7522 100644 --- a/src/config/rule_provider.rs +++ b/src/config/rule_provider.rs @@ -202,8 +202,10 @@ impl RuleCollection { let context = RoutingContext::new(Network::Tcp, &destination).expect("literal address"); if self.behavior == RuleBehavior::Classical { self.classical.iter().any(|rule| { - matches!(rule.kind, RuleKind::IpCidr(_) | RuleKind::GeoIp(_)) - && crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) + matches!( + rule.kind, + RuleKind::IpCidr(_) | RuleKind::GeoIp(_) | RuleKind::IpAsn(_) + ) && crate::routing::matches_rule(rule, &context, geo).unwrap_or(false) }) } else { self.matches(&context, geo, true, false).unwrap_or(false) diff --git a/src/config/rules.rs b/src/config/rules.rs index 49688d5..a519338 100644 --- a/src/config/rules.rs +++ b/src/config/rules.rs @@ -359,17 +359,27 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res None => RuleAction::Direct, }; let mut no_resolve = false; - let mut source = kind == "SRC-GEOIP" || kind == "SRC-IP-CIDR"; + let mut source = matches!(kind.as_str(), "SRC-GEOIP" | "SRC-IP-CIDR" | "SRC-IP-ASN"); for option in fields { if option == "no-resolve" && matches!( kind.as_str(), - "RULE-SET" | "GEOIP" | "SRC-GEOIP" | "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" + "RULE-SET" + | "GEOIP" + | "SRC-GEOIP" + | "IP-CIDR" + | "IP-CIDR6" + | "SRC-IP-CIDR" + | "IP-ASN" + | "SRC-IP-ASN" ) { no_resolve = true; } else if option == "src" - && matches!(kind.as_str(), "RULE-SET" | "GEOIP" | "IP-CIDR" | "IP-CIDR6") + && matches!( + kind.as_str(), + "RULE-SET" | "GEOIP" | "IP-CIDR" | "IP-CIDR6" | "IP-ASN" + ) { source = true; } else { @@ -398,6 +408,13 @@ fn parse(input: &str, targets: Option<&RouteTargetsByName>, depth: usize) -> Res RuleKind::GeoIp(code) } } + "IP-ASN" | "SRC-IP-ASN" => { + if source { + RuleKind::SrcIpAsn(payload.to_owned()) + } else { + RuleKind::IpAsn(payload.to_owned()) + } + } "IP-CIDR" | "IP-CIDR6" | "SRC-IP-CIDR" => { let cidr = parse_ip_cidr(payload, payload.contains(':'))?; if source { diff --git a/src/geodata/asn.rs b/src/geodata/asn.rs new file mode 100644 index 0000000..8019ff0 --- /dev/null +++ b/src/geodata/asn.rs @@ -0,0 +1,123 @@ +//! One owned MMDB, with direct index lookups and no address cache. + +use std::{io::Read, net::IpAddr, path::Path}; + +use maxminddb::Reader; + +use super::{GeoDataError, GeoDataKind, open_asset}; + +#[derive(Debug, Clone, Copy)] +enum Format { + Numeric, + IpInfo, +} + +impl Format { + fn parse(value: &str) -> Result { + match value { + "GeoLite2-ASN" | "DBIP-ASN-Lite (compat=GeoLite2-ASN)" => Ok(Self::Numeric), + "ipinfo generic_asn_free.mmdb" => Ok(Self::IpInfo), + _ => invalid("unsupported ASN database type"), + } + } +} + +pub(super) struct AsnDb { + reader: Reader>, + format: Format, + capacity: usize, +} + +impl AsnDb { + pub(super) fn load(dir: &Path) -> Result { + AsnCandidate::load(dir)?.into_database() + } + + pub(super) fn capacity(&self) -> usize { + self.capacity + } + + pub(super) fn matches(&self, payload: &str, address: IpAddr) -> bool { + let Ok(record) = self.reader.lookup(address) else { + return false; + }; + match self.format { + Format::Numeric => { + let number = if !record.has_data() { + 0 + } else { + match record.decode_path::(&maxminddb::path!["autonomous_system_number"]) { + Ok(Some(value)) => value, + // Malformed/missing fields are not a legitimate absent network. + _ => return false, + } + }; + canonical_number_matches(payload, number) + } + Format::IpInfo => record + .decode_path::<&str>(&maxminddb::path!["asn"]) + .ok() + .flatten() + .and_then(|value| value.strip_prefix("AS")) + .is_some_and(|value| value == payload), + } + } +} + +/// Verified candidate storage. Verification borrows this buffer while the +/// old database remains available; ownership transfers only after retirement. +pub(super) struct AsnCandidate { + bytes: Vec, + format: Format, +} +impl AsnCandidate { + pub(super) fn load(dir: &Path) -> Result { + let (mut file, len) = open_asset(dir, GeoDataKind::Asn)?; + let len = usize::try_from(len) + .map_err(|_| GeoDataError::AllocationFailed { bytes: usize::MAX })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(len) + .map_err(|_| GeoDataError::AllocationFailed { bytes: len })?; + bytes.resize(len, 0); + file.read_exact(&mut bytes) + .map_err(|source| super::io_error(GeoDataKind::Asn, source))?; + let format = { + let reader = + Reader::from_source(bytes.as_slice()).map_err(|_| error("invalid ASN MMDB"))?; + let format = Format::parse(&reader.metadata().database_type)?; + reader + .verify() + .map_err(|_| error("invalid ASN MMDB index or data"))?; + format + }; + Ok(Self { bytes, format }) + } + + pub(super) fn into_database(self) -> Result { + let capacity = self.bytes.capacity(); + // Moves the already verified bytes without a second full buffer or + // another graph verification. Only reader metadata is reconstructed. + let reader = Reader::from_source(self.bytes).map_err(|_| error("invalid ASN MMDB"))?; + Ok(AsnDb { + reader, + format: self.format, + capacity, + }) + } +} + +fn canonical_number_matches(payload: &str, number: u32) -> bool { + !payload.is_empty() + && payload.len() <= 10 + && payload.bytes().all(|byte| byte.is_ascii_digit()) + && (payload.len() == 1 || !payload.starts_with('0')) + && payload.parse::().ok() == Some(number) +} + +fn error(detail: &str) -> GeoDataError { + GeoDataError::InvalidAsn(detail.to_owned()) +} +fn invalid(detail: &str) -> Result { + Err(error(detail)) +} diff --git a/src/geodata/asn_tests.rs b/src/geodata/asn_tests.rs new file mode 100644 index 0000000..01a3e24 --- /dev/null +++ b/src/geodata/asn_tests.rs @@ -0,0 +1,130 @@ +use std::{fs, sync::Arc}; + +use super::*; +use crate::{ + config::{Config, Network, RuleAction}, + routing::{RoutingContext, RuleEvaluation, RuleSet}, + session::Destination, +}; + +pub(super) const FIXTURE: &[u8] = + include_bytes!("../../tests/fixtures/geodata/GeoLite2-ASN-Test.mmdb"); + +fn rule(kind: RuleKind) -> RuleSpec { + RuleSpec { + kind, + action: RuleAction::Reject, + no_resolve: false, + } +} + +#[test] +fn asn_official_fixture_direct_lookup_and_absent_asset() { + let dir = tempfile::tempdir().unwrap(); + assert!(GeoData::load(dir.path(), &[]).unwrap().is_empty()); + fs::write(dir.path().join(ASN_FILE_NAME), FIXTURE).unwrap(); + let rules = [rule(RuleKind::IpAsn("15169".into()))]; + let data = GeoData::load(dir.path(), &rules).unwrap(); + assert_eq!(data.allocation_capacity(), FIXTURE.len()); + assert!(data.matches_asn("15169", "1.0.0.1".parse().unwrap())); + assert!(data.matches_asn("6730", "2001:1700::1".parse().unwrap())); + assert!(data.matches_asn("0", "192.0.2.1".parse().unwrap())); + for payload in ["AS15169", "015169", "+15169", "0"] { + assert!(!data.matches_asn(payload, "1.0.0.1".parse().unwrap())); + } + assert!(!GeoData::EMPTY.matches_asn("0", "192.0.2.1".parse().unwrap())); + assert!(!GeoData::load(dir.path(), &[]).unwrap().asn_available()); +} + +#[test] +fn asn_schema_visitors_and_lazy_resolution_use_actual_context() { + let mut empty = Config::parse_yaml(b"mixed-port: 1080\nrules: ['IP-ASN,,DIRECT']\n").unwrap(); + assert_eq!(empty.rules[0].kind, RuleKind::IpAsn(String::new())); + RuleSet::compile(empty.take_runtime_rules()).unwrap(); + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join(ASN_FILE_NAME), FIXTURE).unwrap(); + let mut config = Config::parse_yaml( + br#" +mixed-port: 1080 +geox-url: {asn: https://assets.example.test/ASN.mmdb} +rule-providers: + local: {type: inline, behavior: classical, payload: ["IP-ASN,AS15169", "SRC-IP-ASN,6730"]} +sub-rules: + nested: ["OR,((IP-ASN,0015169),(SRC-IP-ASN,6730)),DIRECT"] +rules: + - IP-ASN,15169,REJECT,no-resolve + - IP-ASN,15169,REJECT + - IP-ASN,6730,DIRECT,src,no-resolve + - SRC-IP-ASN,6730,DIRECT + - RULE-SET,local,DIRECT +"#, + ) + .unwrap(); + let requirements = GeoRequirements::for_config(&config).unwrap(); + assert!(requirements.requires(GeoDataKind::Asn)); + assert!(requirements.codes(GeoDataKind::Asn).next().is_none()); + assert_eq!(config.rules[2], config.rules[3]); + assert_eq!( + config.geodata_update.as_ref().unwrap().urls.asn, + "https://assets.example.test/ASN.mmdb" + ); + let data = GeoData::load(dir.path(), &config.rules).unwrap(); + let mut runtime_config = config.clone(); + let rules = RuleSet::compile(runtime_config.take_runtime_rules()).unwrap(); + let mut context = RoutingContext::new( + Network::Tcp, + &Destination::domain("example.test", 443).unwrap(), + ) + .unwrap(); + assert!(matches!( + rules.evaluate_with_geo(&context, &data), + RuleEvaluation::NeedsIpResolution { rule_index: 1 } + )); + assert!(matches!( + rules.evaluate_with_geo(&context, &GeoData::EMPTY), + RuleEvaluation::Matched(result) if result.action == RuleAction::Direct + )); + let source_rule = rule(RuleKind::SrcIpAsn("6730".into())); + assert_eq!( + crate::routing::matches_rule(&source_rule, &context, &data), + Ok(false) + ); + context.set_source("[2001:1700::1]:49152".parse().unwrap()); + assert_eq!( + crate::routing::matches_rule(&source_rule, &context, &data), + Ok(true) + ); + let literal = RoutingContext::new( + Network::Tcp, + &Destination::Ip("1.0.0.1:443".parse().unwrap()), + ) + .unwrap(); + assert_eq!( + crate::routing::matches_rule(&rule(RuleKind::IpAsn("15169".into())), &literal, &data), + Ok(true) + ); + // Definition and classical content are independently traversed even when + // the main list has no direct ASN predicate. + config.rules = vec![rule(RuleKind::Match)]; + assert!( + GeoRequirements::for_config(&config) + .unwrap() + .requires(GeoDataKind::Asn) + ); + let collection = crate::config::RuleCollection::from_payload( + vec!["IP-ASN,15169".into()], + crate::config::providers::RuleBehavior::Classical, + ) + .unwrap(); + assert!(collection.matches_ip("1.0.0.1".parse().unwrap(), &data)); + let nested = rule(RuleKind::Not(Box::new(rule(RuleKind::RuleSet { + name: "local".into(), + content: Some(Arc::new(collection)), + source: false, + })))); + assert!( + GeoRequirements::collect(&[nested], &[]) + .unwrap() + .requires(GeoDataKind::Asn) + ); +} diff --git a/src/geodata/manager.rs b/src/geodata/manager.rs index 4c6a976..85680a2 100644 --- a/src/geodata/manager.rs +++ b/src/geodata/manager.rs @@ -119,6 +119,12 @@ impl std::fmt::Debug for DynamicGeoData { } impl GeoMatcher for DynamicGeoData { + fn asn_available(&self) -> bool { + self.current.load().asn_available() + } + fn matches_asn(&self, payload: &str, address: IpAddr) -> bool { + self.current.load().matches_asn(payload, address) + } fn routing_snapshot(&self) -> Option> { Some(self.current.load_full()) } @@ -179,6 +185,7 @@ impl GeoDataResourceReport { pub struct GeoDataLoadReport { pub geosite: GeoDataResourceReport, pub geoip: GeoDataResourceReport, + pub asn: GeoDataResourceReport, pub allocation_capacity: usize, pub peak_allocation_capacity: usize, } @@ -189,6 +196,7 @@ impl GeoDataLoadReport { match kind { GeoDataKind::GeoSite => &self.geosite, GeoDataKind::GeoIp => &self.geoip, + GeoDataKind::Asn => &self.asn, } } } @@ -214,6 +222,7 @@ pub struct GeoResourceState { pub struct GeoDataStatus { pub geosite: GeoResourceState, pub geoip: GeoResourceState, + pub asn: GeoResourceState, } impl GeoDataStatus { @@ -222,6 +231,7 @@ impl GeoDataStatus { match kind { GeoDataKind::GeoSite => &self.geosite, GeoDataKind::GeoIp => &self.geoip, + GeoDataKind::Asn => &self.asn, } } } @@ -233,6 +243,8 @@ pub struct GeoDataReloadReport { pub geosite_available: bool, pub geoip_required: bool, pub geoip_available: bool, + pub asn_required: bool, + pub asn_available: bool, } #[derive(Debug, Error)] @@ -304,6 +316,8 @@ struct PersistentState { geosite: PersistentResourceState, #[serde(default)] geoip: PersistentResourceState, + #[serde(default)] + asn: PersistentResourceState, } impl PersistentState { @@ -311,6 +325,7 @@ impl PersistentState { match kind { GeoDataKind::GeoSite => &mut self.geosite, GeoDataKind::GeoIp => &mut self.geoip, + GeoDataKind::Asn => &mut self.asn, } } } @@ -340,11 +355,49 @@ impl RegistrationEntry { self.report = GeoDataLoadReport { geosite: dormant_report(&self.requirements, GeoDataKind::GeoSite), geoip: dormant_report(&self.requirements, GeoDataKind::GeoIp), + asn: dormant_report(&self.requirements, GeoDataKind::Asn), allocation_capacity: 0, peak_allocation_capacity: 0, }; } + /// ASN candidates are fully verified while old data is still usable. + /// Retain one rollback reference until all other snapshot readers finish. + fn unload_verified_asn( + &mut self, + cancellation: &CancellationToken, + ) -> Result<(), GeoDataManagerError> { + self.drain(cancellation)?; + let mut old = self.matcher.current.swap(Arc::new(GeoData::EMPTY)); + let deadline = Instant::now() + crate::resource::http::DEFAULT_DOWNLOAD_TIMEOUT; + loop { + if let Err(error) = check_cancellation(cancellation) { + self.matcher.activate(old); + return Err(error); + } + // try_unwrap atomically obtains exclusive ownership, so a Weak + // upgrade cannot retain the old payload after this succeeds. + match Arc::try_unwrap(old) { + Ok(snapshot) => { + // Payload fields drop before SnapshotRelease. + drop(snapshot); + break; + } + Err(snapshot) => old = snapshot, + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.matcher.activate(old); + return Err(GeoDataManagerError::Validation( + "GeoData readers did not release the retired snapshot in time".to_owned(), + )); + } + std::thread::sleep(remaining.min(Duration::from_millis(25))); + } + self.unload(); + Ok(()) + } + fn drain(&mut self, cancellation: &CancellationToken) -> Result<(), GeoDataManagerError> { if let Some(retired) = &self.retired { retired.wait(cancellation)?; @@ -525,7 +578,7 @@ impl GeoDataManager { ) -> Result { check_cancellation(cancellation)?; let mut disk = read_state(&self.store_dir)?; - if disk.geosite.updating || disk.geoip.updating { + if disk.geosite.updating || disk.geoip.updating || disk.asn.updating { self.recover_interrupted_state()?; disk = read_state(&self.store_dir)?; } @@ -577,7 +630,7 @@ impl GeoDataManager { pub fn due_resources(&self, now: SystemTime) -> Result, GeoDataManagerError> { let now = unix_seconds(now); let status = self.status()?; - Ok([GeoDataKind::GeoSite, GeoDataKind::GeoIp] + Ok(GeoDataKind::ALL .into_iter() .filter(|kind| { let resource = status.resource(*kind); @@ -595,10 +648,10 @@ impl GeoDataManager { /// A source change is immediately due regardless of the previous source's /// durable next-check time. #[cfg(test)] - pub(crate) fn due_resources_for_active_registration( + pub(crate) fn due_resources_for_active_registration( &self, lease: &GeoDataRegistrationLease, - sources: [(GeoDataKind, &str); 2], + sources: [(GeoDataKind, &str); N], now: SystemTime, ) -> Result, GeoDataManagerError> { self.due_resources_for_active_registration_with_cancellation( @@ -609,10 +662,10 @@ impl GeoDataManager { ) } - pub(crate) fn due_resources_for_active_registration_with_cancellation( + pub(crate) fn due_resources_for_active_registration_with_cancellation( &self, lease: &GeoDataRegistrationLease, - sources: [(GeoDataKind, &str); 2], + sources: [(GeoDataKind, &str); N], now: SystemTime, cancellation: &CancellationToken, ) -> Result, GeoDataManagerError> { @@ -778,7 +831,7 @@ impl GeoDataManager { } let mut state = read_state(&self.store_dir)?; let mut changed = false; - for kind in [GeoDataKind::GeoSite, GeoDataKind::GeoIp] { + for kind in GeoDataKind::ALL { let resource = state.resource_mut(kind); if resource.updating { resource.updating = false; @@ -798,22 +851,30 @@ impl GeoDataManager { &self, kind: GeoDataKind, candidate_dir: &Path, - ) -> Result<(), GeoDataManagerError> { - validate_asset_structure(candidate_dir, kind) - .map_err(|error| GeoDataManagerError::Validation(error.to_string()))?; - - let other = match kind { - GeoDataKind::GeoSite => GeoDataKind::GeoIp, - GeoDataKind::GeoIp => GeoDataKind::GeoSite, + ) -> Result, GeoDataManagerError> { + let asn = if kind == GeoDataKind::Asn { + Some( + super::asn::AsnCandidate::load(candidate_dir) + .map_err(|error| GeoDataManagerError::Validation(error.to_string()))?, + ) + } else { + None }; - let other_source = self.store_dir.join(other.file_name()); - let other_candidate = candidate_dir.join(other.file_name()); - if other_source.is_file() && fs::hard_link(&other_source, &other_candidate).is_err() { - fs::copy(&other_source, &other_candidate) - .map_err(|source| io_at(&other_candidate, source))?; + if kind != GeoDataKind::Asn { + validate_asset_structure(candidate_dir, kind) + .map_err(|error| GeoDataManagerError::Validation(error.to_string()))?; } - Ok(()) + for other in GeoDataKind::ALL.into_iter().filter(|other| *other != kind) { + let other_source = self.store_dir.join(other.file_name()); + let other_candidate = candidate_dir.join(other.file_name()); + if other_source.is_file() && fs::hard_link(&other_source, &other_candidate).is_err() { + fs::copy(&other_source, &other_candidate) + .map_err(|source| io_at(&other_candidate, source))?; + } + } + + Ok(asn) } fn finish_success( @@ -1051,12 +1112,10 @@ impl GeoUpdateSession { let mut registration = lock(&self.manager.registration); self.validate_active_lease(registration.as_ref())?; let candidate = if let Some(entry) = registration.as_mut() - && [GeoDataKind::GeoSite, GeoDataKind::GeoIp] - .into_iter() - .any(|kind| { - let report = entry.report.resource(kind); - report.required && !report.available - }) { + && GeoDataKind::ALL.into_iter().any(|kind| { + let report = entry.report.resource(kind); + report.required && !report.available + }) { entry.unload(); entry.drain(cancellation)?; Some(load_snapshot(&self.manager.store_dir, &entry.requirements)) @@ -1114,7 +1173,16 @@ impl GeoUpdateSession { // Framing checks and disk staging do not allocate matchers. Keep the // old rules until this boundary, then release every old reader before // constructing the new generation. - self.manager.stage_candidate(self.kind, &self.staging_dir)?; + let asn_candidate = self.manager.stage_candidate(self.kind, &self.staging_dir)?; + if let Some(asn_candidate) = asn_candidate { + return self.commit_asn_candidate( + &mut registration, + asn_candidate, + etag, + sha256, + cancellation, + ); + } let candidate = if let Some(entry) = registration.as_mut() && entry.requirements.requires(self.kind) { @@ -1158,6 +1226,116 @@ impl GeoUpdateSession { Ok(reload_report(registration.as_ref())) } + /// ASN validation, persistence and recovery are separate from DAT's + /// existing degraded-reload contract. A failed candidate never becomes + /// the replacement for a usable ASN generation. + fn commit_asn_candidate( + &self, + registration: &mut Option, + candidate: super::asn::AsnCandidate, + etag: Option, + sha256: [u8; 32], + cancellation: &CancellationToken, + ) -> Result { + let destination = self.manager.store_dir.join(GeoDataKind::Asn.file_name()); + let previous_path = self.staging_dir.join("previous-ASN.mmdb"); + let previous = read_state(&self.manager.store_dir)?; + let had_file = destination.is_file(); + if had_file && fs::hard_link(&destination, &previous_path).is_err() { + fs::copy(&destination, &previous_path) + .map_err(|source| io_at(&previous_path, source))?; + } + let mut retired = false; + let mut file_published = false; + let result = (|| { + check_cancellation(cancellation)?; + let loaded = if let Some(entry) = registration.as_mut() + && entry.requirements.requires(GeoDataKind::Asn) + { + entry.unload_verified_asn(cancellation)?; + retired = true; + let (snapshot, report) = + load_snapshot_with_asn(&self.staging_dir, &entry.requirements, Some(candidate)); + if !report.asn.available { + return Err(GeoDataManagerError::Validation( + report + .asn + .error + .unwrap_or_else(|| "ASN candidate unavailable".into()), + )); + } + Some((snapshot, report)) + } else { + None + }; + check_cancellation(cancellation)?; + fs::rename(&self.staging_path, &destination) + .map_err(|source| io_at(&destination, source))?; + file_published = true; + sync_directory(&self.manager.store_dir)?; + self.manager.finish_success( + GeoDataKind::Asn, + self.source_url.clone(), + etag, + hex_digest(&sha256), + )?; + check_cancellation(cancellation)?; + if let Some(entry) = registration.as_mut() + && let Some((snapshot, report)) = loaded + { + entry.publish(snapshot, report); + } + Ok(reload_report(registration.as_ref())) + })(); + if let Err(original) = result { + // The candidate snapshot/buffer has left the closure and dropped + // before recovery reads the previous file into owned storage. + let mut disk_restored = !file_published; + let rollback = if file_published { + (|| { + if had_file { + fs::rename(&previous_path, &destination) + .map_err(|source| io_at(&destination, source))?; + } else { + fs::remove_file(&destination) + .map_err(|source| io_at(&destination, source))?; + } + disk_restored = true; + sync_directory(&self.manager.store_dir)?; + persist_state(&self.manager.store_dir, &previous)?; + *lock(&self.manager.state) = previous; + Ok::<(), GeoDataManagerError>(()) + })() + } else { + Ok(()) + }; + if retired && let Some(entry) = registration.as_mut() { + if disk_restored { + let (snapshot, report) = + load_snapshot(&self.manager.store_dir, &entry.requirements); + entry.publish(snapshot, report); + } else { + // An actual disk/state rollback error is exceptional. Do + // not present uncertain on-disk ASN contents as the old generation. + let mut requirements = entry.requirements.clone(); + requirements.asn = false; + let (snapshot, mut report) = + load_snapshot(&self.manager.store_dir, &requirements); + report.asn = + GeoDataResourceReport::degraded("ASN disk state could not be restored"); + entry.publish(snapshot, report); + } + } + if let Err(rollback) = rollback { + return Err(GeoDataManagerError::Validation(bounded_error(format!( + "ASN update failed: {original}; previous disk state could not be restored: {rollback}" + )))); + } + return Err(original); + } + result + } + fn validate_active_lease( &self, registration: Option<&RegistrationEntry>, @@ -1214,6 +1392,14 @@ impl Drop for GeoUpdateSession { fn load_snapshot( asset_dir: &Path, requirements: &GeoRequirements, +) -> (Arc, GeoDataLoadReport) { + load_snapshot_with_asn(asset_dir, requirements, None) +} + +fn load_snapshot_with_asn( + asset_dir: &Path, + requirements: &GeoRequirements, + candidate: Option, ) -> (Arc, GeoDataLoadReport) { let mut sites = Vec::new(); let mut ips = Vec::new(); @@ -1221,6 +1407,8 @@ fn load_snapshot( let mut peak = 0; let mut site_report = GeoDataResourceReport::not_required(); let mut ip_report = GeoDataResourceReport::not_required(); + let mut asn_report = GeoDataResourceReport::not_required(); + let mut asn = None; if requirements.requires(GeoDataKind::GeoIp) { match load_ips_for_requirements( @@ -1256,12 +1444,32 @@ fn load_snapshot( } } - let snapshot = Arc::new(GeoData::from_loaded(sites, ips, used, peak)); + if requirements.requires(GeoDataKind::Asn) { + match candidate.map_or_else( + || super::asn::AsnDb::load(asset_dir), + super::asn::AsnCandidate::into_database, + ) { + Ok(database) => { + if let Some(total) = used.checked_add(database.capacity()) { + used = total; + peak = peak.max(used); + asn = Some(database); + asn_report = GeoDataResourceReport::available(); + } else { + asn_report = + GeoDataResourceReport::degraded("ASN allocation capacity overflow"); + } + } + Err(error) => asn_report = GeoDataResourceReport::degraded(error), + } + } + let snapshot = Arc::new(GeoData::from_loaded(sites, ips, asn, used, peak)); ( snapshot, GeoDataLoadReport { geosite: site_report, geoip: ip_report, + asn: asn_report, allocation_capacity: used, peak_allocation_capacity: peak, }, @@ -1271,12 +1479,15 @@ fn load_snapshot( fn reload_report(registration: Option<&RegistrationEntry>) -> GeoDataReloadReport { let geosite = registration.map(|entry| &entry.report.geosite); let geoip = registration.map(|entry| &entry.report.geoip); + let asn = registration.map(|entry| &entry.report.asn); GeoDataReloadReport { active_registration: registration.is_some(), geosite_required: geosite.is_some_and(|report| report.required), geosite_available: geosite.is_some_and(|report| report.required && report.available), geoip_required: geoip.is_some_and(|report| report.required), geoip_available: geoip.is_some_and(|report| report.required && report.available), + asn_required: asn.is_some_and(|report| report.required), + asn_available: asn.is_some_and(|report| report.required && report.available), } } @@ -1288,6 +1499,9 @@ fn scoped_status(disk: &PersistentState, report: &GeoDataLoadReport) -> GeoDataS .geosite .with_required(report.geosite.required, site_error), geoip: disk.geoip.with_required(report.geoip.required, ip_error), + asn: disk + .asn + .with_required(report.asn.required, report_error(&report.asn)), } } @@ -1299,6 +1513,7 @@ fn status_for_entry( || GeoDataStatus { geosite: disk.geosite.with_required(false, None), geoip: disk.geoip.with_required(false, None), + asn: disk.asn.with_required(false, None), }, |entry| scoped_status(disk, &entry.report), ) @@ -1511,6 +1726,8 @@ fn asset_generation_changed(previous: &PersistentState, current: &PersistentStat || previous.geosite.last_success != current.geosite.last_success || previous.geoip.hash != current.geoip.hash || previous.geoip.last_success != current.geoip.last_success + || previous.asn.hash != current.asn.hash + || previous.asn.last_success != current.asn.last_success } fn hex_digest(value: &[u8; 32]) -> String { @@ -1610,6 +1827,166 @@ mod tests { } } + #[test] + fn asn_update_verifies_before_retiring_and_preserves_healthy_siblings() { + let dir = tempdir().unwrap(); + let fixture = super::super::asn_tests::FIXTURE; + let asn_path = dir.path().join(super::super::ASN_FILE_NAME); + fs::write(&asn_path, fixture).unwrap(); + fs::write( + dir.path().join(super::super::GEOSITE_FILE_NAME), + site_file("cn", "example.test"), + ) + .unwrap(); + fs::write( + dir.path().join(super::super::GEOIP_FILE_NAME), + ip_file("cn", [10, 0, 0, 0], 8), + ) + .unwrap(); + let manager = GeoDataManager::open(dir.path(), Duration::from_secs(3600)).unwrap(); + let registration = manager + .register(requirements(&[ + rule(RuleKind::IpAsn("15169".into())), + rule(RuleKind::GeoSite("cn".into())), + rule(RuleKind::GeoIp("cn".into())), + ])) + .unwrap(); + let matcher = registration.matcher(); + let before = matcher.snapshot(); + assert!(registration.initial_report().asn.available); + for malformed in [b"broken".to_vec(), { + let mut bytes = fixture.to_vec(); + bytes[..6].fill(255); + bytes + }] { + let update = manager.begin_update(GeoDataKind::Asn).unwrap(); + fs::write(update.temporary_path(), &malformed).unwrap(); + assert!( + update + .commit( + None, + Sha256::digest(&malformed).into(), + malformed.len() as u64 + ) + .is_err() + ); + assert!(Arc::ptr_eq(&before, &matcher.snapshot())); + assert_eq!(fs::read(&asn_path).unwrap(), fixture); + assert!(matcher.matches_asn("15169", "1.0.0.1".parse().unwrap())); + assert!(manager.status().unwrap().asn.last_error.is_some()); + } + let released = before.release.as_ref().unwrap().0.clone(); + drop(before); + let update = manager.begin_update(GeoDataKind::Asn).unwrap(); + fs::write(update.temporary_path(), fixture).unwrap(); + let report = update + .commit( + Some("asn-v1".into()), + Sha256::digest(fixture).into(), + fixture.len() as u64, + ) + .unwrap(); + assert!(*lock(&released.released)); + assert!(report.asn_available && report.geosite_available && report.geoip_available); + assert!(matcher.matches_geosite("cn", "www.example.test")); + assert!(matcher.matches_geoip("cn", "10.0.0.1".parse().unwrap())); + let status = manager.status().unwrap(); + assert_eq!(status.asn.etag.as_deref(), Some("asn-v1")); + assert!(status.asn.hash.is_some() && status.asn.last_success.is_some()); + let current = matcher.snapshot(); + manager + .begin_update(GeoDataKind::Asn) + .unwrap() + .not_modified(Some("asn-v1".into())) + .unwrap(); + assert!(Arc::ptr_eq(¤t, &matcher.snapshot())); + let lease = registration.updater_lease(); + assert_eq!( + manager + .due_resources_for_active_registration( + &lease, + [(GeoDataKind::Asn, "https://assets.example.test/ASN.mmdb")], + SystemTime::now() + ) + .unwrap(), + [GeoDataKind::Asn] + ); + } + + #[test] + fn asn_state_io_failure_restores_old_file_and_matcher() { + let dir = tempdir().unwrap(); + let fixture = super::super::asn_tests::FIXTURE; + let asn_path = dir.path().join(super::super::ASN_FILE_NAME); + fs::write(&asn_path, fixture).unwrap(); + let manager = GeoDataManager::open(dir.path(), Duration::from_secs(3600)).unwrap(); + let registration = manager + .register(requirements(&[rule(RuleKind::IpAsn("15169".into()))])) + .unwrap(); + let matcher = registration.matcher(); + let old = matcher.snapshot(); + let update = manager.begin_update(GeoDataKind::Asn).unwrap(); + fs::write(update.temporary_path(), fixture).unwrap(); + let worker = std::thread::spawn(move || { + update.commit(None, Sha256::digest(fixture).into(), fixture.len() as u64) + }); + let deadline = Instant::now() + Duration::from_secs(2); + while matcher.asn_available() && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(!matcher.asn_available()); + // The commit has verified the candidate and is waiting for this old + // reader. Turn only the state destination into an actual I/O error. + let state_path = dir.path().join(STATE_FILE); + fs::remove_file(&state_path).unwrap(); + fs::create_dir(&state_path).unwrap(); + drop(old); + assert!(worker.join().unwrap().is_err()); + assert_eq!(fs::read(&asn_path).unwrap(), fixture); + assert!(matcher.matches_asn("15169", "1.0.0.1".parse().unwrap())); + fs::remove_dir(&state_path).unwrap(); + assert!(manager.status().unwrap().asn.available); + } + + #[test] + fn asn_retirement_cancellation_restores_previous_snapshot() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join(super::super::ASN_FILE_NAME), + super::super::asn_tests::FIXTURE, + ) + .unwrap(); + let manager = GeoDataManager::open(dir.path(), Duration::from_secs(3600)).unwrap(); + let registration = manager + .register(requirements(&[rule(RuleKind::IpAsn("15169".into()))])) + .unwrap(); + let matcher = registration.matcher(); + let old = matcher.snapshot(); + let cancellation = CancellationToken::new(); + let worker_token = cancellation.clone(); + let worker_manager = manager.clone(); + let worker = std::thread::spawn(move || { + let mut registration = lock(&worker_manager.registration); + registration + .as_mut() + .unwrap() + .unload_verified_asn(&worker_token) + }); + let deadline = Instant::now() + Duration::from_secs(2); + while matcher.asn_available() && Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(!matcher.asn_available()); + cancellation.cancel(); + assert!(matches!( + worker.join().unwrap(), + Err(GeoDataManagerError::Cancelled) + )); + assert!(Arc::ptr_eq(&old, &matcher.snapshot())); + assert!(matcher.matches_asn("15169", "1.0.0.1".parse().unwrap())); + assert!(manager.status().unwrap().asn.available); + } + fn requirements(rules: &[RuleSpec]) -> GeoRequirements { GeoRequirements::collect(rules, &[]).unwrap() } diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index 6dc87da..d9a0133 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -23,6 +23,9 @@ use crate::{ routing::GeoMatcher, }; +mod asn; +#[cfg(test)] +mod asn_tests; mod manager; mod selectors; pub(crate) mod service; @@ -38,22 +41,26 @@ pub use manager::{ pub const GEOSITE_FILE_NAME: &str = "geosite.dat"; pub const GEOIP_FILE_NAME: &str = "geoip.dat"; +pub const ASN_FILE_NAME: &str = "ASN.mmdb"; const MAX_CODE_BYTES: usize = 64; -/// The two supported Xray GeoData assets. +/// Supported DAT category assets and the independent ASN MMDB. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeoDataKind { GeoSite, GeoIp, + Asn, } impl GeoDataKind { + pub const ALL: [Self; 3] = [Self::GeoSite, Self::GeoIp, Self::Asn]; #[must_use] pub const fn file_name(self) -> &'static str { match self { Self::GeoSite => GEOSITE_FILE_NAME, Self::GeoIp => GEOIP_FILE_NAME, + Self::Asn => ASN_FILE_NAME, } } } @@ -63,6 +70,7 @@ impl std::fmt::Display for GeoDataKind { formatter.write_str(match self { Self::GeoSite => "GeoSite", Self::GeoIp => "GeoIP", + Self::Asn => "ASN", }) } } @@ -91,6 +99,8 @@ pub enum GeoDataError { InvalidRegex { code: String, detail: String }, #[error("invalid GeoIP CIDR in `{code}`: {detail}")] InvalidCidr { code: String, detail: String }, + #[error("invalid ASN MMDB: {0}")] + InvalidAsn(String), #[error("GeoData resource `{resource}` is {actual}; limit is {maximum}")] ResourceLimit { resource: &'static str, @@ -110,9 +120,11 @@ impl From for VoleError { /// Diagnostic capacity accounting, never a memory admission policy. /// /// Includes Vole-owned matcher/index vectors and arenas, with old/new buffer -/// overlap during growth. Compiled regex programs and search caches are opaque -/// third-party allocations and are not counted, nor are allocator overhead or -/// the process peak; those require independent measurements. +/// overlap during growth. Owned MMDB file bytes are included, but candidate +/// validation overlap and MMDB metadata/verification allocations are excluded. +/// Compiled regex programs and search caches are opaque third-party allocations +/// and are not counted, nor are allocator overhead or the process peak; those +/// require independent measurements. #[derive(Debug, Default)] struct AllocationLedger { used: usize, @@ -140,6 +152,7 @@ impl AllocationLedger { pub struct GeoData { sites: Vec, ips: Vec, + asn: Option, allocation_capacity: usize, peak_allocation_capacity: usize, // Fields drop in declaration order: signal only after matcher storage is @@ -153,6 +166,7 @@ impl std::fmt::Debug for GeoData { .debug_struct("GeoData") .field("site_categories", &self.sites.len()) .field("ip_categories", &self.ips.len()) + .field("asn_available", &self.asn.is_some()) .field("allocation_capacity", &self.allocation_capacity) .field("peak_allocation_capacity", &self.peak_allocation_capacity) .finish() @@ -169,12 +183,13 @@ impl GeoData { pub const EMPTY: Self = Self { sites: Vec::new(), ips: Vec::new(), + asn: None, allocation_capacity: 0, peak_allocation_capacity: 0, release: None, }; - /// Loads only categories referenced by `rules` from the two fixed sibling + /// Loads referenced DAT categories and the ASN MMDB from fixed sibling /// assets in `config_dir`. pub fn load(config_dir: &Path, rules: &[RuleSpec]) -> Result { Self::load_with_dns_policies(config_dir, rules, &[]) @@ -205,12 +220,20 @@ impl GeoData { if !requested.sites.values.is_empty() { load_site_file(config_dir, &requested.sites, &mut sites, &mut ledger)?; } - Ok(Self::from_loaded(sites, ips, ledger.used, ledger.peak)) + let asn = if requested.asn { + let database = asn::AsnDb::load(config_dir)?; + ledger.reserve(database.capacity())?; + Some(database) + } else { + None + }; + Ok(Self::from_loaded(sites, ips, asn, ledger.used, ledger.peak)) } fn from_loaded( mut sites: Vec, mut ips: Vec, + asn: Option, allocation_capacity: usize, peak_allocation_capacity: usize, ) -> Self { @@ -218,10 +241,12 @@ impl GeoData { // manager reloads share this allocation-free indexing step. sites.sort_unstable_by_key(|category| category.code); ips.sort_unstable_by_key(|category| category.code); - let release = (!sites.is_empty() || !ips.is_empty()).then(manager::SnapshotRelease::new); + let release = (!sites.is_empty() || !ips.is_empty() || asn.is_some()) + .then(manager::SnapshotRelease::new); Self { sites, ips, + asn, allocation_capacity, peak_allocation_capacity, release, @@ -244,7 +269,7 @@ impl GeoData { #[must_use] pub fn is_empty(&self) -> bool { - self.sites.is_empty() && self.ips.is_empty() + self.sites.is_empty() && self.ips.is_empty() && self.asn.is_none() } #[must_use] @@ -257,6 +282,11 @@ impl GeoData { lan_selector(code) || self.ip_category(code).is_some() } + #[must_use] + pub fn asn_available(&self) -> bool { + self.asn.is_some() + } + fn site_category(&self, code: &str) -> Option<&SiteCategory> { self.sites .binary_search_by(|category| category.code.cmp_str(selector_code(code))) @@ -302,6 +332,14 @@ pub(crate) fn matches_lan(code: &str, ip: IpAddr) -> Option { } impl GeoMatcher for GeoData { + fn asn_available(&self) -> bool { + GeoData::asn_available(self) + } + fn matches_asn(&self, payload: &str, address: IpAddr) -> bool { + self.asn + .as_ref() + .is_some_and(|database| database.matches(payload, address)) + } fn geosite_available(&self, code: &str) -> bool { GeoData::geosite_available(self, code) } @@ -411,10 +449,13 @@ impl CodeSet { } } +static EMPTY_CODE_SET: CodeSet = CodeSet::EMPTY; + #[derive(Debug)] struct RequestedCodes { sites: CodeSet, ips: CodeSet, + asn: bool, } impl RequestedCodes { @@ -425,6 +466,7 @@ impl RequestedCodes { let mut requested = Self { sites: CodeSet::EMPTY, ips: CodeSet::EMPTY, + asn: false, }; let mut pending: Vec<_> = rules.into_iter().collect(); while let Some(rule) = pending.pop() { @@ -441,6 +483,7 @@ impl RequestedCodes { | RuleKind::SubRule { predicate: child, .. } => pending.push(child), + RuleKind::IpAsn(_) | RuleKind::SrcIpAsn(_) => requested.asn = true, RuleKind::GeoSite(code) => requested.sites.insert(GeoDataKind::GeoSite, code)?, RuleKind::GeoIp(code) | RuleKind::SrcGeoIp(code) => { requested.ips.insert(GeoDataKind::GeoIp, code)? @@ -455,6 +498,7 @@ impl RequestedCodes { .flat_map(|set| set.classical.iter().cloned()) .collect::>(); let nested = Self::collect(&rules, &[])?; + requested.asn |= nested.asn; for code in nested.sites.selectors { requested.sites.insert(GeoDataKind::GeoSite, &code)?; } @@ -470,7 +514,7 @@ impl RequestedCodes { } const fn total(&self) -> usize { - self.sites.selectors.len() + self.ips.selectors.len() + self.sites.selectors.len() + self.ips.selectors.len() + self.asn as usize } } @@ -485,6 +529,7 @@ impl RequestedCodes { pub struct GeoRequirements { sites: CodeSet, ips: CodeSet, + asn: bool, } impl GeoRequirements { @@ -546,6 +591,7 @@ impl GeoRequirements { let mut result = Self { sites: requested.sites, ips: requested.ips, + asn: requested.asn, }; if config.sniffer.enable { result.extend(&Self::collect(&config.sniffer.skip_src_address, &[])?)?; @@ -565,6 +611,7 @@ impl GeoRequirements { } pub(crate) fn extend(&mut self, other: &Self) -> Result<(), GeoDataError> { + self.asn |= other.asn; for selector in &other.sites.selectors { self.sites.insert(GeoDataKind::GeoSite, selector)?; } @@ -584,12 +631,13 @@ impl GeoRequirements { Ok(Self { sites: requested.sites, ips: requested.ips, + asn: requested.asn, }) } #[must_use] pub const fn is_empty(&self) -> bool { - self.sites.values.is_empty() && self.ips.values.is_empty() + self.sites.values.is_empty() && self.ips.values.is_empty() && !self.asn } #[must_use] @@ -597,6 +645,7 @@ impl GeoRequirements { match kind { GeoDataKind::GeoSite => !self.sites.values.is_empty(), GeoDataKind::GeoIp => !self.ips.values.is_empty(), + GeoDataKind::Asn => self.asn, } } @@ -610,6 +659,7 @@ impl GeoRequirements { let set = match kind { GeoDataKind::GeoSite => &self.sites, GeoDataKind::GeoIp => &self.ips, + GeoDataKind::Asn => &EMPTY_CODE_SET, }; set.selectors.iter().map(String::as_str) } @@ -618,6 +668,7 @@ impl GeoRequirements { match kind { GeoDataKind::GeoSite => &self.sites, GeoDataKind::GeoIp => &self.ips, + GeoDataKind::Asn => &EMPTY_CODE_SET, } } } @@ -663,6 +714,9 @@ fn load_ips_for_requirements( } fn validate_asset_structure(asset_dir: &Path, kind: GeoDataKind) -> Result<(), GeoDataError> { + if kind == GeoDataKind::Asn { + return asn::AsnCandidate::load(asset_dir).map(drop); + } let (mut file, len) = open_asset(asset_dir, kind)?; let mut ledger = AllocationLedger::default(); let _ = index_selected(&mut file, len, kind, &CodeSet::EMPTY, &mut ledger)?; diff --git a/src/geodata/service.rs b/src/geodata/service.rs index 6bffe2b..0e54cf7 100644 --- a/src/geodata/service.rs +++ b/src/geodata/service.rs @@ -143,6 +143,7 @@ impl GeoDataUpdateService { [ (GeoDataKind::GeoSite, urls.geosite.as_str()), (GeoDataKind::GeoIp, urls.geoip.as_str()), + (GeoDataKind::Asn, urls.asn.as_str()), ], SystemTime::now(), &cancellation, @@ -270,6 +271,7 @@ struct RetrySlot { struct RetryState { geosite: RetrySlot, geoip: RetrySlot, + asn: RetrySlot, } impl RetryState { @@ -277,6 +279,7 @@ impl RetryState { match kind { GeoDataKind::GeoSite => &self.geosite, GeoDataKind::GeoIp => &self.geoip, + GeoDataKind::Asn => &self.asn, } } @@ -284,6 +287,7 @@ impl RetryState { match kind { GeoDataKind::GeoSite => &mut self.geosite, GeoDataKind::GeoIp => &mut self.geoip, + GeoDataKind::Asn => &mut self.asn, } } @@ -310,15 +314,19 @@ impl RetryState { } fn next_delay(&self, now: Instant) -> Option { - [self.geosite.retry_at, self.geoip.retry_at] - .into_iter() - .flatten() - .map(|retry_at| retry_at.saturating_duration_since(now)) - .min() + [ + self.geosite.retry_at, + self.geoip.retry_at, + self.asn.retry_at, + ] + .into_iter() + .flatten() + .map(|retry_at| retry_at.saturating_duration_since(now)) + .min() } fn clear_expired_not_due(&mut self, due: &[GeoDataKind], now: Instant) { - for kind in [GeoDataKind::GeoSite, GeoDataKind::GeoIp] { + for kind in GeoDataKind::ALL { if !due.contains(&kind) && self .slot(kind) @@ -335,6 +343,7 @@ fn resource_url(urls: &GeoDataUrls, kind: GeoDataKind) -> &str { match kind { GeoDataKind::GeoSite => &urls.geosite, GeoDataKind::GeoIp => &urls.geoip, + GeoDataKind::Asn => &urls.asn, } } @@ -418,6 +427,7 @@ mod tests { GeoDataUrls { geoip: "https://rules.example.test/geoip.dat".to_owned(), geosite: "https://rules.example.test/geosite.dat".to_owned(), + asn: "https://rules.example.test/ASN.mmdb".to_owned(), }, "fixture".into(), true, @@ -472,6 +482,7 @@ mod tests { let urls = GeoDataUrls { geoip: "https://rules.example.test/custom-geoip".to_owned(), geosite: "https://rules.example.test/custom-geosite".to_owned(), + asn: "https://rules.example.test/custom-asn".to_owned(), }; assert_eq!( resource_url(&urls, GeoDataKind::GeoSite), diff --git a/src/geodata/tests.rs b/src/geodata/tests.rs index b3ff849..a5de45f 100644 --- a/src/geodata/tests.rs +++ b/src/geodata/tests.rs @@ -1172,6 +1172,7 @@ fn large_assets_skip_unselected_payloads_without_file_size_quotas() { file.write_all(&category).unwrap(); file.seek(SeekFrom::Current(padding as i64)).unwrap(); let (selected, rule_kind) = match kind { + GeoDataKind::Asn => unreachable!("this case tests DAT framing"), GeoDataKind::GeoSite => ( site("selected", &[domain(3, "last.test")]), RuleKind::GeoSite("selected".into()), @@ -1184,6 +1185,7 @@ fn large_assets_skip_unselected_payloads_without_file_size_quotas() { file.write_all(&field_bytes(1, &selected)).unwrap(); let data = GeoData::load(dir.path(), &[rule(rule_kind)]).unwrap(); match kind { + GeoDataKind::Asn => unreachable!("this case tests DAT framing"), GeoDataKind::GeoSite => assert!(data.matches_geosite("selected", "last.test")), GeoDataKind::GeoIp => { assert!(data.matches_geoip("selected", "10.0.0.1".parse().unwrap())) diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index 64b1427..dc758bb 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -1185,6 +1185,7 @@ fn geodata_status_data(status: GeoDataStatus) -> Value { json!({ "geosite": geodata_resource_data(status.geosite), "geoip": geodata_resource_data(status.geoip), + "asn": geodata_resource_data(status.asn), }) } diff --git a/src/routing/diagnostics.rs b/src/routing/diagnostics.rs index bfca78b..63c4fe2 100644 --- a/src/routing/diagnostics.rs +++ b/src/routing/diagnostics.rs @@ -182,6 +182,8 @@ fn rule_description(rule: &RuleKind) -> (&'static str, String) { RuleKind::GeoSite(s) => ("GeoSite", s.clone()), RuleKind::GeoIp(s) => ("GeoIP", s.clone()), RuleKind::SrcGeoIp(s) => ("SrcGeoIP", s.clone()), + RuleKind::IpAsn(s) => ("IPASN", s.clone()), + RuleKind::SrcIpAsn(s) => ("SrcIPASN", s.clone()), RuleKind::InTypes(types) => ( "InType", types diff --git a/src/routing/mod.rs b/src/routing/mod.rs index 19587b3..e0cc134 100644 --- a/src/routing/mod.rs +++ b/src/routing/mod.rs @@ -267,6 +267,13 @@ pub trait GeoMatcher: Send + Sync { true } + fn asn_available(&self) -> bool { + false + } + fn matches_asn(&self, _payload: &str, _address: IpAddr) -> bool { + false + } + fn matches_geosite(&self, code: &str, domain: &str) -> bool; fn matches_geoip(&self, code: &str, address: IpAddr) -> bool; } @@ -632,6 +639,9 @@ fn normalize_rule( .map_err(|_| RuleSetError::InvalidGeoCode { rule_index: index })?; *bytes = checked_text_bytes(*bytes, value.len())?; } + RuleKind::IpAsn(value) | RuleKind::SrcIpAsn(value) => { + *bytes = checked_text_bytes(*bytes, value.len())?; + } RuleKind::IpCidr(cidr) | RuleKind::SrcIpCidr(cidr) => { if !valid_cidr(cidr) { return Err(RuleSetError::InvalidCidr { rule_index: index }); @@ -680,7 +690,7 @@ pub(crate) fn uses_destination_ip(rule: &RuleSpec) -> bool { RuleKind::RuleSet { content, source, .. } => !source && content.as_ref().is_some_and(|set| set.uses_ip()), - RuleKind::IpCidr(_) | RuleKind::GeoIp(_) => true, + RuleKind::IpCidr(_) | RuleKind::GeoIp(_) | RuleKind::IpAsn(_) => true, RuleKind::And(children) | RuleKind::Or(children) => { children.iter().any(uses_destination_ip) } @@ -744,6 +754,23 @@ pub(crate) fn matches_rule( false } } + RuleKind::IpAsn(payload) => { + if !geo.asn_available() { + false + } else if let Some(address) = context.effective_ip() { + geo.matches_asn(payload, address) + } else if !rule.no_resolve && context.should_request_ip_resolution() { + return Err(()); + } else { + false + } + } + RuleKind::SrcIpAsn(payload) => { + geo.asn_available() + && context + .source_ip + .is_some_and(|address| geo.matches_asn(payload, address)) + } RuleKind::IpCidr(cidr) => { if let Some(address) = context.effective_ip() { cidr_contains(cidr, address) diff --git a/tests/README.md b/tests/README.md index 96d4190..93a1643 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,7 @@ cargo test --locked --lib config::tls_policy_tests:: cargo test --locked --lib explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it cargo test --locked --lib mutual_tls_identity_is_required_verified_and_not_shared_between_clients cargo test --locked --lib geodata::tests:: +cargo test --locked --features cli --lib asn_ cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: diff --git a/tests/fixtures/geodata/GeoLite2-ASN-Test.mmdb b/tests/fixtures/geodata/GeoLite2-ASN-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..ae4cb25766f79c314d2ed7bfe35863f927455ae0 GIT binary patch literal 12653 zcmZvh2YeLO_Q%iMnGI`3fH>`d%J z>`LrLAXJC%PV7PKNmLS5L^V-E)DqK)I${P9v)JYsKRKCyt0&4OGPe&bcM*}d0WB8v|TB9WnNwlmm8?OhPalvj<{Y5cx3b> zK;^BJR)}CT1k=3(cwFR8;6A$CMcgd~I_*6o_nJv)Ne_QNln;wMke{wR$bg5WU^P8| zh07`tNb;D-Bfz7^Wzg`o=Q(H(#yuhOq=_5U!3<9$;8l@lfR{y{1zx1*b0W`&Xz8857%6CLw2i`Es!Vw(vO_8^l_HBd0tth=qyhpr`xDOb& ztH>IoEW|6lAs^v1ZY1)th=rwo0-(}A1-29U3>Yc$Il_Mu`GNso5?>jZC-$|-H&lL0 zd>7ij=RAKPekA@){Kuehw#d&&w28GCl+MF|Ui7liM#ia)u3(sj{Z5euQ zCDyo5+FGpfp)`Tv+Yl4Qn#57cLzfD%CS#ebGi!=iQ}Ys(*0y<1RI9Z;vNv+D9f%!? zoy6KX?`iEq<*s7w7Ai6M-4Q-rEcCy^_r%)Mz^uJetSTz2iJH(>YX;K+b!ITU5^IK7 z^`SIVtcJX#cc}?3`-#;IEEKCHoJ6Zwdxg?0&ND`|iG|wK;d79Efmm~ic?M>ldy6$c zl;E;YUeW>kB40bF`>_mvGjz~)s#sl2t?H;&?*$KWzDl#0ugh z#GNA6$(YjK%z&Ne$$>(w)5K!`(~=H91Ijb$$p=u>F4oylUM|)-z`15$wC8yYIN!j` z`T`SYT`1N?q4Hv}F5%-}nSaC<)_z%D8n}C~cI!%HxLK^LfE&cR8eqq>*#GpNT?gg$ zdiQiPoff-mvfn7yO-5O?+`zg8o~y*V6?j;z+o-%fA7|acxI4wVD^%Vs);;vRH&osy zMLO;M!~?{G#6t$Txv(i(tIf~_E5v#faes;R81S`Nj|0z%^#t%Vvp*>o+A+f6tYMb& zj9AYasptg*>v;s=4xwaR*c+{v#2UEIUZLl|!cku3V6TbwItTk9w7p5&TVlP2DTM7h03YK zv~@hUGm_a5o?Eqpp1YdW!p+3t?ZK{eLH|=1h}siaBB~NtAgT(e zqi40K8hX|e(+viFZ(s(SLC<l;#uKkZ%@c#YiTMV4F!Vo-g>dmiq5na`k0{>4YCkDnNtce$)+wqhlzav( z64g!5p3oK-wSOq}ib{l1ALEju0><@+wgW_^s6_wM6U-11){--nGfAOnS zt}rlZ`C$^LL!Cm~sR%zq)M=Fd8G4>B#k^6}nQ%Er)LC>n+n|KU*Nl0tsPo`*e(uAF zR-!)Dg$zLdQ$LHk82CukCBSo{2ALtIOs5h0nn zp16Uyk+_MtnYaZ*-zDl+O6-4%{ZHLN<(&q4^7uah#r~%yb+1vX`$XN(fCq>NiHGuW z>S21W5;af^kI?1O(B-jwh9^ShlcJsqrKd$b!?YM6nwxe*RNGiemp$|Doqkc~2dO{$~#s^(#;; z>NmQu|EWKq`G*eY+cZQ2~MBSvW#h?MLo_8_9rKu;G+ zjlxqIP$K$@wMz|Pu*)FrBsLC^y}j5&IoJk}Tm}pid&4m8Mq&@A%f_K{6MFLVyS=H{ zBSYILvC;oZkSI!QMr>|iCJ5UWVZa!%+5hYZ3SFwjuEAi_=~8P@(j)lMFHLs#Kf9jNOoIK-<_A=}iAwfAyMny%=(37fjlrG~`w=4<&qu|63`&0DvmY1x z2}n;d!;@0-n(-{%l-Qo5u>aZ5iIg^r{k#EQN$FCtU!==R80BS&@EMszuSn^Q#-?}4 zepPG^ZNF}m1D`wWH}UxQ5c@5T@;31f@h$3*`F zN?$QBqx{Ulei8duy8IT}ey8mZvHwfkpLrW5Y5#2`M+l2hgiXMREKY?qwvn7>$aLzbNTNC4n3B)$Uz&s~WDmNH>6Q#)* zdPi}lP?}0iBeo^BBT(gfE;|_LFZ8vv6R|U~3&C1)cB6!2uRV7+$gOBkQ>{)VQAJb} zHAF4rrW19TXOB2DDAf})p`0&H1EoetG44{6I5>DZZ41##>_yBnz*`^V(q;f>ab}A% zhswFcJYsKy!EaJp0MAY)+DDv)Mrp$L6{npp`wDU!Y`FFjmr%1C7*Kzr2ECWlAc#Vx=$QkME8nw4REtK*BTp!73Vr}t`DUf zbS`n&|D2no>}ig23zWBsbE}bZ)4d(ayTrMJE_c%Q8Kt|4dkl&mG#Ii0vAuyw^neVB z8OeEwco<{8BhD&Ht0BF_DLo?2qmZ5#=P`i&&v{&&C+PVk@sz<36s_K~XNYHs=L|}j z-Fbn^7Y&Bsf$M;mF*Luf z4}2re2UMrAl)fUqHW>J-+WD5s?+k{P zQTl=S5m4g%8)^R%=Rd$N;`~J0&jv#$Qu-CPKg1bG^gC_!wEdU(a~n=DLV%No-{>^l<|d zw>1OC6BCGSh>64`iIiiosS>H+D3ggP9PG_-uxS$6HZKkR*i=u1-+xB<_rC}~K}U9? zZD#_1|J&6p1)DFC-KfONb^m=h&&y_lWY8C6)9vYH>5Sjkp3L@j_^H*qRxY&uuQ5n< zd&&56FB1=v4Rt}#o$%fIWM{>PuC>~E%#F6R`H6HU<+-hyWo~1pYYp_H_J`H&a-&U^ zZR6V9n4el4@AT79uBvmRb7%NV5`HFA-s*MsdZ{kAI_T^7lFPK&cB2amo4j<|>+H#< z;gm+QbS9q3W_&kTpJ;@*ubZhU`E6#JHdsp^)eO^4Y#QpT7{0xVE;zs9JH&@rq zYMI^YR`JgY{E-vm(efjt)HEYFUA(c@hq|1)m|dLC`cvap6g}Y z7;@Ca{q8`!4;|-5XPDI_{ft&@_9NmW*tSkDjmdf)sFQU!F?=e^uflVy@#1|6Kjl{D z>(PywdeoySJsYbgs99?tF_S*CF>W>X!#A@op6f=ngq=)f))k|<)rIR{)E%EgN)_*LfL?^k8jn!s; z55RR)Q+56Nj=5UrV%@IE9g{tG4k}`KkaVlE>3Gslr*+x|FY4bbYV_|>@8~h-{e-ho z9V}gc$lPfgu?cb(SNn-XXW+JasZ0`2swdv>)>KvIQ_PL0P>^YT*7O04t#bhLPhjjB zZglK-I`xcWbm|dEJ*x^M$AWC8rvi^OU$?vAj4v#X1)XspM{qihNv9W2`)*g(o#l1K zgACI3_uwqKu?q7P4zS$l%*t2|e%y2_p@*L`2E{Tx<-?~krWGaQQ2Z%HQ1Q7=6On`5%`$l=(y`pEZq87t2U;a-DqP~%I`vHbWL+BtF&@lD;8W-m&JY1r4MOS z&=Ft1mhp%xaMzhCTA>gAYNy4G&Wp{SH66((xox-@n*B_VpTcaq^uri?uN!Tsu0{E@ zl($ySYSS?VM(72^=oCV>q9@? zi5qC|(XYbWiDi>@`nIiH?8Orv)}#-4u3X*OG4t?>UU#FlGh3VMO_h(FiN}Pvlz!mr zod|Q^tsZ+99`^ircX!}cRm?F>A+n*)hHEkI#ZVUL4E6f7Ce6{+H>;OQ#d&(iJqp*+ zZC-o{u9oTXrG8g#_1HpRmpOJXr^?OMZA}G><6VBrWE*z9zKrAD@eGE>?S%_3U%G{8 zHm&-8P6jEr8I8<$S$@uM$ZPYC(iw7x#+@6a5?xC$xZ9uT)LU1y2A)HjgC#C{OfrN2 z7{6oL!2LGv|B2L5H6?CzpBZsffLq_Xc!&JVq0seRF-bd(dk8}pH2X`;b(T#n!@L&- zdY7^G&K_N2xNcBn+PStI&Q2|^R_vr(l?pslv-yJ7&hXW)Zxz9HcyqkJA2*I(eRs<1 z?=d^6C7#u_>cnlG?lyWIx=2^Q(e6g))Hk=)&T6i0bDL&2w$)Fst*vpZTg)up*w2lQ zmoeJ2=wIke4K+3OuK88nGOM*^R%Kg#OS7@Qk2%<}pd6>ct<{fTZ(SeT*xC(l_43og z7QSwss~#!tAh{t9=w8&m{l65Af8 z_f0=0WWs9PIv+hRxyOu8JH4kji)a%e=XL`$*=N6uy!^ksHGS;O!a`_@GuB^f9z_JJ4KIz+0w=O&`zr)Q! z7`Tw~n>_X&q^q4<>tdtx%E8#{Hof!HTX0iVx;3?KZLGDj*<4}6cfzXC%epJ2vg;rx6Z+LP2)g^)s+#4938TXdASId03w`sRkNAcF&@Rb>~t z(W-iu Date: Sat, 10 Oct 2026 12:40:49 +0800 Subject: [PATCH 036/445] Add explicit system and DHCP DNS sources --- .github/workflows/test.yml | 3 + Cargo.toml | 2 +- docs/config.yaml | 5 +- docs/invoke-api.md | 23 + docs/tun-icmp-dns.md | 16 +- docs/windows-vpn.md | 2 + src/config/dns.rs | 34 +- src/config/mod.rs | 50 +++ src/dialer.rs | 193 ++++++++- src/dns/mod.rs | 1 + src/dns/runtime.rs | 449 +++++++++++++++++++- src/dns/source.rs | 614 +++++++++++++++++++++++++++ src/dns/source/linux.rs | 444 +++++++++++++++++++ src/dns/source/windows.rs | 160 +++++++ src/invoke/mod.rs | 133 +++++- src/platform/linux_network/native.rs | 36 ++ src/platform/tun_rs_io.rs | 5 + src/runtime.rs | 41 +- src/windows/session.rs | 6 +- tests/README.md | 4 + 20 files changed, 2190 insertions(+), 31 deletions(-) create mode 100644 src/dns/source.rs create mode 100644 src/dns/source/linux.rs create mode 100644 src/dns/source/windows.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b2b6224..c8ba9d9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -189,6 +189,7 @@ jobs: resource::http::tests:: fake_ip_ sniffing::tests:: tcp_sniffer::tests:: dns::fallback::tests:: dns::runtime::tests::dns_fallback_ dns::runtime::tests::resolve_ipv6_ + dns::source::tests:: dns::runtime::tests::dns_source_ dns::runtime::encrypted_tests:: outbound::http::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: inbound::http::parser::tests:: inbound::http::framing::tests:: @@ -213,6 +214,8 @@ jobs: invoke::tests::envelope_and_payload_are_strict invoke::tests::runtime_thread_cannot_reenter_invoke invoke::tests::engine_workers_preserve_invoke_admission_and_shutdown + invoke::tests::update_dns_network_raw_admission_is_bounded_before_value_or_registry_work + invoke::tests::update_dns_network_is_runtime_local_strict_and_monotonic invoke::tests::engine_completion_notifies_after_panic_and_join_reports_failure dns::runtime::tests::subrule_dns_rules_keep_round_robin_for_new_physical_connections dns::runtime::tests::runtime_dns_separates_bootstrap_proxy_direct_and_hosts diff --git a/Cargo.toml b/Cargo.toml index adfe650..8be8c95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,7 +120,7 @@ regex = "1.13" regex-syntax = { version = "0.8", default-features = false, features = ["std", "unicode-case"] } rustls = { version = "=0.23.45", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["raw_value"] } serde_yaml_ng = "0.10" sha2 = "0.11" shake = { version = "0.1.0", optional = true } diff --git a/docs/config.yaml b/docs/config.yaml index b004223..58b685d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -553,7 +553,10 @@ hosts: # enable 开启时使用配置的 resolver,不因上游失败回退到系统 DNS。 # nameserver 默认 [https://doh.pub/dns-query, tls://223.5.5.5:853],不能显式留空。 # 支持裸 IP、UDP/TCP、tls://、http(s)://,允许上游域名;DoH 使用 HTTP/1.1 POST, -# DoT 与 HTTPS 校验证书与名称。default-nameserver 只接受 IP 上游,用于 bootstrap。 +# DoT 与 HTTPS 校验证书与名称。default-nameserver 接受 IP 上游或显式 system,用于 bootstrap。 +# system、system://、dhcp://system 使用物理系统 DNS;dhcp://<精确接口名> 使用该接口的 DHCP DNS。 +# 各角色、policy、fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终物理直连, +# 不应用 fragment/代理规则。只读现有网络来源;缺失、加密要求无法满足或物理绑定失败时不可用。 # rcode://success/format_error/server_failure/name_error/not_implemented/refused 返回本地 RCODE 0–5, # 可用于各 nameserver 列表与 policy(default-nameserver 除外);不建立网络连接或额外设置 RA。 # 无 fragment 时默认 DIRECT;respect-rules: true 时默认 RULES,且必须配置 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index eb79211..8610589 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -119,6 +119,29 @@ stopped -> preparing -> prepared -> starting -> running 参数和幂等语义见“初始化与生命周期”。成功时返回规范化后的 `dataDir`。 +### `updateDnsNetwork` + +初始化后的运行时级方法,不带 `instanceId`。宿主提供完整、只读的物理网络 DNS 快照;不改变 `start(configYaml)`,不配置接口、路由、DNS 或 App 生命周期。 + +```json +{ + "method": "updateDnsNetwork", + "payload": { + "generation": 1, + "system": [ + {"address": "192.0.2.53", "interface": "en0", "scopeId": 0, "requiresEncryption": false} + ], + "dhcp": [ + {"interface": "en0", "servers": [{"address": "192.0.2.54", "scopeId": 0}], "requiresEncryption": false} + ] + } +} +``` + +`generation` 必须是正整数并单调增加;相同代次的相同内容幂等,不同内容或较旧代次失败。system/dhcp 必填,空数组撤销来源;一个 DHCP 接口只能出现一次。所有层级拒绝未知字段,metadata 原始 JSON(含内部空白)及规范化快照各限 64 KiB;在完整 JSON Value 解析前通过借用 RawValue 准入,额外 envelope 字节(含外部空白)独立限 64 KiB。这与其他方法沿用的全 envelope 上限不同。地址只接受非回环的单播字面 IP;IPv6 link-local 必须提供匹配物理接口的非零 scopeId,IPv4 scopeId 必须为 0。interface 是 1–256 字节的精确名称或 UWP 的绑定 GUID,不允许控制字符、路径分隔符或首尾空白。scopeId 默认 0,requiresEncryption 默认 false,宿主必须如实标记加密要求,不能将无法确认的数据描述为明文来源。 + +成功返回 `{}`。快照在同一运行时内共享,优先于 OS 发现,并与全部仍活跃资源代次的 DNS cache/hints 清理共用 epoch 屏障;旧查询不能回填,fake-IP 和活跃流保留。Windows 前台进程和 Session Host 的 runtime registry 各自独立,本方法不提供 IPC。平台可用性和物理绑定见 [DNS 来源](tun-icmp-dns.md#显式系统与-dhcp-来源)。 + ### `getGeoDataState` 运行时级只读方法,要求已经初始化: diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 3ca4c41..e64ad71 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -113,7 +113,8 @@ dns: - `rcode://success`、`rcode://format_error`、`rcode://server_failure`、`rcode://name_error`、`rcode://not_implemented`、`rcode://refused` 分别返回本地 RCODE 0–5;可用于 nameserver、policy、fallback、节点和直连 resolver,不能用于 default-nameserver。 - 同一个上游列表中存在本地 RCODE 时使用第一项本地响应,不建立网络连接,也不把 SERVFAIL/REFUSED 当作上游故障重试。响应保留原查询的 flags 和 additional,仅设置 QR 与 RCODE;不额外开启 RA,不写入结果缓存。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 -- 上游域名由 `default-nameserver` 引导解析,后者只允许 IP 上游。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。显式 system/DHCP nameserver 尚未实现。 +- 上游域名由 `default-nameserver` 引导解析,后者允许 IP 上游和显式 system。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。 +- `system`、`system://`、`dhcp://system` 使用物理系统 DNS;`dhcp://<精确接口名>` 仅使用该接口的 DHCP DNS,不匹配其他接口。各 nameserver 角色、policy 和 fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终直连物理接口,fragment 与 respect-rules 不改其出口。 - 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 - Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或支持的代理组名。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 @@ -153,6 +154,15 @@ Policy 只决定当前 DNS exchange,不改写后续业务动作。分类、属 Regex selector 记录与业务规则共用 `regex::bytes::Regex`;其搜索缓存由正则库管理, 可能分配 scratch 或发生缓存竞争,不承诺 DNS policy 正则搜索零分配。 +### 显式系统与 DHCP 来源 + +App 通过运行时级 [`updateDnsNetwork`](invoke-api.md#updatednsnetwork) 提供完整网络快照,地址必须是字面 IP,接口必须精确。快照优先于 OS 发现,空数组明确撤销来源。替换时原子推进 DNS epoch 并清理全部角色的类型化/wire 缓存及 redir-host 提示;保留 fake-IP 映射和活跃流。旧查询可返回原调用者,但不能回填新网络缓存,新查询不加入旧 singleflight。DNS TCP 连接池按来源代次、接口和地址区分复用。 + +Linux CLI 无宿主快照时仅调用已有 `/usr/bin/busctl`,读取 systemd-resolved 的物理 link DNS 或 NetworkManager 已激活接口的 DHCP4 `domain_name_servers`;原生 DHCP 还需现有 resolved link 的加密策略,无法确认策略时不可用。不启动服务、不读 VPN stub、不发送 DHCP DISCOVER,也不读取其他接口的租约。发现仅在后续 DNS miss 按需刷新,元数据缓存 20 秒;到期本身不主动清结果缓存。重新发现的 endpoint、加密要求或绑定变化时,原子推进共享 epoch/source generation 并清 cache/hints,连接池不能复用旧来源 key。整体发现期限 3 秒,stdout/stderr 各限 64 KiB;取消会关闭并等待子进程。缺服务、无可靠租约或加密要求无法满足时来源不可用。被接管的 TUN 接口、回环/stub 地址和自身网关被排除。与 Mihomo 主动 DISCOVER/DHCPOFFER 的 DHCP 来源机制不同。 + +Apple 使用宿主快照和 `IP_BOUND_IF`/`IPV6_BOUND_IF`;App 负责取得真实物理 DNS 数据。Android 和 Wintun 当前没有可证明的精确物理接口绑定,显式 system/DHCP 来源不可用,不改变现有 protect 与宿主网络隔离合约。UWP 仅在实际 Session Host 的既有物理 GUID 绑定内发现系统 DNS;前台 Invoke registry 不是 IPC,不能通过它更新另一个进程的 VPN 会话。DHCP 租约 metadata 不可靠时保持不可用。来源要求加密但当前 UDP/TCP 路径无法满足时失败关闭,不降级为明文,也不补充公共 DNS。 + + ## Fallback `fallback` 只作用于主 resolver 的 A/AAAA。先应用 nameserver-policy;命中的 policy @@ -244,7 +254,7 @@ wire/RCODE 语义,不使用这段地址合并等待。 - 查询总期限 5 秒;单 nameserver 尝试最多 3 秒,并受剩余时间限制。 - UDP 尝试只打开一个传输;首次发送后 1 秒无合法响应时,在同一传输和 ID 上最多重发一次。 - 前两次 peer/ID/question 不匹配只丢当前响应,第三次使当前尝试失败。 -- 当前策略最多同时发起 4 个上游尝试;优先返回有效响应并取消其余尝试。超时、I/O、格式错误、SERVFAIL、REFUSED 或 REJECT 不作为成功结果。 +- 同一上游策略最多同时发起 4 个实际 endpoint 尝试,字面上游和显式来源展开的 endpoint 共用许可;bootstrap/fallback 是独立子查询,不是全链或全局 socket 额度。优先返回有效响应并取消其余尝试。超时、I/O、格式错误、SERVFAIL、REFUSED 或 REJECT 不作为成功结果。 - 未启用 fallback 或命中 nameserver-policy 时,NXDOMAIN 和 NODATA 是终态。 - 验证来源、ID 和 question 后,UDP TC 在原期限内改用同一 endpoint 的 TCP。TCP TC 仍是失败响应。 - 全部上游失败时生成 SERVFAIL;非法 UDP 查询只丢当前数据报。 @@ -294,7 +304,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only ## 不支持 - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; -- DoQ、DoH HTTP/2/3、显式 system/DHCP nameserver; +- DoQ、DoH HTTP/2/3; - 动态 hosts: lan; - 用户可配置的缓存容量、查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index cf7343c..fef002c 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -153,6 +153,8 @@ Session Host 的每个非回环出站 socket 必须同时应用: `AssociateTransport` 只用于 Provider 的受管理唤醒传输,不用于普通代理流。Session Host 不持有 `VpnChannel`,不能给逐流 socket 获取同类豁免。 +显式 `system` DNS 仅由实际 Session Host 在显式查询按需读取既有物理适配器 GUID 的 DNS 数据(metadata 缓存 20 秒),继续执行同一 `(源 IP, 接口索引)` 绑定;不读取前台 Invoke registry,也不改变控制协议。`GetInterfaceDnsSettings` v3 不可用或存在无法满足的加密要求时来源不可用。原生 IPv6 DNS 的加密策略无法通过该只读查询确认,也保持不可用。无法确认 DHCP option 6 来源时 `dhcp://<接口>` 不可用。系统 DNS 发现不配置或恢复系统网络,App 仍负责原有网络设置和生命周期。 + ## 网络变化 Provider 是物理网络状态的唯一权威,并订阅 `NetworkStatusChanged`。事件到达后等待 2 秒,再复验适配器 GUID、选定源地址、全部去重 on-link prefixes 和 network identity;任一项变化就停止当前 VPN。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 7e916ab..b0fa506 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -59,10 +59,12 @@ impl RawDnsConfig { DnsRoute::Direct }; let default_nameservers = parse(self.default_nameserver, DnsRoute::Direct)?; - if default_nameservers - .iter() - .any(|server| matches!(server.address, DnsAddress::Domain(_))) - { + if default_nameservers.iter().any(|server| { + matches!( + server.address, + DnsAddress::Domain(_) | DnsAddress::Source(DnsSource::Dhcp(_)) + ) + }) { return invalid("default-nameserver requires literal IP addresses"); } let fallback = self @@ -120,10 +122,32 @@ pub(super) fn parse_dns_nameserver( ) -> Result { let bad = || { VoleError::InvalidConfig( - "invalid DNS upstream; supported transports are UDP, TCP, TLS, HTTP(S) and RCODE" + "invalid DNS upstream; supported transports are UDP, TCP, TLS, HTTP(S), RCODE, system and DHCP" .into(), ) }; + // Mihomo's special clients use physical DNS and ignore proxy fragments. + let special = input + .split_once('#') + .map_or(input, |(endpoint, _)| endpoint); + let source = match special { + "system" | "system://" | "dhcp://system" => Some(DnsSource::System), + endpoint if endpoint.starts_with("dhcp://") => { + let interface = &endpoint[7..]; + crate::dns::source::validate_interface(interface).map_err(|_| bad())?; + Some(DnsSource::Dhcp(interface.into())) + } + _ => None, + }; + if let Some(source) = source { + return Ok(DnsNameserver { + address: DnsAddress::Source(source), + transport: DnsTransport::Udp, + port: 53, + route: DnsRoute::Direct, + path: None, + }); + } let (endpoint, route) = match input.split_once('#') { Some((endpoint, fragment)) => { let name = percent_decode(fragment)?; diff --git a/src/config/mod.rs b/src/config/mod.rs index d89cae1..936249a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -323,6 +323,13 @@ pub enum HostValue { pub enum DnsAddress { Ip(IpAddr), Domain(String), + Source(DnsSource), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DnsSource { + System, + Dhcp(Box), } impl std::str::FromStr for DnsAddress { @@ -357,6 +364,8 @@ impl std::fmt::Display for DnsAddress { match self { Self::Ip(ip) => ip.fmt(f), Self::Domain(host) => host.fmt(f), + Self::Source(DnsSource::System) => f.write_str("system"), + Self::Source(DnsSource::Dhcp(_)) => f.write_str("dhcp"), } } } @@ -6231,6 +6240,47 @@ authentication: assert!(!config.dns.enable); } + #[test] + fn dns_source_aliases_preserve_mihomo_roles_and_bootstrap_rules() { + for alias in ["system", "system://", "dhcp://system"] { + let yaml = format!( + "mixed-port: 1080\ndns:\n enable: true\n default-nameserver: ['{alias}']\n nameserver: ['{alias}#unknown']\n fallback: ['{alias}']\n proxy-server-nameserver: ['{alias}']\n direct-nameserver: ['{alias}']\n nameserver-policy: {{example.com: '{alias}'}}\n" + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + for server in config + .dns + .default_nameservers + .iter() + .chain(&config.dns.nameservers) + .chain(&config.dns.fallback.nameservers) + .chain(&config.dns.proxy_nameservers) + .chain(&config.dns.direct_nameservers) + .chain(config.dns.nameserver_policies[0].nameservers.iter()) + { + assert_eq!(server.address, DnsAddress::Source(DnsSource::System)); + assert_eq!(server.route, DnsRoute::Direct); + } + } + let parsed = Config::parse_yaml( + b"mixed-port: 1080\ndns: {nameserver: ['dhcp://physical0#unknown']}\n", + ) + .unwrap(); + assert_eq!( + parsed.dns.nameservers[0].address, + DnsAddress::Source(DnsSource::Dhcp("physical0".into())) + ); + for invalid in ["dhcp://", "dhcp://physical0/", "system://physical0"] { + let yaml = format!("mixed-port: 1080\ndns: {{nameserver: ['{invalid}']}}\n"); + assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); + } + assert!( + Config::parse_yaml( + b"mixed-port: 1080\ndns: {default-nameserver: ['dhcp://physical0']}\n" + ) + .is_err() + ); + } + #[test] fn dns_rcode_nameserver_uses_the_mihomo_names_and_existing_policy_schema() { for (name, rcode) in [ diff --git a/src/dialer.rs b/src/dialer.rs index 887a879..cc15101 100644 --- a/src/dialer.rs +++ b/src/dialer.rs @@ -368,6 +368,11 @@ struct InterfaceBinding { #[derive(Clone)] pub struct Dialer { + dns_sources: Arc, + dns_binding: Option, + dns_tun_interface: Option>, + #[cfg(all(windows, feature = "windows-uwp"))] + physical_adapter: Option>, #[cfg(all(target_os = "linux", feature = "tun"))] linux_policy: Option>, ip_version: crate::config::IpVersion, @@ -385,6 +390,7 @@ impl std::fmt::Debug for Dialer { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut debug = formatter.debug_struct("Dialer"); debug + .field("has_dns_binding", &self.dns_binding.is_some()) .field("has_protector", &self.protector.is_some()) .field("source_binding", &self.source_binding); #[cfg(all(windows, feature = "windows-uwp"))] @@ -399,6 +405,11 @@ impl std::fmt::Debug for Dialer { impl Default for Dialer { fn default() -> Self { Self { + dns_sources: Arc::new(crate::dns::source::NetworkSources::default()), + dns_binding: None, + dns_tun_interface: None, + #[cfg(all(windows, feature = "windows-uwp"))] + physical_adapter: None, #[cfg(all(target_os = "linux", feature = "tun"))] linux_policy: None, ip_version: crate::config::IpVersion::Dual, @@ -415,6 +426,176 @@ impl Default for Dialer { } impl Dialer { + pub(crate) fn with_dns_sources( + mut self, + sources: Arc, + ) -> Self { + self.dns_sources = sources; + self + } + pub(crate) fn dns_sources(&self) -> Arc { + self.dns_sources.clone() + } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn with_dns_tun_interface(mut self, interface: Option>) -> Self { + self.dns_tun_interface = interface; + self + } + pub(crate) fn dns_tun_interface(&self) -> Option> { + self.dns_tun_interface.clone() + } + pub(crate) fn for_dns_source( + &self, + binding: crate::dns::source::DnsBinding, + endpoint: SocketAddr, + ) -> io::Result { + #[cfg(all(windows, feature = "windows-uwp"))] + if self.physical_adapter.as_deref() != Some(binding.interface.as_ref()) + || self.interface_binding.is_none() + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "DNS source is outside the physical network binding", + )); + } + #[cfg(all(windows, feature = "windows-uwp"))] + if let SocketAddr::V6(endpoint) = endpoint + && endpoint.scope_id() != 0 + && self + .interface_binding + .and_then(|binding| binding.ipv6.map(|(_, index)| index.get())) + != Some(endpoint.scope_id()) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "DNS source scope does not match its binding", + )); + } + let _ = endpoint; + #[cfg(any(target_os = "android", all(windows, not(feature = "windows-uwp"))))] + let _ = binding; + #[cfg(any(target_os = "android", all(windows, not(feature = "windows-uwp"))))] + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "exact physical DNS interface binding is unavailable", + )); + #[cfg(not(any(target_os = "android", all(windows, not(feature = "windows-uwp")))))] + { + let mut dialer = self.clone(); + dialer.dns_binding = Some(binding); + Ok(dialer) + } + } + #[cfg(all(windows, feature = "windows-uwp"))] + pub(crate) fn with_physical_adapter(mut self, adapter: Arc) -> Self { + self.physical_adapter = Some(adapter); + self + } + #[cfg(all(windows, feature = "windows-uwp"))] + pub(crate) fn physical_dns_adapter(&self) -> Option> { + self.physical_adapter.clone() + } + #[cfg(any(target_os = "linux", all(windows, feature = "windows-uwp")))] + pub(crate) async fn run_dns_discovery(&self, operation: F) -> io::Result + where + T: Send + 'static, + F: FnOnce(&dyn Fn() -> io::Result<()>) -> io::Result + Send + 'static, + { + self.initialization.run(operation).await + } + #[cfg(unix)] + fn apply_dns_binding(&self, socket: &impl AsRawFd, destination: SocketAddr) -> io::Result<()> { + let _ = socket; + let Some(binding) = &self.dns_binding else { + #[cfg(all(target_os = "linux", feature = "tun"))] + if let Some(policy) = &self.linux_policy { + return policy.apply(socket, destination); + } + return Ok(()); + }; + let name = std::ffi::CString::new(binding.interface.as_ref()) + .map_err(|_| io::ErrorKind::InvalidInput)?; + // SAFETY: `name` is terminated and only inspected by the kernel. + let index = unsafe { libc::if_nametoindex(name.as_ptr()) }; + if index == 0 { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "DNS source interface disappeared", + )); + } + if binding + .interface_index + .is_some_and(|expected| expected.get() != index) + { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "DNS source interface generation changed", + )); + } + if let SocketAddr::V6(address) = destination + && address.scope_id() != 0 + && address.scope_id() != index + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "DNS source scope does not match its interface", + )); + } + #[cfg(all(target_os = "linux", feature = "tun"))] + if let Some(policy) = &self.linux_policy { + return policy.apply_with_interface( + socket, + destination, + Some(binding.interface.as_ref()), + ); + } + #[cfg(target_os = "linux")] + let status = unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + name.as_ptr().cast(), + name.as_bytes_with_nul().len() as libc::socklen_t, + ) + }; + #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] + let status = { + let (level, option) = if destination.is_ipv6() { + (libc::IPPROTO_IPV6, libc::IPV6_BOUND_IF) + } else { + (libc::IPPROTO_IP, libc::IP_BOUND_IF) + }; + // SAFETY: the option is a live u32 and socket is initialized here. + unsafe { + libc::setsockopt( + socket.as_raw_fd(), + level, + option, + (&raw const index).cast(), + std::mem::size_of_val(&index) as libc::socklen_t, + ) + } + }; + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "ios", + target_os = "tvos" + )))] + return Err(io::ErrorKind::Unsupported.into()); + #[cfg(any( + target_os = "linux", + target_os = "macos", + target_os = "ios", + target_os = "tvos" + ))] + if status < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } pub(crate) fn with_connections(mut self, settings: crate::config::ConnectionSettings) -> Self { self.connections = settings; self @@ -573,10 +754,8 @@ impl Dialer { socket.set_nonblocking(true)?; check()?; dialer.protect_socket(&socket)?; - #[cfg(all(target_os = "linux", feature = "tun"))] - if let Some(policy) = &dialer.linux_policy { - policy.apply(&socket, destination)?; - } + #[cfg(unix)] + dialer.apply_dns_binding(&socket, destination)?; check()?; Ok((std::net::UdpSocket::from(socket), guard)) }) @@ -686,10 +865,8 @@ impl Dialer { check()?; dialer.connections.apply(&socket2::SockRef::from(&socket))?; dialer.protect_socket(&socket)?; - #[cfg(all(target_os = "linux", feature = "tun"))] - if let Some(policy) = &dialer.linux_policy { - policy.apply(&socket, address)?; - } + #[cfg(unix)] + dialer.apply_dns_binding(&socket, address)?; check()?; #[cfg(all(windows, feature = "windows-uwp"))] dialer.apply_interface_binding(socket.as_raw_socket(), address)?; diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 8d11a13..4d7a7f5 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod trace; pub mod resolution; pub mod runtime; +pub(crate) mod source; use std::{ fmt, diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index eaa451e..7dcfa69 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -23,7 +23,7 @@ use std::{ use bytes::Bytes; use tokio::{ io::{AsyncReadExt as _, AsyncWriteExt as _}, - sync::{Mutex, RwLock, watch}, + sync::{Mutex, RwLock, Semaphore, watch}, time::{Instant as TokioInstant, sleep_until, timeout_at}, }; use tokio_util::sync::CancellationToken; @@ -108,6 +108,9 @@ pub struct RuntimeDns { egress: DnsEgress, cache: Mutex, cache_epoch: Arc>, + sources: Arc, + source_dialer: Option, + source_exclusions: super::source::SourceExclusions, // Hint lookup and insertion are bounded synchronous work. Keep this // shared TUN hot path free of asynchronous mutex scheduling. hints: Option>>, @@ -129,6 +132,8 @@ struct TcpPoolKey { upstream: DnsNameserver, endpoint: SocketAddr, egress: DnsEgressKey, + source_generation: u64, + source_binding: Option, } struct TcpConnectionPool { @@ -256,6 +261,8 @@ impl TcpConnectionPool { upstream: nameserver.config.clone(), endpoint: nameserver.endpoint(), egress: egress_key, + source_generation: nameserver.source_generation, + source_binding: nameserver.source_binding.clone(), }; let index = state .idle @@ -821,9 +828,24 @@ impl fmt::Debug for RuntimeDns { } impl RuntimeDns { + pub(crate) fn network_sources(&self) -> Arc { + self.sources.clone() + } + pub(crate) fn with_network_sources( + mut self, + dialer: crate::dialer::Dialer, + exclusions: super::source::SourceExclusions, + ) -> Self { + self.sources = dialer.dns_sources(); + self.source_dialer = Some(dialer); + self.source_exclusions = exclusions; + self.share_cache_epoch(self.sources.epoch()); + self + } pub(crate) fn with_mapping_from(mut self, previous: Option<&Self>) -> Self { if let Some(previous) = previous { self.hints = previous.hints.clone(); + self.sources = previous.sources.clone(); self.share_cache_epoch(previous.cache_epoch.clone()); if self.fake_config.enabled == previous.fake_config.enabled && self.fake_config.range == previous.fake_config.range @@ -845,13 +867,19 @@ impl RuntimeDns { .bootstrap = None; } if let Some(bootstrap) = &mut self.bootstrap { - Arc::get_mut(bootstrap) - .expect("fresh DNS bootstrap is exclusively owned") - .cache_epoch = epoch.clone(); + let bootstrap = + Arc::get_mut(bootstrap).expect("fresh DNS bootstrap is exclusively owned"); + bootstrap.cache_epoch = epoch.clone(); + bootstrap.sources = self.sources.clone(); + bootstrap.source_dialer = self.source_dialer.clone(); + bootstrap.source_exclusions = self.source_exclusions.clone(); } for role in roles.iter_mut().flatten() { let role = Arc::get_mut(role).expect("fresh DNS role is exclusively owned"); role.cache_epoch = epoch.clone(); + role.sources = self.sources.clone(); + role.source_dialer = self.source_dialer.clone(); + role.source_exclusions = self.source_exclusions.clone(); role.bootstrap = self.bootstrap.clone(); } [self.proxy_resolver, self.direct_resolver] = roles; @@ -866,6 +894,10 @@ impl RuntimeDns { // Advance before clearing so cancellation during shutdown cannot // allow an old leader to repopulate a partially cleared tree. *epoch = epoch.wrapping_add(1); + self.clear_cache_tree().await; + } + + pub(super) async fn clear_cache_tree(&self) { let mut pending = vec![self]; let mut seen = HashSet::new(); while let Some(resolver) = pending.pop() { @@ -1021,7 +1053,8 @@ impl RuntimeDns { cache: usize, hints: usize, ) -> Self { - let cache_epoch = Arc::new(RwLock::new(0)); + let sources = Arc::new(super::source::NetworkSources::default()); + let cache_epoch = sources.epoch(); let mut bootstrap_config = config.clone(); bootstrap_config.enable = true; bootstrap_config.fallback = crate::config::DnsFallbackConfig::default(); @@ -1079,6 +1112,9 @@ impl RuntimeDns { }, )); } + drop(bootstrap); + root.sources = sources; + root.share_cache_epoch(cache_epoch); root } @@ -1118,6 +1154,9 @@ impl RuntimeDns { config.cache_algorithm, )), cache_epoch, + sources: Arc::new(super::source::NetworkSources::default()), + source_dialer: None, + source_exclusions: super::source::SourceExclusions::default(), hints: (redir_host_entries != 0).then(|| { Arc::new(StdMutex::new(RedirHostHints::with_max_entries( redir_host_entries, @@ -1176,6 +1215,9 @@ impl RuntimeDns { reuse: impl Fn(DnsEgressKey) -> bool, ) -> Result<(DnsEgressKey, Arc), AttemptError> { use crate::routing::proxy_group::InspectedRuleTarget; + if let Some(physical) = &nameserver.physical { + return Ok((DnsEgressKey::Direct, physical.clone())); + } let DnsEgress::Routed { proxies, direct, @@ -1192,6 +1234,9 @@ impl RuntimeDns { DnsAddress::Domain(host) => { Destination::domain(host, nameserver.port).map_err(AttemptError::Io)? } + DnsAddress::Source(_) => { + return Err(AttemptError::Io(super::source::unavailable())); + } }; let inspect = |id| { proxies.inspect_rule_target( @@ -1380,6 +1425,7 @@ impl RuntimeDns { ) -> Result { let address = match &nameserver.address { DnsAddress::Ip(ip) => *ip, + DnsAddress::Source(_) => return Err(AttemptError::Io(super::source::unavailable())), DnsAddress::Domain(host) => { super::trace::record("bootstrap"); let resolver = self @@ -1397,6 +1443,9 @@ impl RuntimeDns { Ok(ResolvedNameserver { config: nameserver.clone(), endpoint: SocketAddr::new(address, nameserver.port), + physical: None, + source_generation: 0, + source_binding: None, }) } @@ -1858,10 +1907,13 @@ impl RuntimeDns { let cacheable = !nameservers .iter() .any(|server| matches!(server.transport, DnsTransport::Rcode(_))); + let permits = Semaphore::new(4); let (wire, response) = exchange_nameservers( nameservers, deadline, - |nameserver| self.exchange_nameserver(nameserver, query, parsed_query), + |nameserver| { + self.exchange_nameserver_limited(nameserver, query, parsed_query, &permits) + }, |(_, response): &(Vec, DnsMessage)| { matches!(response.rcode, RCODE_SERVFAIL | RCODE_REFUSED) .then_some(u16::from(response.rcode)) @@ -1878,10 +1930,11 @@ impl RuntimeDns { classified: &ClassifiedDnsQuery, deadline: TokioInstant, ) -> Result { + let permits = Semaphore::new(4); exchange_nameservers( nameservers, deadline, - |nameserver| self.exchange_opaque_nameserver(nameserver, query, classified), + |nameserver| self.exchange_opaque_nameserver(nameserver, query, classified, &permits), |response: &ValidatedOpaqueResponse| { matches!( response.rcode(), @@ -1894,17 +1947,30 @@ impl RuntimeDns { .await } + #[cfg(test)] async fn exchange_nameserver( &self, nameserver: DnsNameserver, query: &[u8], parsed: &DnsMessage, + ) -> Result<(Vec, DnsMessage), AttemptError> { + self.exchange_nameserver_limited(nameserver, query, parsed, &Semaphore::new(4)) + .await + } + + async fn exchange_nameserver_limited( + &self, + nameserver: DnsNameserver, + query: &[u8], + parsed: &DnsMessage, + permits: &Semaphore, ) -> Result<(Vec, DnsMessage), AttemptError> { self.exchange_validated( nameserver, query, |wire| udp_response_matches_query(parsed, wire), |wire| validate_complete_response(parsed, wire), + permits, ) .await } @@ -1914,6 +1980,7 @@ impl RuntimeDns { nameserver: DnsNameserver, query: &[u8], parsed: &ClassifiedDnsQuery, + permits: &Semaphore, ) -> Result { let local = matches!(nameserver.transport, DnsTransport::Rcode(_)); self.exchange_validated( @@ -1921,6 +1988,7 @@ impl RuntimeDns { query, |wire| udp_response_matches_classified_query(parsed, wire), |wire| validate_opaque_response(parsed, wire).map_err(AttemptError::from), + permits, ) .await .map(|(_, mut parsed)| { @@ -1937,6 +2005,7 @@ impl RuntimeDns { query: &[u8], matches: M, validate: V, + permits: &Semaphore, ) -> Result<(Vec, R), AttemptError> where M: Fn(&[u8]) -> Result, @@ -1951,6 +2020,68 @@ impl RuntimeDns { let parsed = validate(&response)?; return Ok((response, parsed)); } + if let DnsAddress::Source(source) = &nameserver.address { + use futures_util::{StreamExt, stream}; + let selection = self + .sources + .select(source, self.source_dialer.as_ref(), &self.source_exclusions) + .await?; + let mut attempts = stream::iter(selection.endpoints.to_vec()) + .map(|endpoint| { + let mut config = nameserver.clone(); + config.address = DnsAddress::Ip(endpoint.endpoint.ip()); + config.route = DnsRoute::Direct; + let physical = self + .source_dialer + .as_ref() + .map(|dialer| { + dialer + .for_dns_source(endpoint.binding.clone(), endpoint.endpoint) + .map(|dialer| { + Arc::new(crate::outbound::DirectOutbound::new(dialer)) + as Arc + }) + }) + .transpose(); + let resolved = ResolvedNameserver { + config, + endpoint: endpoint.endpoint, + physical: None, + source_generation: selection.generation, + source_binding: Some(endpoint.binding.clone()), + }; + let matches = &matches; + let validate = &validate; + async move { + let mut resolved = resolved; + resolved.physical = physical?; + let _attempt = permits + .acquire() + .await + .map_err(|_| super::source::unavailable())?; + self.exchange_resolved_validated(resolved, query, matches, validate) + .await + } + }) + .buffer_unordered(4); + let mut last = None; + while let Some(result) = attempts.next().await { + match result { + Ok((wire, parsed)) + if !matches!(wire[3] & 0x0f, RCODE_SERVFAIL | RCODE_REFUSED) => + { + return Ok((wire, parsed)); + } + Ok(response) => last = Some(Ok(response)), + Err(error) => { + if !matches!(last, Some(Ok(_))) { + last = Some(Err(error)); + } + } + } + } + return last.unwrap_or_else(|| Err(super::source::unavailable().into())); + } super::trace::record(match nameserver.transport { DnsTransport::Rcode(_) => unreachable!("local response handled above"), DnsTransport::Udp => "udp", @@ -1959,7 +2090,26 @@ impl RuntimeDns { DnsTransport::Https => "https", DnsTransport::Http => "http", }); - let mut server = self.resolve_nameserver(&nameserver).await?; + let server = self.resolve_nameserver(&nameserver).await?; + let _attempt = permits + .acquire() + .await + .map_err(|_| super::source::unavailable())?; + self.exchange_resolved_validated(server, query, matches, validate) + .await + } + + async fn exchange_resolved_validated( + &self, + mut server: ResolvedNameserver, + query: &[u8], + matches: M, + validate: V, + ) -> Result<(Vec, R), AttemptError> + where + M: Fn(&[u8]) -> Result, + V: Fn(&[u8]) -> Result, + { if server.transport == DnsTransport::Udp { let (_, egress) = self.egress_for(&server, Network::Udp, |_| false)?; let response = self.exchange_udp(&server, &egress, query, matches).await?; @@ -2069,6 +2219,8 @@ impl RuntimeDns { idle.key.upstream == nameserver.config && idle.key.endpoint == nameserver.endpoint() && idle.key.egress == egress + && idle.key.source_generation == nameserver.source_generation + && idle.key.source_binding == nameserver.source_binding }) }) }) @@ -2295,6 +2447,9 @@ where struct ResolvedNameserver { config: DnsNameserver, endpoint: SocketAddr, + physical: Option>, + source_generation: u64, + source_binding: Option, } impl std::ops::Deref for ResolvedNameserver { type Target = DnsNameserver; @@ -5456,6 +5611,284 @@ dns: assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 5); } + fn dns_source_snapshot( + generation: u64, + server: &str, + ) -> super::super::source::DnsNetworkUpdate { + use super::super::source::*; + DnsNetworkUpdate { + generation, + system: vec![SystemDnsServer { + address: server.parse().unwrap(), + interface: "physical0".into(), + scope_id: 0, + requires_encryption: false, + }], + dhcp: vec![DhcpDnsInterface { + interface: "physical1".into(), + servers: vec![DnsServerAddress { + address: "192.0.2.53".parse().unwrap(), + scope_id: 0, + }], + requires_encryption: false, + }], + } + } + + #[tokio::test] + async fn dns_source_routes_policy_bootstrap_and_every_role_to_physical_metadata() { + let configuration = crate::config::Config::parse_yaml(b"mixed-port: 1080\ndns:\n enable: true\n use-system-hosts: false\n default-nameserver: [system]\n nameserver: [udp://dns.example]\n proxy-server-nameserver: [system]\n direct-nameserver: [system]\n nameserver-policy: {policy.example: 'dhcp://physical1'}\n").unwrap(); + let answer: IpAddr = "203.0.113.10".parse().unwrap(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + (0..5) + .map(|_| MockReply::Response(ResponseSpec::answer(answer))) + .collect(), + vec![], + )); + let resolver = Arc::new(RuntimeDns::new(&configuration.dns, dispatcher.clone())); + resolver.sources.register(&resolver); + resolver + .sources + .replace(dns_source_snapshot(1, "203.0.113.53")) + .await + .unwrap(); + resolver.resolve("main.example").await.unwrap(); + resolver + .proxy_resolver + .as_ref() + .unwrap() + .resolve("proxy.example") + .await + .unwrap(); + resolver + .direct_resolver + .as_ref() + .unwrap() + .resolve("direct.example") + .await + .unwrap(); + resolver.resolve("policy.example").await.unwrap(); + let expected: Vec = [ + "203.0.113.53", + "203.0.113.10", + "203.0.113.53", + "203.0.113.53", + "192.0.2.53", + ] + .into_iter() + .map(|ip| Destination::Ip(SocketAddr::new(ip.parse().unwrap(), 53))) + .collect(); + assert_eq!(*dispatcher.udp_destinations.lock().unwrap(), expected); + } + + #[tokio::test] + async fn dns_source_replacement_separates_old_flights_and_clears_every_role() { + let old: IpAddr = "203.0.113.10".parse().unwrap(); + let new: IpAddr = "203.0.113.11".parse().unwrap(); + let release = Arc::new(Notify::new()); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![ + MockReply::Gated { + response: ResponseSpec::answer(old), + release: release.clone(), + }, + MockReply::Response(ResponseSpec::answer(new)), + ], + vec![], + )); + let configuration = crate::config::Config::parse_yaml(b"mixed-port: 1080\ndns:\n enable: true\n default-nameserver: [system]\n nameserver: [system]\n proxy-server-nameserver: [system]\n direct-nameserver: [system]\n").unwrap(); + let resolver = Arc::new(RuntimeDns::new(&configuration.dns, dispatcher.clone())); + resolver.sources.register(&resolver); + resolver + .sources + .replace(dns_source_snapshot(1, "203.0.113.53")) + .await + .unwrap(); + let query = build_query(851, "replace.example", QueryType::A).unwrap(); + let pending_query = query.clone(); + let pending_resolver = resolver.clone(); + let pending = + tokio::spawn(async move { pending_resolver.exchange(&pending_query).await.unwrap() }); + tokio::time::timeout(Duration::from_secs(1), async { + while dispatcher.udp_calls.load(Ordering::Acquire) != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + resolver + .sources + .replace(dns_source_snapshot(2, "203.0.113.54")) + .await + .unwrap(); + assert_eq!( + resolver.resolve("replace.example").await.unwrap(), + vec![new] + ); + release.notify_one(); + assert_eq!( + pending.await.unwrap(), + response_for_query(&query, &ResponseSpec::answer(old)) + ); + assert_eq!( + resolver.resolve("replace.example").await.unwrap(), + vec![new] + ); + assert!(resolver.domain_hint(old).await.is_none()); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 2); + assert_eq!( + *dispatcher.udp_destinations.lock().unwrap(), + vec![ + Destination::Ip("203.0.113.53:53".parse().unwrap()), + Destination::Ip("203.0.113.54:53".parse().unwrap()) + ] + ); + resolver + .sources + .replace(dns_source_snapshot(3, "203.0.113.54")) + .await + .unwrap(); + for role in [ + &resolver, + resolver.bootstrap.as_ref().unwrap(), + resolver.proxy_resolver.as_ref().unwrap(), + resolver.direct_resolver.as_ref().unwrap(), + ] { + assert!(role.cache.lock().await.is_empty()); + } + assert!(resolver.domain_hint(new).await.is_none()); + } + + #[tokio::test] + async fn dns_source_tcp_pool_separates_generation_and_binding_with_live_old_connections() { + struct Combined { + udp: Arc, + tcp: Arc, + } + #[async_trait] + impl Dispatcher for Combined { + async fn connect_tcp( + &self, + session: StreamSession, + ) -> Result { + self.tcp.connect_tcp(session).await + } + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + self.udp.open_datagram(session).await + } + } + let old: IpAddr = "203.0.113.10".parse().unwrap(); + let new: IpAddr = "203.0.113.11".parse().unwrap(); + let tcp = Arc::new(ScriptedTcpDispatcher::new(vec![ + vec![ + TcpScriptReply::Response(ResponseSpec::answer(old)), + TcpScriptReply::Response(ResponseSpec::answer(old)), + ], + vec![ + TcpScriptReply::Response(ResponseSpec::answer(new)), + TcpScriptReply::Response(ResponseSpec::answer(new)), + ], + vec![TcpScriptReply::Response(ResponseSpec::answer(new))], + ])); + let udp = Arc::new(MockDispatcher::with_replies( + (0..3) + .map(|_| MockReply::Response(ResponseSpec::truncated())) + .collect(), + vec![], + )); + let configuration = crate::config::Config::parse_yaml( + b"mixed-port: 1080\ndns: {enable: true, nameserver: [system]}\n", + ) + .unwrap(); + let resolver = Arc::new(RuntimeDns::new( + &configuration.dns, + Arc::new(Combined { + udp, + tcp: tcp.clone(), + }), + )); + resolver.sources.register(&resolver); + for (generation, expected) in [(1, old), (2, new), (3, new)] { + let mut snapshot = dns_source_snapshot(generation, "203.0.113.53"); + if generation == 3 { + snapshot.system[0].interface = "physical1".into(); + } + resolver.sources.replace(snapshot).await.unwrap(); + assert_eq!( + resolver + .resolve(&format!("pool{generation}.example")) + .await + .unwrap(), + vec![expected] + ); + assert_eq!(tcp.tcp_calls.load(Ordering::Acquire), generation as usize); + } + assert_eq!(resolver.tcp_pool.state.lock().await.idle.len(), 3); + } + + #[tokio::test] + async fn dns_source_and_literal_share_four_endpoint_attempts() { + let release = Arc::new(Notify::new()); + let address: IpAddr = "203.0.113.10".parse().unwrap(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + (0..4) + .map(|_| MockReply::Gated { + response: ResponseSpec::answer(address), + release: release.clone(), + }) + .collect(), + vec![], + )); + let configuration = crate::config::Config::parse_yaml(b"mixed-port: 1080\ndns: {enable: true, nameserver: [1.1.1.1, system, system, system]}\n").unwrap(); + let resolver = Arc::new(RuntimeDns::new(&configuration.dns, dispatcher.clone())); + let mut snapshot = dns_source_snapshot(1, "203.0.113.53"); + for server in ["203.0.113.54", "203.0.113.55"] { + let mut entry = snapshot.system[0].clone(); + entry.address = server.parse().unwrap(); + snapshot.system.push(entry); + } + resolver.sources.replace(snapshot).await.unwrap(); + let querying = resolver.clone(); + let task = tokio::spawn(async move { querying.resolve("limited.example").await.unwrap() }); + tokio::time::timeout(Duration::from_secs(1), async { + while dispatcher.udp_calls.load(Ordering::Acquire) < 4 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + for _ in 0..10 { + tokio::task::yield_now().await; + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 4); + release.notify_waiters(); + assert_eq!(task.await.unwrap(), vec![address]); + } + + #[tokio::test] + async fn dns_source_unavailable_does_not_use_a_public_fallback() { + let configuration = crate::config::Config::parse_yaml( + b"mixed-port: 1080\ndns: {enable: true, nameserver: [system]}\n", + ) + .unwrap(); + let dispatcher = Arc::new(MockDispatcher::default()); + let resolver = RuntimeDns::new(&configuration.dns, dispatcher.clone()); + resolver + .sources + .replace(super::super::source::DnsNetworkUpdate { + generation: 1, + system: vec![], + dhcp: vec![], + }) + .await + .unwrap(); + assert!(resolver.resolve("unavailable.example").await.is_err()); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); + } + #[tokio::test] async fn dns_rcode_preserves_query_flags_and_wire_without_network_or_result_caching() { for rcode in 0..=5 { diff --git a/src/dns/source.rs b/src/dns/source.rs new file mode 100644 index 0000000..3bfe762 --- /dev/null +++ b/src/dns/source.rs @@ -0,0 +1,614 @@ +//! Explicit physical DNS metadata. This module never configures a network. +use std::{ + collections::HashMap, + io, + net::{IpAddr, SocketAddr, SocketAddrV6}, + sync::{Arc, Mutex as StdMutex, Weak}, + time::{Duration, Instant}, +}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::{Mutex, RwLock}; + +use crate::{config::DnsSource, dialer::Dialer}; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(all(windows, feature = "windows-uwp"))] +pub(crate) mod windows; + +pub(crate) const MAX_METADATA_BYTES: usize = 64 * 1024; +const DISCOVERY_TTL: Duration = Duration::from_secs(20); + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DnsServerAddress { + pub address: IpAddr, + #[serde(default)] + pub scope_id: u32, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SystemDnsServer { + pub address: IpAddr, + pub interface: String, + #[serde(default)] + pub scope_id: u32, + #[serde(default)] + pub requires_encryption: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DhcpDnsInterface { + pub interface: String, + pub servers: Vec, + #[serde(default)] + pub requires_encryption: bool, +} + +/// Host-supplied network data, separate from the Mihomo YAML schema. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DnsNetworkUpdate { + pub generation: u64, + pub system: Vec, + pub dhcp: Vec, +} + +impl DnsNetworkUpdate { + pub(crate) fn validate(&self) -> io::Result<()> { + check_metadata_size(self)?; + if self.generation == 0 { + return Err(invalid("DNS network generation must be positive")); + } + let mut interfaces = std::collections::HashSet::new(); + for server in &self.system { + validate_interface(&server.interface)?; + endpoint(server.address, server.scope_id)?; + } + for lease in &self.dhcp { + validate_interface(&lease.interface)?; + if !interfaces.insert(&lease.interface) { + return Err(invalid("duplicate DHCP DNS interface")); + } + for server in &lease.servers { + endpoint(server.address, server.scope_id)?; + } + } + Ok(()) + } +} + +pub(crate) fn check_metadata_size(value: &impl Serialize) -> io::Result<()> { + struct Size(usize); + impl io::Write for Size { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0 = self + .0 + .checked_add(bytes.len()) + .filter(|size| *size <= MAX_METADATA_BYTES) + .ok_or_else(|| invalid("DNS network metadata exceeds 64 KiB"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + serde_json::to_writer(Size(0), value) + .map_err(|_| invalid("DNS network metadata exceeds 64 KiB")) +} + +pub(crate) fn validate_interface(name: &str) -> io::Result<()> { + if name.is_empty() + || name.len() > 256 + || name.chars().any(char::is_control) + || name.trim() != name + || name.contains(['/', '\\']) + { + return Err(invalid("invalid DNS source interface")); + } + Ok(()) +} + +fn invalid(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::InvalidInput, message) +} +pub(super) fn unavailable() -> io::Error { + io::Error::new( + io::ErrorKind::NotFound, + "physical DNS source is unavailable", + ) +} + +fn endpoint(ip: IpAddr, scope: u32) -> io::Result { + let ip = ip.to_canonical(); + if ip.is_unspecified() || ip.is_multicast() || ip.is_loopback() { + return Err(invalid("DNS source must be a physical unicast address")); + } + match ip { + IpAddr::V4(_) if scope != 0 => Err(invalid("IPv4 DNS source cannot have a scope")), + IpAddr::V6(ip) if ip.is_unicast_link_local() && scope == 0 => { + Err(invalid("link-local DNS source requires a scope")) + } + IpAddr::V6(ip) if scope != 0 => Ok(SocketAddr::V6(SocketAddrV6::new(ip, 53, 0, scope))), + _ => Ok(SocketAddr::new(ip, 53)), + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DnsBinding { + pub interface: Arc, + pub interface_index: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct SourceEndpoint { + pub endpoint: SocketAddr, + pub binding: DnsBinding, + pub requires_encryption: bool, +} + +pub(super) struct SourceSelection { + pub generation: u64, + pub endpoints: Arc<[SourceEndpoint]>, +} + +#[derive(Clone, Default)] +pub(crate) struct SourceExclusions { + pub interface: Option>, + pub addresses: Vec, +} +impl SourceExclusions { + pub(super) fn permits(&self, server: &SourceEndpoint) -> bool { + self.interface.as_deref() != Some(server.binding.interface.as_ref()) + && !self.addresses.contains(&server.endpoint.ip()) + } +} + +struct Discovery { + refreshed: Instant, + endpoints: Arc<[SourceEndpoint]>, +} +#[derive(Default)] +struct SourceState { + generation: u64, + host: Option, + discovery: HashMap, +} + +/// Shared by resource generations. Weak roots make invalidation cover every +/// still-live DNS tree without retaining retired resource generations. +pub(crate) struct NetworkSources { + epoch: Arc>, + state: StdMutex, + roots: StdMutex>>, + discovery: Mutex<()>, +} +impl Default for NetworkSources { + fn default() -> Self { + Self { + epoch: Arc::new(RwLock::new(0)), + state: StdMutex::new(SourceState::default()), + roots: StdMutex::new(Vec::new()), + discovery: Mutex::new(()), + } + } +} +impl std::fmt::Debug for NetworkSources { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NetworkSources").finish_non_exhaustive() + } +} + +impl NetworkSources { + pub(crate) fn epoch(&self) -> Arc> { + self.epoch.clone() + } + pub(crate) fn register(&self, root: &Arc) { + let mut roots = self + .roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + roots.retain(|root| root.strong_count() != 0); + roots.push(Arc::downgrade(root)); + } + async fn clear_roots(&self) { + let roots = { + let mut roots = self + .roots + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + roots.retain(|root| root.strong_count() != 0); + roots.iter().filter_map(Weak::upgrade).collect::>() + }; + for root in roots { + root.clear_cache_tree().await; + } + } + + pub(crate) async fn replace(&self, update: DnsNetworkUpdate) -> io::Result<()> { + update.validate()?; + let mut epoch = self.epoch.write().await; + { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(previous) = &state.host { + if update.generation < previous.generation { + return Err(invalid("obsolete DNS network generation")); + } + if update.generation == previous.generation { + return if *previous == update { + Ok(()) + } else { + Err(invalid("conflicting DNS network generation")) + }; + } + } + state.host = Some(update); + state.discovery = HashMap::new(); + state.generation = state.generation.wrapping_add(1); + } + *epoch = epoch.wrapping_add(1); + self.clear_roots().await; + Ok(()) + } + + pub(super) async fn select( + &self, + source: &DnsSource, + dialer: Option<&Dialer>, + exclusions: &SourceExclusions, + ) -> io::Result { + // Serialize only lazy metadata refreshes, never DNS I/O. The guard is + // cancelled with its query; a host replacement does not wait on it. + let _discovery = self.discovery.lock().await; + let observed = { + let _epoch = self.epoch.read().await; + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(host) = &state.host { + return select_host(host, source, state.generation, exclusions); + } + if let Some(cached) = state.discovery.get(source) + && cached.refreshed.elapsed() < DISCOVERY_TTL + { + return selection(state.generation, cached.endpoints.clone(), exclusions); + } + state.generation + }; + let endpoints = match self.discover(source, dialer, exclusions).await { + Ok(endpoints) => endpoints, + Err(error) if error.kind() == io::ErrorKind::Interrupted => return Err(error), + Err(_) => Vec::new(), + }; + let mut epoch = self.epoch.write().await; + let generation = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // A replaced host snapshot is authoritative even if an old OS + // discovery finished later. Never publish that stale completion. + if let Some(host) = &state.host { + return select_host(host, source, state.generation, exclusions); + } + if state.generation != observed { + return Err(unavailable()); + } + let changed = state + .discovery + .get(source) + .is_none_or(|old| old.endpoints.as_ref() != endpoints); + state + .discovery + .retain(|_, entry| entry.refreshed.elapsed() < DISCOVERY_TTL); + let bytes = state + .discovery + .iter() + .filter(|(key, _)| *key != source) + .map(|(key, entry)| discovery_bytes(key, &entry.endpoints)) + .sum::(); + let added = discovery_bytes(source, &endpoints); + if bytes + .checked_add(added) + .is_none_or(|size| size > MAX_METADATA_BYTES) + { + return Err(unavailable()); + } + if changed { + state.generation = state.generation.wrapping_add(1); + *epoch = epoch.wrapping_add(1); + } + state.discovery.insert( + source.clone(), + Discovery { + refreshed: Instant::now(), + endpoints: endpoints.into(), + }, + ); + (state.generation, changed) + }; + if generation.1 { + self.clear_roots().await; + } + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + selection( + generation.0, + state.discovery.get(source).unwrap().endpoints.clone(), + exclusions, + ) + } + + async fn discover( + &self, + source: &DnsSource, + dialer: Option<&Dialer>, + exclusions: &SourceExclusions, + ) -> io::Result> { + #[cfg(target_os = "linux")] + { + let source = source.clone(); + let exclusions = exclusions.clone(); + dialer + .ok_or_else(unavailable)? + .run_dns_discovery(move |check| linux::discover(&source, &exclusions, check)) + .await + } + #[cfg(all(windows, feature = "windows-uwp"))] + { + if !matches!(source, DnsSource::System) { + return Err(unavailable()); + } + let dialer = dialer.ok_or_else(unavailable)?; + let adapter = dialer.physical_dns_adapter().ok_or_else(unavailable)?; + let snapshot = dialer + .run_dns_discovery(move |check| windows::snapshot(&adapter, check)) + .await?; + Ok(select_host(&snapshot, source, 0, exclusions)? + .endpoints + .to_vec()) + } + #[cfg(not(any(target_os = "linux", all(windows, feature = "windows-uwp"))))] + { + let _ = (source, dialer, exclusions); + Err(unavailable()) + } + } +} + +fn select_host( + host: &DnsNetworkUpdate, + source: &DnsSource, + generation: u64, + exclusions: &SourceExclusions, +) -> io::Result { + let mut servers = Vec::new(); + match source { + DnsSource::System => { + for server in &host.system { + servers.push(SourceEndpoint { + endpoint: endpoint(server.address, server.scope_id)?, + binding: DnsBinding { + interface: server.interface.as_str().into(), + interface_index: None, + }, + requires_encryption: server.requires_encryption, + }); + } + } + DnsSource::Dhcp(interface) => { + if let Some(lease) = host + .dhcp + .iter() + .find(|lease| lease.interface == interface.as_ref()) + { + for server in &lease.servers { + servers.push(SourceEndpoint { + endpoint: endpoint(server.address, server.scope_id)?, + binding: DnsBinding { + interface: lease.interface.as_str().into(), + interface_index: None, + }, + requires_encryption: lease.requires_encryption, + }); + } + } + } + } + selection(generation, servers.into(), exclusions) +} +fn selection( + generation: u64, + servers: Arc<[SourceEndpoint]>, + exclusions: &SourceExclusions, +) -> io::Result { + let servers = servers + .iter() + .filter(|server| exclusions.permits(server)) + .cloned() + .collect::>(); + if servers.is_empty() || servers.iter().any(|server| server.requires_encryption) { + return Err(unavailable()); + } + Ok(SourceSelection { + generation, + endpoints: servers.into(), + }) +} + +fn discovery_bytes(source: &DnsSource, endpoints: &[SourceEndpoint]) -> usize { + let key = match source { + DnsSource::System => 0, + DnsSource::Dhcp(name) => name.len(), + }; + 64 + key + + endpoints + .iter() + .map(|entry| entry.binding.interface.len() + 64) + .sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn update(generation: u64, address: &str) -> DnsNetworkUpdate { + DnsNetworkUpdate { + generation, + system: vec![SystemDnsServer { + address: address.parse().unwrap(), + interface: "physical0".into(), + scope_id: 0, + requires_encryption: false, + }], + dhcp: vec![DhcpDnsInterface { + interface: "physical1".into(), + servers: vec![DnsServerAddress { + address: "192.0.2.53".parse().unwrap(), + scope_id: 0, + }], + requires_encryption: false, + }], + } + } + + #[tokio::test] + async fn replacement_is_monotonic_idempotent_and_selects_exact_interface() { + let sources = NetworkSources::default(); + let current = update(1, "203.0.113.53"); + sources.replace(current.clone()).await.unwrap(); + let epoch = *sources.epoch.read().await; + sources.replace(current.clone()).await.unwrap(); + assert_eq!(*sources.epoch.read().await, epoch); + assert!(sources.replace(update(1, "203.0.113.54")).await.is_err()); + sources.replace(update(2, "203.0.113.54")).await.unwrap(); + assert!(sources.replace(current).await.is_err()); + let system = sources + .select(&DnsSource::System, None, &SourceExclusions::default()) + .await + .unwrap(); + assert_eq!( + system.endpoints[0].endpoint.ip(), + "203.0.113.54".parse::().unwrap() + ); + let dhcp = sources + .select( + &DnsSource::Dhcp("physical1".into()), + None, + &SourceExclusions::default(), + ) + .await + .unwrap(); + assert_eq!( + dhcp.endpoints[0].endpoint.ip(), + "192.0.2.53".parse::().unwrap() + ); + assert!( + sources + .select( + &DnsSource::Dhcp("physical0".into()), + None, + &SourceExclusions::default() + ) + .await + .is_err() + ); + } + + #[tokio::test] + async fn replacement_excludes_tun_and_never_downgrades_encryption() { + let sources = NetworkSources::default(); + let mut snapshot = update(1, "203.0.113.53"); + snapshot.system[0].requires_encryption = true; + sources.replace(snapshot.clone()).await.unwrap(); + assert!( + sources + .select(&DnsSource::System, None, &SourceExclusions::default()) + .await + .is_err() + ); + snapshot.generation = 2; + snapshot.system[0].requires_encryption = false; + sources.replace(snapshot).await.unwrap(); + for exclusions in [ + SourceExclusions { + interface: Some("physical0".into()), + addresses: vec![], + }, + SourceExclusions { + interface: None, + addresses: vec!["203.0.113.53".parse().unwrap()], + }, + ] { + assert!( + sources + .select(&DnsSource::System, None, &exclusions) + .await + .is_err() + ); + } + sources + .replace(DnsNetworkUpdate { + generation: 3, + system: vec![], + dhcp: vec![], + }) + .await + .unwrap(); + assert!( + sources + .select(&DnsSource::System, None, &SourceExclusions::default()) + .await + .is_err() + ); + } + + #[test] + fn metadata_is_strict_bounded_and_rejects_stub_addresses() { + assert!(update(0, "203.0.113.53").validate().is_err()); + let mut large = update(1, "203.0.113.53"); + large.system = vec![large.system[0].clone(); 1024]; + assert!(large.validate().is_err()); + assert!( + serde_json::from_str::( + r#"{"generation":1,"system":[],"dhcp":[],"extra":true}"# + ) + .is_err() + ); + for address in [ + "127.0.0.53", + "::1", + "::ffff:127.0.0.53", + "0.0.0.0", + "224.0.0.1", + "ff02::1", + ] { + assert!(endpoint(address.parse().unwrap(), 0).is_err(), "{address}"); + } + assert!(endpoint("fe80::53".parse().unwrap(), 0).is_err()); + let SocketAddr::V6(scoped) = endpoint("fe80::53".parse().unwrap(), 3).unwrap() else { + panic!("IPv6") + }; + assert_eq!(scoped.scope_id(), 3); + assert!(endpoint("192.0.2.53".parse().unwrap(), 3).is_err()); + for name in [ + "", + "../physical0", + "physical0\n", + " physical0", + "physical0/", + ] { + assert!(validate_interface(name).is_err()); + } + assert!(discovery_bytes(&DnsSource::Dhcp("physical1".into()), &[]) > 64); + } +} diff --git a/src/dns/source/linux.rs b/src/dns/source/linux.rs new file mode 100644 index 0000000..93e5732 --- /dev/null +++ b/src/dns/source/linux.rs @@ -0,0 +1,444 @@ +//! Read-only discovery through services already running on the system bus. +use super::{DnsBinding, MAX_METADATA_BYTES, SourceEndpoint, SourceExclusions, unavailable}; +use crate::config::DnsSource; +use serde_json::Value; +use std::{ + ffi::CString, + io::{self, Read}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + os::fd::AsRawFd, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +const RESOLVE: &str = "org.freedesktop.resolve1"; +const RESOLVE_PATH: &str = "/org/freedesktop/resolve1"; +const RESOLVE_MANAGER: &str = "org.freedesktop.resolve1.Manager"; +const NM: &str = "org.freedesktop.NetworkManager"; +const NM_PATH: &str = "/org/freedesktop/NetworkManager"; + +pub(super) fn discover( + source: &DnsSource, + exclusions: &SourceExclusions, + check: &dyn Fn() -> io::Result<()>, +) -> io::Result> { + let deadline = Instant::now() + Duration::from_secs(3); + let bus = Bus { deadline, check }; + match source { + DnsSource::System => system(&bus, exclusions), + DnsSource::Dhcp(interface) => dhcp(&bus, interface, exclusions), + } +} + +fn system(bus: &Bus<'_>, exclusions: &SourceExclusions) -> io::Result> { + let data = bus.property(RESOLVE, RESOLVE_PATH, RESOLVE_MANAGER, "DNS")?; + let rows = array_body(&data)?; + let mut servers = Vec::new(); + let global_tls = bus + .property(RESOLVE, RESOLVE_PATH, RESOLVE_MANAGER, "DNSOverTLS") + .and_then(|value| string_body(&value).map(str::to_owned))?; + let mut policies = std::collections::HashMap::new(); + for row in rows { + let row = row.as_array().ok_or_else(unavailable)?; + if row.len() != 3 { + return Err(unavailable()); + } + let index = row[0] + .as_u64() + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(unavailable)?; + if index == 0 { + continue; + } // Global servers have no physical link attribution. + let Some(interface) = interface_name(index) else { + continue; + }; + if !physical_interface(&interface, exclusions) { + continue; + } + let encrypted = if let Some(policy) = policies.get(&index) { + *policy + } else { + let path = bus.call( + RESOLVE, + RESOLVE_PATH, + RESOLVE_MANAGER, + "GetLink", + &["i", &index.to_string()], + )?; + let path = string_body(&path)?; + let tls = bus.property(RESOLVE, path, "org.freedesktop.resolve1.Link", "DNSOverTLS")?; + let tls = string_body(&tls)?; + let required = if tls.is_empty() { + global_tls.as_str() + } else { + tls + } != "no"; + policies.insert(index, required); + required + }; + let family = row[1].as_i64().ok_or_else(unavailable)?; + let address = decode_address(family, &row[2])?; + let endpoint = socket_address(address, index)?; + let server = SourceEndpoint { + endpoint, + binding: DnsBinding { + interface: interface.into(), + interface_index: std::num::NonZeroU32::new(index), + }, + requires_encryption: encrypted, + }; + if exclusions.permits(&server) && !servers.contains(&server) { + servers.push(server); + } + } + Ok(servers) +} + +fn dhcp( + bus: &Bus<'_>, + interface: &str, + exclusions: &SourceExclusions, +) -> io::Result> { + super::validate_interface(interface)?; + if !physical_interface(interface, exclusions) { + return Err(unavailable()); + } + let name = CString::new(interface).map_err(|_| unavailable())?; + // SAFETY: `name` is a validated NUL-terminated interface name. + let index = unsafe { libc::if_nametoindex(name.as_ptr()) }; + if index == 0 { + return Err(unavailable()); + } + let device = bus.call(NM, NM_PATH, NM, "GetDeviceByIpIface", &["s", interface])?; + let device = string_body(&device)?; + let state = bus.property(NM, device, "org.freedesktop.NetworkManager.Device", "State")?; + if number_body(&state)? != 100 { + return Err(unavailable()); + } + let lease = bus.property( + NM, + device, + "org.freedesktop.NetworkManager.Device", + "Dhcp4Config", + )?; + let lease = string_body(&lease)?; + if lease == "/" { + return Err(unavailable()); + } + let options = bus.property( + NM, + lease, + "org.freedesktop.NetworkManager.DHCP4Config", + "Options", + )?; + let dns = option(&options, "domain_name_servers").ok_or_else(unavailable)?; + let mut servers = Vec::new(); + for address in dns.split_ascii_whitespace() { + let address = address + .parse::() + .map(IpAddr::V4) + .map_err(|_| unavailable())?; + let server = SourceEndpoint { + endpoint: socket_address(address, index)?, + binding: DnsBinding { + interface: interface.into(), + interface_index: std::num::NonZeroU32::new(index), + }, + requires_encryption: false, + }; + if exclusions.permits(&server) && !servers.contains(&server) { + servers.push(server); + } + } + // Lease ownership does not prove a plaintext link policy. Require a + // policy from the existing resolver; unknown policy is unavailable. + let path = bus.call( + RESOLVE, + RESOLVE_PATH, + RESOLVE_MANAGER, + "GetLink", + &["i", &index.to_string()], + )?; + let path = string_body(&path)?; + let tls = bus.property(RESOLVE, path, "org.freedesktop.resolve1.Link", "DNSOverTLS")?; + let mut tls = string_body(&tls)?.to_owned(); + if tls.is_empty() { + tls = string_body(&bus.property(RESOLVE, RESOLVE_PATH, RESOLVE_MANAGER, "DNSOverTLS")?)? + .to_owned(); + } + for server in &mut servers { + server.requires_encryption = tls != "no"; + } + Ok(servers) +} + +fn physical_interface(name: &str, exclusions: &SourceExclusions) -> bool { + super::validate_interface(name).is_ok() + && exclusions.interface.as_deref() != Some(name) + && name != "lo" + && !std::path::Path::new("/sys/class/net") + .join(name) + .join("tun_flags") + .exists() + && std::fs::read_to_string( + std::path::Path::new("/sys/class/net") + .join(name) + .join("operstate"), + ) + .is_ok_and(|state| matches!(state.trim(), "up" | "unknown")) +} +fn interface_name(index: u32) -> Option { + let mut name = [0 as libc::c_char; libc::IF_NAMESIZE]; + // SAFETY: the buffer has IF_NAMESIZE bytes and remains live for the call. + if unsafe { libc::if_indextoname(index, name.as_mut_ptr()) }.is_null() { + return None; + } + // SAFETY: a successful call produced a terminated interface name. + unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) } + .to_str() + .ok() + .map(str::to_owned) +} +fn socket_address(ip: IpAddr, index: u32) -> io::Result { + let scope = match ip { + IpAddr::V6(ip) if ip.is_unicast_link_local() => index, + _ => 0, + }; + super::endpoint(ip, scope) +} +fn decode_address(family: i64, value: &Value) -> io::Result { + let bytes = value + .as_array() + .ok_or_else(unavailable)? + .iter() + .map(|value| { + value + .as_u64() + .and_then(|value| u8::try_from(value).ok()) + .ok_or_else(unavailable) + }) + .collect::>>()?; + match family { + 2 if bytes.len() == 4 => Ok(IpAddr::V4(Ipv4Addr::new( + bytes[0], bytes[1], bytes[2], bytes[3], + ))), + 10 if bytes.len() == 16 => Ok(IpAddr::V6(Ipv6Addr::from( + <[u8; 16]>::try_from(bytes.as_slice()).unwrap(), + ))), + _ => Err(unavailable()), + } +} +fn body(value: &Value) -> io::Result<&Value> { + value.get("data").ok_or_else(unavailable) +} +fn string_body(value: &Value) -> io::Result<&str> { + let body = body(value)?; + body.as_str() + .or_else(|| { + body.as_array() + .filter(|body| body.len() == 1)? + .first()? + .as_str() + }) + .ok_or_else(unavailable) +} +fn number_body(value: &Value) -> io::Result { + let body = body(value)?; + body.as_u64() + .or_else(|| { + body.as_array() + .filter(|body| body.len() == 1)? + .first()? + .as_u64() + }) + .ok_or_else(unavailable) +} +fn array_body(value: &Value) -> io::Result<&[Value]> { + let data = body(value)?.as_array().ok_or_else(unavailable)?; + // busctl's body contains one array argument. Accept the property form as + // well, where the data itself is that argument. + if let [first] = data.as_slice() + && let Some(first) = first.as_array() + && first.first().is_none_or(Value::is_array) + { + return Ok(first); + } + Ok(data) +} +fn option<'a>(value: &'a Value, key: &str) -> Option<&'a str> { + let mut data = value.get("data")?; + if let Some(array) = data.as_array() + && array.len() == 1 + && array[0].is_object() + { + data = &array[0]; + } + let value = if let Some(object) = data.as_object() { + object.get(key)? + } else { + data.as_array()?.iter().find_map(|entry| { + let pair = entry.as_array()?; + (pair.len() == 2 && pair[0].as_str() == Some(key)).then(|| &pair[1]) + })? + }; + value.as_str().or_else(|| string_body(value).ok()) +} + +struct Bus<'a> { + deadline: Instant, + check: &'a dyn Fn() -> io::Result<()>, +} +impl Bus<'_> { + fn property( + &self, + service: &str, + path: &str, + interface: &str, + property: &str, + ) -> io::Result { + self.run(&["get-property", service, path, interface, property]) + } + fn call( + &self, + service: &str, + path: &str, + interface: &str, + method: &str, + arguments: &[&str], + ) -> io::Result { + let mut argv = vec!["call", service, path, interface, method]; + argv.extend_from_slice(arguments); + self.run(&argv) + } + fn run(&self, argv: &[&str]) -> io::Result { + (self.check)()?; + if Instant::now() >= self.deadline { + return Err(io::ErrorKind::TimedOut.into()); + } + let child = Command::new("/usr/bin/busctl") + .args([ + "--system", + "--json=short", + "--no-pager", + "--auto-start=no", + "--allow-interactive-authorization=no", + "--timeout=3s", + ]) + .args(argv) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + let mut child = OwnedChild(child); + let mut stdout = child.0.stdout.take().ok_or_else(unavailable)?; + let mut stderr = child.0.stderr.take().ok_or_else(unavailable)?; + nonblocking(stdout.as_raw_fd())?; + nonblocking(stderr.as_raw_fd())?; + let mut output = Vec::new(); + let mut errors = 0; + loop { + (self.check)()?; + if Instant::now() >= self.deadline { + return Err(io::ErrorKind::TimedOut.into()); + } + drain(&mut stdout, &mut output)?; + discard(&mut stderr, &mut errors)?; + if let Some(status) = child.0.try_wait()? { + drain(&mut stdout, &mut output)?; + discard(&mut stderr, &mut errors)?; + if !status.success() { + return Err(unavailable()); + } + return serde_json::from_slice(&output).map_err(|_| unavailable()); + } + std::thread::sleep(Duration::from_millis(5)); + } + } +} +struct OwnedChild(Child); +impl Drop for OwnedChild { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } +} +fn nonblocking(fd: i32) -> io::Result<()> { + // SAFETY: fd is a live owned pipe and F_GETFL/F_SETFL use integer flags. + let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) }; + if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} +fn drain(reader: &mut impl Read, output: &mut Vec) -> io::Result<()> { + let mut buffer = [0; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(()), + Ok(count) => { + if output + .len() + .checked_add(count) + .is_none_or(|size| size > MAX_METADATA_BYTES) + { + return Err(unavailable()); + } + output.extend_from_slice(&buffer[..count]); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} +fn discard(reader: &mut impl Read, total: &mut usize) -> io::Result<()> { + let mut buffer = [0; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(()), + Ok(count) => { + *total = total + .checked_add(count) + .filter(|size| *size <= MAX_METADATA_BYTES) + .ok_or_else(unavailable)?; + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => return Ok(()), + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn busctl_property_and_call_bodies_keep_link_and_lease_metadata() { + // busctl get-property dumps the variant's subtree; a method reply + // dumps an array of arguments. Dictionaries use JSON objects. + let property = json!({"type":"a(iiay)","data":[[7,2,[10,0,0,53]]]}); + let rows = array_body(&property).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0][0], 7); + assert_eq!( + decode_address(2, &rows[0][2]).unwrap(), + "10.0.0.53".parse::().unwrap() + ); + assert_eq!( + string_body(&json!({"type":"o","data":["/link/7"]})).unwrap(), + "/link/7" + ); + assert_eq!(number_body(&json!({"type":"u","data":100})).unwrap(), 100); + let lease = json!({"type":"a{sv}","data":{ + "domain_name_servers":{"type":"s","data":"10.0.0.53 10.0.0.54"} + }}); + assert_eq!( + option(&lease, "domain_name_servers"), + Some("10.0.0.53 10.0.0.54") + ); + assert!(decode_address(2, &json!([10, 0, 0, 256])).is_err()); + } +} diff --git a/src/dns/source/windows.rs b/src/dns/source/windows.rs new file mode 100644 index 0000000..a35caf9 --- /dev/null +++ b/src/dns/source/windows.rs @@ -0,0 +1,160 @@ +//! Session-Host discovery for the Provider's immutable physical adapter GUID. +use super::{DnsNetworkUpdate, MAX_METADATA_BYTES, SystemDnsServer, unavailable}; +use std::{ + io, + mem::size_of, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; +use windows::{ + Win32::{ + Foundation::{ERROR_BUFFER_OVERFLOW, NO_ERROR}, + NetworkManagement::IpHelper::{ + DNS_INTERFACE_SETTINGS, DNS_INTERFACE_SETTINGS_VERSION3, DNS_INTERFACE_SETTINGS3, + DNS_SETTING_DOH, DNS_SETTING_DOH_PROFILE, FreeInterfaceDnsSettings, + GET_ADAPTERS_ADDRESSES_FLAGS, GetAdaptersAddresses, GetInterfaceDnsSettings, + IP_ADAPTER_ADDRESSES_LH, + }, + Networking::WinSock::{AF_INET, AF_INET6, AF_UNSPEC, SOCKADDR_IN, SOCKADDR_IN6}, + }, + core::GUID, +}; + +pub(crate) fn snapshot( + adapter_name: &str, + check: &dyn Fn() -> io::Result<()>, +) -> io::Result { + let guid = GUID::try_from(adapter_name).map_err(|_| unavailable())?; + check()?; + let mut settings = Settings(DNS_INTERFACE_SETTINGS3 { + Version: DNS_INTERFACE_SETTINGS_VERSION3, + ..Default::default() + }); + // SAFETY: v3 layout starts with DNS_INTERFACE_SETTINGS, and is live here. + let status = unsafe { GetInterfaceDnsSettings(guid, (&raw mut settings.0).cast()) }; + if status != NO_ERROR { + return Err(unavailable()); + } + let encrypted = settings.0.cServerProperties != 0 + || settings.0.cProfileServerProperties != 0 + || settings.0.Flags & u64::from(DNS_SETTING_DOH | DNS_SETTING_DOH_PROFILE) != 0; + check()?; + let mut storage = vec![0_usize; (15 * 1024_usize).div_ceil(size_of::())]; + for _ in 0..3 { + check()?; + let mut length = + u32::try_from(storage.len() * size_of::()).map_err(|_| unavailable())?; + // SAFETY: the aligned storage remains live while the OS list is read. + let status = unsafe { + GetAdaptersAddresses( + u32::from(AF_UNSPEC.0), + GET_ADAPTERS_ADDRESSES_FLAGS(0), + None, + Some(storage.as_mut_ptr().cast()), + &mut length, + ) + }; + if status == ERROR_BUFFER_OVERFLOW.0 { + if length as usize > MAX_METADATA_BYTES { + return Err(unavailable()); + } + storage.resize( + (length as usize) + .div_ceil(size_of::()) + .max(storage.len() + 1), + 0, + ); + continue; + } + if status != NO_ERROR.0 { + return Err(unavailable()); + } + let mut current = storage.as_ptr().cast::(); + while !current.is_null() { + check()?; + // SAFETY: GetAdaptersAddresses initialized the linked entries. + let adapter = unsafe { &*current }; + if !adapter.AdapterName.is_null() { + // SAFETY: this terminated string belongs to live storage. + let name = unsafe { adapter.AdapterName.to_string() }.map_err(|_| unavailable())?; + if name + .trim_matches(['{', '}']) + .eq_ignore_ascii_case(adapter_name) + { + let mut servers = Vec::new(); + let mut dns = adapter.FirstDnsServerAddress; + while !dns.is_null() { + check()?; + // SAFETY: DNS entries and address pointers belong to the same live storage. + let dns_entry = unsafe { &*dns }; + let address = dns_entry.Address; + if !address.lpSockaddr.is_null() { + // SAFETY: the pointer identifies at least the returned sockaddr header. + let family = unsafe { (*address.lpSockaddr).sa_family }; + let value = if family == AF_INET + && address.iSockaddrLength >= size_of::() as i32 + { + // SAFETY: the family and length identify SOCKADDR_IN. + let address = unsafe { &*address.lpSockaddr.cast::() }; + // SAFETY: S_un_b is the IPv4 octet view. + let bytes = unsafe { address.sin_addr.S_un.S_un_b }; + Some(( + IpAddr::V4(Ipv4Addr::new( + bytes.s_b1, bytes.s_b2, bytes.s_b3, bytes.s_b4, + )), + 0, + )) + } else if family == AF_INET6 + && address.iSockaddrLength >= size_of::() as i32 + { + // SAFETY: family/length identify SOCKADDR_IN6 and its address/scope unions. + let address = + unsafe { &*address.lpSockaddr.cast::() }; + Some(( + IpAddr::V6(Ipv6Addr::from(unsafe { address.sin6_addr.u.Byte })), + unsafe { address.Anonymous.sin6_scope_id }, + )) + } else { + None + }; + if let Some((address, scope)) = value + && super::endpoint(address, scope).is_ok() + { + let server = SystemDnsServer { + address, + interface: adapter_name.to_owned(), + scope_id: scope, + // The read API requires empty input Flags; + // its default settings cover IPv4. Without + // a proven IPv6 policy, do not downgrade an + // IPv6 DNS server to UDP/TCP. + requires_encryption: encrypted || address.is_ipv6(), + }; + if !servers.contains(&server) { + servers.push(server); + } + } + } + dns = dns_entry.Next; + } + let update = DnsNetworkUpdate { + generation: 1, + system: servers, + dhcp: Vec::new(), + }; + update.validate()?; + return Ok(update); + } + } + current = adapter.Next; + } + return Err(unavailable()); + } + Err(unavailable()) +} +struct Settings(DNS_INTERFACE_SETTINGS3); +impl Drop for Settings { + fn drop(&mut self) { + // SAFETY: API-owned fields use its paired deallocator, including a failed call. + unsafe { FreeInterfaceDnsSettings((&raw mut self.0).cast::()) }; + } +} diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index dc758bb..4f45fdd 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -95,6 +95,7 @@ struct RuntimeRegistry { struct RuntimeData { directory: Arc, geodata: Arc, + dns_sources: Arc, } impl Default for RuntimeRegistry { @@ -444,6 +445,7 @@ impl RuntimeRegistry { *current = Some(RuntimeData { directory: initialized.clone(), geodata, + dns_sources: Arc::new(crate::dns::source::NetworkSources::default()), }); Ok(initialized) } @@ -470,6 +472,12 @@ impl RuntimeRegistry { }) } + fn dns_sources(&self) -> Result, InvokeFailure> { + lock(&self.runtime_data) + .as_ref() + .map(|data| data.dns_sources.clone()) + .ok_or_else(|| InvokeFailure::invalid_state("Vole dataDir is not initialized")) + } fn create_instance(&self) -> Result, InvokeFailure> { let mut inner = lock(&self.inner); if inner.instance.is_some() { @@ -651,6 +659,47 @@ fn invoke_instance_guarded( } } +// Borrow the envelope to enforce the small network-metadata admission bound +// before materializing its JSON Value. Other methods retain their own schema. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawRequestEnvelope<'a> { + #[serde(borrow)] + method: &'a serde_json::value::RawValue, + #[serde(borrow)] + payload: &'a serde_json::value::RawValue, + #[serde(rename = "instanceId", default, borrow)] + _instance_id: Option<&'a serde_json::value::RawValue>, +} + +fn check_dns_network_envelope(request: &str) -> Result<(), InvokeFailure> { + let raw: RawRequestEnvelope<'_> = + serde_json::from_str(request).map_err(InvokeFailure::invalid_request)?; + // An ASCII method's JSON spelling can use at most six bytes per letter. + // Decode only that bounded scalar; never search request/config contents. + if raw.method.get().len() > 6 * "updateDnsNetwork".len() + 2 { + return Ok(()); + } + let is_update = serde_json::from_str::(raw.method.get()) + .is_ok_and(|method| method == "updateDnsNetwork"); + if !is_update { + return Ok(()); + } + let bytes = raw.payload.get().len(); + if bytes > crate::dns::source::MAX_METADATA_BYTES { + return Err(InvokeFailure::invalid_request( + "DNS network metadata exceeds 64 KiB", + )); + } + const MAX_DNS_ENVELOPE_OVERHEAD: usize = 64 * 1024; + if request.len() - bytes > MAX_DNS_ENVELOPE_OVERHEAD { + return Err(InvokeFailure::invalid_request( + "DNS network envelope overhead exceeds 64 KiB", + )); + } + Ok(()) +} + fn dispatch_bytes(request: &[u8]) -> Result { if request.len() > MAX_INVOKE_BYTES { return Err(InvokeFailure::invalid_request(format!( @@ -659,6 +708,7 @@ fn dispatch_bytes(request: &[u8]) -> Result { } let request = str::from_utf8(request) .map_err(|_| InvokeFailure::invalid_request("request is not valid UTF-8"))?; + check_dns_network_envelope(request)?; let envelope: RequestEnvelope = serde_json::from_str(request).map_err(InvokeFailure::invalid_request)?; if !envelope.payload.is_object() { @@ -672,6 +722,20 @@ fn dispatch_bytes(request: &[u8]) -> Result { let directory = registry().initialize_data_directory(&payload.data_dir)?; json!({"dataDir": directory.root().to_string_lossy()}) } + "updateDnsNetwork" => { + require_instance_omitted(&envelope)?; + crate::dns::source::check_metadata_size(&envelope.payload) + .map_err(InvokeFailure::from)?; + let update: crate::dns::source::DnsNetworkUpdate = decode_payload(envelope.payload)?; + let sources = registry().dns_sources()?; + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .map_err(InvokeFailure::from)?; + runtime + .block_on(sources.replace(update)) + .map_err(InvokeFailure::from)?; + json!({}) + } "createInstance" => { require_instance_omitted(&envelope)?; let _: EmptyPayload = decode_payload(envelope.payload)?; @@ -930,7 +994,8 @@ impl CoreController { .clone() .map_or_else(Dialer::default, |protector| { Dialer::default().with_protector(protector) - }); + }) + .with_dns_sources(registry().dns_sources()?); let (stop_tx, stop_rx) = oneshot::channel(); let (startup_tx, startup_rx) = std::sync::mpsc::sync_channel(1); let (completed_tx, completed_rx) = tokio::sync::watch::channel(false); @@ -1995,6 +2060,72 @@ rules: assert_failure(&oversized_json); } + #[test] + fn update_dns_network_raw_admission_is_bounded_before_value_or_registry_work() { + let padded = format!( + "{{{}\"generation\":1,\"system\":[],\"dhcp\":[]}}", + " ".repeat(crate::dns::source::MAX_METADATA_BYTES) + ); + for method in ["updateDnsNetwork", r"\u0075pdateDnsNetwork"] { + let envelope = format!(r#"{{"payload":{padded},"method":"{method}"}}"#); + let response = invoke(&envelope); + assert_failure(&response); + assert_eq!( + response["error"], + "invalid request: DNS network metadata exceeds 64 KiB" + ); + } + let overhead = format!( + r#"{{"method":"updateDnsNetwork","payload":{{}},"instanceId":"{}"}}"#, + "x".repeat(64 * 1024) + ); + assert!(check_dns_network_envelope(&overhead).is_err()); + let unrelated = json!({"method":"validateConfig","payload":{"configYaml":"updateDnsNetwork".repeat(8192)}}).to_string(); + assert!(check_dns_network_envelope(&unrelated).is_ok()); + } + + #[test] + fn update_dns_network_is_runtime_local_strict_and_monotonic() { + let _guard = TEST_LOCK.lock().unwrap(); + reset_registry(); + let payload = json!({"generation":1,"system":[{"address":"203.0.113.53","interface":"physical0"}],"dhcp":[]}); + assert_failure(&request("updateDnsNetwork", None, payload.clone())); + let _data = initialize_test_data_directory(); + assert_eq!( + request("updateDnsNetwork", None, payload.clone())["success"], + true + ); + assert_eq!( + request("updateDnsNetwork", None, payload.clone())["success"], + true + ); + assert_failure(&request("updateDnsNetwork", Some("1"), payload)); + assert_failure(&request( + "updateDnsNetwork", + None, + json!({"generation":1,"system":[],"dhcp":[]}), + )); + assert_eq!( + request( + "updateDnsNetwork", + None, + json!({"generation":2,"system":[],"dhcp":[]}) + )["success"], + true + ); + assert_failure(&request( + "updateDnsNetwork", + None, + json!({"generation":1,"system":[],"dhcp":[]}), + )); + assert_failure(&request( + "updateDnsNetwork", + None, + json!({"generation":3,"system":[],"dhcp":[],"extra":true}), + )); + reset_registry(); + } + #[test] fn start_accepts_only_config_yaml_and_removed_prepare_is_unknown() { let _guard = TEST_LOCK.lock().unwrap(); diff --git a/src/platform/linux_network/native.rs b/src/platform/linux_network/native.rs index 61335da..2f2a500 100644 --- a/src/platform/linux_network/native.rs +++ b/src/platform/linux_network/native.rs @@ -33,6 +33,14 @@ impl SocketPolicy { } } pub(crate) fn apply(&self, socket: &impl AsRawFd, destination: SocketAddr) -> io::Result<()> { + self.apply_with_interface(socket, destination, None) + } + pub(crate) fn apply_with_interface( + &self, + socket: &impl AsRawFd, + destination: SocketAddr, + interface: Option<&str>, + ) -> io::Result<()> { if self.mark { let value = netlink::MARK; // SAFETY: socket is live and the option points to a u32 mark. @@ -49,6 +57,34 @@ impl SocketPolicy { return Err(io::Error::last_os_error()); } } + if let Some(interface) = interface { + let name = CString::new(interface).map_err(|_| io::ErrorKind::InvalidInput)?; + // SAFETY: live socket, terminated interface name and a u32 index. + let index = unsafe { libc::if_nametoindex(name.as_ptr()) }; + if index == 0 { + return Err(io::ErrorKind::NotFound.into()); + } + if let SocketAddr::V6(address) = destination + && address.scope_id() != 0 + && address.scope_id() != index + { + return Err(io::ErrorKind::InvalidInput.into()); + } + // SAFETY: name remains live and socket is owned by its initializer. + if unsafe { + libc::setsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + name.as_ptr().cast(), + name.as_bytes_with_nul().len() as libc::socklen_t, + ) + } < 0 + { + return Err(io::Error::last_os_error()); + } + return Ok(()); + } if !self.detect || destination.ip().is_loopback() { return Ok(()); } diff --git a/src/platform/tun_rs_io.rs b/src/platform/tun_rs_io.rs index a410ac9..f309ec8 100644 --- a/src/platform/tun_rs_io.rs +++ b/src/platform/tun_rs_io.rs @@ -58,6 +58,11 @@ impl TunRsIo { }) } + #[cfg(any(target_os = "linux", target_os = "macos"))] + pub(crate) fn interface_name(&self) -> io::Result { + self.device.get_ref().name() + } + #[cfg(target_os = "linux")] pub(crate) fn linux_device(&self) -> &tun_rs::SyncDevice { self.device.get_ref() diff --git a/src/runtime.rs b/src/runtime.rs index c65d427..4982d45 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -567,6 +567,8 @@ impl PreparedCore { ) ))] async fn start_tun_inner(mut self, tun: TunIo, dialer: Dialer) -> io::Result { + #[cfg(any(target_os = "linux", target_os = "macos"))] + let dialer = dialer.with_dns_tun_interface(tun.interface_name().ok().map(Into::into)); self.limits.tun_max_datagram_size = usize::from(self.config.tun.mtu); let tun_count = self .config @@ -664,7 +666,7 @@ impl GraphBuilder { }) as Arc }) .collect::>(); - let direct_raw: Arc = Arc::new(DirectOutbound::new(dialer)); + let direct_raw: Arc = Arc::new(DirectOutbound::new(dialer.clone())); let direct = observe_handshakes_with_stats(direct_raw, handshake_stats.clone()); let proxy_groups = ProxyGroups::with_selections( &self.config.proxy_groups, @@ -699,6 +701,40 @@ impl GraphBuilder { dns_config.cache_max_size, redir_host_entries, ) + .with_network_sources( + dialer.clone(), + crate::dns::source::SourceExclusions { + interface: dialer.dns_tun_interface().or_else(|| { + (self.config.tun.enable && !self.config.tun.device.is_empty()) + .then(|| self.config.tun.device.as_str().into()) + }), + addresses: if self.config.tun.enable { + vec![ + self.config + .dns + .fake_ip + .range + .map(|range| range.interface_address) + .unwrap_or_else(|| "198.18.0.1".parse().unwrap()), + self.config + .dns + .fake_ip + .range + .map(|range| match range.interface_address { + std::net::IpAddr::V4(ip) => std::net::IpAddr::V4( + std::net::Ipv4Addr::from(u32::from(ip).saturating_add(1)), + ), + ip => ip, + }) + .unwrap_or_else(|| "198.18.0.2".parse().unwrap()), + "fdfe:dcba:9876::1".parse().unwrap(), + "fdfe:dcba:9876::2".parse().unwrap(), + ] + } else { + Vec::new() + }, + }, + ) .with_mapping_from(self.previous_dns.as_deref()) .with_fake_ip_store( if self.config.profile.store_fake_ip && self.previous_dns.is_none() { @@ -708,6 +744,9 @@ impl GraphBuilder { }, )?, )); + if let Some(dns) = &dns { + dns.network_sources().register(dns); + } proxy_graph.resolution.bind_runtime(dns.as_ref())?; let health = health::HealthRuntime::new( &self.config, diff --git a/src/windows/session.rs b/src/windows/session.rs index da24f6d..21ce60e 100644 --- a/src/windows/session.rs +++ b/src/windows/session.rs @@ -324,9 +324,9 @@ async fn start_vole( } }); - let running = prepared - .start_tun(tun, dialer_from_binding(&binding)?) - .await?; + let dialer = + dialer_from_binding(&binding)?.with_physical_adapter(binding.adapter_id.as_str().into()); + let running = prepared.start_tun(tun, dialer).await?; Ok((running, tasks)) } diff --git a/tests/README.md b/tests/README.md index 93a1643..929b5b6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -44,6 +44,10 @@ cargo test --locked --lib dns::runtime::tests::dns_cache_ cargo test --locked --lib config::tests::dns_rcode_ cargo test --locked --lib dns::runtime::tests::dns_rcode_ cargo test --locked --lib dns::runtime::tests::dns_flush_ +cargo test --locked --features cli --lib dns::source::tests:: +cargo test --locked --features cli --lib dns::runtime::tests::dns_source_ +cargo test --locked --features cli --lib config::tests::dns_source_ +cargo test --locked --features cli --lib invoke::tests::update_dns_network_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests uv run --project scripts --locked ruff check scripts From 417d127e5ab2a53fd6d0276eefb1c83589840798 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 12:42:30 +0800 Subject: [PATCH 037/445] Share gRPC options and per-node pools across stream protocols --- .github/workflows/test.yml | 2 + docs/config.yaml | 12 ++- docs/mihomo-compatibility.md | 2 + docs/outbounds.md | 8 +- docs/runtime-resource-policy.md | 2 +- docs/vless.md | 2 +- src/config/grpc.rs | 103 +++++++++++++++++++++++++ src/config/mod.rs | 4 +- src/config/trojan.rs | 25 ++---- src/config/vless.rs | 84 +------------------- src/config/vmess.rs | 24 ++---- src/outbound/trojan/outbound.rs | 61 +++++++++------ src/outbound/vless/outbound.rs | 36 +++++---- src/outbound/vmess/outbound.rs | 46 +++++++++-- src/transport/grpc.rs | 3 +- src/transport/grpc_pool.rs | 127 +++++++++++++++++++++++++++++- tests/README.md | 2 + tests/grpc_config.rs | 104 +++++++++++++++++++++++++ tests/grpc_pool.rs | 1 + tests/grpc_protocol_pool.rs | 132 ++++++++++++++++++++++++++++++++ tests/trojan_config.rs | 3 - tests/vless_config.rs | 2 - tests/vmess_config.rs | 2 - 23 files changed, 606 insertions(+), 181 deletions(-) create mode 100644 src/config/grpc.rs create mode 100644 tests/grpc_config.rs create mode 100644 tests/grpc_protocol_pool.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8ba9d9..453e387 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -177,6 +177,7 @@ jobs: xhttp_h3_shutdown hysteria2_paths hysteria2_config shadowsocks_backpressure shadowtls_config shadowtls_stream uot_config shadowsocks_uot hysteria2_packet_ids tuic_config tuic_memory httpupgrade_config httpupgrade_memory vmess_lifecycle + grpc_config grpc_pool grpc_protocol_pool ) target_flags=() for target in "${targets[@]}"; do target_flags+=(--test "$target"); done @@ -196,6 +197,7 @@ jobs: outbound::hysteria2:: outbound::shadowsocks::tests::official_tcp_ outbound::anytls:: outbound::uot:: outbound::tuic:: transport::h2_write::tests:: transport::sing_mux::smux_driver::tests:: transport::sing_mux::tests:: + grpc_duplex_pool_keeps_response platform::linux_network::tests:: platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: platform::windows_wintun_io::tests:: tun_runtime::tests:: invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: resource::probe::tests:: invoke::measure_delay::tests:: diff --git a/docs/config.yaml b/docs/config.yaml index 58b685d..06cf94b 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -179,7 +179,7 @@ sniffer: # - 标准 TLS 支持 skip-cert-verify/fingerprint/name-cert-verify,以及配对的 # 内联 PEM certificate/private-key;不能混用 REALITY。完整策略见 vless.md。 # - WS/gRPC/HTTP/H2 复用共享传输。WS 可 v2ray-http-upgrade 及其 fast-open; -# gRPC 可 UA、空闲 PING、max-connections/min-streams/max-streams 池阈值。 +# gRPC 与 Trojan/VMess 共用 UA、空闲 PING、max-connections/min-streams/max-streams 池阈值。 # - smux 支持 h2mux/smux/yamux、padding/only-tcp 与池阈值;与 Vision 互斥。 # - client-fingerprint 接受 none/chrome/chrome120/firefox/firefox120/safari/safari16; # chrome=Chrome133,firefox=Firefox120,safari=Safari16.0;节点缺省关闭。 @@ -312,8 +312,14 @@ proxies: # 不接受保留握手头、重复大小写头、CR/LF、userinfo 或超限 HTTP 头。 # network: grpc # grpc-opts: - # grpc-service-name: edge # 必填;普通名映射 /edge/Tun;/ 开头使用完整路径 - # ws-opts / grpc-opts 仅可用于对应 network;不提供 gRPC 连接池。 + # grpc-service-name: edge # 省略/空默认 GunService;普通名映射 /edge/Tun;/ 开头使用完整路径 + # grpc-user-agent: grpc-go/1.36.0 # 省略/空使用此默认值 + # ping-interval: 0 # 秒数;0 不主动 PING;负值明确拒绝 + # max-connections: 1 # 正值时优先按连接上限/min-streams 扩容 + # min-streams: 0 + # max-streams: 0 # max-connections 非正时按此复用阈值扩容 + # 三阈值字面全零归一 max-connections=1;负阈值禁用分支。 + # ws-opts / grpc-opts 仅可用于对应 network;grpc-opts 可省略。 - name: vless-edge type: vless diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 9b0ff4d..fe1545c 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -23,6 +23,8 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - DNS 不提供独立 listen、DoQ、DoH/H3、ECS、system/DHCP nameserver 或可配置 ARC 缓存。 DoH 使用 H1 POST;详细解析角色、缓存与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 - 正则使用 Rust regex 子集,明确拒绝 lookaround/backreference。规则与 provider 的协议覆盖仅限已实现字段。 +- gRPC 三协议共用选项和节点连接池。负 ping-interval 明确拒绝,不复制固定参考 + HTTP/2 客户端的立即重复 PING;超出本机可表示期限的正秒数也明确拒绝,不复制 Duration 溢出。 - 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; 与 Mihomo 在读取响应头时更新的时点不同。数值仅接受有限且可表示的十进制数, 不保留非有限/溢出浮点转换的参考行为。 diff --git a/docs/outbounds.md b/docs/outbounds.md index 4a40733..7db9cae 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -91,7 +91,9 @@ fast-open 需要 upgrade。 Upgrade 的 ED 仅允许默认 Sec-WebSocket-Protocol 头(大小写不敏感),不支持 自定义头或路径 ED。101/头限额/截断失败不重放前缀,响应头后的字节保留给协议。 这些选项同样适用于 VMess 的明文和标准 TLS;不改变各协议认证、UDP 编码或关闭责任。 -gRPC service-name 必填,普通名映射 /name/Tun,以 / 开头则为完整路径;不开放池参数。 +gRPC 选项与 VMess/VLESS 共用:省略或空 service-name 默认 GunService,普通名映射 /name/Tun,以 / 开头使用完整路径;省略或空 user-agent 默认 grpc-go/1.36.0。 +ping-interval 为秒,0 不主动 PING,负值明确拒绝;max-connections/min-streams/max-streams 接受带符号整数,负阈值禁用相应分支。三项字面全零归一 max-connections=1;正 max-connections 优先于 max-streams。 +每个节点独立拥有连接池,空闲连接总是复用;复用保留原上游选择、TLS 身份和认证策略,每条逻辑流仍单独发送 Trojan 认证头。 TCP 保留半关闭;gRPC duplex 发送 END_STREAM,不用 RST_STREAM 提前结束读侧。 UDP 使用同一已认证流上的地址/长度/CRLF 帧,无额外 socket/读取泵/池。 @@ -115,7 +117,7 @@ network 默认 tcp,另支持 ws/grpc/http/h2;均可明文或标准 TLS。tls TLS 支持独立的 `name-cert-verify` 和成对内联 PEM `certificate/private-key` 客户端身份。 - WS ED/路径/头及 HTTPUpgrade 普通/fast-open 约束同 Trojan。 -- gRPC service-name 必填,普通名/完整路径规则同 Trojan,无额外池调度器。 +- gRPC 选项、默认值和节点连接池与 Trojan/VLESS 共用,每条逻辑流独立生成 VMess AEAD 认证;关闭逻辑流不关闭物理连接或兄弟流。 - http 是 TCP 首包伪装:method 默认 GET,path 列表省略/空为 /,按 URL.Path 转义; 头为字符串列表,每次握手独立选择路径/头值,Host 默认物理 server authority。 仅首包封装一次,之后恢复原流;禁止 Content-Length/Transfer-Encoding。 @@ -139,7 +141,7 @@ UDP body 最多 15,000 字节;packetaddr 另扣 IPv4 7 / IPv6 19 字节,未 三种 UDP 编码均不接受零长度业务包;HTTPUpgrade 不改变这个边界。 TCP 写块最多 4 KiB 以适配 Mihomo 拷贝边界;UDP 不按此切块。16 位帧计数耗尽前关闭, 不重复 nonce。半帧发送取消关闭,接收取消保留进度;认证/地址/标签错误使关联失效。 -gRPC/H2 driver 由节点跟踪并同步退出,无全局会话额度或历史表。 +gRPC 池与 legacy H2 driver 由节点拥有并同步退出,无全局会话额度或历史表。 ## Hysteria2 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 0009112..2b86913 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -84,7 +84,7 @@ CPU 数判断、业务数量配额、FD 预热或 DNS socket 复用。 `stream-transport` 仅包装传入 IO,不创建 socket 或 DNS。WS/HTTP响应首部最多16 KiB、100字段;WS用户请求头最多100字段,额外固定升级头和early-data仍计入16 KiB总预算。WS early-data最多2,048原始字节,头名称/路径后缀由类型化选项区分。WS消息和单帧最多64 KiB,写入切块16 KiB;HTTP首包正文直接写入,不整体复制或持续按HTTP正文定界。 -共享的无池gRPC/legacy H2适配器每个实例仅拥有一条底层连接与一个逻辑流,不是全局并发许可;VLESS另有下文描述的节点级gRPC池。流窗口64 KiB、连接窗口128 KiB、最大HTTP/2帧和发送缓冲各16 KiB、解码负载64 KiB。读侧按实际消费量释放窗口;每poll最多处理32个片段/控制消息。原有gRPC和legacy H2的shutdown关闭整个逻辑连接;Trojan使用独立duplex模式,仅发送END_STREAM并保留读取。关闭前已接受的数据须到达所提供IO的flush边界,不能仅排入h2队列后立即reset/abort;每个逻辑写端最多一个待确认数据块,Stop可取消等待。两种模式的owner.stop都等待驱动任务退出;Drop只做取消兜底,不作为同步停止通过证据。WS/HTTP适配器按底层CloseWrite语义工作,协议包装层可以根据Mihomo契约结束整个逻辑流。所有握手使用调用方同一个绝对deadline。 +共享的无池gRPC/legacy H2适配器每个实例仅拥有一条底层连接与一个逻辑流,不是全局并发许可。Trojan/VMess/VLESS共用节点级gRPC池,物理连接按节点实例隔离,复用保留原上游与安全策略,每条逻辑流独立认证。流窗口64 KiB、连接窗口128 KiB、最大HTTP/2帧和发送缓冲各16 KiB、解码负载64 KiB。读侧按实际消费量释放窗口;每poll最多处理32个片段/控制消息。Trojan池使用内部duplex模式,仅发送END_STREAM并保留读取和lease;VMess/VLESS只结束当前逻辑流。关闭前已接受的数据须到达所提供IO的flush边界,不能仅排入h2队列后立即reset/abort;每个逻辑写端最多一个待确认数据块,Stop可取消等待。节点池最多保留4条空闲连接,PING ACK期限15秒;节点Stop取消并join全部物理驱动。无池模式的owner.stop同样等待驱动任务退出;Drop只做取消兜底,不作为同步停止通过证据。WS/HTTP适配器按底层CloseWrite语义工作,协议包装层可以根据Mihomo契约结束整个逻辑流。所有握手使用调用方同一个绝对deadline。 XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层处理。元数据仍最多512字节,单payload仍受调用方预算和u16 wire上限约束,不新增全局会话额度。 diff --git a/docs/vless.md b/docs/vless.md index 3f29d43..e91677d 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -11,7 +11,7 @@ - 标准 TLS 支持 `skip-cert-verify`、SHA-256 `fingerprint`、独立 `name-cert-verify`;沿用共享证书验证器的叶 pin / 非叶信任锚优先级。`certificate` / `private-key` 是配对的内联 PEM,解析或密钥不匹配在 IO 前失败,禁止文件路径。REALITY 不能混用这些标准证书策略。 - `client-fingerprint` 使用 [TLS 指纹](tls-client-fingerprint.md)的七值/四模板,适用于 TCP 上的标准 TLS/REALITY(含 Vision 和 XHTTP H1/H2),与证书 pin 独立;`none` / 空串关闭,H3 拒绝已启用的 profile。 - WS 的 ED 支持默认头、自定义头或路径(上限 2048 字节)。`v2ray-http-upgrade` 开启升级后裸流;`v2ray-http-upgrade-fast-open` 必须与前者一起启用,只允许默认 ED 头。fast-open 提前发协议首包但仍等待合法 101,拒绝不返回连接成功。 -- gRPC 接入 `grpc-user-agent`(默认 `grpc-go/1.36.0`)、非负秒数 `ping-interval`(0 不主动 PING),以及三项池阈值。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项全零归一 max-connections=1;显式正 max-connections 与正 max-streams 互斥。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 +- gRPC 与 Trojan/VMess 共用配置:省略或空 `grpc-service-name` 默认 `GunService`,普通名映射 `/name/Tun`,`/` 开头使用完整路径;省略或空 `grpc-user-agent` 默认 `grpc-go/1.36.0`。`ping-interval` 为带符号秒数,0 不主动 PING,负值明确拒绝;三项池阈值接受带符号整数,负阈值禁用相应分支。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项字面全零归一 max-connections=1;正 max-connections 优先于 max-streams。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 - gRPC 不新增业务流/建链数量额度;每个物理连接一个自有驱动,每个等待调用只保留自身 future、取消与原期限,不派生队列任务。每节点最多保留 4 条空闲连接,多余空闲连接退役且不影响活动流。空闲 PING 等待 ACK 最多 15 秒,节点 Stop 取消并 join 所有驱动。 - `network: xhttp` 支持 H1/H2 明文、TLS1.3 或 REALITY,H3 仅标准 TLS1.3。请求字段、独立下载腿、池与版本选择见 [XHTTP](xhttp.md)。H3 的物理 UDP 仍由原 Dialer/上游图创建。 - `smux` 以独立的 h2mux/smux v1/yamux 承载逻辑 TCP/UDP;不是 XUDP。only-tcp、调度及关闭边界见 [XHTTP 与 sing-mux](xhttp.md)。 diff --git a/src/config/grpc.rs b/src/config/grpc.rs new file mode 100644 index 0000000..1a1554a --- /dev/null +++ b/src/config/grpc.rs @@ -0,0 +1,103 @@ +//! Shared Mihomo gRPC options for stream protocols. +use super::{deserialize_present_option, invalid}; +use crate::Result; +use serde::Deserialize; + +#[derive(Clone, PartialEq, Eq)] +pub struct GrpcOptions { + pub user_agent: String, + pub ping_interval: u64, + pub max_connections: usize, + pub min_streams: usize, + pub max_streams: usize, +} +impl std::fmt::Debug for GrpcOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("GrpcOptions").finish_non_exhaustive() + } +} +impl Default for GrpcOptions { + fn default() -> Self { + Self { + user_agent: "grpc-go/1.36.0".into(), + ping_interval: 0, + max_connections: 1, + min_streams: 0, + max_streams: 0, + } + } +} + +#[derive(Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RawGrpc { + #[serde(rename = "grpc-service-name", default)] + service: String, + #[serde( + rename = "grpc-user-agent", + default, + deserialize_with = "deserialize_present_option" + )] + user_agent: Option, + #[serde(rename = "ping-interval", default)] + ping_interval: isize, + #[serde(rename = "max-connections", default)] + max_connections: isize, + #[serde(rename = "min-streams", default)] + min_streams: isize, + #[serde(rename = "max-streams", default)] + max_streams: isize, +} + +impl RawGrpc { + pub(super) fn normalize(self) -> Result<(String, GrpcOptions)> { + if self.ping_interval < 0 { + return invalid("negative gRPC ping-interval is unsupported"); + } + let service = if self.service.is_empty() { + "GunService".into() + } else { + self.service + }; + let path = if service.starts_with('/') { + service + } else { + format!("/{service}/Tun") + }; + if path.contains(['?', '\r', '\n', '#']) { + return invalid("invalid gRPC service path"); + } + let user_agent = self + .user_agent + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "grpc-go/1.36.0".into()); + let ping_interval = self.ping_interval as u64; + if user_agent.len() > 8192 + || user_agent.parse::().is_err() + || std::time::Instant::now() + .checked_add(std::time::Duration::from_secs(ping_interval)) + .is_none() + { + return invalid("invalid gRPC options"); + } + let positive = |value: isize| value.max(0) as usize; + // Match the literal all-zero default before collapsing negative values: + // negative thresholds disable a branch rather than opting into defaults. + let max_connections = + if self.max_connections == 0 && self.min_streams == 0 && self.max_streams == 0 { + 1 + } else { + positive(self.max_connections) + }; + Ok(( + path, + GrpcOptions { + user_agent, + ping_interval, + max_connections, + min_streams: positive(self.min_streams), + max_streams: positive(self.max_streams), + }, + )) + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 936249a..0c82d0a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,9 +1,11 @@ //! Strict parsing for the current Vole YAML configuration. mod fake_ip; mod fallback; +mod grpc; mod sniffer; pub use fake_ip::{FakeIpConfig, FakeIpFilterMode, FakeIpRange}; pub use fallback::DnsFallbackConfig; +pub use grpc::GrpcOptions; mod http_proxy; mod ip_version; pub use http_proxy::HttpOutboundConfig; @@ -54,7 +56,7 @@ pub use shadowsocks::{ShadowTlsConfig, ShadowsocksCipher, ShadowsocksOutboundCon mod trojan; pub use trojan::{TrojanOutboundConfig, TrojanTransport, WebSocketHandshake}; mod vless; -pub use vless::{GrpcOptions, SingMuxConfig, SingMuxProtocol, VlessStreamOptions}; +pub use vless::{SingMuxConfig, SingMuxProtocol, VlessStreamOptions}; pub(crate) mod xhttp; pub use xhttp::{XHttpHeaders, XHttpRequestOptions, XHttpReuseConfig, XHttpVersion}; mod vmess; diff --git a/src/config/trojan.rs b/src/config/trojan.rs index 8da8b41..2c6b0c4 100644 --- a/src/config/trojan.rs +++ b/src/config/trojan.rs @@ -1,9 +1,10 @@ +use super::grpc::RawGrpc; use serde::Deserialize; use std::collections::BTreeMap; use super::{ - ProxyProtocol, TlsClientPolicy, deserialize_present_option, invalid, validate_host, - validate_port, + GrpcOptions, ProxyProtocol, TlsClientPolicy, deserialize_present_option, invalid, + validate_host, validate_port, }; use crate::Result; @@ -29,6 +30,7 @@ pub enum TrojanTransport { }, Grpc { uri: String, + options: GrpcOptions, }, } @@ -166,13 +168,6 @@ struct RawWebSocket { early_data_header_name: Option, } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RawGrpc { - #[serde(rename = "grpc-service-name")] - service: String, -} - fn uri(scheme: &str, server: &str, port: u16, path: &str) -> Result { if !path.starts_with('/') || path.contains(['\r', '\n', '#']) { return invalid("invalid Trojan transport path"); @@ -310,17 +305,11 @@ impl RawTrojan { } transport } - ("grpc", None, Some(grpc)) if !grpc.service.is_empty() => { - let path = if grpc.service.starts_with('/') { - grpc.service - } else { - format!("/{}/Tun", grpc.service) - }; - if path.contains('?') { - return invalid("invalid Trojan gRPC service path"); - } + ("grpc", None, grpc) => { + let (path, options) = grpc.unwrap_or_default().normalize()?; TrojanTransport::Grpc { uri: uri("https", &self.server, self.port, &path)?, + options, } } _ => return invalid("Trojan requires tcp, ws with ws-opts, or grpc with grpc-opts"), diff --git a/src/config/vless.rs b/src/config/vless.rs index 1d6d05f..dc93090 100644 --- a/src/config/vless.rs +++ b/src/config/vless.rs @@ -1,13 +1,13 @@ //! VLESS public normalization. Transport options share the common stream adapters; //! security and VLESS-specific behavior remain independent of VMess. -use super::vmess::{RawGrpc, RawH2, RawHttp, RawWs}; +use super::grpc::RawGrpc; +use super::vmess::{RawH2, RawHttp, RawWs}; use super::*; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct VlessStreamOptions { pub http_upgrade: bool, pub fast_open: bool, - pub grpc: GrpcOptions, pub sing_mux: Option, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] @@ -37,30 +37,6 @@ pub struct SingMuxConfig { #[serde(default)] pub only_tcp: bool, } -#[derive(Clone, PartialEq, Eq)] -pub struct GrpcOptions { - pub user_agent: String, - pub ping_interval: u64, - pub max_connections: usize, - pub min_streams: usize, - pub max_streams: usize, -} -impl std::fmt::Debug for GrpcOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GrpcOptions").finish_non_exhaustive() - } -} -impl Default for GrpcOptions { - fn default() -> Self { - Self { - user_agent: "grpc-go/1.36.0".into(), - ping_interval: 0, - max_connections: 1, - min_streams: 0, - max_streams: 0, - } - } -} #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct RawVlessWs { @@ -81,27 +57,6 @@ struct RawVlessWs { #[serde(rename = "v2ray-http-upgrade-fast-open", default)] fast_open: bool, } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -struct RawVlessGrpc { - #[serde(rename = "grpc-service-name")] - service: String, - #[serde( - rename = "grpc-user-agent", - default, - deserialize_with = "deserialize_present_option" - )] - user_agent: Option, - #[serde(rename = "ping-interval", default)] - ping_interval: u64, - #[serde(rename = "max-connections", default)] - max_connections: usize, - #[serde(rename = "min-streams", default)] - min_streams: usize, - #[serde(rename = "max-streams", default)] - max_streams: usize, -} - #[derive(Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawVless { @@ -170,7 +125,7 @@ pub(super) struct RawVless { default, deserialize_with = "deserialize_present_option" )] - grpc: Option, + grpc: Option, #[serde( rename = "http-opts", default, @@ -289,38 +244,7 @@ impl RawVless { }) }) .transpose()?; - let grpc = self - .grpc - .map(|grpc| { - let user_agent = grpc.user_agent.unwrap_or_else(|| "grpc-go/1.36.0".into()); - if user_agent.len() > 8192 - || user_agent.parse::().is_err() - || (grpc.max_connections > 0 && grpc.max_streams > 0) - || std::time::Instant::now() - .checked_add(std::time::Duration::from_secs(grpc.ping_interval)) - .is_none() - { - return invalid("invalid VLESS gRPC options"); - } - stream_options.grpc = GrpcOptions { - user_agent, - ping_interval: grpc.ping_interval, - max_connections: if grpc.max_connections == 0 - && grpc.min_streams == 0 - && grpc.max_streams == 0 - { - 1 - } else { - grpc.max_connections - }, - min_streams: grpc.min_streams, - max_streams: grpc.max_streams, - }; - Ok(RawGrpc { - service: grpc.service, - }) - }) - .transpose()?; + let grpc = self.grpc; if (network != "xhttp" && self.xhttp_opts.is_some()) || (network == "xhttp" && (ws.is_some() || grpc.is_some() || self.http.is_some() || self.h2.is_some())) diff --git a/src/config/vmess.rs b/src/config/vmess.rs index 87b4ef8..5306046 100644 --- a/src/config/vmess.rs +++ b/src/config/vmess.rs @@ -1,6 +1,7 @@ +use super::grpc::RawGrpc; use super::{ - ProxyProtocol, deserialize_present_option, invalid, parse_standard_uuid, validate_host, - validate_port, + GrpcOptions, ProxyProtocol, deserialize_present_option, invalid, parse_standard_uuid, + validate_host, validate_port, }; use crate::Result; use serde::Deserialize; @@ -29,6 +30,7 @@ pub enum StreamTransport { }, Grpc { uri: String, + options: GrpcOptions, }, Http { method: String, @@ -139,12 +141,6 @@ pub(super) struct RawWs { )] pub(super) early_data_header_name: Option, } -#[derive(Deserialize)] -#[serde(deny_unknown_fields)] -pub(super) struct RawGrpc { - #[serde(rename = "grpc-service-name")] - pub(super) service: String, -} #[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawHttp { @@ -481,17 +477,11 @@ pub(super) fn normalize_transport( } transport } - ("grpc", None, Some(grpc), None, None) if !grpc.service.is_empty() => { - let path = if grpc.service.starts_with('/') { - grpc.service - } else { - format!("/{}/Tun", grpc.service) - }; - if path.contains('?') { - return invalid("invalid VMess gRPC path"); - } + ("grpc", None, grpc, None, None) => { + let (path, options) = grpc.unwrap_or_default().normalize()?; StreamTransport::Grpc { uri: uri(scheme, &authority, &path)?, + options, } } ("http", None, None, http, None) => { diff --git a/src/outbound/trojan/outbound.rs b/src/outbound/trojan/outbound.rs index 6390938..e5ab7c4 100644 --- a/src/outbound/trojan/outbound.rs +++ b/src/outbound/trojan/outbound.rs @@ -3,7 +3,7 @@ use std::io; use crate::outbound::owned_stream::OwnedStream; use async_trait::async_trait; use tokio::io::AsyncWriteExt; -use tokio_util::{sync::CancellationToken, task::TaskTracker}; +use tokio_util::sync::CancellationToken; use crate::{ config::{TrojanOutboundConfig, TrojanTransport}, @@ -12,12 +12,11 @@ use crate::{ ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, server_destination, }, - resources::observation::{self, ResourceKind}, security::{ SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsClientOptions, }, session::{Destination, StreamSession}, - transport::{WebSocketOptions, connect_websocket, grpc_duplex}, + transport::{GrpcPool, WebSocketOptions, connect_websocket}, }; use super::{TrojanAuth, TrojanCommand, TrojanDatagram}; @@ -29,13 +28,12 @@ pub struct TrojanOutbound { tls: StandardTlsClient, cancellation: CancellationToken, transport: Transport, - tasks: TaskTracker, } enum Transport { Tcp, WebSocket(Box), - Grpc(String), + Grpc { uri: String, pool: GrpcPool }, } impl std::fmt::Debug for TrojanOutbound { @@ -74,9 +72,11 @@ impl TrojanOutbound { .websocket_options()? .expect("WebSocket config"), )), - TrojanTransport::Grpc { uri } => Transport::Grpc(uri.clone()), + TrojanTransport::Grpc { uri, options } => Transport::Grpc { + uri: uri.clone(), + pool: GrpcPool::duplex(options.clone()), + }, }, - tasks: TaskTracker::new(), server: server_destination(&config.address, config.port)?, upstream, auth: TrojanAuth::new(&config.password)?, @@ -91,6 +91,20 @@ impl TrojanOutbound { }) } + async fn connect_tls( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let raw = self + .upstream + .connect_server(session, &self.server, context) + .await?; + context + .run_io("Trojan TLS handshake", self.tls.connect(raw.io)) + .await + } + async fn connect( &self, session: StreamSession, @@ -107,24 +121,17 @@ impl TrojanOutbound { biased; () = self.cancellation.cancelled() => Err(DispatchError::NotAllowed), connected = async { - let raw = self.upstream.connect_server(session, &self.server, context).await?; - let stream = context.run_io("Trojan TLS handshake", self.tls.connect(raw.io)).await?; let token = self.cancellation.child_token(); let setup_guard = token.clone().drop_guard(); - let stream = match &self.transport { - Transport::Tcp => stream, - Transport::WebSocket(options) => context.run_io("Trojan WebSocket upgrade",connect_websocket(stream,options,&request,context.deadline())).await?, - Transport::Grpc(uri) => { - let (stream, driver) = context.run_io("Trojan gRPC handshake",grpc_duplex(stream,uri,context.deadline())).await?; - let token = token.clone(); - let observation = observation::track(ResourceKind::Task); - self.tasks.spawn(observation::bind(async move { - let _observation = observation; - token.cancelled().await; - let _ = driver.stop().await; - })); - stream - }, + let stream = if let Transport::Grpc { uri, pool } = &self.transport { + pool.open(uri, context.deadline(), || async { + self.connect_tls(session, context).await.map_err(io::Error::other) + }).await? + } else { + let stream = self.connect_tls(session, context).await?; + if let Transport::WebSocket(options) = &self.transport { + context.run_io("Trojan WebSocket upgrade",connect_websocket(stream,options,&request,context.deadline())).await? + } else { stream } }; let mut stream = OwnedStream::new(stream, token); setup_guard.disarm(); @@ -176,11 +183,15 @@ impl OutboundConnector for TrojanOutbound { fn begin_shutdown(&self) { self.cancellation.cancel(); + if let Transport::Grpc { pool, .. } = &self.transport { + pool.begin_shutdown(); + } } async fn shutdown(&self) { self.begin_shutdown(); - self.tasks.close(); - self.tasks.wait().await; + if let Transport::Grpc { pool, .. } = &self.transport { + pool.shutdown().await; + } } } diff --git a/src/outbound/vless/outbound.rs b/src/outbound/vless/outbound.rs index fc04276..1f0c0cf 100644 --- a/src/outbound/vless/outbound.rs +++ b/src/outbound/vless/outbound.rs @@ -96,7 +96,7 @@ pub struct VlessOutbound { stream_options: crate::config::VlessStreamOptions, cancellation: CancellationToken, tasks: TaskTracker, - grpc_pool: crate::transport::GrpcPool, + grpc_pool: Option, sing_mux: Option, vision: bool, vision_stats: Arc, @@ -477,6 +477,10 @@ impl VlessOutbound { )); } }; + let transport = match &config.transport { + VlessTransport::Stream(transport) => transport.clone(), + VlessTransport::Xhttp(_) => StreamTransport::Tcp, + }; Ok(Self { uuid: config.id, encryption, @@ -488,14 +492,16 @@ impl VlessOutbound { xhttp, download, encoding: config.packet_encoding, - transport: match &config.transport { - VlessTransport::Stream(transport) => transport.clone(), - VlessTransport::Xhttp(_) => StreamTransport::Tcp, - }, cancellation: CancellationToken::new(), stream_options: config.stream_options.clone(), tasks: TaskTracker::new(), - grpc_pool: crate::transport::GrpcPool::new(config.stream_options.grpc.clone()), + grpc_pool: match &transport { + StreamTransport::Grpc { options, .. } => { + Some(crate::transport::GrpcPool::new(options.clone())) + } + _ => None, + }, + transport, sing_mux: config .stream_options .sing_mux @@ -580,8 +586,8 @@ impl VlessOutbound { let (raw,control)=context.run_io("Vision TLS handshake",self.upload.security.connect_vision(raw,self.vision_stats.clone())).await?; vision_control=Some(control); (raw,StreamTransport::Tcp) - } else if let StreamTransport::Grpc{uri}=&self.transport { - let io=self.grpc_pool.open(uri,context.deadline(),||async { + } else if let StreamTransport::Grpc{uri,..}=&self.transport { + let io=self.grpc_pool.as_ref().expect("gRPC pool").open(uri,context.deadline(),||async { self.connect_transport(session,context).await.map_err(io::Error::other) }).await?; (io,StreamTransport::Tcp) @@ -750,11 +756,7 @@ impl PreparedStream { header = bytes::Bytes::new(); } } - StreamTransport::Grpc { uri } => { - let connected = crate::transport::grpc(raw, uri, deadline).await?; - raw = connected.0; - driver = Some(connected.1); - } + StreamTransport::Grpc { .. } => unreachable!("gRPC opened during prepare"), StreamTransport::H2 { uris } => { let connected = crate::transport::legacy_h2( raw, @@ -903,7 +905,9 @@ impl OutboundConnector for VlessOutbound { if let Some(encryption) = &self.encryption { encryption.close(); } - self.grpc_pool.begin_shutdown(); + if let Some(pool) = &self.grpc_pool { + pool.begin_shutdown(); + } if let Some(mux) = &self.sing_mux { mux.begin_stop(); } @@ -918,7 +922,9 @@ impl OutboundConnector for VlessOutbound { self.begin_shutdown(); self.tasks.close(); self.tasks.wait().await; - self.grpc_pool.shutdown().await; + if let Some(pool) = &self.grpc_pool { + pool.shutdown().await; + } if let Some(mux) = &self.sing_mux { mux.stop().await; } diff --git a/src/outbound/vmess/outbound.rs b/src/outbound/vmess/outbound.rs index e78a4ec..ee0de99 100644 --- a/src/outbound/vmess/outbound.rs +++ b/src/outbound/vmess/outbound.rs @@ -14,7 +14,7 @@ use crate::{ SecurityContext, StandardTlsClient, TLS_RESUMPTION_SESSION_BUDGET, TlsClientOptions, }, session::{Datagram, Destination, StreamSession}, - transport::{connect_websocket, grpc, http_obfs, legacy_h2}, + transport::{GrpcPool, connect_websocket, http_obfs, legacy_h2}, }; use async_trait::async_trait; use std::{io, sync::Arc}; @@ -33,6 +33,7 @@ pub struct VmessOutbound { tls: Option, options: Arc, encoding: VmessPacketEncoding, + grpc_pool: Option, cancellation: CancellationToken, tasks: TaskTracker, } @@ -89,10 +90,32 @@ impl VmessOutbound { transport: config.transport.clone(), }), encoding: config.packet_encoding, + grpc_pool: match &config.transport { + VmessTransport::Grpc { options, .. } => Some(GrpcPool::new(options.clone())), + _ => None, + }, cancellation: CancellationToken::new(), tasks: TaskTracker::new(), }) } + async fn connect_transport( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let raw = self + .upstream + .connect_server(session, &self.server, context) + .await? + .io; + if let Some(tls) = &self.tls { + context + .run_io("VMess TLS handshake", tls.connect(raw)) + .await + } else { + Ok(raw) + } + } async fn prepare( &self, session: StreamSession, @@ -104,8 +127,11 @@ impl VmessOutbound { tokio::select! { biased; ()=self.cancellation.cancelled()=>Err(DispatchError::NotAllowed), result=async { - let raw=self.upstream.connect_server(session,&self.server,context).await?.io; - let raw=if let Some(tls)=&self.tls { context.run_io("VMess TLS handshake",tls.connect(raw)).await? } else {raw}; + let raw=if let VmessTransport::Grpc { uri, .. }=&self.options.transport { + self.grpc_pool.as_ref().expect("gRPC pool").open(uri,context.deadline(),||async { + self.connect_transport(session,context).await.map_err(io::Error::other) + }).await? + } else { self.connect_transport(session,context).await? }; let token=self.cancellation.child_token(); Ok(PreparedStream { raw:Box::new(OwnedStream::new(raw,token.clone())),options:self.options.clone(),deadline:context.deadline(),tasks:self.tasks.clone(),token }) }=>result, @@ -170,11 +196,9 @@ impl PreparedStream { .await?; sent = true; } - VmessTransport::Grpc { uri } => { - let connected = grpc(raw, uri, deadline).await?; - raw = connected.0; - driver = Some(connected.1); - } + // prepare already opened this logical gRPC stream. Authentication + // is fresh here, without wrapping or dialing the transport again. + VmessTransport::Grpc { .. } => {} VmessTransport::H2 { uris } => { let uri = &uris[rand::random_range(0..uris.len())]; let connected = legacy_h2(raw, uri, deadline).await?; @@ -285,11 +309,17 @@ impl OutboundConnector for VmessOutbound { } fn begin_shutdown(&self) { self.cancellation.cancel(); + if let Some(pool) = &self.grpc_pool { + pool.begin_shutdown(); + } } async fn shutdown(&self) { self.begin_shutdown(); self.tasks.close(); self.tasks.wait().await; + if let Some(pool) = &self.grpc_pool { + pool.shutdown().await; + } } } impl Drop for VmessOutbound { diff --git a/src/transport/grpc.rs b/src/transport/grpc.rs index 6e37f70..c651db2 100644 --- a/src/transport/grpc.rs +++ b/src/transport/grpc.rs @@ -221,6 +221,7 @@ pub(super) async fn pooled_stream( sender: Sender, uri: &str, user_agent: &str, + half_close: bool, deadline: Instant, lease: super::grpc_pool::Lease, ) -> io::Result { @@ -244,7 +245,7 @@ pub(super) async fn pooled_stream( response, send, Framing::Gun, - false, + half_close, deadline, Arc::default(), None, diff --git a/src/transport/grpc_pool.rs b/src/transport/grpc_pool.rs index 6b8de59..031d999 100644 --- a/src/transport/grpc_pool.rs +++ b/src/transport/grpc_pool.rs @@ -25,6 +25,7 @@ const PING_TIMEOUT: Duration = Duration::from_secs(crate::limits::GRPC_PING_TIME pub struct GrpcPool { options: GrpcOptions, + half_close: bool, connections: Arc>>, opening: Mutex<()>, cancellation: CancellationToken, @@ -34,12 +35,20 @@ impl GrpcPool { pub fn new(options: GrpcOptions) -> Self { Self { options, + half_close: false, connections: Arc::default(), opening: Mutex::default(), cancellation: CancellationToken::new(), tasks: TaskTracker::new(), } } + /// Trojan keeps its response and lease after upload END_STREAM. + #[cfg(any(test, feature = "outbound-trojan"))] + pub(crate) fn duplex(options: GrpcOptions) -> Self { + let mut pool = Self::new(options); + pool.half_close = true; + pool + } /// The factory is invoked only for a new physical connection. A reused /// transport keeps its original upstream selection and TLS identity. pub async fn open( @@ -88,7 +97,15 @@ impl GrpcPool { } } }; - super::grpc::pooled_stream(sender, uri, &self.options.user_agent, deadline, lease).await + super::grpc::pooled_stream( + sender, + uri, + &self.options.user_agent, + self.half_close, + deadline, + lease, + ) + .await }; tokio::select! {biased; ()=self.cancellation.cancelled()=>Err(io::ErrorKind::ConnectionAborted.into()), @@ -176,6 +193,114 @@ impl Drop for GrpcPool { self.cancellation.cancel(); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::resources::observation::{ResourceKind, ResourceProbe}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn grpc_duplex_pool_keeps_response_and_sibling_after_end_stream_and_joins_stop() { + let probe = ResourceProbe::default(); + probe + .scope(async { + tokio::time::timeout(Duration::from_secs(3), async { + let tasks = TaskTracker::new(); + let count = Arc::new(AtomicUsize::new(0)); + let pool = GrpcPool::duplex(GrpcOptions::default()); + let factory = || async { + count.fetch_add(1, Ordering::SeqCst); + let (io, peer) = tokio::io::duplex(65536); + let sessions = tasks.clone(); + tasks.spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + while let Some(Ok((request, mut response))) = connection.accept().await + { + assert_eq!(request.headers()["user-agent"], "grpc-go/1.36.0"); + let mut send = response + .send_response( + http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .unwrap(), + false, + ) + .unwrap(); + let mut receive = request.into_body(); + sessions.spawn(async move { + while let Some(data) = receive.data().await { + let Ok(data) = data else { + return; + }; + receive + .flow_control() + .release_capacity(data.len()) + .unwrap(); + if send.send_data(data, false).is_err() { + return; + } + } + send.send_data( + bytes::Bytes::from_static(b"\0\0\0\0\x06\x0a\x04tail"), + false, + ) + .unwrap(); + let mut trailers = http::HeaderMap::new(); + trailers.insert("grpc-status", "0".parse().unwrap()); + send.send_trailers(trailers).unwrap(); + }); + } + }); + Ok(Box::new(io) as BoxStream) + }; + let deadline = Instant::now() + Duration::from_secs(2); + let mut first = pool + .open("http://example.com/GunService/Tun", deadline, factory) + .await + .unwrap(); + let mut sibling = pool + .open("http://example.com/GunService/Tun", deadline, factory) + .await + .unwrap(); + assert_eq!(count.load(Ordering::SeqCst), 1); + first.write_all(b"first").await.unwrap(); + first.shutdown().await.unwrap(); + first.shutdown().await.unwrap(); + // Upload is closed, but its response and physical lease survive. + assert!(first.write_all(b"closed").await.is_err()); + assert_eq!( + pool.connections.lock().unwrap()[0] + .active + .load(Ordering::Acquire), + 2 + ); + let mut response = Vec::new(); + first.read_to_end(&mut response).await.unwrap(); + assert_eq!(response, b"firsttail"); + sibling.write_all(b"alive").await.unwrap(); + sibling.flush().await.unwrap(); + let mut echo = [0; 5]; + sibling.read_exact(&mut echo).await.unwrap(); + assert_eq!(&echo, b"alive"); + assert_eq!(probe.snapshot().current(ResourceKind::Task), 1); + pool.shutdown().await; + assert_eq!(probe.snapshot().current(ResourceKind::Task), 0); + assert_eq!(probe.snapshot().current(ResourceKind::Pool), 0); + assert!(sibling.write_all(b"stopped").await.is_err()); + drop(first); + drop(sibling); + tasks.close(); + tasks.wait().await; + }) + .await + .unwrap(); + }) + .await; + assert!(probe.snapshot().is_idle()); + } +} struct Connection { sender: Sender, active: Arc, diff --git a/tests/README.md b/tests/README.md index 929b5b6..6f912e8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,8 @@ cargo test --locked --no-default-features --features cli --bin vole cli::tests:: cargo test --locked --no-default-features --features cli --lib invoke::foreground::tests:: cargo test --locked --lib config::tests:: cargo test --locked --lib config::tls_policy_tests:: +cargo test --locked --no-default-features --features cli --test grpc_config --test grpc_pool --test grpc_protocol_pool +cargo test --locked --no-default-features --features cli --lib grpc_duplex_pool_keeps_response cargo test --locked --lib explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it cargo test --locked --lib mutual_tls_identity_is_required_verified_and_not_shared_between_clients cargo test --locked --lib geodata::tests:: diff --git a/tests/grpc_config.rs b/tests/grpc_config.rs new file mode 100644 index 0000000..7fa1c2f --- /dev/null +++ b/tests/grpc_config.rs @@ -0,0 +1,104 @@ +#![cfg(all( + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-vless" +))] +use serde_json::json; +use vole::config::{Config, ProxyProtocol, StreamTransport, TrojanTransport, VlessTransport}; + +#[test] +fn grpc_protocols_share_defaults_signed_thresholds_and_normalization() { + for protocol in ["trojan", "vmess", "vless"] { + for (fields, path, ua, max, min, streams) in [ + (json!({}), "/GunService/Tun", "grpc-go/1.36.0", 1, 0, 0), + ( + json!({"grpc-opts":{"grpc-service-name":"","grpc-user-agent":""}}), + "/GunService/Tun", + "grpc-go/1.36.0", + 1, + 0, + 0, + ), + ( + json!({"grpc-opts":{"grpc-service-name":"edge","grpc-user-agent":"probe","ping-interval":1,"max-connections":2,"min-streams":3,"max-streams":4}}), + "/edge/Tun", + "probe", + 2, + 3, + 4, + ), + ( + json!({"grpc-opts":{"grpc-service-name":"/full/path","max-connections":-1,"min-streams":-1,"max-streams":2}}), + "/full/path", + "grpc-go/1.36.0", + 0, + 0, + 2, + ), + ( + json!({"grpc-opts":{"max-streams":-1}}), + "/GunService/Tun", + "grpc-go/1.36.0", + 0, + 0, + 0, + ), + ] { + let mut node = json!({"name":"edge","type":protocol,"server":"example.com","port":443,"network":"grpc"}); + if protocol == "trojan" { + node["password"] = json!("test"); + } else { + node["uuid"] = json!("07070707-0707-0707-0707-070707070707"); + } + node.as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + let config = Config::parse_yaml( + json!({"mixed-port":1080,"proxies":[node.clone()],"rules":["MATCH,edge"]}) + .to_string() + .as_bytes(), + ) + .unwrap(); + let (uri, options) = match &config.proxies[0].protocol { + ProxyProtocol::Trojan(node) => match &node.transport { + TrojanTransport::Grpc { uri, options } => (uri, options), + _ => unreachable!(), + }, + ProxyProtocol::Vmess(node) => match &node.transport { + StreamTransport::Grpc { uri, options } => (uri, options), + _ => unreachable!(), + }, + ProxyProtocol::Vless(node) => match &node.transport { + VlessTransport::Stream(StreamTransport::Grpc { uri, options }) => { + (uri, options) + } + _ => unreachable!(), + }, + _ => unreachable!(), + }; + assert_eq!(uri.parse::().unwrap().path(), path); + assert_eq!(options.user_agent, ua); + assert_eq!( + ( + options.max_connections, + options.min_streams, + options.max_streams + ), + (max, min, streams) + ); + assert_eq!(options.ping_interval, u64::from(ua == "probe")); + if fields.as_object().unwrap().is_empty() { + let mut unsupported = node.clone(); + unsupported["grpc-opts"] = json!({"ping-interval":-1}); + assert!( + Config::parse_yaml( + json!({"mixed-port":1080,"proxies":[unsupported],"rules":["MATCH,edge"]}) + .to_string() + .as_bytes() + ) + .is_err() + ); + } + } + } +} diff --git a/tests/grpc_pool.rs b/tests/grpc_pool.rs index fb4f627..63be45d 100644 --- a/tests/grpc_pool.rs +++ b/tests/grpc_pool.rs @@ -160,6 +160,7 @@ async fn grpc_pool_matches_both_threshold_policies_and_keeps_other_streams_alive for (max, min, streams, expected) in [ (1, 0, 0, vec![1, 1, 1, 1]), (2, 2, 0, vec![1, 1, 2, 2]), + (2, 2, 1, vec![1, 1, 2, 2]), (2, 0, 0, vec![1, 2, 2, 2]), (0, 0, 2, vec![1, 1, 2, 2, 3]), (0, 2, 0, vec![1, 2, 3, 4]), diff --git a/tests/grpc_protocol_pool.rs b/tests/grpc_protocol_pool.rs new file mode 100644 index 0000000..064be4f --- /dev/null +++ b/tests/grpc_protocol_pool.rs @@ -0,0 +1,132 @@ +#![cfg(feature = "outbound-vmess")] +use std::{ + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::io::AsyncWriteExt; +use tokio_util::task::TaskTracker; +use vole::{ + config::{Config, ProxyProtocol}, + dispatch::{DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + vmess::VmessOutbound, + }, + session::{InboundKind, StreamSession}, +}; + +struct MemoryUpstream { + physical: AtomicUsize, + tasks: TaskTracker, + requests: tokio::sync::mpsc::UnboundedSender>, +} +#[async_trait::async_trait] +impl OutboundConnector for MemoryUpstream { + async fn connect_stream( + &self, + session: StreamSession, + _: &EstablishContext, + ) -> Result { + self.physical.fetch_add(1, Ordering::SeqCst); + let (io, peer) = tokio::io::duplex(65536); + let sessions = self.tasks.clone(); + let requests = self.requests.clone(); + self.tasks.spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + while let Some(Ok((request, mut response))) = connection.accept().await { + assert_eq!(request.uri().path(), "/edge/Tun"); + assert_eq!(request.headers()["user-agent"], "vmess-memory"); + let send = response + .send_response( + http::Response::builder() + .status(200) + .header("content-type", "application/grpc") + .body(()) + .unwrap(), + false, + ) + .unwrap(); + let mut receive = request.into_body(); + let requests = requests.clone(); + sessions.spawn(async move { + let _response = send; + let mut first = Vec::new(); + let mut sent = false; + while let Some(Ok(data)) = receive.data().await { + receive.flow_control().release_capacity(data.len()).unwrap(); + if !sent { + first.extend_from_slice(&data); + if first.len() >= 5 { + let length = + u32::from_be_bytes(first[1..5].try_into().unwrap()) as usize; + assert!(length <= 16384); + if first.len() >= 5 + length { + requests.send(first.clone()).unwrap(); + sent = true; + } + } + } + } + }); + } + }); + Ok(ConnectedStream { + io: Box::new(io), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} + +fn auth_id(record: &[u8]) -> &[u8] { + assert_eq!(record[0], 0); + assert_eq!(record[5], 0x0a); + let mut start = 6; + while record[start] & 0x80 != 0 { + start += 1; + } + &record[start + 1..start + 17] +} + +#[tokio::test] +async fn vmess_grpc_reuses_per_node_transport_with_fresh_authentication_and_joined_stop() { + tokio::time::timeout(Duration::from_secs(3),async { + let config=Config::parse_yaml(b"mixed-port: 1080\nproxies: [{name: edge, type: vmess, server: fixture.invalid, port: 443, uuid: 07070707-0707-0707-0707-070707070707, network: grpc, grpc-opts: {grpc-service-name: edge, grpc-user-agent: vmess-memory}}]\nrules: ['MATCH,edge']").unwrap(); + let ProxyProtocol::Vmess(node)=&config.proxies[0].protocol else { unreachable!() }; + let (requests,mut observed)=tokio::sync::mpsc::unbounded_channel(); + let upstream=Arc::new(MemoryUpstream { physical:AtomicUsize::new(0), tasks:TaskTracker::new(), requests }); + let first=VmessOutbound::new_with_path(node,UpstreamPath::proxy(upstream.clone())).unwrap(); + let second=VmessOutbound::new_with_path(node,UpstreamPath::proxy(upstream.clone())).unwrap(); + let session=StreamSession { metadata:Default::default(), inbound:InboundKind::InternalMeasure, source:"127.0.0.1:1".parse().unwrap(), destination:"192.0.2.1:80".parse::().unwrap().into(), sniffed_domain:None }; + let context=EstablishContext::with_timeout(Duration::from_secs(2)); + let mut stream1=first.connect_stream(session.clone(),&context).await.unwrap().io; + let header1=observed.recv().await.unwrap(); + let mut stream2=first.connect_stream(session.clone(),&context).await.unwrap().io; + let header2=observed.recv().await.unwrap(); + assert_eq!(upstream.physical.load(Ordering::SeqCst),1,"logical reuse must not reselect or dial the upstream"); + assert_ne!(auth_id(&header1),auth_id(&header2),"each logical stream needs fresh VMess AEAD authentication"); + stream1.shutdown().await.unwrap(); + stream2.write_all(b"sibling").await.unwrap(); + stream2.flush().await.unwrap(); + let mut independent=second.connect_stream(session,&context).await.unwrap().io; + let _=observed.recv().await.unwrap(); + assert_eq!(upstream.physical.load(Ordering::SeqCst),2,"distinct node instances must not share a physical pool"); + first.shutdown().await; + assert!(stream2.write_all(b"stopped").await.is_err()); + independent.write_all(b"other-node").await.unwrap(); + independent.flush().await.unwrap(); + second.shutdown().await; + drop((stream1,stream2,independent)); + upstream.tasks.close(); + upstream.tasks.wait().await; + }).await.unwrap(); +} diff --git a/tests/trojan_config.rs b/tests/trojan_config.rs index 0538af7..37ca98e 100644 --- a/tests/trojan_config.rs +++ b/tests/trojan_config.rs @@ -176,11 +176,8 @@ fn trojan_transport_boundaries_fail_before_runtime_io() { for fields in [ json!({"ws-opts":null}), json!({"grpc-opts":null}), - json!({"network":"grpc"}), - json!({"network":"grpc","grpc-opts":{"grpc-service-name":""}}), json!({"network":"grpc","grpc-opts":{"grpc-service-name":"edge?query=1"}}), json!({"network":"grpc","grpc-opts":{"grpc-service-name":null}}), - json!({"network":"grpc","grpc-opts":{"grpc-service-name":"edge","max-connections":1}}), json!({"network":"ws","grpc-opts":{"grpc-service-name":"edge"}}), json!({"network":"grpc","ws-opts":{},"grpc-opts":{"grpc-service-name":"edge"}}), json!({"network":"ws","alpn":[]}), diff --git a/tests/vless_config.rs b/tests/vless_config.rs index 76fc4c9..9d1b7c3 100644 --- a/tests/vless_config.rs +++ b/tests/vless_config.rs @@ -204,8 +204,6 @@ fn extended_ws_and_grpc_fields_are_scoped_and_bounded() { json!({"network":"ws","ws-opts":{"v2ray-http-upgrade-fast-open":true}}), json!({"network":"ws","ws-opts":{"v2ray-http-upgrade":true,"max-early-data":1,"early-data-header-name":"x-ed"}}), json!({"network":"ws","ws-opts":{"v2ray-http-upgrade":null}}), - json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","max-connections":1,"max-streams":2}}), - json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","ping-interval":-1}}), json!({"network":"grpc","grpc-opts":{"grpc-service-name":"test","grpc-user-agent":"bad\r\nvalue"}}), ] { assert!(Config::parse_yaml(&document(fields)).is_err()); diff --git a/tests/vmess_config.rs b/tests/vmess_config.rs index a50023a..d13bef7 100644 --- a/tests/vmess_config.rs +++ b/tests/vmess_config.rs @@ -71,7 +71,6 @@ fn vmess_transport_and_security_combinations_are_strict() { json!({"cipher":"zero","authenticated-length":true}), json!({"network":"bogus"}), json!({"network":"h2"}), - json!({"network":"grpc"}), json!({"tls":false,"servername":"secret.invalid"}), json!({"tls":false,"skip-cert-verify":false}), json!({"tls":false,"alpn":[]}), @@ -84,7 +83,6 @@ fn vmess_transport_and_security_combinations_are_strict() { json!({"network":"http","http-opts":{"headers":{"X-Test":[]}}}), json!({"network":"ws","ws-opts":{"max-early-data":2049}}), json!({"network":"ws","tls":true,"alpn":["h2"]}), - json!({"network":"grpc","grpc-opts":{"grpc-service-name":""}}), ] { assert!( Config::parse_yaml(&document(fields.clone())).is_err(), From e9fe4beeb156fd22851e5ab95b29b049e1bf9a4c Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 12:45:44 +0800 Subject: [PATCH 038/445] Apply shared sing-mux to outbound connectors --- .github/workflows/test.yml | 5 +- Cargo.toml | 5 +- README.md | 6 +- docs/config.yaml | 10 +- docs/outbounds.md | 11 +- docs/runtime-resource-policy.md | 7 + docs/vless.md | 2 +- docs/xhttp.md | 16 +- src/config/mod.rs | 33 +++- src/config/sing_mux.rs | 118 +++++++++++++ src/config/vless.rs | 45 ----- src/outbound/mod.rs | 4 + src/outbound/sing_mux.rs | 294 ++++++++++++++++++++++++++++++++ src/outbound/vless/outbound.rs | 62 ------- src/runtime.rs | 77 +++++++++ src/transport/grpc.rs | 4 +- src/transport/mod.rs | 2 +- src/transport/sing_mux.rs | 65 ++++++- tests/README.md | 3 + tests/sing_mux.rs | 100 +++++++++-- tests/sing_mux_config.rs | 80 ++++++++- 21 files changed, 793 insertions(+), 156 deletions(-) create mode 100644 src/config/sing_mux.rs create mode 100644 src/outbound/sing_mux.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 453e387..f9b5e65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,7 @@ jobs: shadowtls_config shadowtls_stream uot_config shadowsocks_uot hysteria2_packet_ids tuic_config tuic_memory httpupgrade_config httpupgrade_memory vmess_lifecycle grpc_config grpc_pool grpc_protocol_pool + sing_mux_config sing_mux ) target_flags=() for target in "${targets[@]}"; do target_flags+=(--test "$target"); done @@ -198,6 +199,7 @@ jobs: outbound::anytls:: outbound::uot:: outbound::tuic:: transport::h2_write::tests:: transport::sing_mux::smux_driver::tests:: transport::sing_mux::tests:: grpc_duplex_pool_keeps_response + outbound::sing_mux::tests:: platform::linux_network::tests:: platform::tun_rs_io::tests:: platform::windows_tun_io::tests:: platform::windows_wintun_io::tests:: tun_runtime::tests:: invoke::foreground::tests:: runtime::shutdown_tests:: runtime::providers::tests:: resource::probe::tests:: invoke::measure_delay::tests:: @@ -227,6 +229,7 @@ jobs: dns::runtime::tests::dns_policy_selection_is_consistent_across_snapshot_swaps runtime::tests::prepares_builtin_routes_without_static_nodes_or_bootstrap runtime::tests::prepares_udp_capabilities_through_runtime_wrappers + runtime::tests::prepares_shared_mux_capabilities_and_registers_only_final_graph_nodes runtime::tests::controller_diagnostics_are_opt_in_for_business_routes invoke::tests::null_invalid_utf8_and_oversized_input_return_json_failures invoke::tests::same_instance_command_is_fail_fast @@ -257,7 +260,7 @@ jobs: cargo check --locked --no-default-features --features "outbound-$protocol" --lib run_test 1 --features "outbound-$protocol" --lib runtime::tests::integration_feature_admission_is_explicit_without_opening_sockets -- --exact done - for feature in outbound-http inbound-http inbound-socks5 stream-transport quic-transport shadow-tls-v3 tun invoke ffi cli windows-wintun windows-uwp; do + for feature in outbound-http inbound-http inbound-socks5 stream-transport quic-transport sing-mux shadow-tls-v3 tun invoke ffi cli windows-wintun windows-uwp; do cargo check --locked --no-default-features --features "$feature" --lib done cargo check --locked --no-default-features --features cli --bin vole diff --git a/Cargo.toml b/Cargo.toml index 8be8c95..a47456f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ default = [ "outbound-hysteria2", "outbound-tuic", "shadow-tls-v3", + "sing-mux", ] inbound-http = [] inbound-socks5 = [] @@ -49,13 +50,14 @@ outbound-anytls = ["dep:md-5", "tls-fingerprint"] outbound-socks5 = [] outbound-http = [] outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] -outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:zeroize", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn", "dep:yamux", "tokio-util/compat"] +outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:zeroize", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn"] outbound-trojan = ["stream-transport", "tls-fingerprint"] outbound-vmess = ["stream-transport", "tls-fingerprint", "dep:aes", "dep:crc32fast", "dep:md-5", "dep:ring", "dep:shake"] tls-fingerprint = ["dep:boring", "dep:tokio-boring"] shadow-tls-v3 = ["tls-fingerprint", "boring/shadow-tls-v3", "dep:ring", "dep:zeroize"] outbound-hysteria2 = ["quic-transport", "dep:quinn-proto", "dep:h3", "dep:h3-quinn", "dep:blake2"] outbound-tuic = ["quic-transport"] +sing-mux = ["stream-transport", "dep:yamux", "tokio-util/compat"] stream-transport = ["dep:tokio-tungstenite", "dep:httparse", "dep:log"] quic-transport = ["dep:quinn"] # Enables local interoperability-only trust-root injection. Release build @@ -82,6 +84,7 @@ tun = [ "outbound-hysteria2", "outbound-tuic", "shadow-tls-v3", + "sing-mux", ] [dependencies] diff --git a/README.md b/README.md index dc7822f..4dff3a6 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,11 @@ Configuration uses **Mihomo-compatible YAML for the supported feature set**. Vol ## Proxy protocols -All nine protocols below are enabled in the default build. HTTP CONNECT is TCP-only; UDP support for other protocols is configured per node. +All nine protocols below are enabled in the default build. HTTP CONNECT itself is TCP-only; native UDP support for other protocols is configured per node. | Protocol | Capabilities | | --- | --- | -| [VLESS](docs/vless.md) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage, legacy H2, [XHTTP H1/H2/H3](docs/xhttp.md); Vision, Encryption, REALITY, JLS, static ECH and sing-mux in supported combinations | +| [VLESS](docs/vless.md) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage, legacy H2, [XHTTP H1/H2/H3](docs/xhttp.md); Vision, Encryption, REALITY, JLS and static ECH in supported combinations | | [VMess AEAD](docs/outbounds.md#vmess-aead) | TCP, WebSocket / HTTPUpgrade, gRPC, HTTP first-packet camouflage and legacy H2; TCP/UDP, plaintext or standard TLS | | [Trojan](docs/outbounds.md#trojan) | TCP/UDP over TLS TCP, WebSocket / HTTPUpgrade or gRPC | | [Shadowsocks 2022](docs/outbounds.md#shadowsocks-2022) | TCP/UDP; AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305; AES identity chains; optional strict ShadowTLS v3 TCP wrapping and UoT v2 | @@ -34,6 +34,8 @@ All nine protocols below are enabled in the default build. HTTP CONNECT is TCP-o | [Hysteria2](docs/outbounds.md#hysteria2) | QUIC TCP/UDP, bandwidth control, Salamander, port hopping and mTLS | | [TUIC v5](docs/outbounds.md#tuic-v5) | QUIC TCP/UDP, native/quic UDP relay modes and selectable congestion control | +The shared [sing-mux wrapper](docs/xhttp.md#通用-sing-mux) supports all enabled node protocols. It provides TCP and, with `only-tcp: false`, UDP over the node's ordinary TCP path. The peer must provide sing-mux decoding; a normal HTTP/SOCKS5 listener does not automatically decode it. + Standard TLS connections support certificate verification and SHA-256 certificate pins. Applicable TCP TLS paths can use Chrome, Firefox or Safari ClientHello templates; `client-fingerprint` is independent of the certificate `fingerprint`. See [TLS profiles and certificate policy](docs/tls-client-fingerprint.md) for the exact names and combinations. These features do not promise indistinguishability from a browser or unrestricted protocol combinations. SS2022 uses the unmodified official Rust library; its known empty-first-write/server-first and padding risks remain documented in the [Shadowsocks contract](docs/outbounds.md#shadowsocks-2022). diff --git a/docs/config.yaml b/docs/config.yaml index 06cf94b..93ac28a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -180,7 +180,7 @@ sniffer: # 内联 PEM certificate/private-key;不能混用 REALITY。完整策略见 vless.md。 # - WS/gRPC/HTTP/H2 复用共享传输。WS 可 v2ray-http-upgrade 及其 fast-open; # gRPC 与 Trojan/VMess 共用 UA、空闲 PING、max-connections/min-streams/max-streams 池阈值。 -# - smux 支持 h2mux/smux/yamux、padding/only-tcp 与池阈值;与 Vision 互斥。 +# - 公共节点 smux 支持 h2mux/smux/yamux、padding/only-tcp 与池阈值;与 Vision 互斥。 # - client-fingerprint 接受 none/chrome/chrome120/firefox/firefox120/safari/safari16; # chrome=Chrome133,firefox=Firefox120,safari=Safari16.0;节点缺省关闭。 # 适用于标准 TCP TLS/REALITY,不改变证书 fingerprint;下载腿缺省继承、 @@ -195,6 +195,8 @@ sniffer: # 下方 vless-edge 通过 upstream-select 当前选择的 socks-hop 建链: # 客户端 -> socks-hop -> vless-edge -> 目标。 # 所有节点支持 ip-version: dual/ipv4/ipv6/ipv4-prefer/ipv6-prefer;默认 dual。 +# 所有节点支持 smux 包装,要求对端提供 sing-mux 解码;普通 HTTP/SOCKS listener 不自动解码。 +# only-tcp: false 提供 mux UDP,即使底层 udp 为 false;true 保留底层 UDP 能力与编码。 # HTTP(S) CONNECT 示例:type: http,必填 name/server/port,tls 默认 false。 # username/password 均非空才认证;headers 为字符串映射;仅 TCP,不支持 udp: true。 # tls: true 时支持 sni/skip-cert-verify/name-cert-verify/fingerprint, @@ -369,12 +371,12 @@ proxies: # jls-opts: {} # 清除继承的 JLS;非空对象必须提供 username/password smux: enabled: false - protocol: h2mux # h2mux/smux/yamux - max-connections: 1 # 0–i32::MAX;正值与正 max-streams 互斥 + protocol: h2mux # 缺省/空为 h2mux,另支持 smux/yamux + max-connections: 1 # 带符号平台整数;负阈值禁用;正值优先于 max-streams min-streams: 0 max-streams: 0 padding: false - only-tcp: false # true 保留 packet-encoding;false 使用 sing-mux UDP + only-tcp: false # true 委托底层 UDP;false 提供 sing-mux UDP dialer-proxy: upstream-select # REALITY 写法示意:在 VLESS 节点中保留 tls: true,并增加以下 mapping;不要与 diff --git a/docs/outbounds.md b/docs/outbounds.md index 7db9cae..321a497 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -15,7 +15,13 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 - gRPC/legacy H2 首次读取延迟响应头时先检查原建链期限,即使响应已就绪也不能绕过 超时;响应头已确认后的业务读取不再受该期限限制。 - HTTP、SOCKS5、TUN、DNS 选路及 node-only `measureDelay` 使用同一连接器。 - UDP 默认为 false;收发预算分别逐层扣封装开销,超限不截断交付。 + 底层业务 UDP 默认为 false;收发预算分别逐层扣封装开销,超限不截断交付。 +- 所有普通节点都可启用公共 `smux` 包装,使用节点自己的正常 TCP 传输与独立 mux 池, + 业务路由、`dialer-proxy`、provider 代际与测速都使用最终包装器。 + `only-tcp: false` 提供 mux UDP,包括底层 `udp: false` 或仅支持 TCP 的节点; + true 则原样委托底层 UDP,继承其能力。组 `disable-udp` 仍生效。 + 对端必须解码 sing-mux;普通 HTTP/SOCKS5 listener 不因此自动提供解码。 + 字段、半关闭及池边界见 [通用 sing-mux](xhttp.md#通用-sing-mux)。 - TLS 使用共享 WebPKI、叶证书 pin/非叶信任锚与 skip 策略。pin 不匹配不能被 skip 绕过, TLS 握手签名始终验证。节点策略与恢复缓存不可变、互相隔离,标准 TLS 总票据预算为 4。 字段及模板详见 [TLS 证书与指纹](tls-client-fingerprint.md)。 @@ -27,7 +33,8 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 `type: http` 提供 TCP CONNECT;`tls: true` 在代理链路使用共享 TLS 与证书策略。 支持 `username/password`、`headers`、`sni`、`skip-cert-verify`、`name-cert-verify`、 `fingerprint` 及成对的内联 `certificate/private-key`。用户名和密码均非空时发送 Basic 认证。 -HTTP CONNECT 不提供 UDP,也不接受 `udp` 字段。握手沿用原期限,失败关闭, +HTTP CONNECT 本身不提供 UDP,也不接受 `udp` 字段。公共 smux 可承载 UDP。 +握手沿用原期限,失败关闭, 成功后保留响应头后的业务字节。启用此协议需要 `outbound-http` feature。 ## SOCKS5 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 2b86913..0a8e8e4 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -88,6 +88,13 @@ CPU 数判断、业务数量配额、FD 预热或 DNS socket 复用。 XUDP现在只拥有已认证流上的帧编码;VLESS响应头由VLESS包装层处理。元数据仍最多512字节,单payload仍受调用方预算和u16 wire上限约束,不新增全局会话额度。 +公共 sing-mux 包装器按节点拥有独立池,只消费底层协议到约定目标的 TCP IO。 +每池最多保留16条闲连接;命令/物理写队列16项、写块16 KiB,smux每流接收队列4项。 +yamux单连接累计接纳64流后退役,连接接收窗口16 MiB;活动兄弟流继续。 +h2mux/yamux上传半关闭保留读取与lease,smux v1关闭整条逻辑流。 +图只登记最终包装器;begin_shutdown同时关闭mux和底层准入,shutdown先join全部 +mux驱动,再等待底层协议。provider代际与node-only测速复用此屏障,没有全局池。 + ## 协议局部预算 协议专用上限与关闭语义只维护在[入站](inbounds.md)、[出站](outbounds.md)、 diff --git a/docs/vless.md b/docs/vless.md index e91677d..dd77da3 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -14,7 +14,7 @@ - gRPC 与 Trojan/VMess 共用配置:省略或空 `grpc-service-name` 默认 `GunService`,普通名映射 `/name/Tun`,`/` 开头使用完整路径;省略或空 `grpc-user-agent` 默认 `grpc-go/1.36.0`。`ping-interval` 为带符号秒数,0 不主动 PING,负值明确拒绝;三项池阈值接受带符号整数,负阈值禁用相应分支。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项字面全零归一 max-connections=1;正 max-connections 优先于 max-streams。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 - gRPC 不新增业务流/建链数量额度;每个物理连接一个自有驱动,每个等待调用只保留自身 future、取消与原期限,不派生队列任务。每节点最多保留 4 条空闲连接,多余空闲连接退役且不影响活动流。空闲 PING 等待 ACK 最多 15 秒,节点 Stop 取消并 join 所有驱动。 - `network: xhttp` 支持 H1/H2 明文、TLS1.3 或 REALITY,H3 仅标准 TLS1.3。请求字段、独立下载腿、池与版本选择见 [XHTTP](xhttp.md)。H3 的物理 UDP 仍由原 Dialer/上游图创建。 -- `smux` 以独立的 h2mux/smux v1/yamux 承载逻辑 TCP/UDP;不是 XUDP。only-tcp、调度及关闭边界见 [XHTTP 与 sing-mux](xhttp.md)。 +- 公共节点 `smux` 以独立的 h2mux/smux v1/yamux 承载逻辑 TCP/UDP;VLESS 只提供正常 TCP 到约定目标,不拥有第二个 mux 池。它不是 XUDP;only-tcp、调度及关闭边界见 [通用 sing-mux](xhttp.md#通用-sing-mux)。 - Vision 仅允许 `network: tcp`,要求 TLS 1.3/REALITY 或 Encryption;UDP 仅 XUDP。启用 Encryption 后外层 TLS 可省略,存在时仍保持独立认证。配置阶段拒绝 WS/gRPC/HTTP/H2/XHTTP、raw/packetaddr、sing-mux 与 Vision 的组合,包括未启用业务 UDP 但显式选错编码。ECH 和附加安全封装仍拒绝。 - Vision 请求不自动降级;对端用户不支持 Vision 时业务失败。Mihomo 服务端允许为 Vision 用户显式配置空 flow,这属于普通 VLESS 模式,不是错误 flow 的拒绝用例。 diff --git a/docs/xhttp.md b/docs/xhttp.md index 92e8039..86f9f1e 100644 --- a/docs/xhttp.md +++ b/docs/xhttp.md @@ -1,4 +1,4 @@ -# XHTTP 与 VLESS sing-mux +# XHTTP 与通用 sing-mux XHTTP/sing-mux 使用严格字段与类型;未知、null、无效或被忽略的组合在 IO 前拒绝。 REALITY、JLS、静态 ECH 的身份规则见 [VLESS](vless.md),实际验证边界见[验收说明](acceptance.md)。 @@ -75,21 +75,23 @@ packet-up 在发送间隔内聚合小块写入,而不是把每个 write 变成 前五项接受 0–i32::MAX 的字符串区间。h-max-request-times 和 h-max-reusable-secs 只有最大值为零才禁用;例如 `"0-1"` 采样到零仍按零预算或立即到期处理。c-max-reuse-times 则与 Mihomo 一样,采样值为零即不启用该项限制。两腿池与计数独立。已建物理连接保持原上游选择;创建新连接时才使用当前选择。H1 仅复用完整结束的上传请求连接,不复用取消的下载 GET。节点空闲缓存有界;它不构成业务流数量额度。 -## VLESS sing-mux +## 通用 sing-mux -节点级 `smux` 接受 enabled、protocol、max-connections、min-streams、max-streams、padding、only-tcp。enabled 默认 false;protocol 默认 h2mux,也支持 smux v1/yamux。计数是 0–i32::MAX 整数;正 max-connections 与正 max-streams 互斥。padding、only-tcp 默认 false。Vision 与启用的 sing-mux 互斥。 +所有已启用的普通节点协议都接受 `smux`,字段为 enabled、protocol、max-connections、min-streams、max-streams、padding、only-tcp。enabled 默认 false;protocol 缺省/空为 h2mux,也支持 smux v1/yamux。计数为带符号平台整数;负阈值禁用,两个正 max 字段同时配置时采用 max-connections 分支。padding、only-tcp 默认 false。Vision 与启用的 sing-mux 互斥。精简构建可独立启用 `sing-mux`,不要求 VLESS;未启用此 feature 时拒绝 enabled=true。 -空闲物理会话优先复用;max-connections>0 分支按连接数量与 min-streams 扩容,否则按 max-streams 阈值复用。max-connections=max-streams=0 时 min-streams 归一为 8,但不会将其解释为所有调度分支的硬额度。单流关闭不关闭兄弟流,节点 Stop 才取消和等待所有自有驱动。 +空闲物理会话优先复用;max-connections>0 分支按连接数量与 min-streams 扩容,否则按 max-streams 阈值复用。两个 max 字段字面值均为零时 min-streams 归一为 8;归一在负阈值禁用之前执行,不把该值解释为所有调度分支的硬额度。每节点和资源代际的池独立,单流关闭不关闭兄弟流,节点 Stop 才取消和等待所有自有驱动。 每条逻辑流的状态字节沿用该次建链的绝对期限;读取前先检查超时,状态已缓冲也不例外。 物理连接复用不重置期限;状态已确认后的业务读取不受原建链期限限制。 -smux 单流 shutdown 先等待已接受帧写入并 flush,再安排 FIN;h2mux 同样等待数据 -到达上游 IO 的 flush 边界再 reset。节点 Stop 或 Drop 可取消待写,不承诺异常终止交付。 +smux v1 单流 shutdown 先等待已接受帧写入并 flush,再安排 FIN 并结束整条逻辑流。 +h2mux 和 yamux 关闭上传后保留读取与租约,允许对端继续发送尾部;h2mux 的上传 +数据须到达上游 IO 的 flush 边界再发送 END_STREAM。节点 Stop 或 Drop 可取消待写, +不承诺异常终止交付。 yamux 的内部资源边界是单物理连接累计分配 64 个流后退役;旧流继续,新请求另建连接,空闲即回收。该边界避免逻辑取消与库内 reset 回收不同步时误关兄弟流,不是全节点额度,详见 [资源策略](runtime-resource-policy.md)。 -sing-mux 的外层是普通 VLESS TCP 到约定目标,**不是 VLESS CommandMux/XUDP**。only-tcp=false 时 UDP 使用 mux 的逐包地址格式,经受控解析处理域名;true 时保留所选 raw/XUDP/packetaddr 编码。`udp: false` 始终禁止业务 UDP。padding 覆盖握手及最初 16 个物理写入帧;接收增量解析和丢弃 padding,不按声明长度无限分配。 +sing-mux 的外层是底层协议的普通 TCP 到 `sp.mux.sing-box.arpa:444`,VLESS 使用 CommandTCP。对端必须提供 sing-mux 解码;普通 HTTP/SOCKS5 服务器不会自动解码,接受包装配置不等于默认服务器互通。only-tcp=false 时 UDP 使用 mux 的逐包地址格式,经受控解析处理域名,并提供有效 UDP 能力,即使底层 `udp: false`;true 时原样委托底层 UDP,保留其能力、编码、会话和预算。组 disable-udp 仍限制业务。padding 覆盖握手及最初 16 个物理写入帧;接收增量解析和丢弃 padding,不按声明长度无限分配。 ## 关闭、安全与验收边界 diff --git a/src/config/mod.rs b/src/config/mod.rs index 0c82d0a..75f2d2a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,10 +2,12 @@ mod fake_ip; mod fallback; mod grpc; +mod sing_mux; mod sniffer; pub use fake_ip::{FakeIpConfig, FakeIpFilterMode, FakeIpRange}; pub use fallback::DnsFallbackConfig; pub use grpc::GrpcOptions; +pub use sing_mux::{SingMuxConfig, SingMuxProtocol}; mod http_proxy; mod ip_version; pub use http_proxy::HttpOutboundConfig; @@ -56,7 +58,7 @@ pub use shadowsocks::{ShadowTlsConfig, ShadowsocksCipher, ShadowsocksOutboundCon mod trojan; pub use trojan::{TrojanOutboundConfig, TrojanTransport, WebSocketHandshake}; mod vless; -pub use vless::{SingMuxConfig, SingMuxProtocol, VlessStreamOptions}; +pub use vless::VlessStreamOptions; pub(crate) mod xhttp; pub use xhttp::{XHttpHeaders, XHttpRequestOptions, XHttpReuseConfig, XHttpVersion}; mod vmess; @@ -639,6 +641,7 @@ pub enum Network { #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProxyConfig { pub ip_version: IpVersion, + pub smux: Option, pub tag: String, pub dialer_proxy: Option, pub udp: bool, @@ -1449,6 +1452,7 @@ where #[derive(Debug)] struct RawOutbound { ip_version: IpVersion, + smux: Option, protocol: RawProtocol, } impl<'de> Deserialize<'de> for RawOutbound { @@ -1464,9 +1468,22 @@ impl<'de> Deserialize<'de> for RawOutbound { .transpose() .map_err(serde::de::Error::custom)? .unwrap_or_default(); + let smux = value + .as_mapping_mut() + .expect("mapping checked") + .remove("smux") + .map(|value| { + if !value.is_mapping() { + return Err("smux requires a mapping".to_owned()); + } + serde_yaml_ng::from_value(value).map_err(|error| error.to_string()) + }) + .transpose() + .map_err(serde::de::Error::custom)?; let protocol = serde_yaml_ng::from_value(value).map_err(serde::de::Error::custom)?; Ok(Self { ip_version, + smux, protocol, }) } @@ -1880,6 +1897,7 @@ struct RawRealitySettings { #[derive(Debug)] struct PendingProxyConfig { ip_version: IpVersion, + smux: Option, tag: String, dialer_proxy: Option, udp: bool, @@ -2476,6 +2494,7 @@ fn normalize_proxy_graph_with_groups( .zip(dialer_proxies) .map(|(proxy, dialer_proxy)| ProxyConfig { ip_version: proxy.ip_version, + smux: proxy.smux, tag: proxy.tag, dialer_proxy, udp: proxy.udp, @@ -2768,12 +2787,24 @@ impl RawOutbound { (name, dialer_proxy, udp, ProxyProtocol::AnyTls(config)) } }; + let smux = self + .smux + .map(sing_mux::RawSingMux::normalize) + .transpose()? + .flatten(); + if smux.is_some() + && matches!(&protocol, ProxyProtocol::Vless(node) if node.flow == "xtls-rprx-vision") + { + return invalid("VLESS Vision cannot use sing-mux"); + } + let udp = udp || smux.as_ref().is_some_and(|options| !options.only_tcp); validate_route_target_definition_name(&tag, "proxy name")?; if let Some(dialer_proxy) = &dialer_proxy { validate_route_target_name(dialer_proxy, "dialer-proxy")?; } Ok(PendingProxyConfig { ip_version: self.ip_version, + smux, tag, dialer_proxy, udp, diff --git a/src/config/sing_mux.rs b/src/config/sing_mux.rs new file mode 100644 index 0000000..7c6f9ee --- /dev/null +++ b/src/config/sing_mux.rs @@ -0,0 +1,118 @@ +//! Shared node wrapper options; protocol codecs remain independent. +use super::invalid; +use crate::Result; +use serde::Deserialize; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SingMuxProtocol { + #[default] + #[serde(alias = "")] + H2Mux, + Smux, + Yamux, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct SingMuxConfig { + pub enabled: bool, + pub protocol: SingMuxProtocol, + pub max_connections: usize, + pub min_streams: usize, + pub max_streams: usize, + pub padding: bool, + pub only_tcp: bool, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub(super) struct RawSingMux { + #[serde(default)] + enabled: bool, + #[serde(default)] + protocol: SingMuxProtocol, + #[serde(default)] + max_connections: isize, + #[serde(default)] + min_streams: isize, + #[serde(default)] + max_streams: isize, + #[serde(default)] + padding: bool, + #[serde(default)] + only_tcp: bool, +} +impl RawSingMux { + pub(super) fn normalize(self) -> Result> { + if !self.enabled { + return Ok(None); + } + if !cfg!(feature = "sing-mux") { + return invalid("sing-mux support is not enabled in this build"); + } + // Apply the reference default to literal input, before collapsing + // negative counts into disabled threshold conditions. + let min_streams = if self.max_connections == 0 && self.max_streams == 0 { + 8 + } else { + self.min_streams.max(0) as usize + }; + Ok(Some(SingMuxConfig { + enabled: true, + protocol: self.protocol, + max_connections: self.max_connections.max(0) as usize, + min_streams, + max_streams: self.max_streams.max(0) as usize, + padding: self.padding, + only_tcp: self.only_tcp, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(feature = "sing-mux")] + #[test] + fn normalizes_literal_defaults_before_signed_thresholds() { + for (yaml, expected) in [ + ("enabled: true\nmin-streams: 42", (0, 8, 0)), + ( + "enabled: true\nmax-connections: -1\nmin-streams: -2", + (0, 0, 0), + ), + ( + "enabled: true\nmax-connections: 2\nmin-streams: 3\nmax-streams: 4", + (2, 3, 4), + ), + ] { + let raw: RawSingMux = serde_yaml_ng::from_str(yaml).unwrap(); + let options = raw.normalize().unwrap().unwrap(); + assert_eq!( + ( + options.max_connections, + options.min_streams, + options.max_streams + ), + expected + ); + } + } + + #[cfg(not(feature = "sing-mux"))] + #[test] + fn disabled_build_rejects_enabled_wrapper_but_allows_disabled_options() { + let enabled: RawSingMux = serde_yaml_ng::from_str("enabled: true").unwrap(); + assert!( + enabled + .normalize() + .unwrap_err() + .to_string() + .contains("not enabled") + ); + let disabled: RawSingMux = + serde_yaml_ng::from_str("enabled: false\nprotocol: yamux").unwrap(); + assert!(disabled.normalize().unwrap().is_none()); + } +} diff --git a/src/config/vless.rs b/src/config/vless.rs index dc93090..2ba6d27 100644 --- a/src/config/vless.rs +++ b/src/config/vless.rs @@ -8,34 +8,6 @@ use super::*; pub struct VlessStreamOptions { pub http_upgrade: bool, pub fast_open: bool, - pub sing_mux: Option, -} -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SingMuxProtocol { - #[default] - H2Mux, - Smux, - Yamux, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "kebab-case")] -pub struct SingMuxConfig { - #[serde(default)] - pub enabled: bool, - #[serde(default)] - pub protocol: SingMuxProtocol, - #[serde(default)] - pub max_connections: u32, - #[serde(default)] - pub min_streams: u32, - #[serde(default)] - pub max_streams: u32, - #[serde(default)] - pub padding: bool, - #[serde(default)] - pub only_tcp: bool, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -111,9 +83,6 @@ pub(super) struct RawVless { )] xhttp_opts: Option, - #[serde(default, deserialize_with = "deserialize_present_map")] - smux: Option, - #[serde( rename = "ws-opts", default, @@ -205,20 +174,6 @@ impl RawVless { return invalid("VLESS Vision requires TCP, TLS or Encryption, and XUDP"); } let mut stream_options = VlessStreamOptions::default(); - if let Some(mut mux) = self.smux { - if (mux.max_connections > 0 && mux.max_streams > 0) - || [mux.max_connections, mux.min_streams, mux.max_streams] - .iter() - .any(|value| *value > i32::MAX as u32) - || (mux.enabled && vision) - { - return invalid("invalid VLESS sing-mux options"); - } - if mux.max_connections == 0 && mux.max_streams == 0 { - mux.min_streams = 8; - } - stream_options.sing_mux = mux.enabled.then_some(mux); - } let ws = self .ws .map(|ws| { diff --git a/src/outbound/mod.rs b/src/outbound/mod.rs index ac01d4b..8a5cf2c 100644 --- a/src/outbound/mod.rs +++ b/src/outbound/mod.rs @@ -11,6 +11,10 @@ pub mod vmess; #[cfg(feature = "outbound-anytls")] mod anytls; mod connector; +#[cfg(feature = "sing-mux")] +mod sing_mux; +#[cfg(feature = "sing-mux")] +pub use sing_mux::SingMuxConnector; #[cfg(feature = "outbound-shadowsocks")] mod shadowsocks; #[cfg(any(feature = "outbound-anytls", feature = "outbound-shadowsocks"))] diff --git a/src/outbound/sing_mux.rs b/src/outbound/sing_mux.rs new file mode 100644 index 0000000..9bccb7d --- /dev/null +++ b/src/outbound/sing_mux.rs @@ -0,0 +1,294 @@ +//! Node-owned sing-mux adapter over any normal protocol connector. +use super::{ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector}; +use crate::{ + config::SingMuxConfig, + dispatch::{BoxStream, DatagramTransport, DispatchError}, + session::{Destination, StreamSession}, + transport::sing_mux::{Pool, datagram::DatagramIo}, +}; +use async_trait::async_trait; +use std::{io, sync::Arc}; +use tokio_util::sync::CancellationToken; + +pub struct SingMuxConnector { + inner: Arc, + pool: Pool, + cancellation: CancellationToken, +} +impl std::fmt::Debug for SingMuxConnector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SingMuxConnector").finish_non_exhaustive() + } +} +impl SingMuxConnector { + /// Wrap an inner node with normalized, enabled smux options. + pub fn new(inner: Arc, options: SingMuxConfig) -> Self { + Self { + inner, + pool: Pool::new(options), + cancellation: CancellationToken::new(), + } + } + async fn connect( + &self, + mut session: StreamSession, + context: &EstablishContext, + ) -> io::Result { + session.destination = Destination::domain("sp.mux.sing-box.arpa", 444)?; + session.sniffed_domain = None; + self.inner + .connect_stream(session, context) + .await + .map(|connected| connected.io) + .map_err(io::Error::other) + } +} +#[async_trait] +impl OutboundConnector for SingMuxConnector { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + if tokio::time::Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + let effective_peer = session.destination.clone(); + let io = self + .pool + .open(&effective_peer, false, context.deadline(), || { + self.connect(session, context) + }) + .await?; + Ok(ConnectedStream { io, effective_peer }) + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + if tokio::time::Instant::now() >= context.deadline() { + return Err(DispatchError::TimedOut); + } + tokio::select! { biased; + ()=self.cancellation.cancelled()=>Err(DispatchError::NotAllowed), + result=async { + if self.pool.only_tcp() { return self.inner.open_datagram(request, context).await; } + let target = Destination::domain("sp.mux.sing-box.arpa", 444)?; + let session = StreamSession { metadata:request.session.metadata.clone(), inbound:request.session.inbound, source:request.session.source, destination:target.clone(), sniffed_domain:None }; + let io=self.pool.open(&target, true, context.deadline(), || self.connect(session, context)).await?; + Ok(crate::dispatch::bound_datagram(Box::new(DatagramIo::new(io,request.budget(),context.resolution(),context.deadline(),self.cancellation.clone())),request.budget())) + }=>result, + } + } + fn begin_shutdown(&self) { + self.cancellation.cancel(); + self.pool.begin_stop(); + self.inner.begin_shutdown(); + } + async fn shutdown(&self) { + self.begin_shutdown(); + self.pool.stop().await; + self.inner.shutdown().await; + } +} +impl Drop for SingMuxConnector { + fn drop(&mut self) { + self.begin_shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + dispatch::{DatagramBudget, DatagramTransport}, + resources::observation::{ResourceKind, ResourceProbe}, + session::{DatagramSession, InboundKind}, + }; + use std::sync::Mutex; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::{sync::Notify, time::Instant}; + + struct MuxPeer { + calls: Mutex>, + tasks: Mutex>>, + probe: ResourceProbe, + } + #[async_trait] + impl OutboundConnector for MuxPeer { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + self.calls + .lock() + .unwrap() + .push((session.clone(), context.deadline())); + let (raw, mut peer) = tokio::io::duplex(65536); + self.tasks.lock().unwrap().push(tokio::spawn(async move { + let mut handshake = [0; 2]; + peer.read_exact(&mut handshake).await.unwrap(); + assert_eq!(handshake, [0, 2]); + let mut connection = h2::server::handshake(peer).await.unwrap(); + let mut streams = Vec::new(); + while let Some(Ok((request, mut reply))) = connection.accept().await { + assert_eq!(request.method(), "CONNECT"); + let mut output = reply.send_response(http::Response::new(()), false).unwrap(); + output + .send_data(bytes::Bytes::from_static(b"\x00x"), false) + .unwrap(); + streams.push((request, output)); + } + })); + Ok(ConnectedStream { + io: Box::new(raw), + effective_peer: session.destination, + }) + } + async fn open_datagram( + &self, + _request: DatagramRequest, + _context: &EstablishContext, + ) -> Result, DispatchError> { + panic!("mux UDP must use inner TCP rather than native UDP"); + } + async fn shutdown(&self) { + // The wrapper must join its driver before releasing the supplied IO owner. + assert_eq!(self.probe.snapshot().current(ResourceKind::Task), 0); + let tasks = std::mem::take(&mut *self.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + } + } + + #[tokio::test] + async fn shared_wrapper_preserves_logical_peer_and_joins_mux_before_inner() { + let probe = ResourceProbe::default(); + probe + .scope(async { + let inner = Arc::new(MuxPeer { + calls: Mutex::default(), + tasks: Mutex::default(), + probe: probe.clone(), + }); + let node = SingMuxConnector::new( + inner.clone(), + SingMuxConfig { + enabled: true, + max_connections: 1, + ..Default::default() + }, + ); + let session = StreamSession { + metadata: crate::session::InboundMetadata { + local_port: 1080, + user: Some(Arc::from("alice")), + }, + inbound: InboundKind::Socks5, + source: "127.0.0.1:1080".parse().unwrap(), + destination: Destination::domain("business.invalid", 443).unwrap(), + sniffed_domain: Some("sniffed.invalid".to_owned()), + }; + let context = EstablishContext::default(); + let mut first = node + .connect_stream(session.clone(), &context) + .await + .unwrap(); + let mut sibling = node + .connect_stream(session.clone(), &context) + .await + .unwrap(); + assert_eq!(first.effective_peer, session.destination); + assert_eq!(first.io.read_u8().await.unwrap(), b'x'); + assert_eq!(sibling.io.read_u8().await.unwrap(), b'x'); + let calls = inner.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 1); + let physical = &calls[0].0; + assert_eq!(physical.destination.authority(), "sp.mux.sing-box.arpa:444"); + assert_eq!(physical.inbound, session.inbound); + assert_eq!(physical.source, session.source); + assert_eq!(physical.metadata, session.metadata); + assert!(physical.sniffed_domain.is_none()); + assert_eq!(calls[0].1, context.deadline()); + assert_eq!(probe.snapshot().current(ResourceKind::Pool), 1); + assert_eq!(probe.snapshot().current(ResourceKind::Task), 1); + first.io.shutdown().await.unwrap(); + node.shutdown().await; + drop((first, sibling, node)); + assert!(probe.snapshot().is_idle()); + }) + .await; + } + + #[derive(Default)] + struct PendingUdp { + request: Mutex>, + entered: Notify, + stopped: Mutex>, + } + #[async_trait] + impl OutboundConnector for PendingUdp { + async fn connect_stream( + &self, + _session: StreamSession, + _context: &EstablishContext, + ) -> Result { + panic!("only-tcp must not establish a mux TCP stream for UDP"); + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + *self.request.lock().unwrap() = Some((request, context.deadline())); + self.entered.notify_one(); + std::future::pending().await + } + fn begin_shutdown(&self) { + self.stopped.lock().unwrap().push("begin"); + } + async fn shutdown(&self) { + self.stopped.lock().unwrap().push("joined"); + } + } + + #[tokio::test] + async fn only_tcp_delegates_original_udp_metadata_budget_and_cancels_pending_setup() { + let inner = Arc::new(PendingUdp::default()); + let node = SingMuxConnector::new( + inner.clone(), + SingMuxConfig { + enabled: true, + only_tcp: true, + ..Default::default() + }, + ); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::Socks5, + "127.0.0.1:10234".parse().unwrap(), + )) + .with_budget(DatagramBudget::new(913, 1714)); + let context = EstablishContext::default(); + let open = node.open_datagram(request.clone(), &context); + tokio::pin!(open); + tokio::select! { + _ = inner.entered.notified() => {}, + _ = &mut open => panic!("inner setup should remain pending"), + } + let recorded = inner.request.lock().unwrap().clone().unwrap(); + assert_eq!(recorded.0.session, request.session); + assert_eq!(recorded.0.budget(), request.budget()); + assert_eq!(recorded.1, context.deadline()); + node.begin_shutdown(); + assert!(matches!(open.await, Err(DispatchError::NotAllowed))); + node.shutdown().await; + assert_eq!(*inner.stopped.lock().unwrap(), ["begin", "begin", "joined"]); + assert!(matches!( + node.open_datagram(request, &context).await, + Err(DispatchError::NotAllowed) + )); + } +} diff --git a/src/outbound/vless/outbound.rs b/src/outbound/vless/outbound.rs index 1f0c0cf..2266b36 100644 --- a/src/outbound/vless/outbound.rs +++ b/src/outbound/vless/outbound.rs @@ -97,7 +97,6 @@ pub struct VlessOutbound { cancellation: CancellationToken, tasks: TaskTracker, grpc_pool: Option, - sing_mux: Option, vision: bool, vision_stats: Arc, } @@ -502,11 +501,6 @@ impl VlessOutbound { _ => None, }, transport, - sing_mux: config - .stream_options - .sing_mux - .clone() - .map(crate::transport::sing_mux::Pool::new), vision: config.flow == "xtls-rprx-vision", vision_stats: Arc::default(), }) @@ -813,22 +807,6 @@ impl OutboundConnector for VlessOutbound { context: &EstablishContext, ) -> Result { let effective_peer = session.destination.clone(); - if let Some(mux) = &self.sing_mux { - let io = mux - .open(&effective_peer, false, context.deadline(), || async { - let target = crate::session::Destination::domain("sp.mux.sing-box.arpa", 444)?; - let prepared = self - .prepare(session, context) - .await - .map_err(io::Error::other)?; - prepared - .finish(VlessCommand::Tcp, Some(&target)) - .await - .map_err(io::Error::other) - }) - .await?; - return Ok(ConnectedStream { io, effective_peer }); - } let prepared = self.prepare(session, context).await?; let io = prepared .finish(VlessCommand::Tcp, Some(&effective_peer)) @@ -840,40 +818,6 @@ impl OutboundConnector for VlessOutbound { request: DatagramRequest, context: &EstablishContext, ) -> Result, DispatchError> { - if let Some(mux) = &self.sing_mux - && !mux.only_tcp() - { - let target = crate::session::Destination::domain("sp.mux.sing-box.arpa", 444)?; - let session = StreamSession { - metadata: request.session.metadata.clone(), - inbound: request.session.inbound, - source: request.session.source, - destination: target.clone(), - sniffed_domain: None, - }; - let io = mux - .open(&target, true, context.deadline(), || async { - let prepared = self - .prepare(session, context) - .await - .map_err(io::Error::other)?; - prepared - .finish(VlessCommand::Tcp, Some(&target)) - .await - .map_err(io::Error::other) - }) - .await?; - return Ok(crate::dispatch::bound_datagram( - Box::new(crate::transport::sing_mux::datagram::DatagramIo::new( - io, - request.budget(), - context.resolution(), - context.deadline(), - self.cancellation.clone(), - )), - request.budget(), - )); - } let prepared = self .prepare( StreamSession { @@ -908,9 +852,6 @@ impl OutboundConnector for VlessOutbound { if let Some(pool) = &self.grpc_pool { pool.begin_shutdown(); } - if let Some(mux) = &self.sing_mux { - mux.begin_stop(); - } if let Some(xhttp) = &self.xhttp { xhttp.begin_stop(); } @@ -925,9 +866,6 @@ impl OutboundConnector for VlessOutbound { if let Some(pool) = &self.grpc_pool { pool.shutdown().await; } - if let Some(mux) = &self.sing_mux { - mux.stop().await; - } if let Some(xhttp) = &self.xhttp { xhttp.stop().await; } diff --git a/src/runtime.rs b/src/runtime.rs index 4982d45..adc5d2b 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1222,6 +1222,15 @@ fn build_proxy_graph( } }; + #[cfg(feature = "sing-mux")] + let connector: Arc = if let Some(options) = &proxy.smux { + Arc::new(crate::outbound::SingMuxConnector::new( + connector, + options.clone(), + )) + } else { + connector + }; let connector: Arc = Arc::new(crate::outbound::IpVersionConnector { inner: connector, version: proxy.ip_version, @@ -2106,6 +2115,72 @@ proxy-groups: parts.proxy_graph.shutdown().await; } + #[cfg(all( + feature = "sing-mux", + feature = "outbound-http", + feature = "outbound-socks5" + ))] + #[tokio::test] + async fn prepares_shared_mux_capabilities_and_registers_only_final_graph_nodes() { + let prepared = PreparedCore::prepare( + br#" +mixed-port: 1080 +proxies: + - {name: mux-http, type: http, server: 192.0.2.1, port: 1080, smux: {enabled: true}} + - {name: native-udp, type: socks5, server: 192.0.2.2, port: 1080, udp: false, smux: {enabled: true, only-tcp: true}} +proxy-groups: + - {name: selected, type: select, proxies: [mux-http, native-udp]} + - {name: disabled, type: select, proxies: [mux-http], disable-udp: true} +"#, + &FixedResolver, + ResourceLimits::default(), + ).await.unwrap(); + let parts = prepared.build_dispatcher(Dialer::default()).unwrap(); + for (index, expected) in [true, false].into_iter().enumerate() { + assert_eq!( + parts + .resource_targets + .get(ProxyId::new(index).unwrap()) + .unwrap() + .supports_udp(), + expected + ); + let node = &parts.proxy_graph.nodes[index]; + assert_eq!(parts.proxy_graph.lifecycle_order.iter().filter(|target| { + matches!(target, BuiltRouteTarget::Proxy(registered) if Arc::ptr_eq(node, registered)) + }).count(), 1); + } + assert_eq!( + parts + .proxy_graph + .lifecycle_order + .iter() + .filter(|target| matches!(target, BuiltRouteTarget::Proxy(_))) + .count(), + 2 + ); + for (index, expected) in [true, false].into_iter().enumerate() { + assert_eq!( + parts + .resource_targets + .get_route(RouteTargetId::Group(ProxyGroupId::new(index).unwrap())) + .unwrap() + .supports_udp(), + expected + ); + } + parts.proxy_groups.select("selected", "native-udp").unwrap(); + assert!( + !parts + .resource_targets + .get_route(RouteTargetId::Group(ProxyGroupId::new(0).unwrap())) + .unwrap() + .supports_udp() + ); + parts.proxy_graph.begin_shutdown(); + parts.proxy_graph.shutdown().await; + } + #[tokio::test] async fn prepares_builtin_routes_without_static_nodes_or_bootstrap() { let resolver = RecordingResolver::default(); @@ -2539,6 +2614,7 @@ rules: fn socks5(tag: &str) -> ProxyConfig { ProxyConfig { + smux: None, ip_version: crate::config::IpVersion::Dual, tag: tag.to_owned(), dialer_proxy: None, @@ -2555,6 +2631,7 @@ rules: fn anytls(tag: &str) -> ProxyConfig { ProxyConfig { + smux: None, ip_version: crate::config::IpVersion::Dual, tag: tag.to_owned(), dialer_proxy: None, diff --git a/src/transport/grpc.rs b/src/transport/grpc.rs index c651db2..c164213 100644 --- a/src/transport/grpc.rs +++ b/src/transport/grpc.rs @@ -265,7 +265,7 @@ impl Drop for Grpc { } /// sing-mux uses plain HTTP/2 CONNECT streams, not gRPC records. -#[cfg(feature = "outbound-vless")] +#[cfg(feature = "sing-mux")] pub(super) async fn mux_stream(sender: Sender, deadline: Instant) -> io::Result { let writes = sender.writes; let mut sender = sender.request.ready().await.map_err(|_| invalid())?; @@ -279,7 +279,7 @@ pub(super) async fn mux_stream(sender: Sender, deadline: Instant) -> io::Result< response, send, Framing::Plain, - false, + true, deadline, Arc::default(), None, diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 703ae48..262a22a 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -1,6 +1,6 @@ //! Outbound wire transports. -#[cfg(feature = "outbound-vless")] +#[cfg(feature = "sing-mux")] pub(crate) mod sing_mux; #[cfg(feature = "outbound-vless")] pub mod xhttp; diff --git a/src/transport/sing_mux.rs b/src/transport/sing_mux.rs index fa9ae2b..51788fa 100644 --- a/src/transport/sing_mux.rs +++ b/src/transport/sing_mux.rs @@ -1,4 +1,4 @@ -//! Node-owned sing-mux sessions over supplied authenticated VLESS byte streams. +//! Node-owned sing-mux sessions over supplied authenticated protocol streams. //! No sockets, resolver, global pool, or detached protocol tasks live here. use crate::{ config::{SingMuxConfig, SingMuxProtocol}, @@ -104,11 +104,11 @@ impl Pool { let active = c.active.load(Ordering::Acquire); active == 0 || if self.options.max_connections > 0 { - state.connections.len() >= self.options.max_connections as usize - || active < self.options.min_streams as usize + state.connections.len() >= self.options.max_connections + || active < self.options.min_streams } else { self.options.max_streams > 0 - && active < self.options.max_streams as usize + && active < self.options.max_streams } }) .map(|c| c.reserve(&self.state)) @@ -159,6 +159,8 @@ impl Pool { raw: Some(raw), lease: Some(lease), response: false, + half_close: self.options.protocol != SingMuxProtocol::Smux, + upload_closed: false, deadline: Box::pin(tokio::time::sleep_until(deadline)), }) as BoxStream) }; @@ -271,6 +273,8 @@ struct ResponseStream { raw: Option, lease: Option, response: bool, + half_close: bool, + upload_closed: bool, deadline: Pin>, } impl AsyncRead for ResponseStream { @@ -310,17 +314,26 @@ impl AsyncWrite for ResponseStream { cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { + if self.upload_closed { + return Poll::Ready(Err(io::ErrorKind::BrokenPipe.into())); + } Pin::new(self.raw.as_mut().ok_or(io::ErrorKind::BrokenPipe)?).poll_write(cx, buf) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { Pin::new(self.raw.as_mut().ok_or(io::ErrorKind::BrokenPipe)?).poll_flush(cx) } fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.upload_closed { + return Poll::Ready(Ok(())); + } if let Some(raw) = &mut self.raw { ready!(Pin::new(raw).poll_shutdown(cx))?; } - self.raw.take(); - self.lease.take(); + self.upload_closed = true; + if !self.half_close { + self.raw.take(); + self.lease.take(); + } Poll::Ready(Ok(())) } } @@ -330,6 +343,44 @@ mod tests { use super::*; use tokio::io::AsyncReadExt; + #[tokio::test] + async fn half_close_keeps_the_read_side_and_lease_until_drop() { + for half_close in [false, true] { + let (raw, mut peer) = tokio::io::duplex(16); + let active = Arc::new(AtomicUsize::new(1)); + let mut stream = ResponseStream { + raw: Some(Box::new(raw)), + lease: Some(Lease { + active: active.clone(), + pool: Weak::new(), + }), + response: true, + half_close, + upload_closed: false, + deadline: Box::pin(tokio::time::sleep(std::time::Duration::from_secs(1))), + }; + stream.write_all(b"upload").await.unwrap(); + stream.shutdown().await.unwrap(); + stream.shutdown().await.unwrap(); + let mut uploaded = Vec::new(); + peer.read_to_end(&mut uploaded).await.unwrap(); + assert_eq!(uploaded, b"upload"); + assert_eq!(active.load(Ordering::Acquire), usize::from(half_close)); + if half_close { + peer.write_all(b"tail").await.unwrap(); + peer.shutdown().await.unwrap(); + let mut tail = Vec::new(); + stream.read_to_end(&mut tail).await.unwrap(); + assert_eq!(tail, b"tail"); + assert_eq!(active.load(Ordering::Acquire), 1); + } else { + assert_eq!(stream.read(&mut [0; 4]).await.unwrap(), 0); + } + drop(stream); + assert_eq!(active.load(Ordering::Acquire), 0); + } + } + #[tokio::test(start_paused = true)] async fn lazy_status_obeys_setup_deadline_but_established_reads_do_not() { for timing in ["late-first-poll", "late-after-pending", "established"] { @@ -338,6 +389,8 @@ mod tests { raw: Some(Box::new(raw)), lease: None, response: false, + half_close: true, + upload_closed: false, deadline: Box::pin(tokio::time::sleep(std::time::Duration::from_secs(1))), }; if timing == "late-after-pending" { diff --git a/tests/README.md b/tests/README.md index 6f912e8..f92cadd 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,6 +15,9 @@ cargo test --locked --lib config::tests:: cargo test --locked --lib config::tls_policy_tests:: cargo test --locked --no-default-features --features cli --test grpc_config --test grpc_pool --test grpc_protocol_pool cargo test --locked --no-default-features --features cli --lib grpc_duplex_pool_keeps_response +cargo test --locked --no-default-features --features cli --test sing_mux_config --test sing_mux +cargo test --locked --no-default-features --features cli --lib sing_mux +cargo test --locked --no-default-features --features cli --lib prepares_shared_mux_capabilities cargo test --locked --lib explicit_verification_name_does_not_change_sni_or_allow_skip_to_override_it cargo test --locked --lib mutual_tls_identity_is_required_verified_and_not_shared_between_clients cargo test --locked --lib geodata::tests:: diff --git a/tests/sing_mux.rs b/tests/sing_mux.rs index 8f64a68..5c9cdc1 100644 --- a/tests/sing_mux.rs +++ b/tests/sing_mux.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "outbound-vless")] +#![cfg(all(feature = "outbound-vless", feature = "sing-mux"))] use async_trait::async_trait; use bytes::Bytes; use std::{ @@ -14,7 +14,8 @@ use vole::{ config::{Config, ProxyProtocol}, dispatch::{BoxStream, DatagramTransport, DispatchError}, outbound::{ - ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, VlessOutbound, + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, SingMuxConnector, + VlessOutbound, }, session::{Destination, InboundKind, StreamSession}, }; @@ -108,6 +109,8 @@ impl OutboundConnector for MemoryPeer { let mut scratch = [0; 1024]; while let Ok(n) = io.read(&mut scratch).await { if n == 0 { + let _ = io.write_all(b"tail").await; + let _ = io.shutdown().await; break; } io.write_all(&scratch[..n]).await.unwrap(); @@ -142,9 +145,20 @@ impl OutboundConnector for MemoryPeer { false, ) .unwrap(); - while let Some(Ok(data)) = input.data().await { - input.flow_control().release_capacity(data.len()).unwrap(); - output.send_data(data, false).unwrap(); + loop { + match input.data().await { + Some(Ok(data)) => { + input.flow_control().release_capacity(data.len()).unwrap(); + if output.send_data(data, false).is_err() { + break; + } + } + None => { + let _ = output.send_data(Bytes::from_static(b"tail"), true); + break; + } + Some(Err(_)) => break, + } } }); } @@ -165,18 +179,22 @@ impl OutboundConnector for MemoryPeer { Err(DispatchError::NotAllowed) } } -fn outbound(peer: Arc, options: &str) -> VlessOutbound { +fn outbound(peer: Arc, options: &str) -> SingMuxConnector { let raw = format!( "mixed-port: 1080\nproxies:\n- name: edge\n type: vless\n server: example.com\n port: 443\n uuid: 07070707-0707-0707-0707-070707070707\n smux: {{enabled: true, {options}}}\nrules: [MATCH,edge]\n" ); let raw = raw.replace("rules: [MATCH,edge]", "rules: ['MATCH,edge']"); let config = Config::parse_yaml(raw.as_bytes()).unwrap(); + let options = config.proxies[0].smux.clone().unwrap(); let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { unreachable!() }; - VlessOutbound::new_with_upstream(config, peer).unwrap() + SingMuxConnector::new( + Arc::new(VlessOutbound::new_with_upstream(config, peer).unwrap()), + options, + ) } -async fn open(node: &VlessOutbound) -> BoxStream { +async fn open(node: &SingMuxConnector) -> BoxStream { node.connect_stream( StreamSession { metadata: Default::default(), @@ -197,6 +215,56 @@ async fn hello(io: &mut BoxStream) { assert_eq!(&greeting, b"hello"); } #[tokio::test] +async fn shared_mux_preserves_h2mux_yamux_tail_and_smux_whole_close() { + tokio::time::timeout(Duration::from_secs(3), async { + for (protocol, wire) in [("h2mux", 2), ("yamux", 1), ("smux", 0)] { + let peer = Arc::new(MemoryPeer { + count: AtomicUsize::new(0), + tasks: Mutex::new(vec![]), + udp: false, + protocol: wire, + }); + let node = outbound( + peer.clone(), + &format!("protocol: {protocol}, max-connections: 1"), + ); + let mut first = open(&node).await; + let mut sibling = open(&node).await; + hello(&mut first).await; + hello(&mut sibling).await; + assert_eq!(peer.count.load(Ordering::SeqCst), 1); + first.write_all(b"upload").await.unwrap(); + first.shutdown().await.unwrap(); + first.shutdown().await.unwrap(); + assert!(first.write_all(b"closed").await.is_err()); + let mut tail = Vec::new(); + first.read_to_end(&mut tail).await.unwrap(); + assert_eq!( + tail, + if protocol == "smux" { + &b""[..] + } else { + &b"uploadtail"[..] + }, + "{protocol}" + ); + sibling.write_all(b"alive").await.unwrap(); + sibling.flush().await.unwrap(); + let mut response = [0; 5]; + sibling.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"alive"); + node.shutdown().await; + drop((first, sibling)); + let tasks = std::mem::take(&mut *peer.tasks.lock().unwrap()); + for task in tasks { + task.await.unwrap(); + } + } + }) + .await + .unwrap(); +} +#[tokio::test] async fn yamux_replacing_dropped_streams_at_capacity_keeps_the_sibling_alive() { #[cfg(feature = "interop-test")] let _case = vole::resources::case_events::Case::new( @@ -321,13 +389,18 @@ async fn h2mux_sends_idle_ping_and_retires_a_peer_that_never_acknowledges() { ); let raw = serde_json::json!({"mixed-port":1080,"proxies":[{"name":"edge","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707","smux":{"enabled":true,"max-connections":1}}],"rules":["MATCH,edge"]}); let config = Config::parse_yaml(raw.to_string().as_bytes()).unwrap(); + let options = config.proxies[0].smux.clone().unwrap(); let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { unreachable!() }; let (io, mut peer) = tokio::io::duplex(4096); - let node = - VlessOutbound::new_with_upstream(config, Arc::new(CapturePeer(Mutex::new(Some(io))))) - .unwrap(); + let node = SingMuxConnector::new( + Arc::new( + VlessOutbound::new_with_upstream(config, Arc::new(CapturePeer(Mutex::new(Some(io))))) + .unwrap(), + ), + options, + ); let (ping, mut received) = tokio::sync::oneshot::channel(); let peer = tokio::spawn(async move { let mut preface = [0; 45]; @@ -391,9 +464,10 @@ async fn only_tcp_preserves_all_three_vless_udp_wire_commands() { for (codec, command) in [("xudp", 3), ("none", 2), ("packetaddr", 2)] { let raw = serde_json::json!({"mixed-port":1080,"proxies":[{"name":"edge","type":"vless","server":"example.com","port":443,"uuid":"07070707-0707-0707-0707-070707070707","udp":true,"packet-encoding":codec,"smux":{"enabled":true,"protocol":protocol,"padding":true,"only-tcp":true}}],"rules":["MATCH,edge"]}); let config = Config::parse_yaml(raw.to_string().as_bytes()).unwrap(); - let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { unreachable!() }; + let options=config.proxies[0].smux.clone().unwrap(); + let ProxyProtocol::Vless(config) = &config.proxies[0].protocol else { unreachable!() }; let (io, mut peer) = tokio::io::duplex(4096); - let node = VlessOutbound::new_with_upstream(config, Arc::new(CapturePeer(Mutex::new(Some(io))))).unwrap(); + let node = SingMuxConnector::new(Arc::new(VlessOutbound::new_with_upstream(config, Arc::new(CapturePeer(Mutex::new(Some(io))))).unwrap()),options); let mut association = node.open_datagram( DatagramRequest::new(DatagramSession::new(InboundKind::InternalMeasure, "127.0.0.1:1".parse().unwrap())), &EstablishContext::default(), diff --git a/tests/sing_mux_config.rs b/tests/sing_mux_config.rs index 842c57d..58b0c68 100644 --- a/tests/sing_mux_config.rs +++ b/tests/sing_mux_config.rs @@ -1,4 +1,4 @@ -#![cfg(feature = "outbound-vless")] +#![cfg(all(feature = "outbound-vless", feature = "sing-mux"))] use serde_json::json; use vole::config::Config; @@ -23,7 +23,7 @@ fn sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options() { "XHTTP-UNIT", "sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options", ); - for protocol in ["h2mux", "smux", "yamux"] { + for protocol in ["", "h2mux", "smux", "yamux"] { for enabled in [false, true] { assert!(parse( json!({"enabled":enabled,"protocol":protocol,"padding":true, @@ -34,6 +34,10 @@ fn sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options() { } assert!(parse(json!({"enabled":true,"max-streams":3}), false)); assert!(parse(json!({}), false)); + assert!(parse( + json!({"enabled":true,"max-connections":2,"max-streams":3}), + false + )); assert!(!parse(json!([]), false)); for field in [ "enabled", @@ -49,24 +53,84 @@ fn sing_mux_accepts_three_protocols_and_rejects_invalid_or_ignored_options() { } } for field in ["max-connections", "min-streams", "max-streams"] { - for value in [0, 1, 2147483647_u64] { + for value in [-1, 0, 1, 2147483647_i64] { assert!(parse(json!({field:value}), false)); } - assert!(!parse(json!({field:2147483648_u64}), false)); } for options in [ json!(null), json!({"enabled":null}), json!({"protocol":"mux"}), - json!({"protocol":""}), json!({"padding":1}), json!({"only-tcp":null}), - json!({"enabled":true,"max-connections":2,"max-streams":3}), - json!({"max-streams":-1}), - json!({"min-streams":2147483648_u64}), json!({"enabled":true,"brutal":true}), ] { assert!(!parse(options, false)); } assert!(!parse(json!({"enabled":true}), true)); } + +#[cfg(all( + feature = "outbound-http", + feature = "outbound-socks5", + feature = "outbound-trojan", + feature = "outbound-vmess", + feature = "outbound-shadowsocks", + feature = "outbound-anytls", + feature = "outbound-hysteria2", + feature = "outbound-tuic" +))] +#[test] +fn shared_smux_wraps_all_protocols_and_computes_effective_udp_capability() { + for (protocol, fields) in [ + ("http", json!({})), + ("socks5", json!({"udp":false})), + ("trojan", json!({"password":"test","udp":false})), + ( + "vmess", + json!({"uuid":"07070707-0707-0707-0707-070707070707","udp":false}), + ), + ( + "vless", + json!({"uuid":"07070707-0707-0707-0707-070707070707","udp":false}), + ), + ("anytls", json!({"password":"test","udp":false})), + ( + "ss", + json!({"cipher":"2022-blake3-aes-128-gcm","password":"BwcHBwcHBwcHBwcHBwcHBw==","udp":false}), + ), + ("hysteria2", json!({"password":"test"})), + ( + "tuic", + json!({"uuid":"07070707-0707-0707-0707-070707070707","password":"test"}), + ), + ] { + let mut node = json!({"name":"edge","type":protocol,"server":"example.com","port":443}); + node.as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + let parse_node = |node: serde_json::Value| { + Config::parse_yaml( + json!({"mixed-port":1080,"proxies":[node],"rules":["MATCH,edge"]}) + .to_string() + .as_bytes(), + ) + .unwrap() + }; + let native_udp = parse_node(node.clone()).proxies[0].udp; + for only_tcp in [false, true] { + node["smux"] = json!({"enabled":true,"protocol":"","only-tcp":only_tcp,"max-connections":2,"min-streams":3,"max-streams":1}); + let parsed = parse_node(node.clone()); + assert_eq!(parsed.proxies[0].udp, !only_tcp || native_udp, "{protocol}"); + let options = parsed.proxies[0].smux.as_ref().unwrap(); + assert_eq!( + ( + options.max_connections, + options.min_streams, + options.max_streams + ), + (2, 3, 1) + ); + } + } +} From 910a208a488b4f8e82e088adf1e183825c0e5fbe Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 12:48:33 +0800 Subject: [PATCH 039/445] Support bounded subscription name expressions --- Cargo.lock | 29 +++- Cargo.toml | 1 + README.md | 1 + docs/config.yaml | 4 +- docs/mihomo-compatibility.md | 7 +- docs/runtime-resource-policy.md | 10 ++ src/config/mod.rs | 1 + src/config/name_regex.rs | 292 ++++++++++++++++++++++++++++++++ src/config/providers.rs | 201 +++++++++++++++------- src/runtime/providers.rs | 10 +- tests/README.md | 1 + 11 files changed, 490 insertions(+), 67 deletions(-) create mode 100644 src/config/name_regex.rs diff --git a/Cargo.lock b/Cargo.lock index 4b15228..3ca8197 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -664,6 +679,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -2685,6 +2711,7 @@ dependencies = [ "boring-sys", "bytes", "crc32fast", + "fancy-regex", "foreign-types", "futures-util", "h2", @@ -3291,7 +3318,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", "time", ] diff --git a/Cargo.toml b/Cargo.toml index a47456f..9989fe2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ blake3 = { version = "=1.8.7", optional = true } boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", features = ["client-fingerprint"], optional = true } bytes = "1" crc32fast = { version = "1.5.2", optional = true } +fancy-regex = { version = "=0.19.2", default-features = false, features = ["std", "unicode"] } futures-util = { version = "0.3", features = ["sink"] } h2 = "0.4.19" h3 = { version = "=0.0.8", default-features = false, optional = true } diff --git a/README.md b/README.md index 4dff3a6..00830cc 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ Vole builds on and learns from public dependencies, protocol implementations and - TUN dependencies: the local [`vole-netstack`](crates/vole-netstack/README.md) uses [smoltcp](https://github.com/smoltcp-rs/smoltcp); Unix and Windows Wintun packet I/O use [tun-rs](https://github.com/tun-rs/tun-rs) (Apache-2.0). - [Wintun](https://www.wintun.net/): the runtime DLL is supplied by the host, not bundled. The upstream [API header](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) included by tun-rs is copyright 2018–2021 WireGuard LLC, licensed `GPL-2.0 OR MIT`; distribution of the linked API bindings must retain its copyright and MIT alternative license notice. +- Name expressions: [fancy-regex](https://github.com/fancy-regex/fancy-regex) (MIT), used only for bounded provider/group lookaround and backreferences. - Networking and routing references: [clash-rs](https://github.com/Watfaq/clash-rs), [netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp), [Mihomo](https://github.com/MetaCubeX/mihomo), [Xray-core](https://github.com/XTLS/Xray-core) and [Leaf](https://github.com/eycorsican/leaf). These reference projects are not netstack dependencies. - TLS and Shadowsocks: [rustls](https://github.com/rustls/rustls), [boring](https://github.com/cloudflare/boring), [BoringSSL](https://boringssl.googlesource.com/boringssl/) and [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust). Derived replay-window code retains its [MIT notices](src/outbound/shadowsocks/packet_window.rs). - Windows integration: [windows-rs](https://github.com/microsoft/windows-rs), [UWP VPN Plugin Sample](https://github.com/microsoft/UwpVpnPluginSample), [wireguard-uwp-rs](https://github.com/luqmana/wireguard-uwp-rs), [Maple](https://github.com/YtFlow/Maple) and [YtFlowCore](https://github.com/YtFlow/YtFlowCore). diff --git a/docs/config.yaml b/docs/config.yaml index 93ac28a..8992316 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -458,7 +458,9 @@ proxies: # select/url-test/fallback/load-balance 组支持静态 proxies 与动态 use;include-all 同时包含静态节点和所有 providers, # 也可分别使用 include-all-proxies / include-all-providers。后者启用时忽略 use。 # filter 筛选动态成员;exclude-filter / exclude-type 同时排除显式成员。 -# 正则支持 Rust regex 子集,区分大小写;反引号分隔的多表达式按匹配顺序排列。 +# 名称正则区分大小写;支持 lookahead、定长 lookbehind、数字/命名 backreference。 +# 反引号分隔的多表达式按匹配顺序排列。节点改名使用 $1 / ${name} / $$ 等 .NET replacement。 +# 复杂表达式有内部编译/执行预算;失败候选保留旧资源,不静默当不匹配。 # lookaround/backreference 明确拒绝。静态成员保留顺序与重复项。 # 动态组为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 # disable-udp 缺省 false;true 禁用该组的业务 UDP。规则模式跳过不支持 UDP 的目标, diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index fe1545c..3de3f23 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -20,9 +20,12 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - 业务入口只有 mixed-port 与 TUN;不支持 port、socks-port、listeners、redir/tproxy 或服务端协议配置。 - Shadowsocks 只支持三个 2022 cipher;插件仅 strict ShadowTLS v3,UoT 仅 v2。 -- DNS 不提供独立 listen、DoQ、DoH/H3、ECS、system/DHCP nameserver 或可配置 ARC 缓存。 +- DNS 不提供独立 listen、DoQ、DoH/H3 或 ECS。LRU/ARC 与显式 system/DHCP 上游已支持, + 来源发现和物理绑定的可用平台及参考差异以 DNS 契约为准。 DoH 使用 H1 POST;详细解析角色、缓存与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 -- 正则使用 Rust regex 子集,明确拒绝 lookaround/backreference。规则与 provider 的协议覆盖仅限已实现字段。 +- Provider/group 名称筛选和改名支持 lookahead、定长 lookbehind、数字/命名 backreference; + 普通表达式保留 Rust regex 快速路径。复杂表达式和改名有内部预算,超限使候选失败。 + 不支持递归/条件式或完整 .NET 方言;DOMAIN-REGEX 与 GeoSite regex 不扩展。 - gRPC 三协议共用选项和节点连接池。负 ping-interval 明确拒绝,不复制固定参考 HTTP/2 客户端的立即重复 PING;超出本机可表示期限的正秒数也明确拒绝,不复制 Duration 溢出。 - 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 0a8e8e4..939a48b 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -170,6 +170,16 @@ IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不 完整接口见 [运行时 Controller](controller-api.md)。 +## Provider 名称表达式 + +普通筛选表达式继续使用线性时间的 `regex`。必要的 lookaround/backreference 使用 +`fancy-regex`,限制复杂源码 4 KiB、AST 512 节点/32 层、有限重复数 4096、委托编译和 +DFA 缓存各 512 KiB;不接受递归、条件式和控制动词。每次复杂匹配最多 10,000 回溯, +输入名称最多 4 KiB。一次候选共享最多 10,000 次复杂匹配/改名匹配和 100 ms 执行窗口; +改名输出最多 64 KiB,后续表达式继续计费。单次库调用不能中途抢占,调用间检查取消。 +编译、预算、运行错误与取消均使候选失败,保留当前资源和 HTTP 缓存,不静默漏选节点。 +这些边界仅用于名称表达式,不改变 GeoData/DOMAIN-REGEX 或业务连接准入。 + ## GeoData - 所有平台完整加载被引用分类的所选记录,不设记录条数硬上限或截断;GeoIP 与 GeoSite 没有优先额度分配。 diff --git a/src/config/mod.rs b/src/config/mod.rs index 75f2d2a..090b0c3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -35,6 +35,7 @@ use uuid::Uuid; use crate::{Result, VoleError}; mod dns; +mod name_regex; pub mod providers; mod rule_provider; pub(crate) mod rules; diff --git a/src/config/name_regex.rs b/src/config/name_regex.rs new file mode 100644 index 0000000..411a88f --- /dev/null +++ b/src/config/name_regex.rs @@ -0,0 +1,292 @@ +//! Bounded expressions for provider/group node names only. +use super::{Result, VoleError}; +use std::time::{Duration, Instant}; +use tokio_util::sync::CancellationToken; + +const PATTERN_BYTES: usize = 4096; +const NAME_BYTES: usize = 4096; +const OUTPUT_BYTES: usize = 64 * 1024; +const BACKTRACKS: usize = 10_000; + +fn error(message: &'static str) -> VoleError { + VoleError::InvalidConfig(message.into()) +} +pub(super) struct Budget<'a> { + cancel: Option<&'a CancellationToken>, + elapsed: Duration, + remaining: usize, +} +impl<'a> Budget<'a> { + pub fn new(cancel: Option<&'a CancellationToken>) -> Self { + Self { + cancel, + elapsed: Duration::ZERO, + remaining: 10_000, + } + } + pub fn check(&self) -> Result<()> { + if self.cancel.is_some_and(CancellationToken::is_cancelled) { + return Err(error("provider expression cancelled")); + } + Ok(()) + } + fn account(&mut self, elapsed: Duration) -> Result<()> { + self.elapsed = self.elapsed.saturating_add(elapsed); + if self.elapsed >= Duration::from_millis(100) { + return Err(error("provider expression execution budget exceeded")); + } + self.check() + } + fn spend(&mut self, name: &str) -> Result<()> { + self.check()?; + if name.len() > NAME_BYTES + || self.remaining == 0 + || self.elapsed >= Duration::from_millis(100) + { + return Err(error("provider expression execution budget exceeded")); + } + self.remaining -= 1; + Ok(()) + } +} + +pub(super) enum NameRegex { + Fast(regex::Regex), + Complex(Box), +} +impl NameRegex { + pub fn new(pattern: &str) -> Result { + if let Ok(regex) = regex::Regex::new(pattern) { + return Ok(Self::Fast(regex)); + } + if pattern.len() > PATTERN_BYTES { + return Err(error("provider expression compilation budget exceeded")); + } + let tree = fancy_regex::Expr::parse_tree(pattern) + .map_err(|_| error("invalid provider/group expression"))?; + let mut pending = vec![(&tree.expr, 0)]; + let mut count = 0; + while let Some((expr, depth)) = pending.pop() { + count += 1; + if count > 512 || depth > 32 { + return Err(error("provider expression compilation budget exceeded")); + } + use fancy_regex::Expr; + match expr { + Expr::Repeat { lo, hi, .. } if *lo > 4096 || (*hi != usize::MAX && *hi > 4096) => { + return Err(error("provider expression compilation budget exceeded")); + } + Expr::Empty + | Expr::Any { .. } + | Expr::Assertion(_) + | Expr::Literal { .. } + | Expr::Concat(_) + | Expr::Alt(_) + | Expr::Group(_) + | Expr::LookAround(..) + | Expr::Repeat { .. } + | Expr::Delegate { .. } + | Expr::Backref { .. } + | Expr::AtomicGroup(_) => {} + _ => return Err(error("unsupported provider expression syntax")), + } + pending.extend(expr.children_iter().map(|child| (child, depth + 1))); + } + let regex = fancy_regex::RegexBuilder::new(pattern) + .backtrack_limit(BACKTRACKS) + .seek(false) + .delegate_size_limit(512 * 1024) + .delegate_dfa_size_limit(512 * 1024) + .build() + .map_err(|_| error("invalid provider/group expression"))?; + Ok(Self::Complex(Box::new(regex))) + } + pub fn is_match(&self, name: &str, budget: &mut Budget<'_>) -> Result { + budget.check()?; + match self { + Self::Fast(regex) => Ok(regex.is_match(name)), + Self::Complex(regex) => { + budget.spend(name)?; + let started = Instant::now(); + let result = regex + .is_match(name) + .map_err(|_| error("provider expression execution budget exceeded")); + budget.account(started.elapsed())?; + result + } + } + } + pub fn replace(&self, name: &str, target: &str, budget: &mut Budget<'_>) -> Result { + budget.check()?; + let started = Instant::now(); + let result = self.replace_inner(name, target, budget); + budget.account(started.elapsed())?; + result + } + fn replace_inner(&self, name: &str, target: &str, budget: &mut Budget<'_>) -> Result { + let mut output = String::new(); + let mut end = 0; + match self { + Self::Fast(regex) => { + for captures in regex.captures_iter(name) { + budget.spend(name)?; + let matched = captures.get(0).expect("whole match"); + append(&mut output, &name[end..matched.start()])?; + expand( + &mut output, + target, + name, + matched.start(), + matched.end(), + captures.len(), + |i| captures.get(i).map(|m| m.as_str()), + |key| { + regex + .capture_names() + .any(|n| n == Some(key)) + .then(|| captures.name(key).map(|m| m.as_str()).unwrap_or("")) + }, + )?; + end = matched.end(); + } + } + Self::Complex(regex) => { + budget.spend(name)?; + for captures in regex.captures_iter(name) { + budget.spend(name)?; + let captures = captures + .map_err(|_| error("provider expression execution budget exceeded"))?; + let matched = captures.get(0).expect("whole match"); + append(&mut output, &name[end..matched.start()])?; + expand( + &mut output, + target, + name, + matched.start(), + matched.end(), + captures.len(), + |i| captures.get(i).map(|m| m.as_str()), + |key| { + regex + .capture_names() + .any(|n| n == Some(key)) + .then(|| captures.name(key).map(|m| m.as_str()).unwrap_or("")) + }, + )?; + end = matched.end(); + } + } + } + append(&mut output, &name[end..])?; + Ok(output) + } +} +fn append(output: &mut String, value: &str) -> Result<()> { + if output + .len() + .checked_add(value.len()) + .is_none_or(|n| n > OUTPUT_BYTES) + { + return Err(error("provider name replacement output budget exceeded")); + } + output.push_str(value); + Ok(()) +} +// The reference uses .NET replacement syntax: numeric captures stop at a non-digit. +#[allow(clippy::too_many_arguments)] +fn expand<'a>( + output: &mut String, + target: &str, + input: &'a str, + start: usize, + end: usize, + count: usize, + group: impl Fn(usize) -> Option<&'a str>, + named: impl Fn(&str) -> Option<&'a str>, +) -> Result<()> { + let mut rest = target; + while let Some(index) = rest.find('$') { + append(output, &rest[..index])?; + rest = &rest[index..]; + let next = rest.as_bytes().get(1).copied(); + let (consumed, value) = match next { + Some(b'$') => (2, Some("$")), + Some(b'&') => (2, group(0)), + Some(b'`') => (2, Some(&input[..start])), + Some(b'\'') => (2, Some(&input[end..])), + Some(b'_') => (2, Some(input)), + Some(b'+') => (2, (1..count).rev().find_map(&group).or(Some(""))), + Some(b'{') => { + if let Some(close) = rest.find('}') { + let key = &rest[2..close]; + let value = key + .parse::() + .ok() + .filter(|i| *i < count) + .map(|i| group(i).unwrap_or("")) + .or_else(|| named(key)); + (close + 1, value) + } else { + (1, None) + } + } + Some(b'0'..=b'9') => { + let length = 1 + rest[1..].bytes().take_while(u8::is_ascii_digit).count(); + let value = rest[1..length] + .parse::() + .ok() + .filter(|i| *i < count) + .map(|i| group(i).unwrap_or("")); + (length, value) + } + _ => (1, None), + }; + append(output, value.unwrap_or(&rest[..consumed]))?; + rest = &rest[consumed..]; + } + append(output, rest) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn name_expressions_support_real_subscription_patterns() { + let mut budget = Budget::new(None); + let exclude = NameRegex::new(r"^(?!.*(流量|到期)).*(香港|HK)").unwrap(); + assert!(exclude.is_match("香港 HK-01", &mut budget).unwrap()); + assert!(!exclude.is_match("香港 剩余流量", &mut budget).unwrap()); + let repeated = NameRegex::new(r"^(HK|SG)-\1$").unwrap(); + assert!(repeated.is_match("HK-HK", &mut budget).unwrap()); + assert!(!repeated.is_match("HK-SG", &mut budget).unwrap()); + let rename = NameRegex::new(r"(?<=HK-)(\d+)").unwrap(); + assert_eq!( + rename.replace("HK-01", "$1x", &mut budget).unwrap(), + "HK-01x" + ); + let fast = NameRegex::new(r"(?HK)-(\d+)").unwrap(); + assert_eq!( + fast.replace("HK-01", "${area}:$2x:$$:$&", &mut budget) + .unwrap(), + "HK:01x:$:HK-01" + ); + } + #[test] + fn name_expressions_fail_on_budget_and_cancellation() { + let regex = NameRegex::new(r"(?i)(a|b|ab)*(?>c)").unwrap(); + assert!( + regex + .is_match(&"ab".repeat(40), &mut Budget::new(None)) + .is_err() + ); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!( + NameRegex::new("HK") + .unwrap() + .is_match("HK", &mut Budget::new(Some(&cancel))) + .is_err() + ); + assert!(NameRegex::new(r"(?=a)a{1000000}").is_err()); + } +} diff --git a/src/config/providers.rs b/src/config/providers.rs index e298466..751714e 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -1,4 +1,5 @@ //! Provider declarations and deterministic materialization of one resource generation. +use super::name_regex::{Budget, NameRegex}; use super::*; use serde::Deserialize; use serde_yaml_ng::{Mapping, Value}; @@ -263,6 +264,15 @@ impl ProviderTemplate { } } pub fn materialize(self: &Arc, contents: &ProviderContents) -> Result { + self.materialize_with_cancel(contents, None) + } + pub(crate) fn materialize_with_cancel( + self: &Arc, + contents: &ProviderContents, + cancellation: Option<&tokio_util::sync::CancellationToken>, + ) -> Result { + let mut budget = Budget::new(cancellation); + budget.check()?; let mut value = self.base.clone(); let base = value.as_mapping_mut().expect("validated mapping"); let mut proxies: Vec = base @@ -281,7 +291,7 @@ impl ProviderTemplate { return invalid("provider content kind mismatch"); }; let filter = Filter::new(&spec.filter, &spec.exclude_filter, &spec.exclude_type)?; - let nodes = filter.select(nodes.clone(), false)?; + let nodes = filter.select(nodes.clone(), &mut budget)?; let mut names = Vec::new(); for mut node in nodes { if !spec.dialer_proxy.is_empty() { @@ -291,7 +301,7 @@ impl ProviderTemplate { })? .insert("dialer-proxy".into(), spec.dialer_proxy.clone().into()); } - apply_overrides(&mut node, &spec.overrides)?; + apply_overrides(&mut node, &spec.overrides, &mut budget)?; names.push(node_name(&node)?); proxies.push(node); } @@ -346,29 +356,36 @@ impl ProviderTemplate { let dynamic = !used.is_empty() || all_proxies || all_providers; let mut names = declared; if all_proxies { - names.extend( - static_names - .iter() - .filter(|name| filter.includes(name)) - .cloned(), - ); + for name in &static_names { + if filter.includes(name, &mut budget)? { + names.push(name.clone()); + } + } } let mut dynamic_names = Vec::new(); for provider in &used { let members = providers.get(provider).ok_or_else(|| { VoleError::InvalidConfig(format!("unknown proxy provider `{provider}`")) })?; - dynamic_names - .extend(members.iter().filter(|name| filter.includes(name)).cloned()); + for name in members { + if filter.includes(name, &mut budget)? { + dynamic_names.push(name.clone()); + } + } } - filter.sort_matches(&mut dynamic_names); + filter.sort_matches(&mut dynamic_names, &mut budget)?; names.extend(dynamic_names); - names.retain(|name| { - !filter.excludes( - name, - types.get(name).map(String::as_str).unwrap_or("Selector"), - ) - }); + let mut selected = Vec::with_capacity(names.len()); + for name in names { + if !filter.excludes( + &name, + types.get(&name).map(String::as_str).unwrap_or("Selector"), + &mut budget, + )? { + selected.push(name); + } + } + let mut names = selected; if names.is_empty() { if !dynamic { return invalid("proxy group requires proxies or use"); @@ -525,17 +542,17 @@ fn node_name(value: &Value) -> Result { } struct Filter { - include: Vec, - exclude: Vec, + include: Vec, + exclude: Vec, types: Vec, } impl Filter { fn new(include: &str, exclude: &str, types: &str) -> Result { - let compile = |s: &str| -> Result> { + let compile = |s: &str| -> Result> { if s.is_empty() { return Ok(Vec::new()); } - s.split('`').map(|pattern| regex::Regex::new(pattern).map_err(|_| VoleError::InvalidConfig("unsupported provider/group regular expression (lookaround and backreferences are not supported)".into()))).collect() + s.split('`').map(NameRegex::new).collect() }; Ok(Self { include: compile(include)?, @@ -547,48 +564,64 @@ impl Filter { .collect(), }) } - fn includes(&self, name: &str) -> bool { - self.include.is_empty() || self.include.iter().any(|r| r.is_match(name)) + fn match_index(&self, name: &str, budget: &mut Budget<'_>) -> Result> { + if self.include.is_empty() { + return Ok(Some(0)); + } + for (index, regex) in self.include.iter().enumerate() { + if regex.is_match(name, budget)? { + return Ok(Some(index)); + } + } + Ok(None) } - fn excludes(&self, name: &str, kind: &str) -> bool { - self.exclude.iter().any(|r| r.is_match(name)) - || self - .types - .iter() - .any(|ty| ty.eq_ignore_ascii_case(protocol_type(kind))) + fn includes(&self, name: &str, budget: &mut Budget<'_>) -> Result { + Ok(self.match_index(name, budget)?.is_some()) + } + fn excludes(&self, name: &str, kind: &str, budget: &mut Budget<'_>) -> Result { + for regex in &self.exclude { + if regex.is_match(name, budget)? { + return Ok(true); + } + } + Ok(self + .types + .iter() + .any(|ty| ty.eq_ignore_ascii_case(protocol_type(kind)))) } - fn sort_matches(&self, names: &mut Vec) { + fn sort_matches(&self, names: &mut Vec, budget: &mut Budget<'_>) -> Result<()> { if self.include.len() > 1 { - names.sort_by_key(|name| { - self.include - .iter() - .position(|r| r.is_match(name)) - .unwrap_or(usize::MAX) - }); + let mut keyed = names + .drain(..) + .map(|name| Ok((self.match_index(&name, budget)?.unwrap_or(usize::MAX), name))) + .collect::>>()?; + keyed.sort_by_key(|(index, _)| *index); let mut seen = HashSet::new(); - names.retain(|name| seen.insert(name.clone())); + names.extend( + keyed + .into_iter() + .map(|(_, name)| name) + .filter(|name| seen.insert(name.clone())), + ); } + Ok(()) } - fn select(&self, nodes: Vec, _group: bool) -> Result> { + fn select(&self, nodes: Vec, budget: &mut Budget<'_>) -> Result> { let mut result = Vec::new(); for node in nodes { + budget.check()?; let name = node_name(&node)?; let kind = node.get("type").and_then(Value::as_str).unwrap_or_default(); - if self.includes(&name) && !self.excludes(&name, kind) { - result.push(node); + if !self.excludes(&name, kind, budget)? + && let Some(index) = self.match_index(&name, budget)? + { + result.push((index, node)); } } if self.include.len() > 1 { - result.sort_by_key(|node| { - self.include - .iter() - .position(|r| { - r.is_match(node.get("name").and_then(Value::as_str).unwrap_or_default()) - }) - .unwrap_or(usize::MAX) - }); + result.sort_by_key(|(index, _)| *index); } - Ok(result) + Ok(result.into_iter().map(|(_, node)| node).collect()) } } fn protocol_type(kind: &str) -> &str { @@ -615,11 +648,7 @@ fn validate_overrides(overrides: &BTreeMap) -> Result<()> { VoleError::InvalidConfig("invalid provider name replacements".into()) })?; for replacement in replacements { - regex::Regex::new(&replacement.pattern).map_err(|_| { - VoleError::InvalidConfig( - "unsupported provider name replacement expression".into(), - ) - })?; + NameRegex::new(&replacement.pattern)?; } true } @@ -631,7 +660,11 @@ fn validate_overrides(overrides: &BTreeMap) -> Result<()> { } Ok(()) } -fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Result<()> { +fn apply_overrides( + node: &mut Value, + overrides: &BTreeMap, + budget: &mut Budget<'_>, +) -> Result<()> { let original_name = node_name(node)?; let map = node .as_mapping_mut() @@ -668,12 +701,8 @@ fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Res let replacements: Vec = serde_yaml_ng::from_value(replacements.clone()) .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; for replacement in replacements { - let regex = regex::Regex::new(&replacement.pattern).map_err(|_| { - VoleError::InvalidConfig("unsupported provider name replacement expression".into()) - })?; - name = regex - .replace_all(&name, replacement.target.as_str()) - .into_owned(); + let regex = NameRegex::new(&replacement.pattern)?; + name = regex.replace(&name, &replacement.target, budget)?; } } let text = |key| -> Result<&str> { @@ -702,6 +731,56 @@ fn apply_overrides(node: &mut Value, overrides: &BTreeMap) -> Res #[cfg(test)] mod tests { use super::*; + #[test] + fn provider_and_group_name_expressions_materialize_real_templates() { + let config = Config::parse_yaml( + r#" +mixed-port: 1080 +proxy-providers: + nodes: + type: inline + filter: '^(?!.*(流量|到期)).*(HK|SG)' + exclude-filter: '(?<=HK-)99$' + override: + proxy-name: + - {pattern: '(?<=HK-)(\d+)', target: '${1}x'} + payload: + - {name: HK-01, type: socks5, server: 192.0.2.1, port: 1080} + - {name: HK-99, type: socks5, server: 192.0.2.1, port: 1080} + - {name: SG-SG, type: socks5, server: 192.0.2.1, port: 1080} + - {name: SG-SG到期, type: socks5, server: 192.0.2.1, port: 1080} +proxy-groups: + - {name: choose, type: select, use: [nodes], filter: '^(SG)-\1$`HK', exclude-filter: '(?<=HK-)02'} +"# + .as_bytes(), + ) + .unwrap(); + assert_eq!( + config + .proxies + .iter() + .map(|p| p.tag.as_str()) + .collect::>(), + ["HK-01x", "SG-SG"] + ); + assert_eq!( + config.proxy_groups[0] + .members + .iter() + .map(|m| m.name.as_str()) + .collect::>(), + ["SG-SG", "HK-01x"] + ); + let template = config.providers.unwrap(); + let cancelled = tokio_util::sync::CancellationToken::new(); + cancelled.cancel(); + assert!( + template + .materialize_with_cancel(&ProviderContents::new(), Some(&cancelled)) + .is_err() + ); + } + #[test] fn provider_dialer_and_certificate_overrides_materialize_in_reference_order() { for (kind, source) in [ diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index acd745e..9ea6b4b 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -275,9 +275,12 @@ impl ProviderRuntime { .store_dir() .parent() .map(std::path::Path::to_path_buf); + let expression_cancellation = self.cancellation.clone(); let prepared = tokio::task::spawn_blocking( move || -> io::Result<(Config, BuiltRuntimeParts, GeoRequirements)> { - let mut config = template.materialize(&contents).map_err(io::Error::other)?; + let mut config = template + .materialize_with_cancel(&contents, Some(&expression_cancellation)) + .map_err(io::Error::other)?; let requirements = GeoRequirements::for_config(&config).map_err(io::Error::other)?; let rules = RuleSet::compile_config(&mut config).map_err(io::Error::other)?; @@ -716,7 +719,7 @@ mod tests { br#" mixed-port: 1080 proxy-providers: - remote: {type: file, path: nodes.yaml} + remote: {type: file, path: nodes.yaml, filter: '(?=.)'} proxy-groups: - {name: choice, type: select, use: [remote]} dns: @@ -781,6 +784,9 @@ rules: ['MATCH,choice'] assert!(live.update(0).await.is_err()); assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); assert!(live.providers(false)["providers"]["remote"]["error"].is_string()); + std::fs::write(&file, payload(&"x".repeat(4097))).unwrap(); + assert!(live.update(0).await.is_err()); + assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); drop(old_session); live.reap().await; assert!(weak.upgrade().is_none()); diff --git a/tests/README.md b/tests/README.md index f92cadd..8d95290 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,6 +12,7 @@ cargo test --locked --all-features --all-targets --no-run cargo test --locked --no-default-features --features cli --bin vole cli::tests:: cargo test --locked --no-default-features --features cli --lib invoke::foreground::tests:: cargo test --locked --lib config::tests:: +cargo test --locked --lib config::name_regex::tests:: cargo test --locked --lib config::tls_policy_tests:: cargo test --locked --no-default-features --features cli --test grpc_config --test grpc_pool --test grpc_protocol_pool cargo test --locked --no-default-features --features cli --lib grpc_duplex_pool_keeps_response From cfaf4f1293e14187f8db4315e429a81b3877eac3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 13:02:50 +0800 Subject: [PATCH 040/445] Add standard WireGuard outbound and active userspace TCP --- .github/workflows/test.yml | 7 +- Cargo.lock | 367 +++++++++++++++++--- Cargo.toml | 10 +- README.md | 4 +- crates/vole-netstack/README.md | 33 ++ crates/vole-netstack/src/config.rs | 39 ++- crates/vole-netstack/src/lib.rs | 8 +- crates/vole-netstack/src/stack.rs | 459 +++++++++++++++++++++---- crates/vole-netstack/src/tcp.rs | 125 ++++++- crates/vole-netstack/tests/netstack.rs | 324 ++++++++++++++++- docs/config.yaml | 20 ++ docs/mihomo-compatibility.md | 4 +- docs/outbounds.md | 32 ++ readme/README.ru.md | 4 +- readme/README.zh_CN.md | 4 +- src/config/measure.rs | 7 +- src/config/mod.rs | 8 + src/config/wireguard.rs | 164 +++++++++ src/dns/resolution.rs | 17 + src/lib.rs | 3 +- src/outbound/connector.rs | 5 +- src/outbound/mod.rs | 5 +- src/outbound/wireguard/datagram.rs | 212 ++++++++++++ src/outbound/wireguard/engine.rs | 151 ++++++++ src/outbound/wireguard/mod.rs | 140 ++++++++ src/outbound/wireguard/peer.rs | 218 ++++++++++++ src/outbound/wireguard/tests.rs | 357 +++++++++++++++++++ src/runtime.rs | 42 ++- tests/README.md | 3 + tests/feature_foundations.rs | 13 +- 30 files changed, 2620 insertions(+), 165 deletions(-) create mode 100644 src/config/wireguard.rs create mode 100644 src/outbound/wireguard/datagram.rs create mode 100644 src/outbound/wireguard/engine.rs create mode 100644 src/outbound/wireguard/mod.rs create mode 100644 src/outbound/wireguard/peer.rs create mode 100644 src/outbound/wireguard/tests.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f9b5e65..749cda0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -115,6 +115,9 @@ jobs: rustls = package("rustls", "0.23.45") package("tokio-rustls", "0.26.5") package("ring") + wireguard = package("boringtun", "0.7.1") + require(wireguard.get("license") == "BSD-3-Clause", "unapproved WireGuard license") + require(not features(wireguard) & {"device", "ffi-bindings", "jni-bindings"}, "WireGuard must use only the userspace protocol") require("ring" in features(rustls) and not features(rustls) & {"reality", "aws_lc_rs", "fips"}, "rustls must use only the ring TLS provider") hpke = package("hpke", "0.14.1") require(features(hpke) == {"alloc", "aes", "chacha", "x25519", "hkdfsha2"}, "unapproved ECH algorithms") @@ -193,7 +196,7 @@ jobs: dns::runtime::tests::resolve_ipv6_ dns::source::tests:: dns::runtime::tests::dns_source_ dns::runtime::encrypted_tests:: - outbound::http::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: + outbound::http::tests:: outbound::wireguard::tests:: inbound::mixed::tests:: inbound::socks5::association::tests:: inbound::http::parser::tests:: inbound::http::framing::tests:: outbound::hysteria2:: outbound::shadowsocks::tests::official_tcp_ outbound::anytls:: outbound::uot:: outbound::tuic:: transport::h2_write::tests:: @@ -254,7 +257,7 @@ jobs: test "$(grep -Ec '^test result: ok\. [1-9][0-9]* passed; 0 failed;' "$test_log")" -eq "$expected_summaries" rm -f "$test_log" } - protocols=(socks5 anytls shadowsocks trojan vmess vless hysteria2 tuic) + protocols=(socks5 anytls shadowsocks trojan vmess vless hysteria2 tuic wireguard) cargo check --locked --no-default-features --lib for protocol in "${protocols[@]}"; do cargo check --locked --no-default-features --features "outbound-$protocol" --lib diff --git a/Cargo.lock b/Cargo.lock index 3ca8197..6111ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,14 +8,24 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aead" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -24,9 +34,9 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", - "cpufeatures", + "cpufeatures 0.3.1", "zeroize", ] @@ -36,9 +46,9 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ - "aead", + "aead 0.6.1", "aes", - "cipher", + "cipher 0.5.2", "ctr", "ctutils", "ghash", @@ -169,6 +179,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -229,13 +245,22 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake2" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -248,7 +273,16 @@ dependencies = [ "cc", "cfg-if", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", ] [[package]] @@ -285,6 +319,31 @@ dependencies = [ "fslock", ] +[[package]] +name = "boringtun" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15dd6a8a89cbe8997f37ca0cf035e6ea4d64cd2ecea4aed83ffb9f99f7126939" +dependencies = [ + "aead 0.5.2", + "base64 0.22.1", + "blake2 0.10.6", + "chacha20poly1305 0.10.1", + "hex", + "hmac 0.12.1", + "ip_network", + "ip_network_table", + "libc", + "nix 0.31.3", + "parking_lot", + "portable-atomic", + "rand_core 0.6.4", + "ring", + "tracing", + "untrusted 0.9.0", + "x25519-dalek 2.0.1", +] + [[package]] name = "brotli" version = "9.0.0" @@ -363,6 +422,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.2" @@ -370,9 +440,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", - "rand_core", + "cipher 0.5.2", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305 0.8.0", + "zeroize", ] [[package]] @@ -381,10 +464,21 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ - "aead", - "chacha20", - "cipher", - "poly1305", + "aead 0.6.1", + "chacha20 0.10.2", + "cipher 0.5.2", + "poly1305 0.9.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", ] [[package]] @@ -393,9 +487,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer", - "crypto-common", - "inout", + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -464,6 +558,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.1" @@ -482,6 +585,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + [[package]] name = "crypto-common" version = "0.2.2" @@ -490,7 +604,7 @@ checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "getrandom 0.4.3", "hybrid-array", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -499,7 +613,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -511,6 +625,21 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -518,9 +647,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", "curve25519-dalek-derive", - "fiat-crypto", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -594,15 +723,26 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", + "block-buffer 0.12.1", "const-oid", - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -699,6 +839,12 @@ dependencies = [ "getrandom 0.4.3", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -879,6 +1025,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getifaddrs" version = "0.6.2" @@ -913,7 +1069,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1004,13 +1160,28 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -1019,7 +1190,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -1028,15 +1199,15 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5324110b02044183df000f0bd7d2d7e61f1000631508627e77f63983b6fdffb" dependencies = [ - "aead", + "aead 0.6.1", "aes-gcm", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "hkdf", "hybrid-array", - "rand_core", + "rand_core 0.10.1", "sha2", "subtle", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -1223,6 +1394,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inout" version = "0.2.2" @@ -1232,6 +1412,28 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "ip_network" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2f047c0a98b2f299aa5d6d7088443570faae494e9ae1305e48be000c9e0eb1" + +[[package]] +name = "ip_network_table" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4099b7cfc5c5e2fe8c5edf3f6f7adf7a714c9cc697534f63a5a5da30397cb2c0" +dependencies = [ + "ip_network", + "ip_network_table-deps-treebitmap", +] + +[[package]] +name = "ip_network_table-deps-treebitmap" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" + [[package]] name = "ipnet" version = "2.12.2" @@ -1287,7 +1489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.1", ] [[package]] @@ -1386,7 +1588,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -1593,6 +1795,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl-macros" version = "0.1.1" @@ -1680,14 +1888,25 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + [[package]] name = "poly1305" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures", - "universal-hash", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", ] [[package]] @@ -1697,10 +1916,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures", - "universal-hash", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", ] +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.6" @@ -1818,9 +2043,18 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1835,7 +2069,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -2118,8 +2352,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -2129,8 +2363,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -2140,7 +2374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2065b026dbe4f47048eca384adf07f693bf901050d80abd3adb7b2422709b2d" dependencies = [ "aes", - "base64", + "base64 0.23.1", "blake3", "byte_string", "bytes", @@ -2181,7 +2415,7 @@ dependencies = [ "blake3", "bytes", "cfg-if", - "chacha20poly1305", + "chacha20poly1305 0.11.0", "rand", ] @@ -2191,7 +2425,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" dependencies = [ - "digest", + "digest 0.11.3", "keccak", "sponge-cursor", ] @@ -2634,13 +2868,23 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", + "crypto-common 0.2.2", "ctutils", ] @@ -2697,6 +2941,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "vole" version = "0.1.0" @@ -2704,11 +2954,12 @@ dependencies = [ "aes", "arc-swap", "async-trait", - "base64", - "blake2", + "base64 0.23.1", + "blake2 0.11.0", "blake3", "boring", "boring-sys", + "boringtun", "bytes", "crc32fast", "fancy-regex", @@ -3268,14 +3519,26 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x25519-dalek" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ - "curve25519-dalek", - "rand_core", + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 9989fe2..69c8d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ default = [ "outbound-vless", "outbound-hysteria2", "outbound-tuic", + "outbound-wireguard", "shadow-tls-v3", "sing-mux", ] @@ -50,14 +51,15 @@ outbound-anytls = ["dep:md-5", "tls-fingerprint"] outbound-socks5 = [] outbound-http = [] outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] -outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:zeroize", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn"] +outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn"] outbound-trojan = ["stream-transport", "tls-fingerprint"] outbound-vmess = ["stream-transport", "tls-fingerprint", "dep:aes", "dep:crc32fast", "dep:md-5", "dep:ring", "dep:shake"] tls-fingerprint = ["dep:boring", "dep:tokio-boring"] -shadow-tls-v3 = ["tls-fingerprint", "boring/shadow-tls-v3", "dep:ring", "dep:zeroize"] +shadow-tls-v3 = ["tls-fingerprint", "boring/shadow-tls-v3", "dep:ring"] outbound-hysteria2 = ["quic-transport", "dep:quinn-proto", "dep:h3", "dep:h3-quinn", "dep:blake2"] outbound-tuic = ["quic-transport"] sing-mux = ["stream-transport", "dep:yamux", "tokio-util/compat"] +outbound-wireguard = ["dep:boringtun", "dep:vole-netstack"] stream-transport = ["dep:tokio-tungstenite", "dep:httparse", "dep:log"] quic-transport = ["dep:quinn"] # Enables local interoperability-only trust-root injection. Release build @@ -83,6 +85,7 @@ tun = [ "outbound-vless", "outbound-hysteria2", "outbound-tuic", + "outbound-wireguard", "shadow-tls-v3", "sing-mux", ] @@ -94,6 +97,7 @@ async-trait = "0.1" base64 = "0.23" blake2 = { version = "=0.11.0", optional = true } blake3 = { version = "=1.8.7", optional = true } +boringtun = { version = "=0.7.1", default-features = false, optional = true } boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", features = ["client-fingerprint"], optional = true } bytes = "1" crc32fast = { version = "1.5.2", optional = true } @@ -145,7 +149,7 @@ vole-netstack = { path = "crates/vole-netstack", optional = true } vole-blake3-raw = { path = "crates/vole-blake3-raw", optional = true } webpki-roots = "1" yamux = { version = "=0.14.1", optional = true } -zeroize = { version = "=1.9.0", optional = true } +zeroize = { version = "=1.9.0" } zstd = { version = "0.14.0", default-features = false } [target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "macos"))'.dependencies] diff --git a/README.md b/README.md index 00830cc..fb32dd1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Configuration uses **Mihomo-compatible YAML for the supported feature set**. Vol ## Proxy protocols -All nine protocols below are enabled in the default build. HTTP CONNECT itself is TCP-only; native UDP support for other protocols is configured per node. +All ten protocols below are enabled in the default build. HTTP CONNECT itself is TCP-only; native UDP support for other protocols is configured per node. | Protocol | Capabilities | | --- | --- | @@ -33,6 +33,7 @@ All nine protocols below are enabled in the default build. HTTP CONNECT itself i | [SOCKS5](docs/outbounds.md#socks5) | CONNECT and UDP ASSOCIATE, with optional username/password authentication | | [Hysteria2](docs/outbounds.md#hysteria2) | QUIC TCP/UDP, bandwidth control, Salamander, port hopping and mTLS | | [TUIC v5](docs/outbounds.md#tuic-v5) | QUIC TCP/UDP, native/quic UDP relay modes and selectable congestion control | +| [WireGuard](docs/outbounds.md#wireguard) | TCP/UDP over standard WireGuard, with a user-space IP stack | The shared [sing-mux wrapper](docs/xhttp.md#通用-sing-mux) supports all enabled node protocols. It provides TCP and, with `only-tcp: false`, UDP over the node's ordinary TCP path. The peer must provide sing-mux decoding; a normal HTTP/SOCKS5 listener does not automatically decode it. @@ -141,6 +142,7 @@ See [acceptance boundaries](docs/acceptance.md) for the scope of build, network Vole builds on and learns from public dependencies, protocol implementations and platform references: - TUN dependencies: the local [`vole-netstack`](crates/vole-netstack/README.md) uses [smoltcp](https://github.com/smoltcp-rs/smoltcp); Unix and Windows Wintun packet I/O use [tun-rs](https://github.com/tun-rs/tun-rs) (Apache-2.0). +- WireGuard: [boringtun](https://github.com/cloudflare/boringtun) (BSD-3-Clause), without host-device, FFI or JNI features. - [Wintun](https://www.wintun.net/): the runtime DLL is supplied by the host, not bundled. The upstream [API header](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) included by tun-rs is copyright 2018–2021 WireGuard LLC, licensed `GPL-2.0 OR MIT`; distribution of the linked API bindings must retain its copyright and MIT alternative license notice. - Name expressions: [fancy-regex](https://github.com/fancy-regex/fancy-regex) (MIT), used only for bounded provider/group lookaround and backreferences. - Networking and routing references: [clash-rs](https://github.com/Watfaq/clash-rs), [netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp), [Mihomo](https://github.com/MetaCubeX/mihomo), [Xray-core](https://github.com/XTLS/Xray-core) and [Leaf](https://github.com/eycorsican/leaf). These reference projects are not netstack dependencies. diff --git a/crates/vole-netstack/README.md b/crates/vole-netstack/README.md index ab58cff..ecdd435 100644 --- a/crates/vole-netstack/README.md +++ b/crates/vole-netstack/README.md @@ -81,6 +81,39 @@ control.stop().await; smoltcp sockets and woken pending TCP operations. Dropping the sole `PacketStream` also stops the driver and releases all endpoints. +## Active TCP client + +`NetStack::start_client(config, addresses)` returns `ClientNetStackParts` with +the same raw packet endpoints, control and statistics, plus a cloneable +`TcpConnector`. `ClientAddresses` supplies optional IPv4/IPv6 source addresses; +at least one must be unicast. An omitted family fails with `AddrNotAvailable`. +The client mode does not create a passive accept or UDP datagram queue, and +discards incoming TCP packets that do not belong to a requested connection. + +```rust,ignore +let parts = vole_netstack::NetStack::start_client(config, addresses)?; +// The owner must concurrently pump packet_sink and packet_stream. +let stream = parts.tcp_connector.connect(peer, deadline).await?; +assert_eq!(stream.destination_addr(), peer); +parts.control.stop().await; +``` + +`connect` takes an IP `SocketAddr` and an absolute Tokio `Instant`; bounded +command admission and the handshake use that one deadline. It returns only +after the socket reaches Established, with `source_addr` representing the +configured local IP and allocated ephemeral port. `destination_addr` is the +remote peer. The driver alone owns socket state and allocates source ports; +there is no host socket, DNS resolver, system interface, route or NAT setup. + +The command queue holds at most 32 requests, independently of active socket +count. Waiting for queue space remains cancellable and deadline-bound. Future +Drop, deadline or handshake failure aborts the pending socket and reclaims its +flow after any reset reaches the existing output queue; a full output never +blocks Stop. Successful streams retain the existing direction buffers and TCP +half-close behavior. Stop closes admission, wakes queued/in-flight connects +and releases every socket before returning. Passive `start`/`start_tcp` TUN +behavior and their configured bounds remain unchanged. + ## Driver buffering and scheduling Device ingress holds at most one pending packet, not another packet queue. diff --git a/crates/vole-netstack/src/config.rs b/crates/vole-netstack/src/config.rs index 86d6c5d..c843aa0 100644 --- a/crates/vole-netstack/src/config.rs +++ b/crates/vole-netstack/src/config.rs @@ -1,7 +1,40 @@ -use std::time::Duration; +use std::{ + net::{Ipv4Addr, Ipv6Addr}, + time::Duration, +}; use thiserror::Error; +/// Configured source addresses for an active raw-IP client stack. +/// +/// An omitted family is unavailable; the driver never invents a source address. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ClientAddresses { + pub ipv4: Option, + pub ipv6: Option, +} + +impl ClientAddresses { + pub(crate) fn validate(self) -> Result<(), ConfigError> { + if self.ipv4.is_none() && self.ipv6.is_none() { + return Err(ConfigError::NoClientAddress); + } + if self + .ipv4 + .is_some_and(|ip| ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast()) + { + return Err(ConfigError::ClientAddress("IPv4")); + } + if self + .ipv6 + .is_some_and(|ip| ip.is_unspecified() || ip.is_multicast()) + { + return Err(ConfigError::ClientAddress("IPv6")); + } + Ok(()) + } +} + /// Finite buffering and timing configuration for one netstack instance. /// /// Queue counts are hard limits. Each TCP buffer includes both the smoltcp @@ -96,6 +129,10 @@ impl NetStackConfig { #[derive(Debug, Error, Eq, PartialEq)] pub enum ConfigError { + #[error("active netstack requires at least one configured source address")] + NoClientAddress, + #[error("active netstack {0} source address must be unicast")] + ClientAddress(&'static str), #[error("MTU must be between 1280 and 65535 bytes, got {0}")] Mtu(usize), #[error("resource limit `{0}` must be greater than zero")] diff --git a/crates/vole-netstack/src/lib.rs b/crates/vole-netstack/src/lib.rs index 8a3e591..84229c6 100644 --- a/crates/vole-netstack/src/lib.rs +++ b/crates/vole-netstack/src/lib.rs @@ -13,13 +13,13 @@ mod stack; mod tcp; mod udp; -pub use config::{ConfigError, NetStackConfig}; +pub use config::{ClientAddresses, ConfigError, NetStackConfig}; pub use packet::Packet; pub use stack::{ - NetStack, NetStackControl, NetStackError, NetStackParts, NetStackStats, PacketSink, - PacketStream, ResourceSnapshot, TcpNetStackParts, + ClientNetStackParts, NetStack, NetStackControl, NetStackError, NetStackParts, NetStackStats, + PacketSink, PacketStream, ResourceSnapshot, TcpNetStackParts, }; -pub use tcp::{TcpListener, TcpStream}; +pub use tcp::{TcpConnector, TcpListener, TcpStream}; pub use udp::{ UdpDatagram, UdpError, UdpPacketView, UdpSender, UdpSocket, encode_udp_packet_into, parse_udp_packet_view, diff --git a/crates/vole-netstack/src/stack.rs b/crates/vole-netstack/src/stack.rs index cf3b59a..7b61147 100644 --- a/crates/vole-netstack/src/stack.rs +++ b/crates/vole-netstack/src/stack.rs @@ -22,15 +22,20 @@ use tokio::sync::{Notify, mpsc, watch}; use tokio_util::sync::CancellationToken; use crate::{ - NetStackConfig, Packet, + ClientAddresses, NetStackConfig, Packet, config::ConfigError, device::RawIpDevice, icmp::{IcmpIngress, classify as classify_icmp}, - tcp::{FlowKey, TcpListener, TcpStream, TcpStreamHandle}, + tcp::{ + ConnectRequest, FlowKey, TcpConnector, TcpListener, TcpStream, TcpStreamHandle, + connect_stopped, connect_timeout, + }, udp::{UdpDatagram, UdpSocket, parse_udp_packet}, }; const RAW_INGRESS_BATCH: usize = 8; +const TCP_CONNECT_QUEUE: usize = 32; +const EPHEMERAL_PORT_START: u16 = 49_152; /// Running stack and all of its application-facing endpoints. pub struct NetStack { @@ -56,6 +61,60 @@ pub struct TcpNetStackParts { pub stats: NetStackStats, } +/// Active TCP endpoints with configured sources and no passive accept/UDP queue. +pub struct ClientNetStackParts { + pub packet_sink: PacketSink, + pub packet_stream: PacketStream, + pub tcp_connector: TcpConnector, + pub control: NetStackControl, + pub stats: NetStackStats, +} + +struct DriverParts { + packet_sink: PacketSink, + packet_stream: PacketStream, + tcp_listener: Option, + tcp_connector: Option, + udp_socket: Option, + control: NetStackControl, + stats: NetStackStats, +} + +impl DriverParts { + fn into_stack(self) -> NetStack { + NetStack { + parts: NetStackParts { + packet_sink: self.packet_sink, + packet_stream: self.packet_stream, + tcp_listener: self.tcp_listener.expect("passive endpoints requested"), + udp_socket: self.udp_socket.expect("UDP endpoints requested"), + control: self.control, + stats: self.stats, + }, + } + } + + fn into_tcp(self) -> TcpNetStackParts { + TcpNetStackParts { + packet_sink: self.packet_sink, + packet_stream: self.packet_stream, + tcp_listener: self.tcp_listener.expect("passive endpoints requested"), + control: self.control, + stats: self.stats, + } + } + + fn into_client(self) -> ClientNetStackParts { + ClientNetStackParts { + packet_sink: self.packet_sink, + packet_stream: self.packet_stream, + tcp_connector: self.tcp_connector.expect("active endpoints requested"), + control: self.control, + stats: self.stats, + } + } +} + impl NetStack { /// Starts one netstack driver on the current Tokio runtime. /// @@ -64,20 +123,7 @@ impl NetStack { /// Returns [`NetStackError::Config`] for inconsistent bounds and /// [`NetStackError::NoRuntime`] when called outside a Tokio runtime. pub fn start(config: NetStackConfig) -> Result { - Self::start_parts(config, true).map(|(parts, udp_socket)| Self::with_udp(parts, udp_socket)) - } - - fn with_udp(parts: TcpNetStackParts, udp_socket: Option) -> Self { - Self { - parts: NetStackParts { - packet_sink: parts.packet_sink, - packet_stream: parts.packet_stream, - tcp_listener: parts.tcp_listener, - udp_socket: udp_socket.expect("UDP endpoints requested"), - control: parts.control, - stats: parts.stats, - }, - } + Self::start_parts(config, true, None).map(DriverParts::into_stack) } /// Starts only the TCP/ICMP driver; callers handle UDP with the pure codec. @@ -86,13 +132,31 @@ impl NetStack { /// /// Returns the same configuration and runtime errors as [`Self::start`]. pub fn start_tcp(config: NetStackConfig) -> Result { - Self::start_parts(config, false).map(|(parts, _)| parts) + Self::start_parts(config, false, None).map(DriverParts::into_tcp) + } + + /// Starts active TCP using only the supplied local IP addresses. + /// + /// UDP remains caller-owned through the pure packet codec. Unsolicited + /// incoming TCP flows are discarded; passive TUN behavior is unchanged. + /// + /// # Errors + /// + /// Returns the errors of [`Self::start`] and a configuration error when + /// no local address is supplied or a supplied address is not unicast. + pub fn start_client( + config: NetStackConfig, + addresses: ClientAddresses, + ) -> Result { + addresses.validate()?; + Self::start_parts(config, false, Some(addresses)).map(DriverParts::into_client) } fn start_parts( config: NetStackConfig, with_udp: bool, - ) -> Result<(TcpNetStackParts, Option), NetStackError> { + addresses: Option, + ) -> Result { config.validate()?; tokio::runtime::Handle::try_current().map_err(|_| NetStackError::NoRuntime)?; @@ -101,7 +165,36 @@ impl NetStack { let stats = NetStackStats::default(); let (raw_inbound_tx, raw_inbound_rx) = mpsc::channel(config.packet_queue); let (raw_outbound_tx, raw_outbound_rx) = mpsc::channel(config.packet_queue); - let (tcp_accept_tx, tcp_accept_rx) = mpsc::channel(config.tcp_accept_queue); + let (tcp_accept_tx, tcp_listener, client, tcp_connector) = + if let Some(addresses) = addresses { + let (sender, receiver) = mpsc::channel(TCP_CONNECT_QUEUE); + ( + None, + None, + Some(ClientDriver { + addresses, + requests: receiver, + next_port: EPHEMERAL_PORT_START, + }), + Some(TcpConnector { + sender, + addresses, + cancellation: cancellation.clone(), + notify: notify.clone(), + }), + ) + } else { + let (sender, receiver) = mpsc::channel(config.tcp_accept_queue); + ( + Some(sender), + Some(TcpListener { + receiver, + cancellation: cancellation.clone(), + }), + None, + None, + ) + }; let (udp_tx, udp_socket) = if with_udp { let (sender, receiver) = mpsc::channel(config.udp_queue); ( @@ -119,7 +212,7 @@ impl NetStack { let (stopped_tx, stopped_rx) = watch::channel(false); let mtu = config.mtu; - let driver = Driver::new( + let mut driver = Driver::new( config, raw_inbound_rx, raw_outbound_tx.clone(), @@ -130,32 +223,32 @@ impl NetStack { stats.clone(), stopped_tx, ); + if let Some(client) = client { + driver.configure_client(&client.addresses); + driver.client = Some(client); + } tokio::spawn(driver.run()); - Ok(( - TcpNetStackParts { - packet_sink: PacketSink { - sender: raw_inbound_tx, - cancellation: cancellation.clone(), - mtu, - }, - packet_stream: PacketStream { - receiver: raw_outbound_rx, - cancellation: cancellation.clone(), - notify, - }, - tcp_listener: TcpListener { - receiver: tcp_accept_rx, - cancellation: cancellation.clone(), - }, - control: NetStackControl { - cancellation, - stopped: stopped_rx, - }, - stats, + Ok(DriverParts { + packet_sink: PacketSink { + sender: raw_inbound_tx, + cancellation: cancellation.clone(), + mtu, + }, + packet_stream: PacketStream { + receiver: raw_outbound_rx, + cancellation: cancellation.clone(), + notify, }, + tcp_listener, + tcp_connector, udp_socket, - )) + control: NetStackControl { + cancellation, + stopped: stopped_rx, + }, + stats, + }) } #[must_use] @@ -400,13 +493,22 @@ fn validate_raw_packet(packet: &Packet, mtu: usize) -> Result<(), NetStackError> struct TcpEntry { socket: SocketHandle, handle: Arc, + connecting: Option, + connect_cancellation: Option, +} + +struct ClientDriver { + addresses: ClientAddresses, + requests: mpsc::Receiver, + next_port: u16, } struct Driver { config: NetStackConfig, raw_inbound: mpsc::Receiver, raw_outbound: mpsc::Sender, - tcp_accept: mpsc::Sender, + tcp_accept: Option>, + client: Option, udp_outbound: Option>, cancellation: CancellationToken, notify: Arc, @@ -425,7 +527,7 @@ impl Driver { config: NetStackConfig, raw_inbound: mpsc::Receiver, raw_outbound: mpsc::Sender, - tcp_accept: mpsc::Sender, + tcp_accept: Option>, udp_outbound: Option>, cancellation: CancellationToken, notify: Arc, @@ -462,6 +564,7 @@ impl Driver { raw_inbound, raw_outbound, tcp_accept, + client: None, udp_outbound, cancellation, notify, @@ -475,6 +578,46 @@ impl Driver { } } + fn configure_client(&mut self, addresses: &ClientAddresses) { + self.interface.set_any_ip(false); + self.interface.update_ip_addrs(|configured| { + configured.clear(); + if let Some(ip) = addresses.ipv4 { + configured + .push(IpCidr::new(ip.into(), 32)) + .expect("IPv4 address capacity"); + } + if let Some(ip) = addresses.ipv6 { + configured + .push(IpCidr::new(ip.into(), 128)) + .expect("IPv6 address capacity"); + } + }); + self.interface.routes_mut().remove_default_ipv4_route(); + self.interface.routes_mut().remove_default_ipv6_route(); + // The IP-medium device emits every destination into the caller's + // packet path; these routes never configure a system interface. + if let Some(ip) = addresses.ipv4 { + self.interface + .routes_mut() + .add_default_ipv4_route(ip) + .expect("IPv4 route capacity"); + } + if let Some(ip) = addresses.ipv6 { + self.interface + .routes_mut() + .add_default_ipv6_route(ip) + .expect("IPv6 route capacity"); + } + } + + async fn next_connect(client: &mut Option) -> Option { + match client { + Some(client) => client.requests.recv().await, + None => std::future::pending().await, + } + } + async fn run(mut self) { loop { self.drive(); @@ -483,6 +626,10 @@ impl Driver { } let delay = self.next_delay(); + let has_connect_requests = self + .client + .as_ref() + .is_some_and(|client| !client.requests.is_closed()); tokio::select! { biased; () = self.cancellation.cancelled() => break, @@ -500,6 +647,12 @@ impl Driver { tokio::task::consume_budget().await; } } + request = Self::next_connect(&mut self.client), if has_connect_requests => { + if let Some(request) = request { + self.connect_tcp(request); + tokio::task::consume_budget().await; + } + } () = tokio::time::sleep(delay) => {} } } @@ -609,8 +762,16 @@ impl Driver { return; }; - if !self.tcp_entries.contains_key(&flow) && (!syn || ack || !self.admit_tcp(flow)) { - return; + if !self.tcp_entries.contains_key(&flow) { + if self.client.is_some() { + if syn && !ack { + self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); + } + return; + } + if !syn || ack || !self.admit_tcp(flow) { + return; + } } if self.device.push_rx(packet).is_err() { // A pending ingress packet must never be overwritten. @@ -619,35 +780,28 @@ impl Driver { } fn admit_tcp(&mut self, flow: FlowKey) -> bool { - if self.tcp_accept.capacity() == 0 { + let Some(accept) = self.tcp_accept.as_ref() else { + return false; + }; + if accept.capacity() == 0 { self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); return false; } - let recv_layer_buffer = self.config.recv_layer_buffer_size(); - let send_layer_buffer = self.config.send_layer_buffer_size(); - let mut socket = tcp::Socket::new( - tcp::SocketBuffer::new(vec![0_u8; recv_layer_buffer]), - tcp::SocketBuffer::new(vec![0_u8; send_layer_buffer]), - ); - socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(28))); - socket.set_timeout(Some(self.config.tcp_idle_timeout.into())); - socket.set_ack_delay(Some(smoltcp::time::Duration::from_millis(10))); - socket.set_nagle_enabled(false); - socket.set_congestion_control(tcp::CongestionControl::Cubic); + let mut socket = self.new_tcp_socket(); if socket.listen(flow.destination).is_err() { self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); return false; } let handle = Arc::new(TcpStreamHandle::new( - recv_layer_buffer, - send_layer_buffer, + self.config.recv_layer_buffer_size(), + self.config.send_layer_buffer_size(), self.notify.clone(), )); let socket_handle = self.sockets.add(socket); let stream = TcpStream::new(flow, handle.clone()); - if self.tcp_accept.try_send(stream).is_err() { + if accept.try_send(stream).is_err() { self.sockets.remove(socket_handle); self.stats.0.rejected_tcp.fetch_add(1, Ordering::AcqRel); return false; @@ -657,6 +811,8 @@ impl Driver { TcpEntry { socket: socket_handle, handle, + connecting: None, + connect_cancellation: None, }, ); self.half_open.insert(flow); @@ -664,11 +820,109 @@ impl Driver { true } + fn new_tcp_socket(&self) -> tcp::Socket<'static> { + let mut socket = tcp::Socket::new( + tcp::SocketBuffer::new(vec![0; self.config.recv_layer_buffer_size()]), + tcp::SocketBuffer::new(vec![0; self.config.send_layer_buffer_size()]), + ); + socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(28))); + socket.set_timeout(Some(self.config.tcp_idle_timeout.into())); + socket.set_ack_delay(Some(smoltcp::time::Duration::from_millis(10))); + socket.set_nagle_enabled(false); + socket.set_congestion_control(tcp::CongestionControl::Cubic); + socket + } + + fn connect_tcp(&mut self, request: ConnectRequest) { + if request.cancellation.is_cancelled() || request.response.is_closed() { + return; + } + if self.cancellation.is_cancelled() { + let _ = request.response.send(Err(connect_stopped())); + return; + } + if tokio::time::Instant::now() >= request.deadline { + let _ = request.response.send(Err(connect_timeout())); + return; + } + let client = self.client.as_mut().expect("active request has a driver"); + let source = match request.destination { + SocketAddr::V4(_) => client.addresses.ipv4.map(IpAddr::V4), + SocketAddr::V6(_) => client.addresses.ipv6.map(IpAddr::V6), + } + .expect("connector checked configured source family"); + let mut selected = None; + for _ in EPHEMERAL_PORT_START..=u16::MAX { + let port = client.next_port; + client.next_port = if port == u16::MAX { + EPHEMERAL_PORT_START + } else { + port + 1 + }; + let incoming = FlowKey { + source: request.destination, + destination: SocketAddr::new(source, port), + }; + if !self.tcp_entries.contains_key(&incoming) { + selected = Some(incoming); + break; + } + } + let Some(flow) = selected else { + let _ = request.response.send(Err(std::io::Error::new( + std::io::ErrorKind::AddrInUse, + "active TCP source ports exhausted for peer", + ))); + return; + }; + let mut socket = self.new_tcp_socket(); + if socket + .connect(self.interface.context(), flow.source, flow.destination) + .is_err() + { + let _ = request.response.send(Err(std::io::Error::new( + std::io::ErrorKind::AddrNotAvailable, + "active TCP endpoint is unavailable", + ))); + return; + } + let handle = Arc::new(TcpStreamHandle::new( + self.config.recv_layer_buffer_size(), + self.config.send_layer_buffer_size(), + self.notify.clone(), + )); + let socket = self.sockets.add(socket); + let connect_cancellation = Some(request.cancellation.clone()); + self.tcp_entries.insert( + flow, + TcpEntry { + socket, + handle, + connecting: Some(request), + connect_cancellation, + }, + ); + self.half_open.insert(flow); + self.update_flow_stats(); + } + fn drive(&mut self) { if self.raw_outbound.is_closed() { self.cancellation.cancel(); return; } + // Busy packet traffic must not starve command admission. The same + // bounded prefix as raw ingress avoids draining an unbounded burst. + for _ in 0..RAW_INGRESS_BATCH { + let request = self + .client + .as_mut() + .and_then(|client| client.requests.try_recv().ok()); + let Some(request) = request else { + break; + }; + self.connect_tcp(request); + } if !self.device.tx_is_full() { self.interface .poll(Instant::now(), &mut self.device, &mut self.sockets); @@ -687,8 +941,9 @@ impl Driver { .and_then(parse_tcp_flow) .map(|(flow, _, _)| flow); let mut inactive = Vec::new(); - for (flow, entry) in &self.tcp_entries { + for (flow, entry) in &mut self.tcp_entries { let socket = self.sockets.get_mut::(entry.socket); + Self::finish_connect(*flow, entry, socket); let handle = &entry.handle; let mut received = false; @@ -776,6 +1031,55 @@ impl Driver { self.update_flow_stats(); } + fn finish_connect(incoming: FlowKey, entry: &mut TcpEntry, socket: &mut tcp::Socket<'_>) { + let handle = &entry.handle; + if entry + .connect_cancellation + .as_ref() + .is_some_and(CancellationToken::is_cancelled) + { + // Covers cancellation after the response was queued but before + // the caller accepted its stream, including an established race. + socket.abort(); + handle.dropped.store(true, Ordering::Release); + } + let request = entry.connecting.take_if(|request| { + request.response.is_closed() + || request.cancellation.is_cancelled() + || tokio::time::Instant::now() >= request.deadline + || matches!( + socket.state(), + tcp::State::Established | tcp::State::CloseWait | tcp::State::Closed + ) + }); + let Some(request) = request else { + return; + }; + let result = if request.response.is_closed() || request.cancellation.is_cancelled() { + Err(connect_stopped()) + } else if tokio::time::Instant::now() >= request.deadline { + Err(connect_timeout()) + } else if socket.state() == tcp::State::Closed { + Err(std::io::Error::new( + std::io::ErrorKind::ConnectionRefused, + "active TCP handshake was closed", + )) + } else { + Ok(TcpStream::new( + FlowKey { + source: incoming.destination, + destination: incoming.source, + }, + handle.clone(), + )) + }; + if result.is_err() { + socket.abort(); + handle.dropped.store(true, Ordering::Release); + } + let _ = request.response.send(result); + } + fn next_delay(&mut self) -> Duration { let smoltcp_delay = self .interface @@ -784,11 +1088,21 @@ impl Driver { // A ready egress timer cannot make progress while output is full. // PacketStream notifies on capacity recovery; retain a bounded timer // for lifecycle maintenance instead of repeatedly sleeping for zero. - if smoltcp_delay.is_zero() && self.device.tx_is_full() { + let delay = if smoltcp_delay.is_zero() && self.device.tx_is_full() { self.config.max_poll_interval } else { smoltcp_delay.min(self.config.max_poll_interval) - } + }; + self.tcp_entries + .values() + .filter_map(|entry| entry.connecting.as_ref()) + .fold(delay, |delay, request| { + delay.min( + request + .deadline + .saturating_duration_since(tokio::time::Instant::now()), + ) + }) } fn update_flow_stats(&self) { @@ -812,8 +1126,17 @@ impl Driver { fn shutdown(&mut self) { self.cancellation.cancel(); self.device.discard_rx(); - for entry in self.tcp_entries.values() { + if let Some(client) = &mut self.client { + client.requests.close(); + while let Ok(request) = client.requests.try_recv() { + let _ = request.response.send(Err(connect_stopped())); + } + } + for entry in self.tcp_entries.values_mut() { entry.handle.mark_stopped(); + if let Some(request) = entry.connecting.take() { + let _ = request.response.send(Err(connect_stopped())); + } } self.tcp_entries.clear(); self.half_open.clear(); @@ -931,7 +1254,7 @@ mod tests { config, inbound, outbound, - accept, + Some(accept), Some(udp), CancellationToken::new(), Arc::new(Notify::new()), @@ -1001,7 +1324,7 @@ mod tests { config, inbound, outbound, - accept, + Some(accept), Some(udp), CancellationToken::new(), Arc::new(Notify::new()), @@ -1073,7 +1396,7 @@ mod tests { config, inbound, outbound, - accept, + Some(accept), None, CancellationToken::new(), Arc::new(Notify::new()), diff --git a/crates/vole-netstack/src/tcp.rs b/crates/vole-netstack/src/tcp.rs index 756e44c..9011692 100644 --- a/crates/vole-netstack/src/tcp.rs +++ b/crates/vole-netstack/src/tcp.rs @@ -13,10 +13,127 @@ use std::{ use futures_util::task::AtomicWaker; use tokio::{ io::{AsyncRead, AsyncWrite, ReadBuf}, - sync::{Notify, mpsc}, + sync::{Notify, mpsc, oneshot}, + time::Instant, }; use tokio_util::sync::CancellationToken; +use crate::ClientAddresses; + +pub(crate) struct ConnectRequest { + pub(crate) destination: SocketAddr, + pub(crate) deadline: Instant, + pub(crate) cancellation: CancellationToken, + pub(crate) response: oneshot::Sender>, +} + +struct ConnectGuard { + cancellation: CancellationToken, + notify: Arc, + armed: bool, +} + +impl Drop for ConnectGuard { + fn drop(&mut self) { + if self.armed { + self.cancellation.cancel(); + self.notify.notify_one(); + } + } +} + +/// Active TCP connections driven entirely by the stack's raw-IP endpoints. +#[derive(Clone)] +pub struct TcpConnector { + pub(crate) sender: mpsc::Sender, + pub(crate) cancellation: CancellationToken, + pub(crate) notify: Arc, + pub(crate) addresses: ClientAddresses, +} + +impl TcpConnector { + /// Connects to an IP endpoint and returns only after TCP is established. + /// + /// Queue admission and handshake share the supplied absolute deadline. + /// Dropping this future cancels its pending request and socket; no background + /// connect task survives the caller. The stack performs no DNS or host I/O. + /// + /// # Errors + /// + /// Returns `AddrNotAvailable` for an unconfigured address family, + /// `InvalidInput` for a non-unicast/zero-port peer, `TimedOut` at the + /// deadline, or an I/O error when TCP fails or the stack stops. + pub async fn connect( + &self, + destination: SocketAddr, + deadline: Instant, + ) -> std::io::Result { + let invalid_ip = match destination.ip() { + std::net::IpAddr::V4(ip) => { + ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast() + } + std::net::IpAddr::V6(ip) => ip.is_unspecified() || ip.is_multicast(), + }; + if invalid_ip || destination.port() == 0 { + return Err(Error::new( + ErrorKind::InvalidInput, + "invalid active TCP peer", + )); + } + if (destination.is_ipv4() && self.addresses.ipv4.is_none()) + || (destination.is_ipv6() && self.addresses.ipv6.is_none()) + { + return Err(Error::new( + ErrorKind::AddrNotAvailable, + "active TCP source family is unavailable", + )); + } + if Instant::now() >= deadline { + return Err(connect_timeout()); + } + let cancellation = CancellationToken::new(); + let mut guard = ConnectGuard { + cancellation: cancellation.clone(), + notify: self.notify.clone(), + armed: true, + }; + let (response, received) = oneshot::channel(); + let request = ConnectRequest { + destination, + deadline, + cancellation, + response, + }; + tokio::select! { + biased; + () = self.cancellation.cancelled() => return Err(connect_stopped()), + () = tokio::time::sleep_until(deadline) => return Err(connect_timeout()), + result = self.sender.send(request) => result.map_err(|_| connect_stopped())?, + } + let result = tokio::select! { + biased; + () = self.cancellation.cancelled() => Err(connect_stopped()), + () = tokio::time::sleep_until(deadline) => Err(connect_timeout()), + result = received => result.unwrap_or_else(|_| Err(connect_stopped())), + }; + if result.is_ok() { + guard.armed = false; + } + result + } +} + +pub(crate) fn connect_stopped() -> Error { + Error::new( + ErrorKind::ConnectionAborted, + "netstack is stopping or stopped", + ) +} + +pub(crate) fn connect_timeout() -> Error { + Error::new(ErrorKind::TimedOut, "netstack TCP connect deadline elapsed") +} + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) struct FlowKey { pub(crate) source: SocketAddr, @@ -119,7 +236,7 @@ impl TcpStreamHandle { } } -/// One TCP flow intercepted from the raw-IP side. +/// One intercepted or actively established userspace TCP flow. pub struct TcpStream { flow: FlowKey, pub(crate) handle: Arc, @@ -130,13 +247,13 @@ impl TcpStream { Self { flow, handle } } - /// Address of the application that wrote the original TUN packet. + /// Intercepted application address, or local source of an active connection. #[must_use] pub const fn source_addr(&self) -> SocketAddr { self.flow.source } - /// Original destination that the outbound dispatcher must dial. + /// Original intercepted destination, or remote peer of an active connection. #[must_use] pub const fn destination_addr(&self) -> SocketAddr { self.flow.destination diff --git a/crates/vole-netstack/tests/netstack.rs b/crates/vole-netstack/tests/netstack.rs index d078cb6..bbe5d51 100644 --- a/crates/vole-netstack/tests/netstack.rs +++ b/crates/vole-netstack/tests/netstack.rs @@ -1,14 +1,334 @@ use std::{ - net::{Ipv4Addr, Ipv6Addr, SocketAddr}, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, time::Duration, }; use bytes::Bytes; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use vole_netstack::{NetStack, NetStackConfig, NetStackError, Packet, UdpDatagram}; +use vole_netstack::{ + ClientAddresses, NetStack, NetStackConfig, NetStackError, Packet, UdpDatagram, +}; const WAIT: Duration = Duration::from_secs(2); +fn client_addresses() -> ClientAddresses { + ClientAddresses { + ipv4: Some("10.23.0.2".parse().unwrap()), + ipv6: Some("fd23::2".parse().unwrap()), + } +} + +fn tcp_flow(packet: &Packet) -> Flow { + let bytes = packet.data(); + let offset = ip_header_len(bytes); + let (source, destination) = match bytes[0] >> 4 { + 4 => ( + Ipv4Addr::from(<[u8; 4]>::try_from(&bytes[12..16]).unwrap()).into(), + Ipv4Addr::from(<[u8; 4]>::try_from(&bytes[16..20]).unwrap()).into(), + ), + 6 => ( + Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[8..24]).unwrap()).into(), + Ipv6Addr::from(<[u8; 16]>::try_from(&bytes[24..40]).unwrap()).into(), + ), + _ => unreachable!(), + }; + Flow { + source: SocketAddr::new( + source, + u16::from_be_bytes(bytes[offset..offset + 2].try_into().unwrap()), + ), + destination: SocketAddr::new( + destination, + u16::from_be_bytes(bytes[offset + 2..offset + 4].try_into().unwrap()), + ), + } +} + +async fn wait_no_tcp(stats: &vole_netstack::NetStackStats) { + tokio::time::timeout(WAIT, async { + while stats.snapshot().active_tcp != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("TCP state was not reclaimed"); + assert_eq!(stats.snapshot().half_open_tcp, 0); +} + +async fn timeout_tcp_flag(stream: &mut vole_netstack::PacketStream, flag: u8) -> Packet { + loop { + let packet = timeout_packet(stream).await; + let offset = ip_header_len(packet.data()); + if packet.data()[offset + 13] & flag != 0 { + return packet; + } + } +} + +#[tokio::test] +async fn active_tcp_uses_configured_sources_and_delivers_established_duplex_streams() { + let mut parts = NetStack::start_client(NetStackConfig::default(), client_addresses()).unwrap(); + for destination in ["198.51.100.20:443", "[2001:db8:2::20]:443"] { + let destination: SocketAddr = destination.parse().unwrap(); + let mut connect = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_packet(&mut parts.packet_stream).await; + let flow = tcp_flow(&syn); + assert_eq!(flow.destination, destination); + assert_eq!( + flow.source.ip(), + if destination.is_ipv4() { + IpAddr::V4(client_addresses().ipv4.unwrap()) + } else { + IpAddr::V6(client_addresses().ipv6.unwrap()) + } + ); + let offset = ip_header_len(syn.data()); + assert_eq!( + syn.data()[offset + 13] & (TcpFlags::SYN | TcpFlags::ACK), + TcpFlags::SYN + ); + assert!( + futures_util::poll!(connect.as_mut()).is_pending(), + "connect returned before the handshake" + ); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + let local_sequence = tcp_sequence(&syn).wrapping_add(1); + parts + .packet_sink + .send(build_tcp( + &peer, + 800, + local_sequence, + TcpFlags::SYN | TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + let mut stream = tokio::time::timeout(WAIT, connect).await.unwrap().unwrap(); + assert_eq!(stream.source_addr(), flow.source); + assert_eq!(stream.destination_addr(), destination); + assert_eq!(parts.stats.snapshot().half_open_tcp, 0); + stream.write_all(b"hello").await.unwrap(); + loop { + let packet = timeout_packet(&mut parts.packet_stream).await; + let offset = ip_header_len(packet.data()); + let start = offset + usize::from(packet.data()[offset + 12] >> 4) * 4; + if start != packet.len() { + assert_eq!(&packet.data()[start..], b"hello"); + assert_eq!(tcp_flow(&packet).source, flow.source); + break; + } + } + parts + .packet_sink + .send(build_tcp( + &peer, + 801, + local_sequence.wrapping_add(5), + TcpFlags::ACK, + b"world", + )) + .await + .unwrap(); + let mut payload = [0; 5]; + tokio::time::timeout(WAIT, stream.read_exact(&mut payload)) + .await + .unwrap() + .unwrap(); + assert_eq!(&payload, b"world"); + stream.shutdown().await.unwrap(); + timeout_tcp_flag(&mut parts.packet_stream, 1).await; + parts + .packet_sink + .send(build_tcp( + &peer, + 806, + local_sequence.wrapping_add(6), + TcpFlags::ACK | 1, + b"tail", + )) + .await + .unwrap(); + let mut tail = Vec::new(); + tokio::time::timeout(WAIT, stream.read_to_end(&mut tail)) + .await + .unwrap() + .unwrap(); + assert_eq!(tail, b"tail"); + } + parts.control.stop().await; + assert_eq!(parts.stats.snapshot().active_tcp, 0); +} + +#[tokio::test] +async fn active_tcp_refused_handshake_reclaims_its_socket() { + let mut parts = NetStack::start_client(NetStackConfig::default(), client_addresses()).unwrap(); + let mut connect = Box::pin(parts.tcp_connector.connect( + "198.51.100.20:443".parse().unwrap(), + tokio::time::Instant::now() + WAIT, + )); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_packet(&mut parts.packet_stream).await; + let flow = tcp_flow(&syn); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + parts + .packet_sink + .send(build_tcp( + &peer, + 0, + tcp_sequence(&syn).wrapping_add(1), + TcpFlags::ACK | 4, + &[], + )) + .await + .unwrap(); + assert_eq!( + tokio::time::timeout(WAIT, connect) + .await + .unwrap() + .unwrap_err() + .kind(), + std::io::ErrorKind::ConnectionRefused + ); + wait_no_tcp(&parts.stats).await; + parts.control.stop().await; +} + +#[tokio::test] +async fn active_tcp_cancel_and_deadline_emit_reset_and_reclaim_state() { + for (drop_future, establish) in [(true, false), (false, false), (true, true)] { + let mut parts = NetStack::start_client( + NetStackConfig { + max_poll_interval: Duration::from_secs(5), + ..NetStackConfig::default() + }, + client_addresses(), + ) + .unwrap(); + let mut connect = Box::pin(parts.tcp_connector.connect( + "198.51.100.20:443".parse().unwrap(), + tokio::time::Instant::now() + Duration::from_millis(150), + )); + assert!(futures_util::poll!(connect.as_mut()).is_pending()); + let syn = timeout_packet(&mut parts.packet_stream).await; + if establish { + let flow = tcp_flow(&syn); + let peer = Flow { + source: flow.destination, + destination: flow.source, + }; + parts + .packet_sink + .send(build_tcp( + &peer, + 800, + tcp_sequence(&syn).wrapping_add(1), + TcpFlags::SYN | TcpFlags::ACK, + &[], + )) + .await + .unwrap(); + timeout_tcp_flag(&mut parts.packet_stream, TcpFlags::ACK).await; + tokio::time::timeout(WAIT, async { + while parts.stats.snapshot().half_open_tcp != 0 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // The caller has not polled the queued Established result. Drop + // must still abort, rather than leaving an unclaimed live socket. + } + if drop_future { + drop(connect); + } else { + assert_eq!( + connect.await.unwrap_err().kind(), + std::io::ErrorKind::TimedOut + ); + } + let reset = timeout_packet(&mut parts.packet_stream).await; + let offset = ip_header_len(reset.data()); + assert_ne!(reset.data()[offset + 13] & 4, 0); + assert_eq!(tcp_flow(&reset).source, tcp_flow(&syn).source); + wait_no_tcp(&parts.stats).await; + parts.control.stop().await; + } +} + +#[tokio::test] +async fn active_tcp_stop_releases_full_output_pending_connects_and_rejects_unsolicited_syn() { + let mut parts = NetStack::start_client( + NetStackConfig { + packet_queue: 1, + max_poll_interval: Duration::from_secs(5), + ..NetStackConfig::default() + }, + client_addresses(), + ) + .unwrap(); + let destination = "198.51.100.20:443".parse().unwrap(); + let mut first = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + let mut second = Box::pin( + parts + .tcp_connector + .connect(destination, tokio::time::Instant::now() + WAIT), + ); + assert!(futures_util::poll!(first.as_mut()).is_pending()); + assert!(futures_util::poll!(second.as_mut()).is_pending()); + let stranger = Flow { + source: destination, + destination: "10.23.0.2:8000".parse().unwrap(), + }; + parts + .packet_sink + .send(build_tcp(&stranger, 100, 0, TcpFlags::SYN, &[])) + .await + .unwrap(); + tokio::time::timeout(WAIT, async { + while parts.stats.snapshot().active_tcp != 2 || parts.stats.snapshot().rejected_tcp != 1 { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + // The sole output slot still contains a SYN; stop must not await draining. + tokio::time::timeout(Duration::from_millis(500), parts.control.stop()) + .await + .unwrap(); + assert_eq!( + first.await.unwrap_err().kind(), + std::io::ErrorKind::ConnectionAborted + ); + assert_eq!( + second.await.unwrap_err().kind(), + std::io::ErrorKind::ConnectionAborted + ); + wait_no_tcp(&parts.stats).await; + assert!(parts.packet_stream.recv().await.is_none()); + assert!(matches!( + parts + .packet_sink + .try_send(build_tcp(&stranger, 100, 0, TcpFlags::SYN, &[])), + Err(NetStackError::Stopped) + )); +} + #[tokio::test] async fn ready_tcp_burst_preserves_flow_binding_and_neighboring_icmp() { let mut parts = NetStack::start(NetStackConfig { diff --git a/docs/config.yaml b/docs/config.yaml index 8992316..1c1250a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -202,6 +202,26 @@ sniffer: # tls: true 时支持 sni/skip-cert-verify/name-cert-verify/fingerprint, # 以及成对的内联 certificate/private-key。支持 dialer-proxy;详见 outbounds.md。 proxies: + # 标准 WireGuard 单 peer:用户态内层 TCP/UDP,不创建系统接口/路由/NAT。 + # 密钥为公开测试值,不可用于真实连接。外层始终需要双向 UDP;业务 udp 默认 false。 + # ip/ipv6 接受 IP 或 CIDR,按实际地址族选择,每族最多一个;至少配置一个。 + # MTU 缺省/0 为 1408,范围 1280–65503;实际上游双向预算须至少 MTU+32。 + # 与 Mihomo 一致,单 peer 顶层 allowed-ips 接受但忽略。完整行为见 outbounds.md#wireguard。 + - name: wireguard-edge + type: wireguard + server: wireguard.example.com + port: 51820 + ip: 10.84.0.2/24 + # ipv6: fd84::2/64 + private-key: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" + public-key: "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=" + # pre-shared-key: "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=" + mtu: 1408 + persistent-keepalive: 0 # uint16 秒;0 不定期保活 + allowed-ips: [] # 单 peer 忽略;当前不接受 peers 等扩展字段 + udp: true + # dialer-proxy: upstream-select # 上游必须提供足够的双向 UDP 预算 + # TUIC v5:固定 QUIC/TLS1.3;必填 UUID 和原样 password(可空,最多 65535 UTF-8 字节)。 # 不接受 v4、token、0-RTT、client-fingerprint、mTLS、ECH 或其他 TLS 后端选项。 - name: tuic-edge diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 3de3f23..59364bf 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -11,7 +11,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 | 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | | DNS | hosts、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | | mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | -| 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022;依赖启用的 Cargo feature | +| 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022、标准 WireGuard 单 peer;依赖启用的 Cargo feature | | 嗅探 | TUN/mixed TCP 的 HTTP/TLS;TUN UDP 的 QUIC;域名/地址排除、强制条件和目标覆盖 | | Linux 自建设备 | auto-detect-interface、auto-route、systemd-resolved DNS 接管;失败和停止时恢复本次修改 | | 运行状态 | 连接快照、流量计数和关闭;组与资源状态;规则命中、有效配置、受限 DNS 诊断 | @@ -31,6 +31,8 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; 与 Mihomo 在读取响应头时更新的时点不同。数值仅接受有限且可表示的十进制数, 不保留非有限/溢出浮点转换的参考行为。 +- WireGuard 使用用户态标准单 peer;每个本地地址族最多一个地址,MTU 至少1280。 + 顶层 allowed-ips 在单 peer 模式与参考一致地忽略,扩展 peers/remote DNS 尚不接受。 - 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 - 未配置入站的配置不能启动。Controller 沿用回环地址、secret 及运行配置准入,见 [Controller API](controller-api.md)。 diff --git a/docs/outbounds.md b/docs/outbounds.md index 321a497..036d8a3 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -314,3 +314,35 @@ client-first 路径,server-first/空首包列为已知限制,不计成功; Mihomo 仅提供单 PSK;原样 ssserver 验证 AES 单层 EIH 多用户 TCP/UDP 终结及错误身份。 MTU1500 大包夹具显式启用官方双向 IP 分片。自有 1/2 层身份中继不是原生任意多层证明。 所有结果与平台/发布签收分开,见[验收边界](acceptance.md)。 + + +## WireGuard + +`type: wireguard` 使用官方 boringtun 的标准 Noise 协议和用户态 IP 栈。 +`outbound-wireguard` 可独立启用,不依赖 TUN;不创建系统接口、路由或 NAT。 +单 peer 必填 server/port、private-key/public-key,ip/ipv6 至少提供一个本地地址; +地址接受 IP 或 CIDR,保留主机地址,每族最多一个。pre-shared-key 可选。 +密钥必须是解码后 32 字节的标准 Base64,保存时清零回收且 Debug/错误不暴露密钥。 + +MTU 省略/0 为 1408,范围 1280–65503;外层实际双向 UDP 预算必须至少为 MTU+32, +不足时关闭上游,不静默缩小 MTU。udp 缺省 false,只控制业务 UDP,外层始终需要 +双向数据报能力。persistent-keepalive 为 uint16 秒,0 关闭定期保活。 +与固定 Mihomo 参考一致,无 peers 时顶层 allowed-ips 接受但不参与选择: +本地配置的地址族使用全地址路由。当前不接受 peers、reserved、remote-dns-resolve、 +dns、refresh-server-ip-interval、Amnezia 或系统接口模式字段。 + +每节点、资源代际按需拥有一套 TCP 栈与 peer 驱动,内层 UDP 使用真实临时端口。 +外层经过共享 Dialer/UpstreamPath,节点端点由代理 DNS 角色解析并固定在物理会话; +旧会话保留建链组快照与已认证入站身份。业务域名用受控本地 DNS,并在配置的本地 +地址族中选择;遵循节点 ip-version、全局 IPv6 开关与原建链期限,不在失败时回落 DIRECT。 +真实握手完成才交付业务流;握手重试和后续保活使用官方协议定时器。 + +每节点 raw 包队列和 peer 发送队列各 32 项;每 UDP 关联响应队列 64 项。 +UDP 负载上限为 MTU 减本地 IP/UDP 头,再与调用方收发预算分别取交集; +双栈节点保守按 48 字节扣除,只有 IPv4 时扣 28 字节。满队列丢当前包, +TCP 依赖重传。解密后校验本地目的地址、来源地址族和关联端口,不交付陌生数据。 +这些是局部结构边界,不是业务连接总数限制。 + +握手、收发与重试均可取消;peer/栈任务由节点持有,调用方 future 丢弃不会丢失 +任务所有权。Stop 关闭新准入、取消并等待驱动、关闭上游(最多一秒),随后等待 +用户态栈退出。失败物理会话只由新业务重建,不重放已发送的业务。 diff --git a/readme/README.ru.md b/readme/README.ru.md index 60b24b2..e59b891 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -20,7 +20,7 @@ Vole — прокси-ядро на Rust для VPN-клиентов и лока ## Прокси-протоколы -Все девять протоколов ниже включены по умолчанию. HTTP CONNECT поддерживает только TCP; UDP для остальных протоколов настраивается на уровне узла. +Все десять протоколов ниже включены по умолчанию. HTTP CONNECT поддерживает только TCP; UDP для остальных протоколов настраивается на уровне узла. | Протокол | Возможности | | --- | --- | @@ -33,6 +33,7 @@ Vole — прокси-ядро на Rust для VPN-клиентов и лока | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT и UDP ASSOCIATE, с опциональной аутентификацией по имени пользователя и паролю | | [Hysteria2](../docs/outbounds.md#hysteria2) | TCP/UDP через QUIC, управление пропускной способностью, Salamander, смена портов и mTLS | | [TUIC v5](../docs/outbounds.md#tuic-v5) | TCP/UDP через QUIC, режимы передачи UDP native/quic и выбор алгоритма управления перегрузкой | +| [WireGuard](../docs/outbounds.md#wireguard) | TCP/UDP через стандартный WireGuard с пользовательским IP-стеком | Стандартные TLS-соединения поддерживают проверку сертификатов и закрепление сертификата по SHA-256. Поддерживаемые TCP-пути с TLS могут использовать шаблоны ClientHello Chrome, Firefox или Safari; `client-fingerprint` не зависит от `fingerprint` сертификата. Точные имена и сочетания описаны в [профилях TLS и политике сертификатов](../docs/tls-client-fingerprint.md). Эти функции не гарантируют неотличимость от браузера или произвольные сочетания протоколов. @@ -140,6 +141,7 @@ void VoleFree(char *response); Vole использует публичные зависимости и опирается на реализации протоколов и примеры платформенной интеграции: - Зависимости TUN: локальный [`vole-netstack`](../crates/vole-netstack/README.md) использует [smoltcp](https://github.com/smoltcp-rs/smoltcp), а пакетный I/O на Unix и Windows Wintun — [tun-rs](https://github.com/tun-rs/tun-rs) (Apache-2.0). +- WireGuard: [boringtun](https://github.com/cloudflare/boringtun) (BSD-3-Clause), без host-device, FFI и JNI. - [Wintun](https://www.wintun.net/): DLL предоставляет хост; Vole её не включает. Исходный [заголовок API](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) в tun-rs имеет copyright 2018–2021 WireGuard LLC и лицензию `GPL-2.0 OR MIT`; при распространении связанных API bindings необходимо сохранить copyright и уведомление об альтернативной лицензии MIT. - Архитектурные ориентиры для сети и маршрутизации: [clash-rs](https://github.com/Watfaq/clash-rs), [netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp), [Mihomo](https://github.com/MetaCubeX/mihomo), [Xray-core](https://github.com/XTLS/Xray-core) и [Leaf](https://github.com/eycorsican/leaf). Эти проекты не являются зависимостями netstack. - TLS и Shadowsocks: [rustls](https://github.com/rustls/rustls), [boring](https://github.com/cloudflare/boring), [BoringSSL](https://boringssl.googlesource.com/boringssl/) и [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust). Заимствованный код replay-window сохраняет [уведомления MIT](../src/outbound/shadowsocks/packet_window.rs). diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index a1a4a51..05e677c 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -20,7 +20,7 @@ Vole 是用于 VPN 客户端和本地代理的 Rust 代理内核,提供原生 ## 代理协议 -默认构建启用下列全部九种协议。HTTP CONNECT 仅支持 TCP;其他协议的 UDP 能力按节点配置。 +默认构建启用下列全部十种协议。HTTP CONNECT 仅支持 TCP;其他协议的 UDP 能力按节点配置。 | 协议 | 能力 | | --- | --- | @@ -33,6 +33,7 @@ Vole 是用于 VPN 客户端和本地代理的 Rust 代理内核,提供原生 | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT 和 UDP ASSOCIATE,可选用户名/密码认证 | | [Hysteria2](../docs/outbounds.md#hysteria2) | QUIC TCP/UDP、带宽控制、Salamander、端口跳跃和 mTLS | | [TUIC v5](../docs/outbounds.md#tuic-v5) | QUIC TCP/UDP、native/quic UDP 转发模式,以及可选的拥塞控制算法 | +| [WireGuard](../docs/outbounds.md#wireguard) | 标准 WireGuard TCP/UDP,使用用户态 IP 栈 | 标准 TLS 连接支持证书验证与 SHA-256 证书固定。适用的 TCP TLS 路径可使用 Chrome、Firefox 或 Safari ClientHello 模板;`client-fingerprint` 与证书 `fingerprint` 相互独立。准确的名称和支持组合见 [TLS 配置与证书策略](../docs/tls-client-fingerprint.md)。这些功能不保证与浏览器不可区分,也不支持任意协议组合。 @@ -140,6 +141,7 @@ benchmark 工程同时负责协议互通(`interop`)与内存压力(`stress Vole 使用并参考以下公开依赖、协议实现和平台资料: - TUN 依赖:自有 [`vole-netstack`](../crates/vole-netstack/README.md) 使用 [smoltcp](https://github.com/smoltcp-rs/smoltcp),Unix 与 Windows Wintun 包 I/O 使用 [tun-rs](https://github.com/tun-rs/tun-rs)(Apache-2.0)。 +- WireGuard:[boringtun](https://github.com/cloudflare/boringtun)(BSD-3-Clause),不启用 host-device、FFI 或 JNI。 - [Wintun](https://www.wintun.net/):运行时 DLL 由宿主提供,不随 Vole 捆绑。tun-rs 中的上游 [API 头文件](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) 版权为 2018–2021 WireGuard LLC,许可证为 `GPL-2.0 OR MIT`;分发链接的 API bindings 时须保留其版权和 MIT 可选许可证声明。 - 网络与路由参考:[clash-rs](https://github.com/Watfaq/clash-rs)、[netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp)、[Mihomo](https://github.com/MetaCubeX/mihomo)、[Xray-core](https://github.com/XTLS/Xray-core) 和 [Leaf](https://github.com/eycorsican/leaf)。这些参考项目并非 netstack 依赖。 - TLS 与 Shadowsocks:[rustls](https://github.com/rustls/rustls)、[boring](https://github.com/cloudflare/boring)、[BoringSSL](https://boringssl.googlesource.com/boringssl/) 和 [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust)。衍生的重放窗口代码保留了 [MIT 声明](../src/outbound/shadowsocks/packet_window.rs)。 diff --git a/src/config/measure.rs b/src/config/measure.rs index f650c86..ae703e2 100644 --- a/src/config/measure.rs +++ b/src/config/measure.rs @@ -87,14 +87,11 @@ impl RawMeasureConfig { #[cfg(test)] mod tests { #[test] - fn measurement_rejects_future_protocols_and_oversized_documents() { + fn measurement_rejects_oversized_documents() { let _case = crate::resources::case_events::Case::new( "FOUNDATIONS-SCHEMA", - "measurement_rejects_future_protocols_and_oversized_documents", + "measurement_rejects_oversized_documents", ); - let yaml = - b"proxies:\n - name: node\n type: wireguard\n server: example.com\n port: 443\n"; - assert!(super::MeasureConfig::parse_yaml(yaml).is_err()); assert!( super::MeasureConfig::parse_yaml(&vec![b'x'; super::super::MAX_CONFIG_BYTES + 1]) .is_err() diff --git a/src/config/mod.rs b/src/config/mod.rs index 090b0c3..76a2a96 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -50,6 +50,8 @@ mod ech; pub use ech::StaticEchConfig; mod hysteria2; pub use hysteria2::{Hysteria2Hopping, Hysteria2OutboundConfig}; +mod wireguard; +pub use wireguard::{WireGuardKey, WireGuardOutboundConfig}; mod tuic; pub use tuic::{TuicCongestion, TuicOutboundConfig, TuicUdpMode}; mod jls; @@ -652,6 +654,7 @@ pub struct ProxyConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProxyProtocol { Http(HttpOutboundConfig), + WireGuard(WireGuardOutboundConfig), Tuic(TuicOutboundConfig), Hysteria2(Hysteria2OutboundConfig), Vmess(VmessOutboundConfig), @@ -666,6 +669,7 @@ impl ProxyConfig { #[must_use] pub fn address(&self) -> &str { match &self.protocol { + ProxyProtocol::WireGuard(config) => &config.address, ProxyProtocol::Http(config) => &config.address, ProxyProtocol::Tuic(config) => &config.address, ProxyProtocol::Hysteria2(config) => &config.address, @@ -681,6 +685,7 @@ impl ProxyConfig { #[must_use] pub const fn port(&self) -> u16 { match &self.protocol { + ProxyProtocol::WireGuard(config) => config.port, ProxyProtocol::Http(config) => config.port, ProxyProtocol::Tuic(config) => config.port, ProxyProtocol::Hysteria2(config) => config.port, @@ -1496,6 +1501,8 @@ impl<'de> Deserialize<'de> for RawOutbound { // protocol fields inline avoids extra heap allocations in the startup path. #[allow(clippy::large_enum_variant)] enum RawProtocol { + #[serde(rename = "wireguard")] + WireGuard(#[serde(deserialize_with = "wireguard::deserialize")] wireguard::RawWireGuard), #[serde(rename = "http")] Http(http_proxy::RawHttp), #[serde(rename = "tuic")] @@ -2680,6 +2687,7 @@ pub(crate) fn proxy_graph_order( impl RawOutbound { fn normalize(self) -> Result { let (tag, dialer_proxy, udp, protocol) = match self.protocol { + RawProtocol::WireGuard(raw) => raw.normalize()?, RawProtocol::Http(raw) => raw.normalize()?, RawProtocol::Hysteria2(raw) => raw.normalize()?, RawProtocol::Tuic(raw) => raw.normalize()?, diff --git a/src/config/wireguard.rs b/src/config/wireguard.rs new file mode 100644 index 0000000..f98024e --- /dev/null +++ b/src/config/wireguard.rs @@ -0,0 +1,164 @@ +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::Deserialize; +use zeroize::Zeroizing; + +use super::{ProxyProtocol, invalid, validate_host, validate_port}; +use crate::Result; + +#[derive(Clone, PartialEq, Eq)] +pub struct WireGuardKey(Zeroizing<[u8; 32]>); +impl std::fmt::Debug for WireGuardKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("WireGuardKey { .. }") + } +} +impl WireGuardKey { + fn parse(value: String) -> Result { + let value = Zeroizing::new(value); + let bytes = Zeroizing::new(STANDARD.decode(value.as_bytes()).map_err(|_| { + crate::VoleError::InvalidConfig("WireGuard requires a 32-byte base64 key".into()) + })?); + let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + crate::VoleError::InvalidConfig("WireGuard requires a 32-byte base64 key".into()) + })?; + Ok(Self(Zeroizing::new(bytes))) + } + #[cfg(feature = "outbound-wireguard")] + pub(crate) fn bytes(&self) -> [u8; 32] { + *self.0 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct WireGuardOutboundConfig { + pub address: String, + pub port: u16, + pub ipv4: Option, + pub ipv6: Option, + pub private_key: WireGuardKey, + pub public_key: WireGuardKey, + pub pre_shared_key: Option, + pub mtu: u16, + pub persistent_keepalive: u16, + /// Mihomo ignores this top-level option without `peers`. + pub allowed_ips: Vec, +} +impl std::fmt::Debug for WireGuardOutboundConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WireGuardOutboundConfig") + .field("mtu", &self.mtu) + .finish_non_exhaustive() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub(super) struct RawWireGuard { + name: String, + server: String, + port: u16, + #[serde(default)] + ip: String, + #[serde(default)] + ipv6: String, + private_key: String, + public_key: String, + #[serde(default)] + pre_shared_key: String, + #[serde(default)] + mtu: u16, + #[serde(default)] + persistent_keepalive: u16, + #[serde(default)] + allowed_ips: Vec, + #[serde(default)] + udp: bool, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + dialer_proxy: Option, +} +impl std::fmt::Debug for RawWireGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RawWireGuard").finish_non_exhaustive() + } +} +pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>( + d: D, +) -> std::result::Result { + RawWireGuard::deserialize(d) + .map_err(|_| serde::de::Error::custom("invalid WireGuard configuration fields")) +} +impl RawWireGuard { + pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { + if !cfg!(feature = "outbound-wireguard") { + return invalid("WireGuard support is disabled at build time"); + } + let mut ipv4 = None; + let mut ipv6 = None; + for value in [&self.ip, &self.ipv6].into_iter().filter(|s| !s.is_empty()) { + let (address, prefix) = value + .split_once('/') + .map_or((value.as_str(), None), |(a, p)| (a, Some(p))); + let ip = address.parse::().map_err(|_| { + crate::VoleError::InvalidConfig("invalid WireGuard local address".into()) + })?; + if let Some(prefix) = prefix { + let prefix = prefix.parse::().map_err(|_| { + crate::VoleError::InvalidConfig("invalid WireGuard local prefix".into()) + })?; + if prefix > if ip.is_ipv4() { 32 } else { 128 } { + return invalid("invalid WireGuard local prefix"); + } + } + match ip { + IpAddr::V4(ip) if ipv4.is_none() => ipv4 = Some(ip), + IpAddr::V6(ip) if ipv6.is_none() => ipv6 = Some(ip), + _ => return invalid("WireGuard requires at most one local address per family"), + } + } + let config = WireGuardOutboundConfig { + address: self.server, + port: self.port, + ipv4, + ipv6, + private_key: WireGuardKey::parse(self.private_key)?, + public_key: WireGuardKey::parse(self.public_key)?, + pre_shared_key: (!self.pre_shared_key.is_empty()) + .then(|| WireGuardKey::parse(self.pre_shared_key)) + .transpose()?, + mtu: if self.mtu == 0 { 1408 } else { self.mtu }, + persistent_keepalive: self.persistent_keepalive, + allowed_ips: self.allowed_ips, + }; + config.validate()?; + Ok(( + self.name, + self.dialer_proxy, + self.udp, + ProxyProtocol::WireGuard(config), + )) + } +} +impl WireGuardOutboundConfig { + pub(crate) fn validate(&self) -> Result<()> { + validate_host(&self.address, "WireGuard server")?; + validate_port(self.port, "WireGuard")?; + if self.ipv4.is_none() && self.ipv6.is_none() { + return invalid("WireGuard requires a local address"); + } + if self + .ipv4 + .is_some_and(|ip| ip.is_unspecified() || ip.is_multicast() || ip.is_broadcast()) + || self + .ipv6 + .is_some_and(|ip| ip.is_unspecified() || ip.is_multicast()) + { + return invalid("WireGuard requires unicast local addresses"); + } + if !(1280..=65503).contains(&self.mtu) { + return invalid("WireGuard MTU must be between 1280 and 65503"); + } + Ok(()) + } +} diff --git a/src/dns/resolution.rs b/src/dns/resolution.rs index a47a85b..552b62a 100644 --- a/src/dns/resolution.rs +++ b/src/dns/resolution.rs @@ -116,6 +116,23 @@ impl ResolutionContext { .ok_or(DispatchError::HostUnreachable) } + /// A raw-IP node cannot originate a family without a configured local IP. + /// Keep the same business DNS role, policy ordering and absolute deadline. + #[cfg(feature = "outbound-wireguard")] + pub(crate) async fn resolve_local_family( + &self, + target: &Destination, + deadline: Instant, + ipv4: bool, + ipv6: bool, + ) -> Result { + self.resolve_addresses(target, deadline, false) + .await? + .into_iter() + .find(|address| if address.is_ipv4() { ipv4 } else { ipv6 }) + .ok_or(DispatchError::HostUnreachable) + } + pub async fn resolve_proxy( &self, target: &Destination, diff --git a/src/lib.rs b/src/lib.rs index d143356..8c907b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,8 @@ pub mod routing; feature = "outbound-trojan", feature = "outbound-vmess", feature = "outbound-hysteria2", - feature = "outbound-tuic" + feature = "outbound-tuic", + feature = "outbound-wireguard" ), any(feature = "invoke", test) ))] diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index 2a07a40..6c1d64f 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -250,7 +250,7 @@ impl EstablishContext { /// Only for a new physical path of an already authenticated session, never /// for extending an in-progress setup. Freeze its group choices and give /// this socket's complete upstream establishment a new absolute deadline. - #[cfg(feature = "outbound-hysteria2")] + #[cfg(any(test, feature = "outbound-hysteria2"))] pub(crate) fn authenticated_continuation(&self) -> Self { Self { deadline: Instant::now() + DEFAULT_ESTABLISH_TIMEOUT, @@ -673,7 +673,8 @@ impl UpstreamPath { feature = "outbound-shadowsocks", feature = "outbound-vless", feature = "outbound-hysteria2", - feature = "outbound-tuic" + feature = "outbound-tuic", + feature = "outbound-wireguard" ))] pub(crate) async fn datagram_server( &self, diff --git a/src/outbound/mod.rs b/src/outbound/mod.rs index 8a5cf2c..c9d0c74 100644 --- a/src/outbound/mod.rs +++ b/src/outbound/mod.rs @@ -7,6 +7,8 @@ pub mod http; pub mod trojan; #[cfg(feature = "outbound-vmess")] pub mod vmess; +#[cfg(feature = "outbound-wireguard")] +pub mod wireguard; #[cfg(feature = "outbound-anytls")] mod anytls; @@ -61,7 +63,8 @@ pub mod hysteria2; feature = "outbound-vmess", feature = "outbound-vless", feature = "outbound-hysteria2", - feature = "outbound-tuic" + feature = "outbound-tuic", + feature = "outbound-wireguard" ))] mod owned_stream; #[cfg(feature = "outbound-tuic")] diff --git a/src/outbound/wireguard/datagram.rs b/src/outbound/wireguard/datagram.rs new file mode 100644 index 0000000..d3f3fed --- /dev/null +++ b/src/outbound/wireguard/datagram.rs @@ -0,0 +1,212 @@ +use super::engine::Engine; +use crate::{ + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + dns::resolution::ResolutionContext, + outbound::DEFAULT_ESTABLISH_TIMEOUT, + resources::observation::{self, ResourceKind}, + session::{Datagram, Destination}, +}; +use async_trait::async_trait; +use bytes::Bytes; +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, + sync::{Arc, Mutex, Weak}, +}; +use tokio::sync::mpsc; +use tokio::time::Instant; +use vole_netstack::{Packet, UdpDatagram, encode_udp_packet_into}; + +const FIRST_PORT: u16 = 1024; +const RESPONSE_QUEUE: usize = 64; +struct Entry { + id: u64, + slot: Weak, +} +struct State { + next: u16, + id: u64, + entries: HashMap, +} +impl Default for State { + fn default() -> Self { + Self { + next: 49152, + id: 0, + entries: HashMap::new(), + } + } +} +#[derive(Default)] +pub(super) struct Registry(Mutex); +pub(super) struct Slot { + pub port: u16, + id: u64, + registry: Arc, + sender: mpsc::Sender, + maximum: u16, + _observation: observation::Guard, +} +impl Registry { + fn open( + self: &Arc, + maximum: u16, + ) -> Result<(Arc, mpsc::Receiver), DispatchError> { + let mut state = self.0.lock().unwrap(); + for _ in FIRST_PORT..=u16::MAX { + let port = state.next; + state.next = if port == u16::MAX { + FIRST_PORT + } else { + port + 1 + }; + if state + .entries + .get(&port) + .is_some_and(|entry| entry.slot.strong_count() > 0) + { + continue; + } + state.id = state.id.checked_add(1).ok_or_else(|| { + DispatchError::Other("WireGuard UDP lease identifiers exhausted".into()) + })?; + let id = state.id; + let (sender, receiver) = mpsc::channel(RESPONSE_QUEUE); + let slot = Arc::new(Slot { + port, + id, + registry: self.clone(), + sender, + maximum, + _observation: observation::track(ResourceKind::Association), + }); + state.entries.insert( + port, + Entry { + id, + slot: Arc::downgrade(&slot), + }, + ); + return Ok((slot, receiver)); + } + Err(DispatchError::Other( + "WireGuard UDP source ports exhausted".into(), + )) + } + pub fn deliver(&self, source: SocketAddr, destination: SocketAddr, payload: &[u8]) { + let slot = self + .0 + .lock() + .unwrap() + .entries + .get(&destination.port()) + .and_then(|entry| entry.slot.upgrade()); + if let Some(slot) = slot.filter(|slot| payload.len() <= usize::from(slot.maximum)) { + let _ = slot.sender.try_send(Datagram { + remote: Destination::Ip(source), + payload: Bytes::copy_from_slice(payload), + sniffed_domain: None, + }); + } + } +} +impl Drop for Slot { + fn drop(&mut self) { + let mut state = self.registry.0.lock().unwrap(); + if state + .entries + .get(&self.port) + .is_some_and(|entry| entry.id == self.id) + { + state.entries.remove(&self.port); + } + if state.entries.capacity() > state.entries.len().saturating_mul(4).max(64) { + state.entries.shrink_to_fit(); + } + } +} +pub(super) struct Datagrams { + engine: Arc, + slot: Option>, + receiver: mpsc::Receiver, + resolution: ResolutionContext, + budget: DatagramBudget, + frame: Vec, +} +impl Datagrams { + pub fn new( + engine: Arc, + request: DatagramBudget, + resolution: ResolutionContext, + ) -> Result { + let overhead = if engine.ipv6.is_some() { 48 } else { 28 }; + let maximum = (engine.mtu - overhead) as u16; + let budget = DatagramBudget::new( + maximum.min(request.transmit()), + maximum.min(request.receive()), + ); + let (slot, receiver) = engine.registry.open(budget.receive())?; + let mtu = engine.mtu; + Ok(Self { + engine, + slot: Some(slot), + receiver, + resolution, + budget, + frame: Vec::with_capacity(mtu), + }) + } + fn port(&self) -> Result { + self.slot + .as_ref() + .map(|slot| slot.port) + .ok_or(DispatchError::ConnectionRefused) + } +} +#[async_trait] +impl DatagramTransport for Datagrams { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + self.budget + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + let port = self.port()?; + if datagram.payload.len() > usize::from(self.budget.transmit()) { + return Err(DispatchError::Other( + "WireGuard datagram exceeds transmit budget".into(), + )); + } + let deadline = Instant::now() + DEFAULT_ESTABLISH_TIMEOUT; + let destination = tokio::select! {biased;()=self.engine.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=self.resolution.resolve_local_family(&datagram.remote,deadline,self.engine.ipv4.is_some(),self.engine.ipv6.is_some())=>result?}; + let source = SocketAddr::new(self.engine.local(destination.ip())?, port); + encode_udp_packet_into( + &UdpDatagram::new(source, destination, datagram.payload), + self.engine.mtu, + &mut self.frame, + ) + .map_err(|_| DispatchError::Other("invalid WireGuard datagram".into()))?; + let packet = Packet::new(Bytes::copy_from_slice(&self.frame)); + tokio::select! {biased; + ()=self.engine.cancel.cancelled()=>Err(DispatchError::ConnectionRefused), + result=tokio::time::timeout_at(deadline,self.engine.tx.send(packet))=>result.map_err(|_|DispatchError::TimedOut)?.map_err(|_|DispatchError::ConnectionRefused), + } + } + async fn receive(&mut self) -> Result { + self.port()?; + tokio::select! {biased;()=self.engine.cancel.cancelled()=>Err(DispatchError::ConnectionRefused),datagram=self.receiver.recv()=>datagram.ok_or(DispatchError::ConnectionRefused)} + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.slot.take(); + self.receiver.close(); + Ok(()) + } +} + +pub(super) fn local_destination( + config: &crate::config::WireGuardOutboundConfig, + destination: IpAddr, +) -> bool { + match destination { + IpAddr::V4(ip) => config.ipv4 == Some(ip), + IpAddr::V6(ip) => config.ipv6 == Some(ip), + } +} diff --git a/src/outbound/wireguard/engine.rs b/src/outbound/wireguard/engine.rs new file mode 100644 index 0000000..63a7a8d --- /dev/null +++ b/src/outbound/wireguard/engine.rs @@ -0,0 +1,151 @@ +use super::{datagram::Registry, peer}; +use crate::{ + config::WireGuardOutboundConfig, + dispatch::DispatchError, + outbound::{EstablishContext, UpstreamPath}, + resources::observation, + session::{DatagramSession, Destination}, +}; +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::{Arc, Mutex}, +}; +use tokio::{ + sync::{Mutex as AsyncMutex, mpsc, watch}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; +use vole_netstack::{ + ClientAddresses, ClientNetStackParts, NetStack, NetStackConfig, NetStackControl, Packet, + PacketStream, TcpConnector, +}; + +#[derive(Clone, Copy)] +pub(super) enum Ready { + Waiting, + Established, + Failed, +} + +pub(super) struct Engine { + pub cancel: CancellationToken, + pub tcp: TcpConnector, + pub registry: Arc, + pub tx: mpsc::Sender, + pub ipv4: Option, + pub ipv6: Option, + pub mtu: usize, + ready: watch::Receiver, + control: NetStackControl, + tasks: Mutex>>, + stop: AsyncMutex<()>, +} +impl Engine { + pub fn start( + config: Arc, + server: Destination, + upstream: UpstreamPath, + session: DatagramSession, + context: EstablishContext, + cancel: CancellationToken, + ) -> Result, DispatchError> { + let addresses = ClientAddresses { + ipv4: config.ipv4, + ipv6: config.ipv6, + }; + let ClientNetStackParts { + packet_sink, + packet_stream, + tcp_connector, + control, + .. + } = NetStack::start_client( + NetStackConfig { + mtu: usize::from(config.mtu), + packet_queue: 32, + ..Default::default() + }, + addresses, + ) + .map_err(|_| DispatchError::Other("WireGuard stack initialization failed".into()))?; + let (tx, rx) = mpsc::channel(32); + let (ready, state) = watch::channel(Ready::Waiting); + let registry = Arc::new(Registry::default()); + let engine = Arc::new(Self { + cancel: cancel.clone(), + tcp: tcp_connector, + registry: registry.clone(), + tx: tx.clone(), + ipv4: config.ipv4, + ipv6: config.ipv6, + mtu: usize::from(config.mtu), + ready: state, + control, + tasks: Mutex::new(Vec::new()), + stop: AsyncMutex::new(()), + }); + let router = observation::spawn(pump_output(packet_stream, tx, cancel.clone())); + let peer = observation::spawn(peer::drive( + config, + server, + upstream, + session, + context, + cancel, + rx, + ready, + packet_sink, + registry, + )); + *engine.tasks.lock().unwrap() = vec![router, peer]; + Ok(engine) + } + pub fn local(&self, ip: IpAddr) -> Result { + match ip { + IpAddr::V4(_) => self.ipv4.map(IpAddr::V4), + IpAddr::V6(_) => self.ipv6.map(IpAddr::V6), + } + .ok_or(DispatchError::HostUnreachable) + } + pub async fn wait_ready(&self, context: &EstablishContext) -> Result<(), DispatchError> { + let mut ready = self.ready.clone(); + context.run("WireGuard handshake",async { + loop { + match *ready.borrow_and_update() {Ready::Established=>return Ok(()),Ready::Failed=>return Err(DispatchError::ConnectionRefused),Ready::Waiting=>{}} + tokio::select! {biased;()=self.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=ready.changed()=>if result.is_err(){return Err(DispatchError::ConnectionRefused);}} + } + }).await + } + pub async fn stop(&self) { + let _stop = self.stop.lock().await; + self.cancel.cancel(); + let tasks = std::mem::take(&mut *self.tasks.lock().unwrap()); + for task in tasks { + let _ = task.await; + } + self.control.stop().await; + } +} +impl Drop for Engine { + fn drop(&mut self) { + self.cancel.cancel(); + self.control.cancel(); + for task in self.tasks.get_mut().unwrap().drain(..) { + task.abort(); + } + } +} + +async fn pump_output( + mut packets: PacketStream, + tx: mpsc::Sender, + cancel: CancellationToken, +) { + loop { + let packet = tokio::select! {biased;()=cancel.cancelled()=>break,packet=packets.recv()=>match packet {Some(packet)=>packet,None=>break}}; + // This single-peer route seam becomes longest-prefix peer selection in + // the multi-peer extension. All flows share the same active stack. + tokio::select! {biased;()=cancel.cancelled()=>break,result=tx.send(packet)=>if result.is_err(){break;}} + } + cancel.cancel(); +} diff --git a/src/outbound/wireguard/mod.rs b/src/outbound/wireguard/mod.rs new file mode 100644 index 0000000..a508ad3 --- /dev/null +++ b/src/outbound/wireguard/mod.rs @@ -0,0 +1,140 @@ +//! Standard WireGuard over shared controlled UDP and the active raw-IP stack. +mod datagram; +mod engine; +mod peer; +#[cfg(test)] +mod tests; + +use crate::{ + config::WireGuardOutboundConfig, + dispatch::{DatagramTransport, DispatchError}, + outbound::{ + ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, + owned_stream::OwnedStream, server_destination, + }, + resources::observation, + session::{DatagramSession, Destination, StreamSession}, +}; +use async_trait::async_trait; +use engine::Engine; +use std::{ + io, + sync::{Arc, Mutex}, +}; +use tokio::sync::Mutex as AsyncMutex; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +pub struct WireGuardOutbound { + config: Arc, + server: Destination, + upstream: UpstreamPath, + current: AsyncMutex>>, + cancel: CancellationToken, + admission: Mutex<()>, + retired: TaskTracker, +} +impl std::fmt::Debug for WireGuardOutbound { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WireGuardOutbound").finish_non_exhaustive() + } +} +impl WireGuardOutbound { + pub fn new_with_path( + config: &WireGuardOutboundConfig, + upstream: UpstreamPath, + ) -> io::Result { + config.validate().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "invalid WireGuard configuration", + ) + })?; + Ok(Self { + config: Arc::new(config.clone()), + server: server_destination(&config.address, config.port)?, + upstream, + current: AsyncMutex::new(None), + cancel: CancellationToken::new(), + admission: Mutex::new(()), + retired: TaskTracker::new(), + }) + } + async fn engine( + &self, + session: DatagramSession, + context: &EstablishContext, + ) -> Result, DispatchError> { + let engine = tokio::select! { biased; + ()=self.cancel.cancelled()=>return Err(DispatchError::NotAllowed), + result=context.run("WireGuard initialize",async { + let mut current=self.current.lock().await; + if let Some(engine)=current.as_ref().filter(|engine|!engine.cancel.is_cancelled()) { return Ok(engine.clone()); } + let _admission=self.admission.lock().unwrap(); + if self.cancel.is_cancelled() {return Err(DispatchError::NotAllowed);} + if let Some(old)=current.take() {self.retired.spawn(observation::task(async move {old.stop().await;}));} + let engine=Engine::start(self.config.clone(),self.server.clone(),self.upstream.clone(),session,context.clone(),self.cancel.child_token())?; + // Publish ownership before waiting for network setup or authentication. + *current=Some(engine.clone()); + Ok(engine) + })=>result?, + }; + engine.wait_ready(context).await?; + Ok(engine) + } +} +#[async_trait] +impl OutboundConnector for WireGuardOutbound { + async fn connect_stream( + &self, + session: StreamSession, + context: &EstablishContext, + ) -> Result { + let datagram = + DatagramSession::new(session.inbound, session.source).with_metadata(session.metadata); + let engine = self.engine(datagram, context).await?; + let resolution = context.resolution(); + let remote = tokio::select! {biased; ()=engine.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=resolution.resolve_local_family(&session.destination, context.deadline(), engine.ipv4.is_some(), engine.ipv6.is_some())=>result?}; + engine.local(remote.ip())?; + let stream = tokio::select! {biased; + ()=engine.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused), + result=context.run_io("WireGuard TCP",engine.tcp.connect(remote,context.deadline()))=>result?, + }; + Ok(ConnectedStream { + io: Box::new(OwnedStream::new( + Box::new(stream), + engine.cancel.child_token(), + )), + effective_peer: Destination::Ip(remote), + }) + } + async fn open_datagram( + &self, + request: DatagramRequest, + context: &EstablishContext, + ) -> Result, DispatchError> { + let engine = self.engine(request.session.clone(), context).await?; + Ok(Box::new(datagram::Datagrams::new( + engine, + request.budget(), + context.resolution(), + )?)) + } + fn begin_shutdown(&self) { + let _admission = self.admission.lock().unwrap(); + self.cancel.cancel(); + self.retired.close(); + } + async fn shutdown(&self) { + self.begin_shutdown(); + self.retired.wait().await; + let engine = self.current.lock().await.take(); + if let Some(engine) = engine { + engine.stop().await; + } + } +} +impl Drop for WireGuardOutbound { + fn drop(&mut self) { + self.cancel.cancel(); + } +} diff --git a/src/outbound/wireguard/peer.rs b/src/outbound/wireguard/peer.rs new file mode 100644 index 0000000..3cdadfa --- /dev/null +++ b/src/outbound/wireguard/peer.rs @@ -0,0 +1,218 @@ +use super::{ + datagram::{Registry, local_destination}, + engine::Ready, +}; +use crate::{ + config::WireGuardOutboundConfig, + dispatch::{DatagramBudget, DatagramTransport, DispatchError}, + outbound::{DEFAULT_ESTABLISH_TIMEOUT, DatagramRequest, EstablishContext, UpstreamPath}, + session::{Datagram, DatagramSession, Destination}, +}; +use boringtun::{ + noise::{Tunn, TunnResult, errors::WireGuardError}, + x25519, +}; +use bytes::Bytes; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::Duration, +}; +use tokio::{ + sync::{mpsc, watch}, + time::{Instant, MissedTickBehavior}, +}; +use tokio_util::sync::CancellationToken; +use vole_netstack::{Packet, PacketSink, parse_udp_packet_view}; + +#[allow(clippy::too_many_arguments)] +pub(super) async fn drive( + config: Arc, + server: Destination, + upstream: UpstreamPath, + session: DatagramSession, + context: EstablishContext, + cancel: CancellationToken, + mut packets: mpsc::Receiver, + ready: watch::Sender, + sink: PacketSink, + registry: Arc, +) { + let wire_size = config.mtu + 32; + let request = + DatagramRequest::new(session).with_budget(DatagramBudget::new(wire_size, wire_size)); + let setup = async { + let server = upstream.datagram_server(&server, &context).await?; + let endpoint = context + .resolution() + .resolve_proxy(&server, context.deadline()) + .await? + .addresses + .into_iter() + .next() + .ok_or(DispatchError::HostUnreachable)?; + let transport = upstream.open_datagram(request, &context).await?; + Ok((endpoint, transport)) + }; + let result = tokio::select! {biased;()=cancel.cancelled()=>Err(DispatchError::NotAllowed),result=context.run("WireGuard outer UDP",setup)=>result}; + if let Ok((endpoint, mut transport)) = result { + let budget = transport.payload_budget(&Destination::Ip(endpoint)); + if budget.transmit() >= wire_size && budget.receive() >= wire_size { + let mut tunn = Tunn::new( + x25519::StaticSecret::from(config.private_key.bytes()), + x25519::PublicKey::from(config.public_key.bytes()), + config.pre_shared_key.as_ref().map(|key| key.bytes()), + Some(config.persistent_keepalive), + rand::random::() >> 8, + None, + ); + let mut scratch = vec![0; usize::from(wire_size).max(148)]; + let initial = tunn.format_handshake_initiation(&mut scratch, true); + let initial = handle( + initial, + &config, + endpoint, + transport.as_mut(), + &cancel, + context.deadline(), + &sink, + ®istry, + ) + .await; + if initial.is_ok() { + let _ = run_peer( + &mut tunn, + &config, + endpoint, + transport.as_mut(), + &cancel, + &context, + &mut packets, + &ready, + &sink, + ®istry, + &mut scratch, + ) + .await; + } + } + cancel.cancel(); + let _ = tokio::time::timeout(Duration::from_secs(1), transport.close()).await; + } + ready.send_replace(Ready::Failed); + cancel.cancel(); +} + +#[allow(clippy::too_many_arguments)] +async fn run_peer( + tunn: &mut Tunn, + config: &WireGuardOutboundConfig, + endpoint: SocketAddr, + transport: &mut dyn DatagramTransport, + cancel: &CancellationToken, + context: &EstablishContext, + packets: &mut mpsc::Receiver, + ready: &watch::Sender, + sink: &PacketSink, + registry: &Registry, + scratch: &mut [u8], +) -> Result<(), DispatchError> { + let mut timer = tokio::time::interval(Duration::from_millis(250)); + timer.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut established = false; + loop { + let deadline = if established { + Instant::now() + DEFAULT_ESTABLISH_TIMEOUT + } else { + context.deadline() + }; + let result = tokio::select! {biased; + ()=cancel.cancelled()=>return Err(DispatchError::ConnectionRefused), + ()=tokio::time::sleep_until(context.deadline()),if !established=>return Err(DispatchError::TimedOut), + datagram=transport.receive()=> { + let datagram=datagram?; + if datagram.remote!=Destination::Ip(endpoint) || datagram.payload.len()>scratch.len(){continue;} + let result=tunn.decapsulate(Some(endpoint.ip()),&datagram.payload,scratch); + handle(result,config,endpoint,transport,cancel,deadline,sink,registry).await?; + loop { + let result=tunn.decapsulate(None,&[],scratch); + if matches!(result,TunnResult::Done){break;} + handle(result,config,endpoint,transport,cancel,deadline,sink,registry).await?; + } + None + }, + packet=packets.recv(),if established=> { + let packet=packet.ok_or(DispatchError::ConnectionRefused)?; + Some(tunn.encapsulate(packet.data(),scratch)) + }, + _=timer.tick()=>Some(tunn.update_timers(scratch)), + }; + if let Some(result) = result { + handle( + result, config, endpoint, transport, cancel, deadline, sink, registry, + ) + .await?; + } + if !established && tunn.time_since_last_handshake().is_some() { + established = true; + ready.send_replace(Ready::Established); + tracing::debug!("WireGuard handshake ready"); + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn handle( + result: TunnResult<'_>, + config: &WireGuardOutboundConfig, + endpoint: SocketAddr, + transport: &mut dyn DatagramTransport, + cancel: &CancellationToken, + deadline: Instant, + sink: &PacketSink, + registry: &Registry, +) -> Result<(), DispatchError> { + match result { + TunnResult::Done => Ok(()), + TunnResult::Err(WireGuardError::ConnectionExpired) => Err(DispatchError::ConnectionRefused), + TunnResult::Err(_) => Ok(()), + TunnResult::WriteToNetwork(packet) => { + let datagram = Datagram { + remote: Destination::Ip(endpoint), + payload: Bytes::copy_from_slice(packet), + sniffed_domain: None, + }; + tokio::select! {biased;()=cancel.cancelled()=>Err(DispatchError::ConnectionRefused),result=tokio::time::timeout_at(deadline,transport.send(datagram))=>result.map_err(|_|DispatchError::TimedOut)?} + } + TunnResult::WriteToTunnelV4(packet, _) | TunnResult::WriteToTunnelV6(packet, _) => { + let Some((source, destination)) = addresses(packet) else { + return Ok(()); + }; + if !local_destination(config, destination) || source.is_ipv4() != destination.is_ipv4() + { + return Ok(()); + } + // Single-peer Mihomo advertises the entire configured local family; + // top-level allowed-ips is intentionally ignored in this mode. + if let Some(udp) = parse_udp_packet_view(packet) { + registry.deliver(udp.source, udp.destination, udp.payload); + } else { + let _ = sink.try_send(Packet::new(Bytes::copy_from_slice(packet))); + } + Ok(()) + } + } +} +fn addresses(packet: &[u8]) -> Option<(IpAddr, IpAddr)> { + match packet.first()? >> 4 { + 4 if packet.len() >= 20 => Some(( + IpAddr::from(<[u8; 4]>::try_from(&packet[12..16]).ok()?), + IpAddr::from(<[u8; 4]>::try_from(&packet[16..20]).ok()?), + )), + 6 if packet.len() >= 40 => Some(( + IpAddr::from(<[u8; 16]>::try_from(&packet[8..24]).ok()?), + IpAddr::from(<[u8; 16]>::try_from(&packet[24..40]).ok()?), + )), + _ => None, + } +} diff --git a/src/outbound/wireguard/tests.rs b/src/outbound/wireguard/tests.rs new file mode 100644 index 0000000..566173d --- /dev/null +++ b/src/outbound/wireguard/tests.rs @@ -0,0 +1,357 @@ +//! Actual Noise handshake and raw TCP/UDP over bounded memory IO. +use super::*; +use crate::{ + dispatch::DatagramBudget, + resources::observation::{ResourceKind, ResourceProbe}, + session::{Datagram, InboundKind, InboundMetadata}, +}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use boringtun::{ + noise::{Tunn, TunnResult}, + x25519, +}; +use bytes::Bytes; +use std::{ + collections::VecDeque, + future::pending, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + task::JoinHandle, +}; +use vole_netstack::{ + NetStack, NetStackConfig, NetStackControl, Packet, PacketSink, PacketStream, TcpNetStackParts, + UdpDatagram, encode_udp_packet_into, parse_udp_packet_view, +}; + +const ENDPOINT: &str = "198.51.100.1:51820"; +fn config() -> WireGuardOutboundConfig { + let client = STANDARD.encode([1u8; 32]); + let peer = + STANDARD.encode(x25519::PublicKey::from(&x25519::StaticSecret::from([2u8; 32])).as_bytes()); + let yaml = format!( + "mixed-port: 1080\nproxies: [{{name: wg, type: wireguard, server: 198.51.100.1, port: 51820, ip: 10.0.0.2/24, ipv6: 'fd00::2/64', private-key: '{client}', public-key: '{peer}', udp: true}}]\nrules: ['MATCH,wg']" + ); + let parsed = crate::config::Config::parse_yaml(yaml.as_bytes()).unwrap(); + let crate::config::ProxyProtocol::WireGuard(config) = parsed.proxies[0].protocol.clone() else { + panic!() + }; + config +} +fn session() -> StreamSession { + StreamSession { + metadata: InboundMetadata::new(1080), + inbound: InboundKind::Https, + source: "192.0.2.1:20000".parse().unwrap(), + destination: Destination::Ip("10.0.0.1:80".parse().unwrap()), + sniffed_domain: None, + } +} + +struct Peer { + silent: bool, + sends: Arc, + closes: Arc, +} +#[async_trait] +impl OutboundConnector for Peer { + async fn connect_stream( + &self, + _: StreamSession, + _: &EstablishContext, + ) -> Result { + Err(DispatchError::NotAllowed) + } + async fn open_datagram( + &self, + _: DatagramRequest, + _: &EstablishContext, + ) -> Result, DispatchError> { + let TcpNetStackParts { + packet_sink, + packet_stream, + mut tcp_listener, + control, + .. + } = NetStack::start_tcp(NetStackConfig { + mtu: 1408, + ..Default::default() + }) + .unwrap(); + let echo = observation::spawn(async move { + if let Some(mut stream) = tcp_listener.accept().await { + let mut bytes = [0; 4]; + if stream.read_exact(&mut bytes).await.is_ok() { + stream.write_all(&bytes).await.unwrap(); + stream.shutdown().await.unwrap(); + } + } + }); + Ok(Box::new(MemoryPeer { + tunn: Tunn::new( + x25519::StaticSecret::from([2; 32]), + x25519::PublicKey::from(&x25519::StaticSecret::from([1; 32])), + None, + None, + 2, + None, + ), + sink: packet_sink, + packets: packet_stream, + control, + echo: Some(echo), + network: VecDeque::new(), + silent: self.silent, + sends: self.sends.clone(), + closes: self.closes.clone(), + scratch: vec![0; 1440], + })) + } +} +struct MemoryPeer { + tunn: Tunn, + sink: PacketSink, + packets: PacketStream, + control: NetStackControl, + echo: Option>, + network: VecDeque, + silent: bool, + sends: Arc, + closes: Arc, + scratch: Vec, +} +impl MemoryPeer { + fn network(&mut self, result: TunnResult<'_>) -> Option> { + match result { + TunnResult::WriteToNetwork(packet) => { + self.network.push_back(Bytes::copy_from_slice(packet)); + None + } + TunnResult::WriteToTunnelV4(packet, _) | TunnResult::WriteToTunnelV6(packet, _) => { + Some(packet.to_vec()) + } + TunnResult::Done => None, + TunnResult::Err(error) => panic!("memory peer rejected protocol: {error:?}"), + } + } + async fn decrypt(&mut self, packet: &[u8]) -> Result<(), DispatchError> { + // Separate scratch permits the result borrow while updating fixture queues. + let mut scratch = std::mem::take(&mut self.scratch); + let result = self.tunn.decapsulate(None, packet, &mut scratch); + let raw = self.network(result); + self.scratch = scratch; + if let Some(raw) = raw { + if let Some(udp) = parse_udp_packet_view(&raw) { + let datagram = UdpDatagram::new( + udp.destination, + udp.source, + Bytes::copy_from_slice(udp.payload), + ); + let mut response = Vec::with_capacity(1408); + encode_udp_packet_into(&datagram, 1408, &mut response).unwrap(); + self.encrypt(&response); + } else { + self.sink + .send(Packet::new(raw)) + .await + .map_err(|_| DispatchError::ConnectionRefused)?; + } + } + loop { + let mut scratch = std::mem::take(&mut self.scratch); + let result = self.tunn.decapsulate(None, &[], &mut scratch); + let done = matches!(result, TunnResult::Done); + let _ = self.network(result); + self.scratch = scratch; + if done { + break; + } + } + Ok(()) + } + fn encrypt(&mut self, packet: &[u8]) { + let mut scratch = std::mem::take(&mut self.scratch); + let result = self.tunn.encapsulate(packet, &mut scratch); + let _ = self.network(result); + self.scratch = scratch; + } +} +#[async_trait] +impl DatagramTransport for MemoryPeer { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + DatagramBudget::new(1440, 1440) + } + async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { + assert_eq!(datagram.remote, Destination::Ip(ENDPOINT.parse().unwrap())); + self.sends.fetch_add(1, Ordering::Relaxed); + if !self.silent { + self.decrypt(&datagram.payload).await?; + } + Ok(()) + } + async fn receive(&mut self) -> Result { + loop { + if let Some(payload) = self.network.pop_front() { + return Ok(Datagram { + remote: Destination::Ip(ENDPOINT.parse().unwrap()), + payload, + sniffed_domain: None, + }); + } + if self.silent { + return pending().await; + } + let packet = self + .packets + .recv() + .await + .ok_or(DispatchError::ConnectionRefused)?; + self.encrypt(packet.data()); + } + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.control.stop().await; + if let Some(echo) = self.echo.take() { + let _ = echo.await; + } + self.closes.fetch_add(1, Ordering::Relaxed); + Ok(()) + } +} +impl Drop for MemoryPeer { + fn drop(&mut self) { + self.control.cancel(); + if let Some(echo) = self.echo.take() { + echo.abort(); + } + } +} + +fn outbound(silent: bool) -> (Arc, Arc, Arc) { + let sends = Arc::new(AtomicUsize::new(0)); + let closes = Arc::new(AtomicUsize::new(0)); + let peer = Arc::new(Peer { + silent, + sends: sends.clone(), + closes: closes.clone(), + }); + ( + Arc::new(WireGuardOutbound::new_with_path(&config(), UpstreamPath::Proxy(peer)).unwrap()), + sends, + closes, + ) +} + +#[tokio::test] +async fn wireguard_memory_noise_tcp_dual_stack_udp_and_stop_release() { + let probe = ResourceProbe::default(); + probe + .scope(async { + let (outbound, _, closes) = outbound(false); + let context = EstablishContext::with_timeout(Duration::from_secs(2)); + let mut connected = outbound.connect_stream(session(), &context).await.unwrap(); + connected.io.write_all(b"test").await.unwrap(); + let mut echoed = [0; 4]; + tokio::time::timeout(Duration::from_secs(2), connected.io.read_exact(&mut echoed)) + .await + .unwrap() + .unwrap(); + assert_eq!(&echoed, b"test"); + assert_eq!(connected.effective_peer, session().destination); + let request = + DatagramRequest::new(DatagramSession::new(InboundKind::Socks5, session().source)) + .with_max_response_payload_size(1200); + let mut udp = outbound.open_datagram(request, &context).await.unwrap(); + assert_eq!(udp.payload_budget(&session().destination).receive(), 1200); + for remote in ["10.0.0.1:53", "[fd00::1]:53"] { + let remote = Destination::Ip(remote.parse().unwrap()); + udp.send(Datagram { + remote: remote.clone(), + payload: Bytes::from_static(b"udp"), + sniffed_domain: None, + }) + .await + .unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(2), udp.receive()) + .await + .unwrap() + .unwrap(); + assert_eq!(reply.remote, remote); + assert_eq!(reply.payload.as_ref(), b"udp"); + } + udp.close().await.unwrap(); + drop(udp); + drop(connected); + let weak = Arc::downgrade(outbound.current.lock().await.as_ref().unwrap()); + outbound.shutdown().await; + assert_eq!(closes.load(Ordering::Relaxed), 1); + assert!(weak.upgrade().is_none()); + assert_eq!(probe.snapshot().current(ResourceKind::Task), 0); + assert!(probe.snapshot().is_idle()); + }) + .await; +} + +#[tokio::test] +async fn wireguard_memory_unanswered_handshake_deadline_drop_and_stop() { + let probe = ResourceProbe::default(); + probe + .scope(async { + let (outbound, sends, closes) = outbound(true); + let result = outbound + .connect_stream( + session(), + &EstablishContext::with_timeout(Duration::from_millis(30)), + ) + .await; + assert!(matches!( + result, + Err(DispatchError::TimedOut) | Err(DispatchError::ConnectionRefused) + )); + assert!(sends.load(Ordering::Relaxed) >= 1); + // A new generation is published and owned even if its caller disappears. + tokio::time::sleep(Duration::from_millis(5)).await; + let second = outbound.clone(); + let task = tokio::spawn(async move { + second + .connect_stream( + session(), + &EstablishContext::with_timeout(Duration::from_secs(30)), + ) + .await + }); + // Boringtun uses a real monotonic clock: witness one actual timer retry. + tokio::time::timeout(Duration::from_secs(7), async { + while sends.load(Ordering::Relaxed) < 3 { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + task.abort(); + let _ = task.await; + assert!(outbound.current.lock().await.is_some()); + tokio::time::timeout(Duration::from_secs(1), outbound.shutdown()) + .await + .unwrap(); + assert_eq!(closes.load(Ordering::Relaxed), 2); + assert!(probe.snapshot().is_idle()); + }) + .await; +} + +#[test] +fn wireguard_configuration_keys_prefixes_defaults_and_redaction() { + let config = config(); + assert_eq!(config.ipv4, Some("10.0.0.2".parse().unwrap())); + assert_eq!(config.ipv6, Some("fd00::2".parse().unwrap())); + assert_eq!(config.mtu, 1408); + assert!(!format!("{config:?}").contains(&STANDARD.encode([1u8; 32]))); + let yaml = "mixed-port: 1080\nproxies: [{name: wg, type: wireguard, server: 198.51.100.1, port: 51820, ip: 10.0.0.2, private-key: secret-sentinel, public-key: secret-sentinel}]"; + let error = crate::config::Config::parse_yaml(yaml.as_bytes()) + .unwrap_err() + .to_string(); + assert!(!error.contains("secret-sentinel")); +} diff --git a/src/runtime.rs b/src/runtime.rs index adc5d2b..2b62677 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -477,13 +477,14 @@ impl PreparedCore { resources, .. } = self.build_dispatcher(dialer)?; - let control = resources.as_ref().map(|resources| { - resources.clone() as Arc - }); - let controller = self - .bind_controller(None, proxy_groups) - .await? - .map(|controller| controller.with_resources(control)); + let controller = self.bind_controller(None, proxy_groups).await?; + #[cfg(any(feature = "inbound-http", feature = "inbound-socks5"))] + let controller = { + let control = resources.as_ref().map(|resources| { + resources.clone() as Arc + }); + controller.map(|controller| controller.with_resources(control)) + }; RunningCore::start_components( &self.config.inbounds, dispatcher, @@ -1004,6 +1005,24 @@ fn build_proxy_graph( &node_dialer, )?; let connector: Arc = match &proxy.protocol { + ProxyProtocol::WireGuard(config) => { + #[cfg(feature = "outbound-wireguard")] + { + Arc::new( + crate::outbound::wireguard::WireGuardOutbound::new_with_path( + config, upstream, + )?, + ) + } + #[cfg(not(feature = "outbound-wireguard"))] + { + let _ = (config, upstream); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "WireGuard support is disabled", + )); + } + } ProxyProtocol::Http(config) => { #[cfg(feature = "outbound-http")] { @@ -1317,7 +1336,8 @@ async fn prepare_proxy_endpoints( let upload_port = proxy.port(); let download = match &proxy.protocol { ProxyProtocol::Vless(config) => config.download(), - ProxyProtocol::Http(_) + ProxyProtocol::WireGuard(_) + | ProxyProtocol::Http(_) | ProxyProtocol::Socks5(_) | ProxyProtocol::Trojan(_) | ProxyProtocol::Vmess(_) @@ -1435,6 +1455,7 @@ fn security_counts(proxies: &[ProxyConfig]) -> (usize, usize) { standard_count + usize::from(config.tls.is_some()), ), ProxyProtocol::Tuic(_) => (client_count + 1, standard_count), + ProxyProtocol::WireGuard(_) => (client_count, standard_count), }, ) } @@ -1888,6 +1909,11 @@ mod tests { ", uuid: 08080808-0808-0808-0808-080808080808, password: fixture", cfg!(feature = "outbound-tuic"), ), + ( + "wireguard", + ", ip: 10.84.0.2, private-key: AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=, public-key: AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=", + cfg!(feature = "outbound-wireguard"), + ), ]; for (protocol, fields, enabled) in variants { let _case = crate::resources::case_events::Case::new("INTEGRATION-FEATURE", protocol); diff --git a/tests/README.md b/tests/README.md index 8d95290..ecc5e36 100644 --- a/tests/README.md +++ b/tests/README.md @@ -25,6 +25,7 @@ cargo test --locked --lib geodata::tests:: cargo test --locked --features cli --lib asn_ cargo test --locked --all-features --lib resource::http::tests:: cargo test --locked --all-features --lib outbound::http::tests:: +cargo test --locked --all-features --lib outbound::wireguard::tests:: cargo test --locked --all-features --lib platform::linux_network::tests:: cargo test --locked --all-features --lib connections::tests:: cargo test --locked --all-features --lib routing::diagnostics::tests:: @@ -85,6 +86,8 @@ Python,直接运行此检查,仅验证各自构建的产物。 在 Windows 上,`windows-uwp` 与 `windows-wintun` 互斥,编译与 Clippy 应明确选择 所测后端而非 `--all-features`。名称选择仍需已安装包验证,不以对象级测试代替。 +`outbound::wireguard::tests::` 在内存中交换真实 Noise 与 raw-IP 包,覆盖主动 TCP、双栈 UDP、真实握手重试、取消与 Stop;不打开 socket 或系统接口。 + 宿主只执行明确的纯内存过滤器;全目标使用 `--no-run`。 `invoke::tests::` 含监听器用例,不可整体在宿主执行;可执行过滤器以 [Tests workflow](../.github/workflows/test.yml) 为准。 diff --git a/tests/feature_foundations.rs b/tests/feature_foundations.rs index 90fd823..4c207af 100644 --- a/tests/feature_foundations.rs +++ b/tests/feature_foundations.rs @@ -1,15 +1,12 @@ use vole::config::Config; #[test] -fn feature_skeletons_do_not_open_unimplemented_yaml_or_measurement_protocols() { - // Keep the stable FOUNDATIONS assertion ID as a regression for the retired protocol. - #[cfg(feature = "interop-test")] - let mut _case = vole::resources::case_events::Case::new( - "FOUNDATIONS-SCHEMA", - "feature_skeletons_do_not_open_unimplemented_yaml_or_measurement_protocols", +fn wireguard_yaml_follows_its_own_feature() { + let yaml = b"mixed-port: 1080\nproxies: [{name: wg, type: wireguard, server: 192.0.2.1, port: 51820, ip: 10.84.0.2, private-key: AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=, public-key: AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=}]\nrules: ['MATCH,wg']\n"; + assert_eq!( + Config::parse_yaml(yaml).is_ok(), + cfg!(feature = "outbound-wireguard") ); - let yaml = "proxies:\n - name: node\n type: wireguard\n server: example.com\n port: 443\nrules:\n - MATCH,node\n"; - assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); } #[test] From 4453f197a55d62f07405e5ff47322390640d37e9 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 13:13:26 +0800 Subject: [PATCH 041/445] Add WireGuard peer routing and remote DNS --- README.md | 2 +- docs/config.yaml | 15 +- docs/mihomo-compatibility.md | 7 +- docs/outbounds.md | 67 +++-- readme/README.ru.md | 2 +- readme/README.zh_CN.md | 2 +- src/config/mod.rs | 2 +- src/config/wireguard.rs | 232 ++++++++++++++++-- src/dns/resolution.rs | 110 ++++++--- src/dns/runtime.rs | 67 ++++- src/outbound/connector.rs | 45 +++- src/outbound/wireguard/channel.rs | 124 ++++++++++ src/outbound/wireguard/datagram.rs | 21 +- src/outbound/wireguard/engine.rs | 199 ++++++++++----- src/outbound/wireguard/mod.rs | 35 +-- src/outbound/wireguard/peer.rs | 338 +++++++++++++++----------- src/outbound/wireguard/resolver.rs | 72 ++++++ src/outbound/wireguard/routes.rs | 70 ++++++ src/outbound/wireguard/tests.rs | 377 ++++++++++++++++++++++++++++- tests/README.md | 2 +- 20 files changed, 1455 insertions(+), 334 deletions(-) create mode 100644 src/outbound/wireguard/channel.rs create mode 100644 src/outbound/wireguard/resolver.rs create mode 100644 src/outbound/wireguard/routes.rs diff --git a/README.md b/README.md index fb32dd1..d551e76 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ All ten protocols below are enabled in the default build. HTTP CONNECT itself is | [SOCKS5](docs/outbounds.md#socks5) | CONNECT and UDP ASSOCIATE, with optional username/password authentication | | [Hysteria2](docs/outbounds.md#hysteria2) | QUIC TCP/UDP, bandwidth control, Salamander, port hopping and mTLS | | [TUIC v5](docs/outbounds.md#tuic-v5) | QUIC TCP/UDP, native/quic UDP relay modes and selectable congestion control | -| [WireGuard](docs/outbounds.md#wireguard) | TCP/UDP over standard WireGuard, with a user-space IP stack | +| [WireGuard](docs/outbounds.md#wireguard) | TCP/UDP over standard WireGuard, multiple peers and node DNS, with a user-space IP stack | The shared [sing-mux wrapper](docs/xhttp.md#通用-sing-mux) supports all enabled node protocols. It provides TCP and, with `only-tcp: false`, UDP over the node's ordinary TCP path. The peer must provide sing-mux decoding; a normal HTTP/SOCKS5 listener does not automatically decode it. diff --git a/docs/config.yaml b/docs/config.yaml index 1c1250a..740148f 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -202,7 +202,7 @@ sniffer: # tls: true 时支持 sni/skip-cert-verify/name-cert-verify/fingerprint, # 以及成对的内联 certificate/private-key。支持 dialer-proxy;详见 outbounds.md。 proxies: - # 标准 WireGuard 单 peer:用户态内层 TCP/UDP,不创建系统接口/路由/NAT。 + # 标准 WireGuard:用户态内层 TCP/UDP,不创建系统接口/路由/NAT。 # 密钥为公开测试值,不可用于真实连接。外层始终需要双向 UDP;业务 udp 默认 false。 # ip/ipv6 接受 IP 或 CIDR,按实际地址族选择,每族最多一个;至少配置一个。 # MTU 缺省/0 为 1408,范围 1280–65503;实际上游双向预算须至少 MTU+32。 @@ -218,7 +218,18 @@ proxies: # pre-shared-key: "AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=" mtu: 1408 persistent-keepalive: 0 # uint16 秒;0 不定期保活 - allowed-ips: [] # 单 peer 忽略;当前不接受 peers 等扩展字段 + allowed-ips: [] # 无 peers 时忽略;各本地地址族使用全地址路由 + reserved: [] # 空或三个字节;peer 缺省继承顶层值 + remote-dns-resolve: false # true 且 dns 非空时,业务域名通过本节点查询 + dns: [] # 支持 Core DNS 上游;上游域名仍由 Core 代理 DNS bootstrap + refresh-server-ip-interval: 0 # 秒;0 不更新,新业务触发到期检查 + # peers 非空时忽略顶层 server/port/public-key;每项 allowed-ips 必须非空。 + # peers: + # - server: wireguard.example.com + # port: 51820 + # public-key: "AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=" + # allowed-ips: ["0.0.0.0/0"] + # reserved: [0, 0, 0] udp: true # dialer-proxy: upstream-select # 上游必须提供足够的双向 UDP 预算 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 59364bf..fa7ce0a 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -11,7 +11,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 | 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | | DNS | hosts、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | | mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | -| 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022、标准 WireGuard 单 peer;依赖启用的 Cargo feature | +| 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022、标准 WireGuard 单/多 peer;依赖启用的 Cargo feature | | 嗅探 | TUN/mixed TCP 的 HTTP/TLS;TUN UDP 的 QUIC;域名/地址排除、强制条件和目标覆盖 | | Linux 自建设备 | auto-detect-interface、auto-route、systemd-resolved DNS 接管;失败和停止时恢复本次修改 | | 运行状态 | 连接快照、流量计数和关闭;组与资源状态;规则命中、有效配置、受限 DNS 诊断 | @@ -31,8 +31,9 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; 与 Mihomo 在读取响应头时更新的时点不同。数值仅接受有限且可表示的十进制数, 不保留非有限/溢出浮点转换的参考行为。 -- WireGuard 使用用户态标准单 peer;每个本地地址族最多一个地址,MTU 至少1280。 - 顶层 allowed-ips 在单 peer 模式与参考一致地忽略,扩展 peers/remote DNS 尚不接受。 +- WireGuard 使用标准用户态单/多 peer、最长前缀选择、reserved、节点远端 DNS 与按需端点更新; + 每个本地地址族最多一个地址,MTU 至少1280。无 peers 时顶层 allowed-ips 与参考一致地忽略。 + peer 仅在实际使用时解析/建链;Mihomo 在节点初始化时解析所有 peer。 - 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 - 未配置入站的配置不能启动。Controller 沿用回环地址、secret 及运行配置准入,见 [Controller API](controller-api.md)。 diff --git a/docs/outbounds.md b/docs/outbounds.md index 036d8a3..b2f365d 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -320,29 +320,44 @@ MTU1500 大包夹具显式启用官方双向 IP 分片。自有 1/2 层身份中 `type: wireguard` 使用官方 boringtun 的标准 Noise 协议和用户态 IP 栈。 `outbound-wireguard` 可独立启用,不依赖 TUN;不创建系统接口、路由或 NAT。 -单 peer 必填 server/port、private-key/public-key,ip/ipv6 至少提供一个本地地址; -地址接受 IP 或 CIDR,保留主机地址,每族最多一个。pre-shared-key 可选。 -密钥必须是解码后 32 字节的标准 Base64,保存时清零回收且 Debug/错误不暴露密钥。 - -MTU 省略/0 为 1408,范围 1280–65503;外层实际双向 UDP 预算必须至少为 MTU+32, -不足时关闭上游,不静默缩小 MTU。udp 缺省 false,只控制业务 UDP,外层始终需要 -双向数据报能力。persistent-keepalive 为 uint16 秒,0 关闭定期保活。 -与固定 Mihomo 参考一致,无 peers 时顶层 allowed-ips 接受但不参与选择: -本地配置的地址族使用全地址路由。当前不接受 peers、reserved、remote-dns-resolve、 -dns、refresh-server-ip-interval、Amnezia 或系统接口模式字段。 - -每节点、资源代际按需拥有一套 TCP 栈与 peer 驱动,内层 UDP 使用真实临时端口。 -外层经过共享 Dialer/UpstreamPath,节点端点由代理 DNS 角色解析并固定在物理会话; -旧会话保留建链组快照与已认证入站身份。业务域名用受控本地 DNS,并在配置的本地 -地址族中选择;遵循节点 ip-version、全局 IPv6 开关与原建链期限,不在失败时回落 DIRECT。 -真实握手完成才交付业务流;握手重试和后续保活使用官方协议定时器。 - -每节点 raw 包队列和 peer 发送队列各 32 项;每 UDP 关联响应队列 64 项。 -UDP 负载上限为 MTU 减本地 IP/UDP 头,再与调用方收发预算分别取交集; -双栈节点保守按 48 字节扣除,只有 IPv4 时扣 28 字节。满队列丢当前包, -TCP 依赖重传。解密后校验本地目的地址、来源地址族和关联端口,不交付陌生数据。 -这些是局部结构边界,不是业务连接总数限制。 - -握手、收发与重试均可取消;peer/栈任务由节点持有,调用方 future 丢弃不会丢失 -任务所有权。Stop 关闭新准入、取消并等待驱动、关闭上游(最多一秒),随后等待 -用户态栈退出。失败物理会话只由新业务重建,不重放已发送的业务。 +private-key 必填,ip/ipv6 至少提供一个本地地址;地址接受 IP 或 CIDR,保留主机地址, +每族最多一个。密钥必须是解码后 32 字节的标准 Base64,保存时清零回收,Debug/错误不暴露密钥。 + +未配置 peers 时,server/port/public-key 必填,pre-shared-key 可选;顶层 allowed-ips +与 Mihomo 一致地忽略,每个本地地址族使用全地址路由。有 peers 时,顶层端点与公钥 +不参与选择,每个 peer 必须提供 server/port/public-key 与非空 allowed-ips CIDR 列表。 +发送目的地址与解密包的来源地址使用同一不可变最长前缀表;相同前缀最后声明的 peer +优先。同一公钥的重复声明更新端点,保留未覆盖的 PSK,并追加允许前缀。 + +reserved 缺省为空(全零),否则必须为三个字节。peer 缺省继承顶层值;发送时写入 +WireGuard 消息头的保留字节,接收时清零后验证 Noise。MTU 省略/0 为 1408,范围 +1280–65503;外层实际双向 UDP 预算必须至少为 MTU+32,不足时关闭上游,不静默缩小。 +udp 缺省 false,只控制业务 UDP;外层始终需要双向数据报。persistent-keepalive 为 +uint16 秒,0 关闭定期保活。当前不接受 Amnezia、workers、ip-stack 或系统接口模式字段。 + +每节点、资源代际按需拥有一套 TCP 栈;每个实际使用的 peer 独立拥有 Noise、外层会话 +和驱动,未使用的 peer 不启动网络任务。内层 UDP 使用真实临时端口。外层经过共享 +Dialer/UpstreamPath,节点端点由 Core 代理 DNS 角色解析;旧会话保留建链组快照与已认证 +入站身份。真实握手完成才交付业务流,握手重试与保活使用官方定时器;一个 peer +失败不会关闭其他 peer 或整个共享栈,后续新业务可重建失败会话,不重放旧业务。 + +remote-dns-resolve 默认 false;只有 true 且 dns 非空时,业务域名使用节点私有 DNS, +查询经过该 WireGuard 节点的内层 TCP/UDP。dns 支持 Core 的 UDP/TCP/DoT/DoH/RCODE +上游;`#route` 不改变这个固定出口。DNS 上游域名仍由 Core 的代理 DNS 角色 bootstrap, +不通过节点私有解析器解析自身端点。私有缓存有界,查询由调用方持有,不保留空闲 TCP +连接或独立后台清理任务。关闭该开关或 dns 为空时使用受控本地 DNS;两种路径都遵循 +节点 ip-version、全局 IPv6 开关、本地地址族与原建链期限,失败不回落 DIRECT。 + +refresh-server-ip-interval 默认 0,关闭更新;非零值以秒计,新业务在间隔到期后触发 +所有已建立 peer 的端点重新解析,负值每次检查均到期。不创建额外周期任务。IP 不变时保留物理会话; +变化时保留共享栈、TCP 状态和 UDP 端口,替换外层会话并重新握手。解析或新上游配置失败 +保留原端点,下次业务重试。 + +节点 raw 包队列和各 peer 发送队列各 32 项;每 UDP 关联响应队列 64 项。UDP 负载上限 +为 MTU 减本地 IP/UDP 头,再与调用方收发预算分别取交集;双栈保守扣 48 字节,IPv4 +扣 28 字节。满队列丢当前包,TCP 依赖重传。解密后校验本地目的地址、来源地址族、 +最长前缀所属 peer 和关联端口;这些是局部结构边界,不是业务连接总数限制。 + +握手、收发、更新与重试均可取消;驱动由节点持有,调用方 future 丢弃不会丢失任务 +所有权。Stop 关闭新准入,取消并等待所有 peer 与退役驱动,关闭各上游(分别最多一秒), +随后等待用户态栈退出;私有 DNS 使用弱节点引用,不延长节点生命周期。 diff --git a/readme/README.ru.md b/readme/README.ru.md index e59b891..009d969 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -33,7 +33,7 @@ Vole — прокси-ядро на Rust для VPN-клиентов и лока | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT и UDP ASSOCIATE, с опциональной аутентификацией по имени пользователя и паролю | | [Hysteria2](../docs/outbounds.md#hysteria2) | TCP/UDP через QUIC, управление пропускной способностью, Salamander, смена портов и mTLS | | [TUIC v5](../docs/outbounds.md#tuic-v5) | TCP/UDP через QUIC, режимы передачи UDP native/quic и выбор алгоритма управления перегрузкой | -| [WireGuard](../docs/outbounds.md#wireguard) | TCP/UDP через стандартный WireGuard с пользовательским IP-стеком | +| [WireGuard](../docs/outbounds.md#wireguard) | TCP/UDP через стандартный WireGuard, несколько пиров и DNS узла, с пользовательским IP-стеком | Стандартные TLS-соединения поддерживают проверку сертификатов и закрепление сертификата по SHA-256. Поддерживаемые TCP-пути с TLS могут использовать шаблоны ClientHello Chrome, Firefox или Safari; `client-fingerprint` не зависит от `fingerprint` сертификата. Точные имена и сочетания описаны в [профилях TLS и политике сертификатов](../docs/tls-client-fingerprint.md). Эти функции не гарантируют неотличимость от браузера или произвольные сочетания протоколов. diff --git a/readme/README.zh_CN.md b/readme/README.zh_CN.md index 05e677c..392135e 100644 --- a/readme/README.zh_CN.md +++ b/readme/README.zh_CN.md @@ -33,7 +33,7 @@ Vole 是用于 VPN 客户端和本地代理的 Rust 代理内核,提供原生 | [SOCKS5](../docs/outbounds.md#socks5) | CONNECT 和 UDP ASSOCIATE,可选用户名/密码认证 | | [Hysteria2](../docs/outbounds.md#hysteria2) | QUIC TCP/UDP、带宽控制、Salamander、端口跳跃和 mTLS | | [TUIC v5](../docs/outbounds.md#tuic-v5) | QUIC TCP/UDP、native/quic UDP 转发模式,以及可选的拥塞控制算法 | -| [WireGuard](../docs/outbounds.md#wireguard) | 标准 WireGuard TCP/UDP,使用用户态 IP 栈 | +| [WireGuard](../docs/outbounds.md#wireguard) | 标准 WireGuard TCP/UDP、多 peer 与节点 DNS,使用用户态 IP 栈 | 标准 TLS 连接支持证书验证与 SHA-256 证书固定。适用的 TCP TLS 路径可使用 Chrome、Firefox 或 Safari ClientHello 模板;`client-fingerprint` 与证书 `fingerprint` 相互独立。准确的名称和支持组合见 [TLS 配置与证书策略](../docs/tls-client-fingerprint.md)。这些功能不保证与浏览器不可区分,也不支持任意协议组合。 diff --git a/src/config/mod.rs b/src/config/mod.rs index 76a2a96..ff63505 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -51,7 +51,7 @@ pub use ech::StaticEchConfig; mod hysteria2; pub use hysteria2::{Hysteria2Hopping, Hysteria2OutboundConfig}; mod wireguard; -pub use wireguard::{WireGuardKey, WireGuardOutboundConfig}; +pub use wireguard::{WireGuardKey, WireGuardOutboundConfig, WireGuardPeerConfig}; mod tuic; pub use tuic::{TuicCongestion, TuicOutboundConfig, TuicUdpMode}; mod jls; diff --git a/src/config/wireguard.rs b/src/config/wireguard.rs index f98024e..558f726 100644 --- a/src/config/wireguard.rs +++ b/src/config/wireguard.rs @@ -4,7 +4,9 @@ use base64::{Engine as _, engine::general_purpose::STANDARD}; use serde::Deserialize; use zeroize::Zeroizing; -use super::{ProxyProtocol, invalid, validate_host, validate_port}; +use super::{ + DnsNameserver, DnsRoute, IpCidr, ProxyProtocol, invalid, validate_host, validate_port, +}; use crate::Result; #[derive(Clone, PartialEq, Eq)] @@ -30,20 +32,36 @@ impl WireGuardKey { *self.0 } } - +#[derive(Clone, PartialEq, Eq)] +pub struct WireGuardPeerConfig { + pub address: String, + pub port: u16, + pub public_key: WireGuardKey, + pub pre_shared_key: Option, + pub reserved: [u8; 3], + pub allowed_ips: Vec, +} +impl std::fmt::Debug for WireGuardPeerConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WireGuardPeerConfig") + .finish_non_exhaustive() + } +} #[derive(Clone, PartialEq, Eq)] pub struct WireGuardOutboundConfig { + /// Representative first endpoint for the existing graph/measurement visitor. pub address: String, pub port: u16, pub ipv4: Option, pub ipv6: Option, pub private_key: WireGuardKey, - pub public_key: WireGuardKey, - pub pre_shared_key: Option, pub mtu: u16, pub persistent_keepalive: u16, - /// Mihomo ignores this top-level option without `peers`. - pub allowed_ips: Vec, + pub peers: Vec, + pub routes: Vec<(IpCidr, usize)>, + /// Active node resolver; absent when the switch is false or DNS list empty. + pub dns: Vec, + pub refresh_server_ip_interval: i64, } impl std::fmt::Debug for WireGuardOutboundConfig { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -52,27 +70,52 @@ impl std::fmt::Debug for WireGuardOutboundConfig { .finish_non_exhaustive() } } - +#[derive(Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +struct RawPeer { + server: String, + port: u16, + public_key: String, + #[serde(default)] + pre_shared_key: String, + #[serde(default)] + reserved: Vec, + #[serde(default)] + allowed_ips: Vec, +} #[derive(Deserialize)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub(super) struct RawWireGuard { name: String, + #[serde(default)] server: String, + #[serde(default)] port: u16, #[serde(default)] ip: String, #[serde(default)] ipv6: String, private_key: String, + #[serde(default)] public_key: String, #[serde(default)] pre_shared_key: String, #[serde(default)] + reserved: Vec, + #[serde(default)] + allowed_ips: Vec, + #[serde(default)] + peers: Vec, + #[serde(default)] mtu: u16, #[serde(default)] persistent_keepalive: u16, #[serde(default)] - allowed_ips: Vec, + remote_dns_resolve: bool, + #[serde(default)] + dns: Vec, + #[serde(default)] + refresh_server_ip_interval: i64, #[serde(default)] udp: bool, #[serde(default, deserialize_with = "super::deserialize_present_option")] @@ -89,6 +132,64 @@ pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>( RawWireGuard::deserialize(d) .map_err(|_| serde::de::Error::custom("invalid WireGuard configuration fields")) } +fn reserved(value: Vec, default: [u8; 3]) -> Result<[u8; 3]> { + if value.is_empty() { + Ok(default) + } else { + value.try_into().map_err(|_| { + crate::VoleError::InvalidConfig("WireGuard reserved requires three bytes".into()) + }) + } +} +fn supported_node_dns(nameserver: &DnsNameserver) -> bool { + matches!( + nameserver.address, + super::DnsAddress::Ip(_) | super::DnsAddress::Domain(_) + ) && matches!( + nameserver.transport, + super::DnsTransport::Udp + | super::DnsTransport::Tcp + | super::DnsTransport::Tls + | super::DnsTransport::Http + | super::DnsTransport::Https + | super::DnsTransport::Rcode(_) + ) +} + +impl RawPeer { + fn normalize(self, default: [u8; 3]) -> Result { + validate_host(&self.server, "WireGuard server")?; + validate_port(self.port, "WireGuard")?; + if self.allowed_ips.is_empty() { + return invalid("WireGuard peer requires allowed-ips"); + } + let ips = self + .allowed_ips + .iter() + .map(|v| { + super::parse_ip_cidr( + v, + v.split_once('/') + .and_then(|(ip, _)| ip.parse::().ok()) + .is_some_and(|ip| ip.is_ipv6()), + ) + .map_err(|_| { + crate::VoleError::InvalidConfig("invalid WireGuard allowed prefix".into()) + }) + }) + .collect::>>()?; + Ok(WireGuardPeerConfig { + address: self.server, + port: self.port, + public_key: WireGuardKey::parse(self.public_key)?, + pre_shared_key: (!self.pre_shared_key.is_empty()) + .then(|| WireGuardKey::parse(self.pre_shared_key)) + .transpose()?, + reserved: reserved(self.reserved, default)?, + allowed_ips: ips, + }) + } +} impl RawWireGuard { pub(super) fn normalize(self) -> Result<(String, Option, bool, ProxyProtocol)> { if !cfg!(feature = "outbound-wireguard") { @@ -104,10 +205,10 @@ impl RawWireGuard { crate::VoleError::InvalidConfig("invalid WireGuard local address".into()) })?; if let Some(prefix) = prefix { - let prefix = prefix.parse::().map_err(|_| { + let p = prefix.parse::().map_err(|_| { crate::VoleError::InvalidConfig("invalid WireGuard local prefix".into()) })?; - if prefix > if ip.is_ipv4() { 32 } else { 128 } { + if p > if ip.is_ipv4() { 32 } else { 128 } { return invalid("invalid WireGuard local prefix"); } } @@ -117,19 +218,91 @@ impl RawWireGuard { _ => return invalid("WireGuard requires at most one local address per family"), } } + let global = reserved(self.reserved, [0; 3])?; + let mut peers = Vec::::new(); + let mut routes = Vec::new(); + if self.peers.is_empty() { + // Fixed reference ignores top-level allowed-ips in single-peer mode. + let _ = self.allowed_ips; + let mut allowed_ips = Vec::new(); + if ipv4.is_some() { + allowed_ips.push("0.0.0.0/0".into()); + } + if ipv6.is_some() { + allowed_ips.push("::/0".into()); + } + peers.push( + RawPeer { + server: self.server, + port: self.port, + public_key: self.public_key, + pre_shared_key: self.pre_shared_key, + reserved: Vec::new(), + allowed_ips, + } + .normalize(global)?, + ); + routes.extend(peers[0].allowed_ips.iter().map(|cidr| (*cidr, 0))); + } else { + for raw in self.peers { + let peer = raw.normalize(global)?; + // WireGuard's device identifies peers by key. Repeated declarations + // update that peer rather than inventing a second crypto identity. + if let Some(index) = peers + .iter() + .position(|old| old.public_key == peer.public_key) + { + routes.extend(peer.allowed_ips.iter().map(|cidr| (*cidr, index))); + let old = &mut peers[index]; + old.address = peer.address; + old.port = peer.port; + old.reserved = peer.reserved; + if peer.pre_shared_key.is_some() { + old.pre_shared_key = peer.pre_shared_key; + } + old.allowed_ips.extend(peer.allowed_ips); + } else { + routes.extend(peer.allowed_ips.iter().map(|cidr| (*cidr, peers.len()))); + peers.push(peer); + } + } + } + let dns = if self.remote_dns_resolve && !self.dns.is_empty() { + self.dns + .iter() + .map(|v| { + // The reference replaces every parsed DNS route with this + // WireGuard adapter; a named fragment cannot select an + // external group or bypass the node's raw-IP data plane. + let endpoint = v.split_once('#').map_or(v.as_str(), |(v, _)| v); + let nameserver = super::parse_dns_nameserver( + endpoint, + &super::RouteTargetsByName::new(), + DnsRoute::Direct, + )?; + if !supported_node_dns(&nameserver) { + return invalid( + "WireGuard node DNS requires an explicit network or RCODE upstream", + ); + } + Ok(nameserver) + }) + .collect::>>()? + } else { + Vec::new() + }; let config = WireGuardOutboundConfig { - address: self.server, - port: self.port, + address: peers[0].address.clone(), + port: peers[0].port, ipv4, ipv6, private_key: WireGuardKey::parse(self.private_key)?, - public_key: WireGuardKey::parse(self.public_key)?, - pre_shared_key: (!self.pre_shared_key.is_empty()) - .then(|| WireGuardKey::parse(self.pre_shared_key)) - .transpose()?, mtu: if self.mtu == 0 { 1408 } else { self.mtu }, persistent_keepalive: self.persistent_keepalive, - allowed_ips: self.allowed_ips, + peers, + routes, + dns, + refresh_server_ip_interval: self.refresh_server_ip_interval, }; config.validate()?; Ok(( @@ -142,8 +315,29 @@ impl RawWireGuard { } impl WireGuardOutboundConfig { pub(crate) fn validate(&self) -> Result<()> { - validate_host(&self.address, "WireGuard server")?; - validate_port(self.port, "WireGuard")?; + if self.peers.is_empty() { + return invalid("WireGuard requires a peer"); + } + if self + .dns + .iter() + .any(|nameserver| !supported_node_dns(nameserver)) + { + return invalid("WireGuard node DNS requires an explicit network or RCODE upstream"); + } + for peer in &self.peers { + validate_host(&peer.address, "WireGuard server")?; + validate_port(peer.port, "WireGuard")?; + if peer.allowed_ips.is_empty() { + return invalid("WireGuard peer requires allowed-ips"); + } + } + if self.routes.iter().any(|(prefix, index)| { + *index >= self.peers.len() + || prefix.prefix_len > if prefix.network.is_ipv4() { 32 } else { 128 } + }) { + return invalid("invalid WireGuard peer route"); + } if self.ipv4.is_none() && self.ipv6.is_none() { return invalid("WireGuard requires a local address"); } diff --git a/src/dns/resolution.rs b/src/dns/resolution.rs index 552b62a..e346aa4 100644 --- a/src/dns/resolution.rs +++ b/src/dns/resolution.rs @@ -133,6 +133,27 @@ impl ResolutionContext { .ok_or(DispatchError::HostUnreachable) } + #[cfg(feature = "outbound-wireguard")] + pub(crate) fn ipv6_allowed(&self) -> bool { + self.inner.as_ref().is_none_or(|state| state.ipv6) + } + + #[cfg(feature = "outbound-wireguard")] + pub(crate) async fn resolve_node_local( + &self, + target: &Destination, + deadline: Instant, + dns: &RuntimeDns, + ipv4: bool, + ipv6: bool, + ) -> Result { + self.resolve_addresses_via(target, deadline, false, Some(dns)) + .await? + .into_iter() + .find(|address| if address.is_ipv4() { ipv4 } else { ipv6 }) + .ok_or(DispatchError::HostUnreachable) + } + pub async fn resolve_proxy( &self, target: &Destination, @@ -154,6 +175,17 @@ impl ResolutionContext { target: &Destination, deadline: Instant, proxy: bool, + ) -> Result, DispatchError> { + self.resolve_addresses_via(target, deadline, proxy, None) + .await + } + + async fn resolve_addresses_via( + &self, + target: &Destination, + deadline: Instant, + proxy: bool, + node_dns: Option<&RuntimeDns>, ) -> Result, DispatchError> { let deadline = inherited_deadline(deadline); if deadline <= Instant::now() { @@ -179,9 +211,16 @@ impl ResolutionContext { unreachable!() }; let host = canonicalize_name(host).map_err(|_| DispatchError::HostUnreachable)?; - let inner = self.inner.as_ref().ok_or_else(unavailable)?; - let source = inner.source.get().ok_or_else(unavailable)?; - let key = (Arc::as_ptr(inner) as usize, proxy, host.clone()); + let inner = self.inner.as_ref(); + let source = inner.and_then(|inner| inner.source.get()); + if node_dns.is_none() && source.is_none() { + return Err(unavailable()); + } + let identity = node_dns.map_or_else( + || Arc::as_ptr(inner.unwrap()) as usize, + |dns| dns as *const RuntimeDns as usize, + ); + let key = (identity, proxy, host.clone()); let mut active = ACTIVE.try_with(Clone::clone).unwrap_or(ActiveResolution { deadline, names: Vec::new(), @@ -198,42 +237,51 @@ impl ResolutionContext { crate::resources::observation::ResourceKind::Waiter, ); let lookup = async { - let addresses = match source { - Source::Runtime(dns) => { - let dns = dns - .as_ref() - .and_then(Weak::upgrade) - .ok_or_else(unavailable)?; - if proxy { - dns.resolve_proxy(&host).await - } else { - dns.resolve(&host).await - } + let addresses = if let Some(dns) = node_dns { + dns.resolve(&host) + .await .map_err(|_| DispatchError::HostUnreachable)? .into_iter() .map(|ip| SocketAddr::new(ip, *port)) .collect::>() - } - Source::Measurement(bootstrap) => { - let resolved = bootstrap - .resolve(&host, *port) - .await - .map_err(|_| DispatchError::HostUnreachable)?; - if resolved.logical_host != host - || resolved.port != *port - || resolved - .addresses - .iter() - .any(|address| address.port() != *port) - { - return Err(DispatchError::HostUnreachable); + } else { + match source.expect("checked resolver source") { + Source::Runtime(dns) => { + let dns = dns + .as_ref() + .and_then(Weak::upgrade) + .ok_or_else(unavailable)?; + if proxy { + dns.resolve_proxy(&host).await + } else { + dns.resolve(&host).await + } + .map_err(|_| DispatchError::HostUnreachable)? + .into_iter() + .map(|ip| SocketAddr::new(ip, *port)) + .collect::>() + } + Source::Measurement(bootstrap) => { + let resolved = bootstrap + .resolve(&host, *port) + .await + .map_err(|_| DispatchError::HostUnreachable)?; + if resolved.logical_host != host + || resolved.port != *port + || resolved + .addresses + .iter() + .any(|address| address.port() != *port) + { + return Err(DispatchError::HostUnreachable); + } + resolved.addresses } - resolved.addresses } }; let mut addresses: Vec<_> = addresses .into_iter() - .filter(|address| inner.ipv6 || address.is_ipv4()) + .filter(|address| inner.is_none_or(|inner| inner.ipv6) || address.is_ipv4()) .collect(); self.ip_version.order(&mut addresses); if addresses.is_empty() { @@ -244,7 +292,7 @@ impl ResolutionContext { }; tokio::select! { biased; - _ = inner.cancel.cancelled() => Err(DispatchError::NotAllowed), + _ = async { if let Some(inner)=inner { inner.cancel.cancelled().await } else { std::future::pending::<()>().await } } => Err(DispatchError::NotAllowed), result = timeout_at(deadline, ACTIVE.scope(active, lookup)) => result.unwrap_or(Err(DispatchError::TimedOut)), } } diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 7dcfa69..fdb8cf5 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -100,6 +100,7 @@ pub struct RuntimeDns { fake_filter: super::fake_ip::Filter, fake_pools: Option>, bootstrap: Option>, + node_bootstrap: Option, proxy_resolver: Option>, direct_resolver: Option>, nameserver_policies: Box<[DnsNameserverPolicy]>, @@ -962,6 +963,34 @@ impl RuntimeDns { Self::new_with_geo_matcher(config, dispatcher, Arc::new(EmptyGeoMatcher)) } + /// A raw-IP node's private resolver uses its own data plane, while DNS + /// server domains bootstrap through the Core's original proxy DNS role. + #[cfg(feature = "outbound-wireguard")] + pub(crate) fn new_for_node( + config: &DnsConfig, + dispatcher: Arc, + bootstrap: super::resolution::ResolutionContext, + ) -> Self { + let mut resolver = Self::assemble_leaf( + config, + DnsEgress::Single(dispatcher), + Arc::new(EmptyGeoMatcher), + config.cache_max_size, + 0, + Arc::new(RwLock::new(0)), + ); + resolver.node_bootstrap = Some(bootstrap); + // No idle background reaper belongs to this private node resolver. + // Queries remain caller-owned; the raw-IP engine owns transport stop. + let pool = Arc::get_mut(&mut resolver.tcp_pool).expect("fresh node DNS pool"); + pool.max_idle = 0; + pool.max_idle_per_key = 0; + let stats = Arc::get_mut(&mut pool.stats).expect("fresh node DNS counters"); + stats.idle_limit = 0; + stats.idle_per_key_limit = 0; + resolver + } + /// Creates a single-egress resolver with an explicit GeoData matcher. /// This is useful to embedders that need nameserver-policy without the /// built-in DIRECT/proxy dispatcher registry. @@ -1142,6 +1171,7 @@ impl RuntimeDns { fake_filter: super::fake_ip::Filter::new(&config.fake_ip), fake_pools: None, bootstrap: None, + node_bootstrap: None, proxy_resolver: None, direct_resolver: None, nameservers: config.nameservers.clone().into_boxed_slice(), @@ -1428,16 +1458,33 @@ impl RuntimeDns { DnsAddress::Source(_) => return Err(AttemptError::Io(super::source::unavailable())), DnsAddress::Domain(host) => { super::trace::record("bootstrap"); - let resolver = self - .bootstrap - .as_ref() - .ok_or_else(|| io::Error::other("DNS bootstrap is unavailable"))?; - Box::pin(resolver.resolve(host)) - .await - .map_err(|_| io::Error::other("DNS bootstrap failed"))? - .into_iter() - .next() - .ok_or_else(|| io::Error::other("DNS bootstrap returned no address"))? + if let Some(context) = &self.node_bootstrap { + let destination = Destination::domain(host.clone(), nameserver.port)?; + context + .resolve_proxy( + &destination, + super::resolution::inherited_deadline( + TokioInstant::now() + QUERY_TIMEOUT, + ), + ) + .await? + .addresses + .into_iter() + .next() + .ok_or_else(|| io::Error::other("DNS bootstrap returned no address"))? + .ip() + } else { + let resolver = self + .bootstrap + .as_ref() + .ok_or_else(|| io::Error::other("DNS bootstrap is unavailable"))?; + Box::pin(resolver.resolve(host)) + .await + .map_err(|_| io::Error::other("DNS bootstrap failed"))? + .into_iter() + .next() + .ok_or_else(|| io::Error::other("DNS bootstrap returned no address"))? + } } }; Ok(ResolvedNameserver { diff --git a/src/outbound/connector.rs b/src/outbound/connector.rs index 6c1d64f..f114a66 100644 --- a/src/outbound/connector.rs +++ b/src/outbound/connector.rs @@ -231,6 +231,18 @@ impl EstablishContext { } } + #[cfg(feature = "outbound-wireguard")] + pub(crate) fn with_metadata(mut self, metadata: &crate::session::InboundMetadata) -> Self { + self.inbound = metadata.clone(); + self + } + + #[cfg(feature = "outbound-wireguard")] + pub(crate) fn capped_by(mut self, deadline: Instant) -> Self { + self.deadline = self.deadline.min(inherited_deadline(deadline)); + self + } + #[must_use] pub const fn deadline(&self) -> Instant { self.deadline @@ -250,7 +262,7 @@ impl EstablishContext { /// Only for a new physical path of an already authenticated session, never /// for extending an in-progress setup. Freeze its group choices and give /// this socket's complete upstream establishment a new absolute deadline. - #[cfg(any(test, feature = "outbound-hysteria2"))] + #[cfg(any(test, feature = "outbound-hysteria2", feature = "outbound-wireguard"))] pub(crate) fn authenticated_continuation(&self) -> Self { Self { deadline: Instant::now() + DEFAULT_ESTABLISH_TIMEOUT, @@ -566,6 +578,37 @@ impl std::fmt::Debug for UpstreamPath { } impl UpstreamPath { + /// A multi-endpoint raw-IP node shares the declared graph, while each + /// physical peer has its own server and may refresh a previously pinned IP. + #[cfg(feature = "outbound-wireguard")] + pub(crate) fn for_server(&self, server: &Destination, refresh: bool) -> Self { + match self { + Self::Direct { endpoint, dialer } + if refresh || validate_prepared_endpoint(server, endpoint).is_err() => + { + Self::LazyDirect { + dialer: dialer.clone(), + } + } + Self::Group { + server: original, + group, + endpoint, + dialer, + } => Self::Group { + server: server.clone(), + group: group.clone(), + endpoint: if refresh || original != server { + None + } else { + endpoint.clone() + }, + dialer: dialer.clone(), + }, + _ => self.clone(), + } + } + #[must_use] pub const fn direct(endpoint: ResolvedEndpoint, dialer: Dialer) -> Self { Self::Direct { endpoint, dialer } diff --git a/src/outbound/wireguard/channel.rs b/src/outbound/wireguard/channel.rs new file mode 100644 index 0000000..2018125 --- /dev/null +++ b/src/outbound/wireguard/channel.rs @@ -0,0 +1,124 @@ +use super::{datagram::Registry, peer, routes::Routes}; +use crate::{ + config::WireGuardOutboundConfig, + dispatch::DispatchError, + outbound::{EstablishContext, UpstreamPath}, + resources::observation, + session::DatagramSession, +}; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::{ + sync::{Mutex as AsyncMutex, mpsc, oneshot, watch}, + task::JoinHandle, + time::Instant, +}; +use tokio_util::sync::CancellationToken; +use vole_netstack::{Packet, PacketSink}; +#[derive(Clone, Copy)] +pub(super) enum Ready { + Waiting, + Established, + Failed, +} +pub(super) struct Refresh { + pub context: EstablishContext, + pub reply: oneshot::Sender<()>, +} +pub(super) struct Channel { + pub tx: mpsc::Sender, + pub cancel: CancellationToken, + ready: watch::Receiver, + commands: mpsc::Sender, + initial: EstablishContext, + updated: Arc>, + refresh: AsyncMutex<()>, + task: Mutex>>, + stop: AsyncMutex<()>, +} +impl Channel { + #[allow(clippy::too_many_arguments)] + pub fn start( + config: Arc, + index: usize, + upstream: UpstreamPath, + session: DatagramSession, + context: EstablishContext, + cancel: CancellationToken, + sink: PacketSink, + registry: Arc, + routes: Arc, + ) -> Arc { + let (tx, rx) = mpsc::channel(32); + let (ready, state) = watch::channel(Ready::Waiting); + let (commands, requests) = mpsc::channel(1); + let updated = Arc::new(Mutex::new(Instant::now())); + let channel = Arc::new(Self { + tx, + cancel: cancel.clone(), + ready: state, + commands, + initial: context.clone(), + updated: updated.clone(), + refresh: AsyncMutex::new(()), + task: Mutex::new(None), + stop: AsyncMutex::new(()), + }); + let task = observation::spawn(peer::drive( + config, index, upstream, session, context, cancel, rx, requests, ready, sink, registry, + routes, updated, + )); + *channel.task.lock().unwrap() = Some(task); + channel + } + pub fn is_ready(&self) -> bool { + !self.cancel.is_cancelled() && matches!(*self.ready.borrow(), Ready::Established) + } + pub async fn wait_ready(&self, context: &EstablishContext) -> Result<(), DispatchError> { + let mut ready = self.ready.clone(); + context.run("WireGuard handshake",async{ + loop { + match *ready.borrow_and_update(){Ready::Established=>return Ok(()),Ready::Failed=>return Err(DispatchError::ConnectionRefused),Ready::Waiting=>{}} + tokio::select!{biased;()=self.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=ready.changed()=>if result.is_err(){return Err(DispatchError::ConnectionRefused);}} + } + }).await + } + pub async fn refresh_if_due(&self, interval: i64, caller: &EstablishContext) { + if interval == 0 || !self.is_ready() { + return; + } + let Ok(_guard) = self.refresh.try_lock() else { + return; + }; + if self.updated.lock().unwrap().elapsed() < Duration::from_secs(interval.max(0) as u64) { + return; + } + let context = self + .initial + .authenticated_continuation() + .capped_by(caller.deadline()); + let (reply, done) = oneshot::channel(); + let _ = tokio::select! {biased;()=self.cancel.cancelled()=>Err(DispatchError::ConnectionRefused),result=caller.run("WireGuard endpoint refresh",async{ + self.commands.send(Refresh{context,reply}).await.map_err(|_|DispatchError::ConnectionRefused)?; + done.await.map_err(|_|DispatchError::ConnectionRefused) + })=>result}; + } + pub async fn stop(&self) { + let _guard = self.stop.lock().await; + self.cancel.cancel(); + let task = self.task.lock().unwrap().take(); + if let Some(task) = task { + let _ = task.await; + } + } +} +impl Drop for Channel { + fn drop(&mut self) { + self.cancel.cancel(); + if let Some(task) = self.task.get_mut().unwrap().take() { + task.abort(); + } + } +} diff --git a/src/outbound/wireguard/datagram.rs b/src/outbound/wireguard/datagram.rs index d3f3fed..c041694 100644 --- a/src/outbound/wireguard/datagram.rs +++ b/src/outbound/wireguard/datagram.rs @@ -4,7 +4,7 @@ use crate::{ dns::resolution::ResolutionContext, outbound::DEFAULT_ESTABLISH_TIMEOUT, resources::observation::{self, ResourceKind}, - session::{Datagram, Destination}, + session::{Datagram, DatagramSession, Destination}, }; use async_trait::async_trait; use bytes::Bytes; @@ -130,6 +130,7 @@ pub(super) struct Datagrams { slot: Option>, receiver: mpsc::Receiver, resolution: ResolutionContext, + session: DatagramSession, budget: DatagramBudget, frame: Vec, } @@ -138,6 +139,7 @@ impl Datagrams { engine: Arc, request: DatagramBudget, resolution: ResolutionContext, + session: DatagramSession, ) -> Result { let overhead = if engine.ipv6.is_some() { 48 } else { 28 }; let maximum = (engine.mtu - overhead) as u16; @@ -152,6 +154,7 @@ impl Datagrams { slot: Some(slot), receiver, resolution, + session, budget, frame: Vec::with_capacity(mtu), }) @@ -176,7 +179,19 @@ impl DatagramTransport for Datagrams { )); } let deadline = Instant::now() + DEFAULT_ESTABLISH_TIMEOUT; - let destination = tokio::select! {biased;()=self.engine.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=self.resolution.resolve_local_family(&datagram.remote,deadline,self.engine.ipv4.is_some(),self.engine.ipv6.is_some())=>result?}; + let destination = + super::resolver::resolve(&self.engine, &datagram.remote, &self.resolution, deadline) + .await?; + let context = crate::outbound::EstablishContext::with_resolution( + DEFAULT_ESTABLISH_TIMEOUT, + self.resolution.clone(), + ) + .with_metadata(&self.session.metadata) + .capped_by(deadline); + let channel = self + .engine + .peer(destination.ip(), self.session.clone(), &context) + .await?; let source = SocketAddr::new(self.engine.local(destination.ip())?, port); encode_udp_packet_into( &UdpDatagram::new(source, destination, datagram.payload), @@ -187,7 +202,7 @@ impl DatagramTransport for Datagrams { let packet = Packet::new(Bytes::copy_from_slice(&self.frame)); tokio::select! {biased; ()=self.engine.cancel.cancelled()=>Err(DispatchError::ConnectionRefused), - result=tokio::time::timeout_at(deadline,self.engine.tx.send(packet))=>result.map_err(|_|DispatchError::TimedOut)?.map_err(|_|DispatchError::ConnectionRefused), + result=tokio::time::timeout_at(deadline,channel.tx.send(packet))=>result.map_err(|_|DispatchError::TimedOut)?.map_err(|_|DispatchError::ConnectionRefused), } } async fn receive(&mut self) -> Result { diff --git a/src/outbound/wireguard/engine.rs b/src/outbound/wireguard/engine.rs index 63a7a8d..e59caba 100644 --- a/src/outbound/wireguard/engine.rs +++ b/src/outbound/wireguard/engine.rs @@ -1,58 +1,54 @@ -use super::{datagram::Registry, peer}; +use super::{channel::Channel, datagram::Registry, peer, routes::Routes}; use crate::{ config::WireGuardOutboundConfig, dispatch::DispatchError, + dns::resolution::ResolutionContext, outbound::{EstablishContext, UpstreamPath}, resources::observation, - session::{DatagramSession, Destination}, + session::DatagramSession, }; use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::{Arc, Mutex}, + sync::{Arc, Mutex, OnceLock}, }; -use tokio::{ - sync::{Mutex as AsyncMutex, mpsc, watch}, - task::JoinHandle, -}; -use tokio_util::sync::CancellationToken; +use tokio::{sync::Mutex as AsyncMutex, task::JoinHandle}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; use vole_netstack::{ - ClientAddresses, ClientNetStackParts, NetStack, NetStackConfig, NetStackControl, Packet, + ClientAddresses, ClientNetStackParts, NetStack, NetStackConfig, NetStackControl, PacketSink, PacketStream, TcpConnector, }; - -#[derive(Clone, Copy)] -pub(super) enum Ready { - Waiting, - Established, - Failed, +#[derive(Default)] +struct PeerSlot { + gate: AsyncMutex<()>, + current: Mutex>>, } - pub(super) struct Engine { pub cancel: CancellationToken, pub tcp: TcpConnector, pub registry: Arc, - pub tx: mpsc::Sender, pub ipv4: Option, pub ipv6: Option, pub mtu: usize, - ready: watch::Receiver, + pub resolution: ResolutionContext, + pub remote: OnceLock, + pub config: Arc, + routes: Arc, + upstream: UpstreamPath, + slots: Arc<[PeerSlot]>, + sink: PacketSink, control: NetStackControl, - tasks: Mutex>>, + router: Mutex>>, + retired: TaskTracker, + admission: Mutex<()>, stop: AsyncMutex<()>, } impl Engine { pub fn start( config: Arc, - server: Destination, upstream: UpstreamPath, - session: DatagramSession, context: EstablishContext, cancel: CancellationToken, ) -> Result, DispatchError> { - let addresses = ClientAddresses { - ipv4: config.ipv4, - ipv6: config.ipv6, - }; let ClientNetStackParts { packet_sink, packet_stream, @@ -65,39 +61,42 @@ impl Engine { packet_queue: 32, ..Default::default() }, - addresses, + ClientAddresses { + ipv4: config.ipv4, + ipv6: config.ipv6, + }, ) .map_err(|_| DispatchError::Other("WireGuard stack initialization failed".into()))?; - let (tx, rx) = mpsc::channel(32); - let (ready, state) = watch::channel(Ready::Waiting); - let registry = Arc::new(Registry::default()); + let routes = Arc::new(Routes::new(&config)); + let slots: Arc<[PeerSlot]> = (0..config.peers.len()) + .map(|_| PeerSlot::default()) + .collect(); let engine = Arc::new(Self { cancel: cancel.clone(), tcp: tcp_connector, - registry: registry.clone(), - tx: tx.clone(), + registry: Arc::new(Registry::default()), ipv4: config.ipv4, ipv6: config.ipv6, mtu: usize::from(config.mtu), - ready: state, + resolution: context.resolution(), + remote: OnceLock::new(), + config, + routes: routes.clone(), + upstream, + slots: slots.clone(), + sink: packet_sink, control, - tasks: Mutex::new(Vec::new()), + router: Mutex::new(None), + retired: TaskTracker::new(), + admission: Mutex::new(()), stop: AsyncMutex::new(()), }); - let router = observation::spawn(pump_output(packet_stream, tx, cancel.clone())); - let peer = observation::spawn(peer::drive( - config, - server, - upstream, - session, - context, + *engine.router.lock().unwrap() = Some(observation::spawn(pump_output( + packet_stream, + slots, + routes, cancel, - rx, - ready, - packet_sink, - registry, - )); - *engine.tasks.lock().unwrap() = vec![router, peer]; + ))); Ok(engine) } pub fn local(&self, ip: IpAddr) -> Result { @@ -107,21 +106,77 @@ impl Engine { } .ok_or(DispatchError::HostUnreachable) } - pub async fn wait_ready(&self, context: &EstablishContext) -> Result<(), DispatchError> { - let mut ready = self.ready.clone(); - context.run("WireGuard handshake",async { - loop { - match *ready.borrow_and_update() {Ready::Established=>return Ok(()),Ready::Failed=>return Err(DispatchError::ConnectionRefused),Ready::Waiting=>{}} - tokio::select! {biased;()=self.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=ready.changed()=>if result.is_err(){return Err(DispatchError::ConnectionRefused);}} - } - }).await + pub async fn peer( + &self, + ip: IpAddr, + session: DatagramSession, + context: &EstablishContext, + ) -> Result, DispatchError> { + self.local(ip)?; + let index = self + .routes + .select(ip) + .ok_or(DispatchError::HostUnreachable)?; + let slot = &self.slots[index]; + let channel = tokio::select! {biased;()=self.cancel.cancelled()=>return Err(DispatchError::NotAllowed),result=context.run("WireGuard peer initialize",async{ + let _gate=slot.gate.lock().await;let mut current=slot.current.lock().unwrap(); + if let Some(channel)=current.as_ref().filter(|c|!c.cancel.is_cancelled()){return Ok(channel.clone());} + let _admission=self.admission.lock().unwrap();if self.cancel.is_cancelled(){return Err(DispatchError::NotAllowed);} + if let Some(old)=current.take(){self.retired.spawn(observation::task(async move{old.stop().await;}));} + let channel=Channel::start(self.config.clone(),index,self.upstream.clone(),session,context.clone(),self.cancel.child_token(),self.sink.clone(),self.registry.clone(),self.routes.clone()); + *current=Some(channel.clone());Ok(channel) + })=>result?}; + channel.wait_ready(context).await?; + self.refresh_active(context).await; + channel.wait_ready(context).await?; + Ok(channel) } + async fn refresh_active(&self, context: &EstablishContext) { + let interval = self.config.refresh_server_ip_interval; + if interval == 0 { + return; + } + // A business call can refresh every live peer, including one whose + // route has not recently carried traffic. Unused peers stay lazy. + let channels = self + .slots + .iter() + .filter_map(|slot| { + slot.current + .lock() + .unwrap() + .as_ref() + .filter(|channel| channel.is_ready()) + .cloned() + }) + .collect::>(); + futures_util::future::join_all( + channels + .iter() + .map(|channel| channel.refresh_if_due(interval, context)), + ) + .await; + } + pub async fn stop(&self) { let _stop = self.stop.lock().await; - self.cancel.cancel(); - let tasks = std::mem::take(&mut *self.tasks.lock().unwrap()); - for task in tasks { - let _ = task.await; + { + let _admission = self.admission.lock().unwrap(); + self.cancel.cancel(); + self.retired.close(); + } + let channels = self + .slots + .iter() + .filter_map(|slot| slot.current.lock().unwrap().take()) + .collect::>(); + for channel in channels { + channel.stop().await; + } + self.retired.wait().await; + let router = self.router.lock().unwrap().take(); + if let Some(router) = router { + let _ = router.await; } self.control.stop().await; } @@ -130,22 +185,34 @@ impl Drop for Engine { fn drop(&mut self) { self.cancel.cancel(); self.control.cancel(); - for task in self.tasks.get_mut().unwrap().drain(..) { - task.abort(); + if let Some(router) = self.router.get_mut().unwrap().take() { + router.abort(); } } } - async fn pump_output( mut packets: PacketStream, - tx: mpsc::Sender, + slots: Arc<[PeerSlot]>, + routes: Arc, cancel: CancellationToken, ) { loop { - let packet = tokio::select! {biased;()=cancel.cancelled()=>break,packet=packets.recv()=>match packet {Some(packet)=>packet,None=>break}}; - // This single-peer route seam becomes longest-prefix peer selection in - // the multi-peer extension. All flows share the same active stack. - tokio::select! {biased;()=cancel.cancelled()=>break,result=tx.send(packet)=>if result.is_err(){break;}} + let packet = tokio::select! {biased;()=cancel.cancelled()=>break,packet=packets.recv()=>match packet{Some(p)=>p,None=>break}}; + let Some((_, dst)) = peer::addresses(packet.data()) else { + continue; + }; + let Some(index) = routes.select(dst) else { + continue; + }; + if let Some(channel) = slots[index] + .current + .lock() + .unwrap() + .as_ref() + .filter(|c| c.is_ready()) + { + let _ = channel.tx.try_send(packet); + } } cancel.cancel(); } diff --git a/src/outbound/wireguard/mod.rs b/src/outbound/wireguard/mod.rs index a508ad3..d6d4120 100644 --- a/src/outbound/wireguard/mod.rs +++ b/src/outbound/wireguard/mod.rs @@ -1,7 +1,10 @@ //! Standard WireGuard over shared controlled UDP and the active raw-IP stack. +mod channel; mod datagram; mod engine; mod peer; +mod resolver; +mod routes; #[cfg(test)] mod tests; @@ -10,7 +13,7 @@ use crate::{ dispatch::{DatagramTransport, DispatchError}, outbound::{ ConnectedStream, DatagramRequest, EstablishContext, OutboundConnector, UpstreamPath, - owned_stream::OwnedStream, server_destination, + owned_stream::OwnedStream, }, resources::observation, session::{DatagramSession, Destination, StreamSession}, @@ -26,7 +29,6 @@ use tokio_util::{sync::CancellationToken, task::TaskTracker}; pub struct WireGuardOutbound { config: Arc, - server: Destination, upstream: UpstreamPath, current: AsyncMutex>>, cancel: CancellationToken, @@ -51,7 +53,6 @@ impl WireGuardOutbound { })?; Ok(Self { config: Arc::new(config.clone()), - server: server_destination(&config.address, config.port)?, upstream, current: AsyncMutex::new(None), cancel: CancellationToken::new(), @@ -59,11 +60,7 @@ impl WireGuardOutbound { retired: TaskTracker::new(), }) } - async fn engine( - &self, - session: DatagramSession, - context: &EstablishContext, - ) -> Result, DispatchError> { + async fn engine(&self, context: &EstablishContext) -> Result, DispatchError> { let engine = tokio::select! { biased; ()=self.cancel.cancelled()=>return Err(DispatchError::NotAllowed), result=context.run("WireGuard initialize",async { @@ -72,13 +69,12 @@ impl WireGuardOutbound { let _admission=self.admission.lock().unwrap(); if self.cancel.is_cancelled() {return Err(DispatchError::NotAllowed);} if let Some(old)=current.take() {self.retired.spawn(observation::task(async move {old.stop().await;}));} - let engine=Engine::start(self.config.clone(),self.server.clone(),self.upstream.clone(),session,context.clone(),self.cancel.child_token())?; + let engine=Engine::start(self.config.clone(),self.upstream.clone(),context.clone(),self.cancel.child_token())?; // Publish ownership before waiting for network setup or authentication. *current=Some(engine.clone()); Ok(engine) })=>result?, }; - engine.wait_ready(context).await?; Ok(engine) } } @@ -91,18 +87,24 @@ impl OutboundConnector for WireGuardOutbound { ) -> Result { let datagram = DatagramSession::new(session.inbound, session.source).with_metadata(session.metadata); - let engine = self.engine(datagram, context).await?; - let resolution = context.resolution(); - let remote = tokio::select! {biased; ()=engine.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=resolution.resolve_local_family(&session.destination, context.deadline(), engine.ipv4.is_some(), engine.ipv6.is_some())=>result?}; + let engine = self.engine(context).await?; + let remote = resolver::resolve( + &engine, + &session.destination, + &context.resolution(), + context.deadline(), + ) + .await?; + let channel = engine.peer(remote.ip(), datagram, context).await?; engine.local(remote.ip())?; let stream = tokio::select! {biased; - ()=engine.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused), + ()=channel.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused), result=context.run_io("WireGuard TCP",engine.tcp.connect(remote,context.deadline()))=>result?, }; Ok(ConnectedStream { io: Box::new(OwnedStream::new( Box::new(stream), - engine.cancel.child_token(), + channel.cancel.child_token(), )), effective_peer: Destination::Ip(remote), }) @@ -112,11 +114,12 @@ impl OutboundConnector for WireGuardOutbound { request: DatagramRequest, context: &EstablishContext, ) -> Result, DispatchError> { - let engine = self.engine(request.session.clone(), context).await?; + let engine = self.engine(context).await?; Ok(Box::new(datagram::Datagrams::new( engine, request.budget(), context.resolution(), + request.session, )?)) } fn begin_shutdown(&self) { diff --git a/src/outbound/wireguard/peer.rs b/src/outbound/wireguard/peer.rs index 3cdadfa..b326e9e 100644 --- a/src/outbound/wireguard/peer.rs +++ b/src/outbound/wireguard/peer.rs @@ -1,11 +1,15 @@ use super::{ + channel::{Ready, Refresh}, datagram::{Registry, local_destination}, - engine::Ready, + routes::Routes, }; use crate::{ - config::WireGuardOutboundConfig, + config::{WireGuardOutboundConfig, WireGuardPeerConfig}, dispatch::{DatagramBudget, DatagramTransport, DispatchError}, - outbound::{DEFAULT_ESTABLISH_TIMEOUT, DatagramRequest, EstablishContext, UpstreamPath}, + outbound::{ + DEFAULT_ESTABLISH_TIMEOUT, DatagramRequest, EstablishContext, UpstreamPath, + server_destination, + }, session::{Datagram, DatagramSession, Destination}, }; use boringtun::{ @@ -15,7 +19,7 @@ use boringtun::{ use bytes::Bytes; use std::{ net::{IpAddr, SocketAddr}, - sync::Arc, + sync::{Arc, Mutex}, time::Duration, }; use tokio::{ @@ -24,25 +28,31 @@ use tokio::{ }; use tokio_util::sync::CancellationToken; use vole_netstack::{Packet, PacketSink, parse_udp_packet_view}; - -#[allow(clippy::too_many_arguments)] -pub(super) async fn drive( +struct Driver { config: Arc, - server: Destination, + index: usize, upstream: UpstreamPath, session: DatagramSession, - context: EstablishContext, cancel: CancellationToken, - mut packets: mpsc::Receiver, ready: watch::Sender, sink: PacketSink, registry: Arc, -) { - let wire_size = config.mtu + 32; - let request = - DatagramRequest::new(session).with_budget(DatagramBudget::new(wire_size, wire_size)); - let setup = async { - let server = upstream.datagram_server(&server, &context).await?; + routes: Arc, + updated: Arc>, +} +impl Driver { + fn peer(&self) -> &WireGuardPeerConfig { + &self.config.peers[self.index] + } + async fn endpoint( + &self, + context: &EstablishContext, + refresh: bool, + ) -> Result<(SocketAddr, UpstreamPath), DispatchError> { + let server = server_destination(&self.peer().address, self.peer().port) + .map_err(|_| DispatchError::HostUnreachable)?; + let path = self.upstream.for_server(&server, refresh); + let server = path.datagram_server(&server, context).await?; let endpoint = context .resolution() .resolve_proxy(&server, context.deadline()) @@ -51,159 +61,209 @@ pub(super) async fn drive( .into_iter() .next() .ok_or(DispatchError::HostUnreachable)?; - let transport = upstream.open_datagram(request, &context).await?; - Ok((endpoint, transport)) + Ok((endpoint, path)) + } + async fn transport( + &self, + endpoint: SocketAddr, + path: &UpstreamPath, + context: &EstablishContext, + ) -> Result, DispatchError> { + let size = self.config.mtu + 32; + let request = + DatagramRequest::new(self.session.clone()).with_budget(DatagramBudget::new(size, size)); + let mut transport = path.open_datagram(request, context).await?; + let budget = transport.payload_budget(&Destination::Ip(endpoint)); + if budget.transmit() < size || budget.receive() < size { + let _ = tokio::time::timeout(Duration::from_secs(1), transport.close()).await; + return Err(DispatchError::Other( + "WireGuard MTU exceeds upstream UDP budget".into(), + )); + } + Ok(transport) + } + async fn handle( + &self, + result: TunnResult<'_>, + endpoint: SocketAddr, + transport: &mut dyn DatagramTransport, + deadline: Instant, + ) -> Result<(), DispatchError> { + match result { + TunnResult::Done => Ok(()), + TunnResult::Err(WireGuardError::ConnectionExpired) => { + Err(DispatchError::ConnectionRefused) + } + TunnResult::Err(_) => Ok(()), + TunnResult::WriteToNetwork(packet) => { + let mut wire = packet.to_vec(); + wire[1..4].copy_from_slice(&self.peer().reserved); + let datagram = Datagram { + remote: Destination::Ip(endpoint), + payload: Bytes::from(wire), + sniffed_domain: None, + }; + tokio::select! {biased;()=self.cancel.cancelled()=>Err(DispatchError::ConnectionRefused),result=tokio::time::timeout_at(deadline,transport.send(datagram))=>result.map_err(|_|DispatchError::TimedOut)?} + } + TunnResult::WriteToTunnelV4(packet, _) | TunnResult::WriteToTunnelV6(packet, _) => { + let Some((src, dst)) = addresses(packet) else { + return Ok(()); + }; + if !local_destination(&self.config, dst) + || src.is_ipv4() != dst.is_ipv4() + || self.routes.select(src) != Some(self.index) + { + return Ok(()); + } + if let Some(udp) = parse_udp_packet_view(packet) { + self.registry + .deliver(udp.source, udp.destination, udp.payload); + } else { + let _ = self + .sink + .try_send(Packet::new(Bytes::copy_from_slice(packet))); + } + Ok(()) + } + } + } +} +#[allow(clippy::too_many_arguments)] +pub(super) async fn drive( + config: Arc, + index: usize, + upstream: UpstreamPath, + session: DatagramSession, + context: EstablishContext, + cancel: CancellationToken, + mut packets: mpsc::Receiver, + mut commands: mpsc::Receiver, + ready: watch::Sender, + sink: PacketSink, + registry: Arc, + routes: Arc, + updated: Arc>, +) { + let driver = Driver { + config, + index, + upstream, + session, + cancel, + ready, + sink, + registry, + routes, + updated, }; - let result = tokio::select! {biased;()=cancel.cancelled()=>Err(DispatchError::NotAllowed),result=context.run("WireGuard outer UDP",setup)=>result}; + let setup = async { + let (endpoint, path) = driver.endpoint(&context, false).await?; + Ok((endpoint, driver.transport(endpoint, &path, &context).await?)) + }; + let result = tokio::select! {biased;()=driver.cancel.cancelled()=>Err(DispatchError::NotAllowed),result=context.run("WireGuard outer UDP",setup)=>result}; if let Ok((endpoint, mut transport)) = result { - let budget = transport.payload_budget(&Destination::Ip(endpoint)); - if budget.transmit() >= wire_size && budget.receive() >= wire_size { - let mut tunn = Tunn::new( - x25519::StaticSecret::from(config.private_key.bytes()), - x25519::PublicKey::from(config.public_key.bytes()), - config.pre_shared_key.as_ref().map(|key| key.bytes()), - Some(config.persistent_keepalive), - rand::random::() >> 8, - None, - ); - let mut scratch = vec![0; usize::from(wire_size).max(148)]; - let initial = tunn.format_handshake_initiation(&mut scratch, true); - let initial = handle( - initial, - &config, + *driver.updated.lock().unwrap() = Instant::now(); + let mut tunn = Tunn::new( + x25519::StaticSecret::from(driver.config.private_key.bytes()), + x25519::PublicKey::from(driver.peer().public_key.bytes()), + driver.peer().pre_shared_key.as_ref().map(|key| key.bytes()), + Some(driver.config.persistent_keepalive), + rand::random::() >> 8, + None, + ); + let size = usize::from(driver.config.mtu + 32).max(148); + let mut scratch = vec![0; size]; + let mut network = vec![0; size]; + let first = tunn.format_handshake_initiation(&mut scratch, true); + if driver + .handle(first, endpoint, transport.as_mut(), context.deadline()) + .await + .is_ok() + { + let _ = run_peer( + &driver, + &mut tunn, endpoint, - transport.as_mut(), - &cancel, - context.deadline(), - &sink, - ®istry, + &mut transport, + &context, + &mut packets, + &mut commands, + &mut scratch, + &mut network, ) .await; - if initial.is_ok() { - let _ = run_peer( - &mut tunn, - &config, - endpoint, - transport.as_mut(), - &cancel, - &context, - &mut packets, - &ready, - &sink, - ®istry, - &mut scratch, - ) - .await; - } } - cancel.cancel(); + driver.cancel.cancel(); let _ = tokio::time::timeout(Duration::from_secs(1), transport.close()).await; } - ready.send_replace(Ready::Failed); - cancel.cancel(); + driver.ready.send_replace(Ready::Failed); + driver.cancel.cancel(); } - #[allow(clippy::too_many_arguments)] async fn run_peer( + driver: &Driver, tunn: &mut Tunn, - config: &WireGuardOutboundConfig, - endpoint: SocketAddr, - transport: &mut dyn DatagramTransport, - cancel: &CancellationToken, + mut endpoint: SocketAddr, + transport: &mut Box, context: &EstablishContext, packets: &mut mpsc::Receiver, - ready: &watch::Sender, - sink: &PacketSink, - registry: &Registry, + commands: &mut mpsc::Receiver, scratch: &mut [u8], + network: &mut [u8], ) -> Result<(), DispatchError> { let mut timer = tokio::time::interval(Duration::from_millis(250)); timer.set_missed_tick_behavior(MissedTickBehavior::Skip); let mut established = false; + let mut handshake_deadline = context.deadline(); loop { let deadline = if established { Instant::now() + DEFAULT_ESTABLISH_TIMEOUT } else { - context.deadline() + handshake_deadline }; - let result = tokio::select! {biased; - ()=cancel.cancelled()=>return Err(DispatchError::ConnectionRefused), - ()=tokio::time::sleep_until(context.deadline()),if !established=>return Err(DispatchError::TimedOut), - datagram=transport.receive()=> { - let datagram=datagram?; - if datagram.remote!=Destination::Ip(endpoint) || datagram.payload.len()>scratch.len(){continue;} - let result=tunn.decapsulate(Some(endpoint.ip()),&datagram.payload,scratch); - handle(result,config,endpoint,transport,cancel,deadline,sink,registry).await?; - loop { - let result=tunn.decapsulate(None,&[],scratch); - if matches!(result,TunnResult::Done){break;} - handle(result,config,endpoint,transport,cancel,deadline,sink,registry).await?; - } - None + tokio::select! {biased; + ()=driver.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused), + ()=tokio::time::sleep_until(handshake_deadline),if !established=>return Err(DispatchError::TimedOut), + datagram=transport.receive()=>{ + let datagram=datagram?;let len=datagram.payload.len(); + if datagram.remote!=Destination::Ip(endpoint)||!(4..=network.len()).contains(&len){continue;} + network[..len].copy_from_slice(&datagram.payload);network[1..4].fill(0); + let result=tunn.decapsulate(Some(endpoint.ip()),&network[..len],scratch); + let authenticated=matches!(network[0],1|2)&&matches!(result,TunnResult::WriteToNetwork(_))&&tunn.time_since_last_handshake().is_some(); + driver.handle(result,endpoint,transport.as_mut(),deadline).await?; + loop{let result=tunn.decapsulate(None,&[],scratch);if matches!(result,TunnResult::Done){break;}driver.handle(result,endpoint,transport.as_mut(),deadline).await?;} + if !established&&authenticated{established=true;driver.ready.send_replace(Ready::Established);tracing::debug!("WireGuard handshake ready");} }, - packet=packets.recv(),if established=> { + packet=packets.recv(),if established=>{ let packet=packet.ok_or(DispatchError::ConnectionRefused)?; - Some(tunn.encapsulate(packet.data(),scratch)) + driver.handle(tunn.encapsulate(packet.data(),scratch),endpoint,transport.as_mut(),deadline).await?; }, - _=timer.tick()=>Some(tunn.update_timers(scratch)), - }; - if let Some(result) = result { - handle( - result, config, endpoint, transport, cancel, deadline, sink, registry, - ) - .await?; - } - if !established && tunn.time_since_last_handshake().is_some() { - established = true; - ready.send_replace(Ready::Established); - tracing::debug!("WireGuard handshake ready"); - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn handle( - result: TunnResult<'_>, - config: &WireGuardOutboundConfig, - endpoint: SocketAddr, - transport: &mut dyn DatagramTransport, - cancel: &CancellationToken, - deadline: Instant, - sink: &PacketSink, - registry: &Registry, -) -> Result<(), DispatchError> { - match result { - TunnResult::Done => Ok(()), - TunnResult::Err(WireGuardError::ConnectionExpired) => Err(DispatchError::ConnectionRefused), - TunnResult::Err(_) => Ok(()), - TunnResult::WriteToNetwork(packet) => { - let datagram = Datagram { - remote: Destination::Ip(endpoint), - payload: Bytes::copy_from_slice(packet), - sniffed_domain: None, - }; - tokio::select! {biased;()=cancel.cancelled()=>Err(DispatchError::ConnectionRefused),result=tokio::time::timeout_at(deadline,transport.send(datagram))=>result.map_err(|_|DispatchError::TimedOut)?} - } - TunnResult::WriteToTunnelV4(packet, _) | TunnResult::WriteToTunnelV6(packet, _) => { - let Some((source, destination)) = addresses(packet) else { - return Ok(()); - }; - if !local_destination(config, destination) || source.is_ipv4() != destination.is_ipv4() - { - return Ok(()); - } - // Single-peer Mihomo advertises the entire configured local family; - // top-level allowed-ips is intentionally ignored in this mode. - if let Some(udp) = parse_udp_packet_view(packet) { - registry.deliver(udp.source, udp.destination, udp.payload); - } else { - let _ = sink.try_send(Packet::new(Bytes::copy_from_slice(packet))); - } - Ok(()) + command=commands.recv(),if established=>{ + let Some(command)=command else {return Err(DispatchError::ConnectionRefused);}; + let refresh=async{ + let(next,path)=driver.endpoint(&command.context,true).await?; + if next==endpoint{return Ok(None);} + Ok(Some((next,driver.transport(next,&path,&command.context).await?))) + }; + let update=tokio::select!{biased;()=driver.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=command.context.run("WireGuard refresh",refresh)=>result}; + if let Ok(next)=update{ + *driver.updated.lock().unwrap()=Instant::now(); + if let Some((next,new))=next{ + let mut old=std::mem::replace(transport,new);endpoint=next; + established=false;handshake_deadline=command.context.deadline();driver.ready.send_replace(Ready::Waiting); + let _=tokio::time::timeout(Duration::from_secs(1),old.close()).await; + driver.handle(tunn.format_handshake_initiation(scratch,true),endpoint,transport.as_mut(),handshake_deadline).await?; + } + } + // DNS/setup failure retains the old endpoint and is retried by + // a later business call, as in reference updateServerAddr. + let _=command.reply.send(()); + }, + _=timer.tick()=>{driver.handle(tunn.update_timers(scratch),endpoint,transport.as_mut(),deadline).await?;}, } } } -fn addresses(packet: &[u8]) -> Option<(IpAddr, IpAddr)> { +pub(super) fn addresses(packet: &[u8]) -> Option<(IpAddr, IpAddr)> { match packet.first()? >> 4 { 4 if packet.len() >= 20 => Some(( IpAddr::from(<[u8; 4]>::try_from(&packet[12..16]).ok()?), diff --git a/src/outbound/wireguard/resolver.rs b/src/outbound/wireguard/resolver.rs new file mode 100644 index 0000000..746fbb0 --- /dev/null +++ b/src/outbound/wireguard/resolver.rs @@ -0,0 +1,72 @@ +//! Node DNS has a weak engine link; endpoint bootstrap never enters this resolver. +use super::{datagram::Datagrams, engine::Engine}; +use crate::{ + config::DnsConfig, + dispatch::{BoxStream, DatagramBudget, DatagramTransport, DispatchError, Dispatcher}, + dns::{resolution::ResolutionContext, runtime::RuntimeDns}, + outbound::{DEFAULT_ESTABLISH_TIMEOUT, EstablishContext, owned_stream::OwnedStream}, + session::{DatagramSession, Destination, StreamSession}, +}; +use async_trait::async_trait; +use std::{ + net::SocketAddr, + sync::{Arc, Weak}, +}; +use tokio::time::Instant; +pub(super) async fn resolve( + engine: &Arc, + target: &Destination, + resolution: &ResolutionContext, + deadline: Instant, +) -> Result { + tokio::select! {biased;()=engine.cancel.cancelled()=>Err(DispatchError::ConnectionRefused),result=async{ + if engine.config.dns.is_empty(){return resolution.resolve_local_family(target,deadline,engine.ipv4.is_some(),engine.ipv6.is_some()).await;} + let dns=engine.remote.get_or_init(||{ + let mut config=DnsConfig{enable:true,ipv6:engine.ipv6.is_some()&&resolution.ipv6_allowed(),nameservers:engine.config.dns.clone(),use_hosts:false,use_system_hosts:false,..Default::default()}; + config.hosts.clear();config.default_nameservers.clear(); + RuntimeDns::new_for_node(&config,Arc::new(NodeDns{engine:Arc::downgrade(engine)}),engine.resolution.clone()) + }); + resolution.resolve_node_local(target,deadline,dns,engine.ipv4.is_some(),engine.ipv6.is_some()).await + }=>result} +} +struct NodeDns { + engine: Weak, +} +#[async_trait] +impl Dispatcher for NodeDns { + async fn connect_tcp(&self, session: StreamSession) -> Result { + let engine = self + .engine + .upgrade() + .ok_or(DispatchError::ConnectionRefused)?; + let Destination::Ip(remote) = session.destination else { + return Err(DispatchError::HostUnreachable); + }; + let context = + EstablishContext::with_resolution(DEFAULT_ESTABLISH_TIMEOUT, engine.resolution.clone()); + let datagram = + DatagramSession::new(session.inbound, session.source).with_metadata(session.metadata); + let channel = engine.peer(remote.ip(), datagram, &context).await?; + let stream = tokio::select! {biased;()=channel.cancel.cancelled()=>return Err(DispatchError::ConnectionRefused),result=context.run_io("WireGuard DNS TCP",engine.tcp.connect(remote,context.deadline()))=>result?}; + Ok(Box::new(OwnedStream::new( + Box::new(stream), + channel.cancel.child_token(), + ))) + } + async fn open_datagram( + &self, + session: DatagramSession, + ) -> Result, DispatchError> { + let engine = self + .engine + .upgrade() + .ok_or(DispatchError::ConnectionRefused)?; + let resolution = engine.resolution.clone(); + Ok(Box::new(Datagrams::new( + engine, + DatagramBudget::new(u16::MAX, session.max_response_payload_size()), + resolution, + session, + )?)) + } +} diff --git a/src/outbound/wireguard/routes.rs b/src/outbound/wireguard/routes.rs new file mode 100644 index 0000000..12704ae --- /dev/null +++ b/src/outbound/wireguard/routes.rs @@ -0,0 +1,70 @@ +//! Immutable longest-prefix ownership, shared by send and authenticated receive. +use crate::config::WireGuardOutboundConfig; +use std::{collections::HashMap, net::IpAddr}; +pub(super) struct Routes { + v4: HashMap<(u32, u8), usize>, + v6: HashMap<(u128, u8), usize>, +} +impl Routes { + pub fn new(config: &WireGuardOutboundConfig) -> Self { + let mut routes = Self { + v4: HashMap::new(), + v6: HashMap::new(), + }; + for (cidr, index) in &config.routes { + match cidr.network { + IpAddr::V4(ip) => { + let network = if cidr.prefix_len == 0 { + 0 + } else { + u32::from(ip) & (u32::MAX << (32 - cidr.prefix_len)) + }; + routes.v4.insert((network, cidr.prefix_len), *index); + } + IpAddr::V6(ip) => { + let network = if cidr.prefix_len == 0 { + 0 + } else { + u128::from(ip) & (u128::MAX << (128 - cidr.prefix_len)) + }; + routes.v6.insert((network, cidr.prefix_len), *index); + } + } + } + routes + } + pub fn select(&self, ip: IpAddr) -> Option { + match ip { + IpAddr::V4(ip) => { + let ip = u32::from(ip); + (0..=32).rev().find_map(|prefix| { + self.v4 + .get(&( + if prefix == 0 { + 0 + } else { + ip & (u32::MAX << (32 - prefix)) + }, + prefix, + )) + .copied() + }) + } + IpAddr::V6(ip) => { + let ip = u128::from(ip); + (0..=128).rev().find_map(|prefix| { + self.v6 + .get(&( + if prefix == 0 { + 0 + } else { + ip & (u128::MAX << (128 - prefix)) + }, + prefix, + )) + .copied() + }) + } + } + } +} diff --git a/src/outbound/wireguard/tests.rs b/src/outbound/wireguard/tests.rs index 566173d..afe7d34 100644 --- a/src/outbound/wireguard/tests.rs +++ b/src/outbound/wireguard/tests.rs @@ -27,10 +27,14 @@ use vole_netstack::{ }; const ENDPOINT: &str = "198.51.100.1:51820"; +const SECOND: &str = "198.51.100.2:51820"; fn config() -> WireGuardOutboundConfig { + config_with_peer(2) +} +fn config_with_peer(peer_secret: u8) -> WireGuardOutboundConfig { let client = STANDARD.encode([1u8; 32]); - let peer = - STANDARD.encode(x25519::PublicKey::from(&x25519::StaticSecret::from([2u8; 32])).as_bytes()); + let peer = STANDARD + .encode(x25519::PublicKey::from(&x25519::StaticSecret::from([peer_secret; 32])).as_bytes()); let yaml = format!( "mixed-port: 1080\nproxies: [{{name: wg, type: wireguard, server: 198.51.100.1, port: 51820, ip: 10.0.0.2/24, ipv6: 'fd00::2/64', private-key: '{client}', public-key: '{peer}', udp: true}}]\nrules: ['MATCH,wg']" ); @@ -51,7 +55,12 @@ fn session() -> StreamSession { } struct Peer { + multi: bool, + reserved: bool, + records: Arc>>, + dns_queries: Arc, silent: bool, + silent_first: bool, sends: Arc, closes: Arc, } @@ -90,6 +99,11 @@ impl OutboundConnector for Peer { } }); Ok(Box::new(MemoryPeer { + endpoint: None, + multi: self.multi, + reserved: self.reserved, + records: self.records.clone(), + dns_queries: self.dns_queries.clone(), tunn: Tunn::new( x25519::StaticSecret::from([2; 32]), x25519::PublicKey::from(&x25519::StaticSecret::from([1; 32])), @@ -104,6 +118,7 @@ impl OutboundConnector for Peer { echo: Some(echo), network: VecDeque::new(), silent: self.silent, + silent_first: self.silent_first, sends: self.sends.clone(), closes: self.closes.clone(), scratch: vec![0; 1440], @@ -111,6 +126,11 @@ impl OutboundConnector for Peer { } } struct MemoryPeer { + endpoint: Option, + multi: bool, + reserved: bool, + records: Arc>>, + dns_queries: Arc, tunn: Tunn, sink: PacketSink, packets: PacketStream, @@ -118,6 +138,7 @@ struct MemoryPeer { echo: Option>, network: VecDeque, silent: bool, + silent_first: bool, sends: Arc, closes: Arc, scratch: Vec, @@ -126,7 +147,11 @@ impl MemoryPeer { fn network(&mut self, result: TunnResult<'_>) -> Option> { match result { TunnResult::WriteToNetwork(packet) => { - self.network.push_back(Bytes::copy_from_slice(packet)); + let mut packet = packet.to_vec(); + if self.reserved { + packet[1..4].copy_from_slice(&[7, 8, 9]); + } + self.network.push_back(Bytes::from(packet)); None } TunnResult::WriteToTunnelV4(packet, _) | TunnResult::WriteToTunnelV6(packet, _) => { @@ -144,11 +169,21 @@ impl MemoryPeer { self.scratch = scratch; if let Some(raw) = raw { if let Some(udp) = parse_udp_packet_view(&raw) { - let datagram = UdpDatagram::new( - udp.destination, - udp.source, - Bytes::copy_from_slice(udp.payload), - ); + let payload = if self.multi && udp.destination.port() == 53 { + self.dns_queries.fetch_add(1, Ordering::Relaxed); + let query = crate::dns::parse_query(udp.payload).unwrap(); + assert_eq!(query.question.name, "remote.example"); + let mut wire = udp.payload.to_vec(); + wire[2..4].copy_from_slice(&0x8180u16.to_be_bytes()); + wire[6..8].copy_from_slice(&1u16.to_be_bytes()); + wire.extend_from_slice(&[ + 0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 30, 0, 4, 10, 1, 0, 1, + ]); + Bytes::from(wire) + } else { + Bytes::copy_from_slice(udp.payload) + }; + let datagram = UdpDatagram::new(udp.destination, udp.source, payload); let mut response = Vec::with_capacity(1408); encode_udp_packet_into(&datagram, 1408, &mut response).unwrap(); self.encrypt(&response); @@ -184,10 +219,42 @@ impl DatagramTransport for MemoryPeer { DatagramBudget::new(1440, 1440) } async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { - assert_eq!(datagram.remote, Destination::Ip(ENDPOINT.parse().unwrap())); + if self.endpoint.is_none() { + assert!( + datagram.remote == Destination::Ip(ENDPOINT.parse().unwrap()) + || datagram.remote == Destination::Ip(SECOND.parse().unwrap()) + ); + self.endpoint = Some(datagram.remote.clone()); + if self.multi && datagram.remote == Destination::Ip(SECOND.parse().unwrap()) { + self.tunn = Tunn::new( + x25519::StaticSecret::from([3; 32]), + x25519::PublicKey::from(&x25519::StaticSecret::from([1; 32])), + None, + None, + 3, + None, + ); + } + } + assert_eq!(self.endpoint.as_ref(), Some(&datagram.remote)); + if self.reserved { + assert_eq!( + &datagram.payload[1..4], + if self.multi && datagram.remote == Destination::Ip(SECOND.parse().unwrap()) { + &[4, 5, 6] + } else { + &[1, 2, 3] + } + ); + } + self.records.lock().unwrap().push(datagram.remote.clone()); self.sends.fetch_add(1, Ordering::Relaxed); - if !self.silent { - self.decrypt(&datagram.payload).await?; + if !self.silent + && !(self.silent_first && datagram.remote == Destination::Ip(ENDPOINT.parse().unwrap())) + { + let mut wire = datagram.payload.to_vec(); + wire[1..4].fill(0); + self.decrypt(&wire).await?; } Ok(()) } @@ -195,12 +262,15 @@ impl DatagramTransport for MemoryPeer { loop { if let Some(payload) = self.network.pop_front() { return Ok(Datagram { - remote: Destination::Ip(ENDPOINT.parse().unwrap()), + remote: self.endpoint.clone().unwrap(), payload, sniffed_domain: None, }); } - if self.silent { + if self.silent + || (self.silent_first + && self.endpoint == Some(Destination::Ip(ENDPOINT.parse().unwrap()))) + { return pending().await; } let packet = self @@ -233,7 +303,12 @@ fn outbound(silent: bool) -> (Arc, Arc, Arc, + rotating: bool, +} +#[async_trait] +impl crate::dialer::Resolver for Bootstrap { + async fn resolve(&self, host: &str, port: u16) -> io::Result { + self.calls.fetch_add(1, Ordering::Relaxed); + let ip = if self.rotating { + assert_eq!(host, "rotate.example"); + if self.calls.load(Ordering::Relaxed) == 1 { + "198.51.100.1" + } else { + "198.51.100.2" + } + } else { + assert_eq!(host, "dns.example"); + "10.0.0.1" + }; + Ok(crate::dialer::ResolvedEndpoint { + logical_host: host.into(), + port, + addresses: vec![std::net::SocketAddr::new(ip.parse().unwrap(), port)], + }) + } +} +#[tokio::test] +async fn wireguard_peers_longest_prefix_reserved_and_remote_dns_have_owned_io() { + let probe = ResourceProbe::default(); + probe.scope(async { + let mut config = config(); + config.ipv6 = None; + config.peers[0].allowed_ips.retain(|p| p.network.is_ipv4()); + config.routes.retain(|(p, _)| p.network.is_ipv4()); + config.peers[0].reserved = [1, 2, 3]; + let private = STANDARD.encode([1; 32]); + let public = STANDARD.encode(x25519::PublicKey::from(&x25519::StaticSecret::from([3; 32])).as_bytes()); + let yaml = format!( + "mixed-port: 1080\nproxies: [{{name: wg, type: wireguard, server: 198.51.100.2, port: 51820, ip: 10.0.0.2, private-key: '{private}', public-key: '{public}'}}]\nrules: ['MATCH,wg']" + ); + let second = crate::config::Config::parse_yaml(yaml.as_bytes()).unwrap(); + let crate::config::ProxyProtocol::WireGuard(mut second) = second.proxies[0].protocol.clone() else { panic!() }; + let mut peer = second.peers.remove(0); + peer.reserved = [4, 5, 6]; + peer.allowed_ips = vec![crate::config::IpCidr { + network: "10.1.0.0".parse().unwrap(), prefix_len: 16, + }]; + config.routes.push((peer.allowed_ips[0], 1)); + config.peers.push(peer); + config.dns = vec![crate::config::DnsNameserver { + transport: crate::config::DnsTransport::Udp, + address: crate::config::DnsAddress::Domain("dns.example".into()), + port: 53, route: crate::config::DnsRoute::Direct, path: None, + }]; + let records = Arc::>>::default(); + let closes = Arc::::default(); + let queries = Arc::::default(); + let carrier = Arc::new(Peer { + multi: true, reserved: true, records: records.clone(), + dns_queries: queries.clone(), silent: false, silent_first: false, + sends: Arc::default(), closes: closes.clone(), + }); + let outbound = WireGuardOutbound::new_with_path(&config, UpstreamPath::Proxy(carrier)).unwrap(); + let calls = Arc::::default(); + let resolution = crate::dns::resolution::ResolutionContext::measurement( + Arc::new(Bootstrap { calls: calls.clone(), rotating: false }), true, + ); + let context = EstablishContext::with_resolution(Duration::from_secs(3), resolution); + let mut request = session(); + request.destination = Destination::domain("remote.example", 80).unwrap(); + let mut connected = outbound.connect_stream(request, &context).await.unwrap(); + connected.io.write_all(b"peer").await.unwrap(); + let mut echoed = [0; 4]; + tokio::time::timeout(Duration::from_secs(2), connected.io.read_exact(&mut echoed)).await.unwrap().unwrap(); + assert_eq!(&echoed, b"peer"); + assert_eq!(connected.effective_peer, Destination::Ip("10.1.0.1:80".parse().unwrap())); + assert_eq!(queries.load(Ordering::Relaxed), 1); + assert_eq!(calls.load(Ordering::Relaxed), 1); + assert!(records.lock().unwrap().contains(&Destination::Ip(ENDPOINT.parse().unwrap()))); + assert!(records.lock().unwrap().contains(&Destination::Ip(SECOND.parse().unwrap()))); + drop(connected); + outbound.shutdown().await; + assert_eq!(closes.load(Ordering::Relaxed), 2); + assert!(probe.snapshot().is_idle()); + }).await; +} +#[tokio::test] +async fn wireguard_endpoint_refresh_keeps_the_stack_and_releases_old_peer_io() { + let probe = ResourceProbe::default(); + probe + .scope(async { + let mut config = config(); + config.address = "rotate.example".into(); + config.peers[0].address = config.address.clone(); + config.peers[0].reserved = [1, 2, 3]; + config.refresh_server_ip_interval = 1; + let records = Arc::>>::default(); + let closes = Arc::::default(); + let carrier = Arc::new(Peer { + multi: false, + reserved: true, + records: records.clone(), + dns_queries: Arc::default(), + silent: false, + silent_first: false, + sends: Arc::default(), + closes: closes.clone(), + }); + let outbound = + WireGuardOutbound::new_with_path(&config, UpstreamPath::Proxy(carrier)).unwrap(); + let calls = Arc::::default(); + let resolution = crate::dns::resolution::ResolutionContext::measurement( + Arc::new(Bootstrap { + calls: calls.clone(), + rotating: true, + }), + true, + ); + let context = EstablishContext::with_resolution(Duration::from_secs(4), resolution); + let request = DatagramRequest::new(DatagramSession::new( + InboundKind::Socks5, + "192.0.2.1:23000".parse().unwrap(), + )); + let mut udp = outbound.open_datagram(request, &context).await.unwrap(); + let engine = Arc::downgrade(outbound.current.lock().await.as_ref().unwrap()); + let remote = Destination::Ip("10.0.0.1:53".parse().unwrap()); + for payload in [b"old", b"new"] { + if payload == b"new" { + tokio::time::sleep(Duration::from_millis(1010)).await; + } + udp.send(Datagram { + remote: remote.clone(), + payload: Bytes::copy_from_slice(payload), + sniffed_domain: None, + }) + .await + .unwrap(); + let reply = tokio::time::timeout(Duration::from_secs(2), udp.receive()) + .await + .unwrap() + .unwrap(); + assert_eq!(reply.payload.as_ref(), payload); + } + assert!(engine.ptr_eq(&Arc::downgrade( + outbound.current.lock().await.as_ref().unwrap() + ))); + assert_eq!(calls.load(Ordering::Relaxed), 2); + assert_eq!(closes.load(Ordering::Relaxed), 1); + assert!( + records + .lock() + .unwrap() + .contains(&Destination::Ip(SECOND.parse().unwrap())) + ); + udp.close().await.unwrap(); + drop(udp); + outbound.shutdown().await; + assert_eq!(closes.load(Ordering::Relaxed), 2); + assert!(probe.snapshot().is_idle()); + }) + .await; +} + +#[tokio::test] +async fn wireguard_failed_peer_keeps_other_peer_and_shared_stack_live() { + let probe = ResourceProbe::default(); + probe + .scope(async { + let mut config = config(); + let mut second = config_with_peer(3).peers.remove(0); + second.address = "198.51.100.2".into(); + second.allowed_ips = vec![crate::config::IpCidr { + network: "10.1.0.0".parse().unwrap(), + prefix_len: 16, + }]; + config.routes.push((second.allowed_ips[0], 1)); + config.peers.push(second); + let closes = Arc::::default(); + let carrier = Arc::new(Peer { + multi: true, + reserved: false, + records: Arc::default(), + dns_queries: Arc::default(), + silent: false, + silent_first: true, + sends: Arc::default(), + closes: closes.clone(), + }); + let outbound = + WireGuardOutbound::new_with_path(&config, UpstreamPath::Proxy(carrier)).unwrap(); + let failed = outbound + .connect_stream( + session(), + &EstablishContext::with_timeout(Duration::from_millis(30)), + ) + .await; + assert!(matches!( + failed, + Err(DispatchError::TimedOut) | Err(DispatchError::ConnectionRefused) + )); + let engine = Arc::downgrade(outbound.current.lock().await.as_ref().unwrap()); + let mut request = session(); + request.destination = Destination::Ip("10.1.0.1:80".parse().unwrap()); + let mut stream = outbound + .connect_stream( + request, + &EstablishContext::with_timeout(Duration::from_secs(2)), + ) + .await + .unwrap(); + stream.io.write_all(b"live").await.unwrap(); + let mut reply = [0; 4]; + tokio::time::timeout(Duration::from_secs(2), stream.io.read_exact(&mut reply)) + .await + .unwrap() + .unwrap(); + assert_eq!(&reply, b"live"); + assert!(engine.ptr_eq(&Arc::downgrade( + outbound.current.lock().await.as_ref().unwrap() + ))); + assert!( + !outbound + .current + .lock() + .await + .as_ref() + .unwrap() + .cancel + .is_cancelled() + ); + drop(stream); + outbound.shutdown().await; + assert_eq!(closes.load(Ordering::Relaxed), 2); + assert!(probe.snapshot().is_idle()); + }) + .await; +} diff --git a/tests/README.md b/tests/README.md index ecc5e36..a426dbb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -86,7 +86,7 @@ Python,直接运行此检查,仅验证各自构建的产物。 在 Windows 上,`windows-uwp` 与 `windows-wintun` 互斥,编译与 Clippy 应明确选择 所测后端而非 `--all-features`。名称选择仍需已安装包验证,不以对象级测试代替。 -`outbound::wireguard::tests::` 在内存中交换真实 Noise 与 raw-IP 包,覆盖主动 TCP、双栈 UDP、真实握手重试、取消与 Stop;不打开 socket 或系统接口。 +`outbound::wireguard::tests::` 在内存中交换真实 Noise 与 raw-IP 包,覆盖主动 TCP、双栈 UDP、真实握手重试、多 peer 最长前缀与 reserved、节点远端 DNS、端点更新、取消与 Stop;不打开 socket 或系统接口。 宿主只执行明确的纯内存过滤器;全目标使用 `--no-run`。 `invoke::tests::` 含监听器用例,不可整体在宿主执行;可执行过滤器以 From ad307cd3be8f06b43fe521577582cae4bd8917c4 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 14:45:10 +0800 Subject: [PATCH 042/445] fix: align supported Mihomo defaults and transport authority --- docs/config.yaml | 16 ++-- docs/controller-api.md | 4 +- docs/inbounds.md | 2 +- docs/invoke-api.md | 4 +- docs/mihomo-compatibility.md | 4 +- docs/outbounds.md | 15 ++-- docs/vless.md | 1 + docs/xhttp.md | 4 +- src/config/http_proxy.rs | 32 ++------ src/config/hysteria2.rs | 42 ++++------- src/config/mod.rs | 123 ++++++++++++++++-------------- src/config/providers.rs | 26 ++++++- src/config/shadowsocks.rs | 10 +-- src/config/tls_policy_tests.rs | 113 +++++++++++++++++++++++++++- src/config/trojan.rs | 49 ++++++------ src/config/tuic.rs | 19 +++-- src/config/vless.rs | 35 ++++----- src/config/vmess.rs | 73 +++++++++++------- src/config/xhttp.rs | 93 ++++++++++++++--------- src/resource/http.rs | 133 +++++++++++++++++---------------- src/tun_runtime/tests.rs | 4 +- tests/hysteria2_config.rs | 19 ++++- tests/proxy_group_compat.rs | 41 +++++----- tests/shadowtls_config.rs | 24 ++++-- tests/trojan_config.rs | 68 ++++++++++++++++- tests/tuic_config.rs | 27 +++++-- tests/vless_config.rs | 91 +++++++++++++++++++++- tests/vmess_config.rs | 65 +++++++++++++++- tests/xhttp_config.rs | 97 +++++++++++++++++++++++- 29 files changed, 861 insertions(+), 373 deletions(-) diff --git a/docs/config.yaml b/docs/config.yaml index 740148f..1e4ec4c 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -72,7 +72,7 @@ authentication: - "probe-user:probe-password" # 可选的运行时 Controller,地址必须是回环 IP 和显式非零端口;合法 mixed-only -# 静态节点与纯 DIRECT 配置也可使用,运行配置仍需开启 mixed-port 或 TUN。 +# 静态节点、纯 DIRECT 及未启用代理入口的配置也可使用。 # 同时显式配置 proxy-groups 时 secret 必填;没有显式代理组时可省略。 # secret 出现时必须为 1–255 UTF-8 字节,并作为全部 Controller 路由的 Bearer token。 # Controller 提供 GET /traffic、GET /group、GET /group/{name}、 @@ -99,7 +99,7 @@ secret: "vole-runtime-secret" # dns-hijack 缺省 [0.0.0.0:53];[] 关闭。只在 dns.enable:true 的 TUN TCP/UDP 上生效。 # 精确 IP:port 匹配指定目标;any/未指定地址匹配任何地址族的 53 端口。 # udp-timeout 单位秒,缺省或 0 为 300;控制源关联及各目的流的空闲失效。 -# 配置必须启用 mixed-port 或 TUN。 +# mixed-port 与 TUN 均关闭时仍可运行 Controller 和资源任务。 tun: # auto-route: true # Linux:自动路由;DNS 启用且劫持网关时接管 systemd-resolved # auto-detect-interface: true # Linux:出站自动绑定物理接口 @@ -137,7 +137,7 @@ sniffer: # 引用精确匹配且不做 Unicode normalization 或大小写折叠。名称必须为非空字符串; # DIRECT、REJECT、REJECT-DROP、COMPATIBLE、PASS、PASS-RULE 为保留定义名。 # 路径与 URL 中的保留字符由调用方按相应协议编码,不限制节点本身的名称。 -# dialer-proxy 可引用具体节点或 select 组。全部上游和组成员(包括未选成员)须无环。 +# dialer-proxy 可指定 DIRECT 或引用具体节点、策略组。全部上游和组成员(包括未选成员)须无环。 # 代理数量和链深没有独立上限,仍受 YAML 大小与名称唯一约束。 # 实际建立物理首跳时才通过节点 resolver 解析主端点和独立下载端点。 # 未选节点的 DNS 失败不阻塞准备;DNS 关闭时才使用系统 resolver。 @@ -307,7 +307,7 @@ proxies: # headers: {Host: [cover.example:443], X-Custom: [one, two]} # 每次握手各选一个路径与每个头的一个值;禁止 Content-Length/Transfer-Encoding。 # network: h2 - # h2-opts: + # h2-opts: # 省略/host: [] 使用 www.example.com;path 省略/空为 / # host: [cover.example:443] # 必填、非空;每流选一个,不改变 TLS 认证名 # path: /cover # 默认 /;普通 PUT body,不使用 gRPC framing @@ -442,7 +442,7 @@ proxies: # skip-cert-verify 不能覆盖 fingerprint;pin 是证书 SHA-256,不是客户端伪造指纹。 # 叶 pin 直接决定信任;非叶 pin 作为根继续验证链和名称。所有策略均验证握手签名。 # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 客户端身份。 - # 不支持 REALITY、fp、ECH 或 session/padding 调参字段,详见 outbounds.md#anytls。 + # 支持共享 client-fingerprint;不支持 REALITY、ECH 或 session/padding 调参字段,详见 outbounds.md#anytls。 # TCP 使用 AnyTLS session;udp: true 使用 UoT v2 datagram mode。 - name: anytls-edge type: anytls @@ -462,7 +462,7 @@ proxies: # AES password 可用 iPSK1:...:iPSKn:uPSK 身份链,其他算法仅单一 Base64 PSK。 # 以下仅为公开测试 key,不可用于真实服务。UDP 默认 false。 # 上游 padding 风险和验收边界见 outbounds.md#shadowsocks-2022; - # 完整互通尚未签收。dialer-proxy 可引用具体节点或 select 组。 + # dialer-proxy 可指定 DIRECT 或引用具体节点、策略组。互通范围见 acceptance.md。 - name: ss-edge type: ss server: ss.example.com @@ -492,7 +492,7 @@ proxies: # 名称正则区分大小写;支持 lookahead、定长 lookbehind、数字/命名 backreference。 # 反引号分隔的多表达式按匹配顺序排列。节点改名使用 $1 / ${name} / $$ 等 .NET replacement。 # 复杂表达式有内部编译/执行预算;失败候选保留旧资源,不静默当不匹配。 -# lookaround/backreference 明确拒绝。静态成员保留顺序与重复项。 +# 静态成员保留顺序与重复项。 # 动态组为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 # disable-udp 缺省 false;true 禁用该组的业务 UDP。规则模式跳过不支持 UDP 的目标, # 继续匹配后续规则;global 模式及显式 DNS 出口仍会拒绝。hidden/icon 为 Controller 元数据。 @@ -699,7 +699,7 @@ sub-rules: # measureDelay 使用独立的 node-only 配置:顶层只能包含 proxies,不能包含 # proxy-groups。节点字段和代理图校验与上文一致,但不能出现 mixed-port/authentication、 -# tun、sniffer、Controller、DNS、rules 或 GeoData;此接口的 dialer-proxy 只能引用具体 +# tun、sniffer、Controller、DNS、rules 或 GeoData;此接口的 dialer-proxy 可指定 DIRECT 或具体 # 节点。Vole 从“未被任何 dialer-proxy 引用”的节点推导唯一链头,且该链必须覆盖 # 全部节点。测速不创建监听器、公共实例、TUN/protect 租约、DNS、规则引擎、代理组 # 或 GeoData 注册。 diff --git a/docs/controller-api.md b/docs/controller-api.md index a081d88..adec049 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -9,8 +9,8 @@ external-controller: 127.0.0.1:9090 secret: "vole-runtime-secret" ``` -- 省略 `external-controller` 表示不启动 Controller,也不采集业务连接、规则命中和建链诊断,此时不能单独配置 `secret`。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 -- `external-controller` 只接受带显式非零端口的回环 IP 地址。合法的 mixed-only 静态节点和纯 DIRECT 配置均可使用;非 TUN 配置仍通过 `mixed-port` 满足入站要求。 +- 省略或空串 `external-controller` 表示不启动 Controller,也不采集业务连接、规则命中和建链诊断,此时不能单独配置 `secret`。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 +- `external-controller` 只接受带显式非零端口的回环 IP 地址。静态节点、纯 DIRECT 及关闭业务入口的配置均可使用。 - 只要同时显式配置代理组与 Controller,`secret` 就必填;没有显式代理组时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 - `validateConfig` 只校验字段;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 diff --git a/docs/inbounds.md b/docs/inbounds.md index e4177a7..a6b6dbb 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -5,7 +5,7 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 ## 监听与认证 -- `mixed-port` 省略或 0 关闭,启用时为 1–65535;混合入口和 TUN 至少启用一个。 +- `mixed-port` 省略或 0 关闭,启用时为 1–65535;关闭混合入口和 TUN 的配置仍可启动。 同一 TCP 端口接入 HTTP 转发、CONNECT、Upgrade 和 SOCKS5 CONNECT、UDP ASSOCIATE。 SOCKS5 UDP 随入口启用并绑定同地址、同端口,不提供独立入站 UDP 开关。 - 顶层 `port`、`socks-port`、`udp` 和 `listeners` 均拒绝,不迁移或忽略旧字段。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 8610589..53727c9 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -226,7 +226,7 @@ stopped -> preparing -> prepared -> starting -> running } ``` -无需 `initialize`,完成大小、YAML、结构、共享 route-target 命名空间、引用和字段组合校验。节点的 `dialer-proxy` 可引用节点或组;全部上游和组成员边(包括未选成员)统一做无环校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。 +无需 `initialize`,完成大小、YAML、结构、共享 route-target 命名空间、引用和字段组合校验。节点的 `dialer-proxy` 可指定 DIRECT 或引用节点、组;全部上游和组成员边(包括未选成员)统一做无环校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。 ### `start` @@ -372,7 +372,7 @@ Unix 等待 SIGINT/SIGTERM,Windows 等待 Ctrl+C/Ctrl+Break;标准输入先 - `configYamls` 接受 1–5 份非空节点配置,`timeout` 为 1–30 秒。 - 同一运行时一次只允许一个测速批次,最多并发五个私有工作器。 -- 节点配置顶层只允许 `proxies`,不接受 `proxy-groups`;`dialer-proxy` 也只能引用具体节点。Vole 推导唯一链头,且该链必须覆盖全部节点。 +- 节点配置顶层只允许 `proxies`,不接受 `proxy-groups`;`dialer-proxy` 可指定 DIRECT 或引用具体节点。Vole 推导唯一链头,且该链必须覆盖全部节点。 - 工作器只准备出站图并执行 TCP、可选 TLS 和 HTTP/1.1 HEAD;不创建公共实例、监听器、TUN、DNS、规则、嗅探器或 GeoData。 - 宿主测量组成员时,按当前选择快照展开具体链后提交;上游选到 DIRECT 时移除相应 `dialer-proxy`,选到 REJECT 时不发起该项测量。该快照不改变运行中的组选择。 - URL 必须是无 userinfo 和 fragment 的绝对 HTTP/HTTPS URL;HTTPS 使用发布信任根。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index fa7ce0a..364179f 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -36,8 +36,8 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 peer 仅在实际使用时解析/建链;Mihomo 在节点初始化时解析所有 peer。 - 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 -- 未配置入站的配置不能启动。Controller 沿用回环地址、secret 及运行配置准入,见 [Controller API](controller-api.md)。 - /dns/query 查询真实上游;连接表按 UDP 目标流管理,memory 不代表进程 RSS。 +- 未启用 mixed-port 或 TUN 时仍可运行 Controller 和资源任务。Controller 沿用回环地址、secret 准入,见 [Controller API](controller-api.md)。 + /dns/query 查询真实上游;连接表按 UDP 目标流管理,memory 返回当前进程 resident bytes,平台口径不同。 - fake-IP TTL 最小一秒;映射生命周期独立于 TTL。本地选择和映射存储不保证与 Mihomo 数据库互读。 - Apple、Android、Windows UWP 的网络设置、权限和 App 生命周期仍归宿主;Windows Wintun 使用外置 DLL。 借用 Unix fd 只复制并保留原描述符。Linux 扩展路由、旁路由、防火墙/NAT、进程/UID 规则不在当前范围。 diff --git a/docs/outbounds.md b/docs/outbounds.md index b2f365d..5868859 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -7,7 +7,7 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 - 物理 TCP/UDP 只能由共享 Dialer 创建,保留 Android protect、Windows 物理绑定和受控解析。 传输包装器只消费 IO,不另建 socket 或系统 resolver。 -- `dialer-proxy` 可引用节点或策略组;与业务路由共享选择,完整 DAG 在配置期验证。 +- `dialer-proxy` 省略/空串使用物理 Dialer,也可指定 DIRECT 或引用节点、策略组;与业务路由共享选择,完整 DAG 在配置期验证。 一次建链使用组快照和同一绝对期限;已有连接不随切组迁移,无自动 failover 或 DIRECT 回落。 - 节点 `ip-version` 为 `dual`(默认)、`ipv4`、`ipv6`、`ipv4-prefer` 或 `ipv6-prefer`。 限制或排序该节点的物理服务器地址与本地解析结果,仍受全局 IPv6 开关约束。 @@ -25,7 +25,10 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 - TLS 使用共享 WebPKI、叶证书 pin/非叶信任锚与 skip 策略。pin 不匹配不能被 skip 绕过, TLS 握手签名始终验证。节点策略与恢复缓存不可变、互相隔离,标准 TLS 总票据预算为 4。 字段及模板详见 [TLS 证书与指纹](tls-client-fingerprint.md)。 -- 未知字段、null、错型、非法枚举或不适用的传输选项在网络 IO 前拒绝。 + SNI/servername 空串使用协议默认名称;pin/name-cert-verify 空串关闭对应覆盖。 + certificate/private-key 均省略或为空时不启用客户端身份;非空身份仍须成对且有效。 +- 未知字段、错型、非法枚举或不适用的传输选项在网络 IO 前拒绝;显式 null 通常也拒绝, + HTTP 出站已有的可选 TLS 字段保留 null 等同省略的行为。 凭据、UUID、业务目标和完整请求不进入 Debug/错误;资源与 Stop 见[公共策略](runtime-resource-policy.md)。 ## HTTP / HTTPS CONNECT @@ -98,6 +101,7 @@ fast-open 需要 upgrade。 Upgrade 的 ED 仅允许默认 Sec-WebSocket-Protocol 头(大小写不敏感),不支持 自定义头或路径 ED。101/头限额/截断失败不重放前缀,响应头后的字节保留给协议。 这些选项同样适用于 VMess 的明文和标准 TLS;不改变各协议认证、UDP 编码或关闭责任。 +gRPC 的 HTTP authority 使用非空 SNI,否则使用 server,不包含端口;不改变物理拨号地址。 gRPC 选项与 VMess/VLESS 共用:省略或空 service-name 默认 GunService,普通名映射 /name/Tun,以 / 开头使用完整路径;省略或空 user-agent 默认 grpc-go/1.36.0。 ping-interval 为秒,0 不主动 PING,负值明确拒绝;max-connections/min-streams/max-streams 接受带符号整数,负阈值禁用相应分支。三项字面全零归一 max-connections=1;正 max-connections 优先于 max-streams。 每个节点独立拥有连接池,空闲连接总是复用;复用保留原上游选择、TLS 身份和认证策略,每条逻辑流仍单独发送 Trojan 认证头。 @@ -118,17 +122,18 @@ aes-128-gcm、chacha20-poly1305、none;zero 归一 none。auto 按实际 AES none 只关闭 body 加密,仍验证响应身份;不能启用 global-padding/authenticated-length, 两开关默认 false。 -network 默认 tcp,另支持 ws/grpc/http/h2;均可明文或标准 TLS。tls 默认 false, +network 省略/空串为 tcp,另支持 ws/grpc/http/h2;均可明文或标准 TLS。tls 默认 false, 关闭时禁止 TLS 字段,即便空串、空列表或 false。认证名依次为 servername、WS Host 去端口、server;HTTP 伪装 Host/H2 authority 不替代认证名。WS 需 http/1.1,gRPC/H2 需 h2。 TLS 支持独立的 `name-cert-verify` 和成对内联 PEM `certificate/private-key` 客户端身份。 - WS ED/路径/头及 HTTPUpgrade 普通/fast-open 约束同 Trojan。 - gRPC 选项、默认值和节点连接池与 Trojan/VLESS 共用,每条逻辑流独立生成 VMess AEAD 认证;关闭逻辑流不关闭物理连接或兄弟流。 + authority 使用非空 servername,否则使用 server:port;name-cert-verify 不参与 authority。 - http 是 TCP 首包伪装:method 默认 GET,path 列表省略/空为 /,按 URL.Path 转义; 头为字符串列表,每次握手独立选择路径/头值,Host 默认物理 server authority。 仅首包封装一次,之后恢复原流;禁止 Content-Length/Transfer-Encoding。 -- h2 需要非空 authority 列表,path 默认 /,每流选一个 authority,通过 HTTP/2 PUT body +- h2 的 authority 列表省略/空时使用 www.example.com,path 省略/空时为 /,每流选一个 authority,通过 HTTP/2 PUT body 双向传输;明文 prior knowledge、TLS h2,无 gRPC 帧。 关闭对齐 Mihomo:TCP/WS 可保留上传 EOF 后的读取;gRPC/HTTP/H2 关闭整条逻辑流, @@ -195,7 +200,7 @@ pacer;这是混淆不是认证,错误密钥由 QUIC 拒绝,短包有界丢 ## TUIC v5 固定 QUIC/TLS 1.3,复用官方 Quinn、rustls/ring 与共享受控数据报适配器。 -仅 v5;必填标准 UUID 与原样 UTF-8 password,后者可空、最长 65,535 字节。 +仅 v5;必填标准 UUID。password 为原样 UTF-8,省略/空串为空密码,最长 65,535 字节。 SNI 缺省 server,ALPN 缺省 h3;显式空列表拒绝,非空有序列表必须实际协商命中。 使用共享 pin/skip/独立验证名;无命名 TCP 指纹、mTLS、ECH、0-RTT 或跨节点恢复。 拥塞使用官方 Quinn cubic(默认)、new_reno 或 bbr,不承诺与其他实现的吞吐相同。 diff --git a/docs/vless.md b/docs/vless.md index dd77da3..b84f094 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -13,6 +13,7 @@ - WS 的 ED 支持默认头、自定义头或路径(上限 2048 字节)。`v2ray-http-upgrade` 开启升级后裸流;`v2ray-http-upgrade-fast-open` 必须与前者一起启用,只允许默认 ED 头。fast-open 提前发协议首包但仍等待合法 101,拒绝不返回连接成功。 - gRPC 与 Trojan/VMess 共用配置:省略或空 `grpc-service-name` 默认 `GunService`,普通名映射 `/name/Tun`,`/` 开头使用完整路径;省略或空 `grpc-user-agent` 默认 `grpc-go/1.36.0`。`ping-interval` 为带符号秒数,0 不主动 PING,负值明确拒绝;三项池阈值接受带符号整数,负阈值禁用相应分支。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项字面全零归一 max-connections=1;正 max-connections 优先于 max-streams。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 - gRPC 不新增业务流/建链数量额度;每个物理连接一个自有驱动,每个等待调用只保留自身 future、取消与原期限,不派生队列任务。每节点最多保留 4 条空闲连接,多余空闲连接退役且不影响活动流。空闲 PING 等待 ACK 最多 15 秒,节点 Stop 取消并 join 所有驱动。 +- gRPC authority 使用非空显式 `servername`,否则使用 server:port;`name-cert-verify` 只改变证书验证名称。legacy H2 的 host 省略或为空列表时使用 www.example.com,path 省略或为空时使用 `/`。 - `network: xhttp` 支持 H1/H2 明文、TLS1.3 或 REALITY,H3 仅标准 TLS1.3。请求字段、独立下载腿、池与版本选择见 [XHTTP](xhttp.md)。H3 的物理 UDP 仍由原 Dialer/上游图创建。 - 公共节点 `smux` 以独立的 h2mux/smux v1/yamux 承载逻辑 TCP/UDP;VLESS 只提供正常 TCP 到约定目标,不拥有第二个 mux 池。它不是 XUDP;only-tcp、调度及关闭边界见 [通用 sing-mux](xhttp.md#通用-sing-mux)。 - Vision 仅允许 `network: tcp`,要求 TLS 1.3/REALITY 或 Encryption;UDP 仅 XUDP。启用 Encryption 后外层 TLS 可省略,存在时仍保持独立认证。配置阶段拒绝 WS/gRPC/HTTP/H2/XHTTP、raw/packetaddr、sing-mux 与 Vision 的组合,包括未启用业务 UDP 但显式选错编码。ECH 和附加安全封装仍拒绝。 diff --git a/docs/xhttp.md b/docs/xhttp.md index 86f9f1e..9c39fa7 100644 --- a/docs/xhttp.md +++ b/docs/xhttp.md @@ -9,11 +9,11 @@ REALITY、JLS、静态 ECH 的身份规则见 [VLESS](vless.md),实际验证 H1/H2 可使用明文、标准 TLS 1.3、REALITY(经典默认、显式混合模式)或 JLS。H3 必须使用标准 TLS 1.3,不允许同腿明文、REALITY 或 JLS。QUIC 收发走受控数据报,上游有效预算小于 1200 字节即拒绝,不绕开代理、protect 或接口绑定;路径 MTU 探测关闭。 -`xhttp-opts.mode` 接受 `auto`(默认)、`packet-up`、`stream-up`、`stream-one`。auto 在 REALITY 无下载配置时选择 stream-one,有下载配置时选择 stream-up,其余选择 packet-up。stream-one 是一条双工 POST,不允许 `download-settings`;其余模式以同一会话 ID 关联上传与下载。`path` 默认 `/`,`host` 显式非空优先,否则使用该腿 servername/server;IPv6 authority 使用方括号。主腿与下载腿的 host 均禁止 userinfo(`@`)。 +`xhttp-opts.mode` 接受 `auto`(省略或空串默认)、`packet-up`、`stream-up`、`stream-one`。auto 在 REALITY 无下载配置时选择 stream-one,有下载配置时选择 stream-up,其余选择 packet-up。stream-one 是一条双工 POST,不允许 `download-settings`;其余模式以同一会话 ID 关联上传与下载。`path` 省略或空串默认 `/`,`host` 显式非空优先,否则使用该腿 servername/server;IPv6 authority 使用方括号。主腿与下载腿的 host 均禁止 userinfo(`@`)。 ## 请求字段 -区间字段均为字符串 `"N"` 或 `"N-M"`,不接受数字、负数、倒置区间、空串或溢出。每次采样保持在闭区间内。 +区间字段均为字符串 `"N"` 或 `"N-M"`;空串或纯空白使用字段默认,数字及区间两端允许外围空白和正号。不接受数字类型、负数、倒置区间或溢出。每次采样保持在闭区间内。 | 字段 | 默认及约束 | | --- | --- | diff --git a/src/config/http_proxy.rs b/src/config/http_proxy.rs index 4b2efbd..4f6f90a 100644 --- a/src/config/http_proxy.rs +++ b/src/config/http_proxy.rs @@ -85,25 +85,13 @@ impl RawHttp { return invalid("HTTP CONNECT does not carry a request body"); } let tls = if self.tls { - let name = self.sni.unwrap_or_else(|| self.server.clone()); + let name = self + .sni + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| self.server.clone()); validate_host(&name, "HTTP sni")?; - if let Some(verify) = &self.name_cert_verify { - validate_host(verify, "HTTP verification name")?; - } - let identity = match (self.certificate, self.private_key) { - (Some(certificate), Some(private_key)) => { - crate::security::TlsClientIdentity::from_pem(&certificate, &private_key) - .map_err(|_| { - VoleError::InvalidConfig("invalid HTTP client identity".into()) - })?; - Some(TlsIdentityPem { - certificate, - private_key, - }) - } - (None, None) => None, - _ => return invalid("HTTP certificate and private-key must be paired"), - }; + let verification_name = normalize_verification_name(self.name_cert_verify)?; + let identity = normalize_client_identity(self.certificate, self.private_key)?; Some(TlsConfig { server_name: name, alpn: vec![b"http/1.1".to_vec()], @@ -114,12 +102,8 @@ impl RawHttp { identity, certificate: TlsCertificatePolicy { skip_cert_verify: self.skip_cert_verify, - verification_name: self.name_cert_verify, - fingerprint: self - .fingerprint - .as_deref() - .map(vless::parse_pin) - .transpose()?, + verification_name, + fingerprint: normalize_certificate_fingerprint(self.fingerprint.as_deref())?, }, }) } else { diff --git a/src/config/hysteria2.rs b/src/config/hysteria2.rs index 42d16d7..984b8e1 100644 --- a/src/config/hysteria2.rs +++ b/src/config/hysteria2.rs @@ -1,8 +1,9 @@ use serde::Deserialize; use super::{ - ProxyProtocol, TlsCertificatePolicy, TlsConfig, TlsIdentityPem, deserialize_present_option, - invalid, normalize_verification_name, validate_host, validate_port, + ProxyProtocol, TlsCertificatePolicy, TlsConfig, deserialize_present_option, invalid, + normalize_certificate_fingerprint, normalize_client_identity, normalize_verification_name, + validate_host, validate_port, }; use crate::Result; @@ -124,6 +125,7 @@ impl RawHysteria2 { } let hopping = self .ports + .filter(|ports| !ports.is_empty()) .map(|ports| { let ports = parse_ports(&ports)?; let (min_seconds, max_seconds) = parse_interval(self.hop_interval.as_ref())?; @@ -150,12 +152,18 @@ impl RawHysteria2 { { return invalid("invalid or over-limit Hysteria2 authentication header"); } - let obfs_password = match (self.obfs.as_deref(), self.obfs_password) { + let obfs_password = match ( + self.obfs.as_deref().filter(|obfs| !obfs.is_empty()), + self.obfs_password, + ) { (None, None) => None, (Some("salamander"), Some(password)) if !password.is_empty() => Some(password), _ => return invalid("Hysteria2 Salamander requires obfs and a nonempty obfs-password"), }; - let server_name = self.sni.unwrap_or_else(|| self.server.clone()); + let server_name = self + .sni + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| self.server.clone()); validate_host(&server_name, "Hysteria2 sni")?; let mut alpn = self.alpn.unwrap_or_default(); if alpn.is_empty() { @@ -166,30 +174,8 @@ impl RawHysteria2 { { return invalid("invalid Hysteria2 ALPN list"); } - let identity = match (self.certificate, self.private_key) { - (None, None) => None, - (Some(certificate), Some(private_key)) => { - #[cfg(feature = "outbound-hysteria2")] - crate::security::TlsClientIdentity::from_pem(&certificate, &private_key).map_err( - |_| crate::VoleError::InvalidConfig("invalid Hysteria2 client identity".into()), - )?; - Some(TlsIdentityPem { - certificate, - private_key, - }) - } - _ => return invalid("Hysteria2 certificate and private-key must be paired"), - }; - let fingerprint = self - .fingerprint - .map(|pin| { - super::vless::parse_pin(&pin).map_err(|_| { - crate::VoleError::InvalidConfig( - "invalid Hysteria2 certificate fingerprint".into(), - ) - }) - }) - .transpose()?; + let identity = normalize_client_identity(self.certificate, self.private_key)?; + let fingerprint = normalize_certificate_fingerprint(self.fingerprint.as_deref())?; let udp_mtu = if self.udp_mtu == 0 { 1197 } else { diff --git a/src/config/mod.rs b/src/config/mod.rs index ff63505..fb67717 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2067,9 +2067,6 @@ impl RawVoleConfig { let tun = self .tun .map_or_else(|| Ok(TunConfig::default()), RawTunConfig::normalize)?; - if mixed_port.is_none() && !tun.enable { - return invalid("configuration requires mixed-port or tun.enable: true"); - } let sniffer = self.sniffer.map_or_else( || Ok(SnifferConfig::disabled()), RawSnifferConfig::normalize, @@ -2190,7 +2187,7 @@ fn normalize_external_controller( secret: Option, has_proxy_groups: bool, ) -> Result> { - let Some(listen) = listen else { + let Some(listen) = listen.filter(|listen| !listen.is_empty()) else { if secret.is_some() { return invalid("secret requires external-controller"); } @@ -2483,13 +2480,14 @@ fn normalize_proxy_graph_with_groups( proxy .dialer_proxy .as_deref() + .filter(|tag| *tag != "DIRECT") .map(|tag| { if let Some(id) = proxy_group_ids.get(tag) { return Ok(RouteTargetId::Group(*id)); } proxy_ids.get(tag).copied().map(RouteTargetId::Proxy).ok_or_else(|| { VoleError::InvalidConfig(format!( - "proxy `{}` dialer-proxy `{tag}` does not reference a configured proxy or group name", + "proxy `{}` dialer-proxy `{tag}` does not reference DIRECT or a configured proxy or group name", proxy.tag )) }) @@ -2751,10 +2749,7 @@ impl RawOutbound { config.tls = Some(TlsClientPolicy { certificate: TlsCertificatePolicy { skip_cert_verify: skip_cert_verify.unwrap_or(false), - fingerprint: fingerprint - .as_deref() - .map(vless::parse_pin) - .transpose()?, + fingerprint: normalize_certificate_fingerprint(fingerprint.as_deref())?, verification_name: normalize_verification_name(name_cert_verify)?, }, identity: normalize_client_identity(certificate, private_key)?, @@ -2808,6 +2803,7 @@ impl RawOutbound { } let udp = udp || smux.as_ref().is_some_and(|options| !options.only_tcp); validate_route_target_definition_name(&tag, "proxy name")?; + let dialer_proxy = dialer_proxy.filter(|tag| !tag.is_empty()); if let Some(dialer_proxy) = &dialer_proxy { validate_route_target_name(dialer_proxy, "dialer-proxy")?; } @@ -2904,7 +2900,9 @@ fn normalize_anytls( "AnyTLS password must contain between 1 and {MAX_ANYTLS_PASSWORD_BYTES} UTF-8 bytes" )); } - let server_name = sni.unwrap_or_else(|| server.clone()); + let server_name = sni + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| server.clone()); validate_host(&server_name, "AnyTLS sni")?; let alpn = alpn .unwrap_or_default() @@ -2924,17 +2922,7 @@ fn normalize_anytls( "AnyTLS alpn names must be 1..255 bytes and the encoded list at most 65533 bytes", ); } - let fingerprint = fingerprint.map(|value| { - let hex = value.trim().bytes().filter(|byte| *byte != b':').collect::>(); - let mut pin = [0; 32]; - if hex.len() != 64 || hex.iter().any(|byte| !byte.is_ascii_hexdigit()) { - return invalid("AnyTLS fingerprint must be a SHA-256 certificate digest in hex or colon-separated hex"); - } - for (index, pair) in hex.as_chunks::<2>().0.iter().enumerate() { - pin[index] = ((pair[0] as char).to_digit(16).unwrap() * 16 + (pair[1] as char).to_digit(16).unwrap()) as u8; - } - Ok(pin) - }).transpose()?; + let fingerprint = normalize_certificate_fingerprint(fingerprint.as_deref())?; Ok(AnyTlsOutboundConfig { address: server, port, @@ -2953,6 +2941,13 @@ fn normalize_anytls( }) } +fn normalize_certificate_fingerprint(fingerprint: Option<&str>) -> Result> { + fingerprint + .filter(|value| !value.is_empty()) + .map(vless::parse_pin) + .transpose() +} + fn normalize_verification_name(name: Option) -> Result> { let name = name.filter(|name| !name.is_empty()); if let Some(name) = &name { @@ -2965,13 +2960,11 @@ fn normalize_client_identity( certificate: Option, private_key: Option, ) -> Result> { - match (certificate, private_key) { + match ( + certificate.filter(|pem| !pem.is_empty()), + private_key.filter(|pem| !pem.is_empty()), + ) { (None, None) => Ok(None), - (Some(certificate), Some(private_key)) - if certificate.is_empty() && private_key.is_empty() => - { - Ok(None) - } (Some(certificate), Some(private_key)) => { crate::security::TlsClientIdentity::from_pem(&certificate, &private_key) .map_err(|_| VoleError::InvalidConfig("invalid TLS client identity".into()))?; @@ -3000,11 +2993,11 @@ impl RawXHttpSettings { let headers = XHttpHeaders::normalize(self.headers.clone())?; let has_download = self.download_settings.is_some(); let mode = match self.mode.as_str() { - "auto" if matches!(security, SecurityConfig::Reality(_)) && has_download => { + "" | "auto" if matches!(security, SecurityConfig::Reality(_)) && has_download => { XHttpMode::StreamUp } - "auto" if matches!(security, SecurityConfig::Reality(_)) => XHttpMode::StreamOne, - "auto" | "packet-up" => XHttpMode::PacketUp, + "" | "auto" if matches!(security, SecurityConfig::Reality(_)) => XHttpMode::StreamOne, + "" | "auto" | "packet-up" => XHttpMode::PacketUp, "stream-one" if !has_download => XHttpMode::StreamOne, "stream-one" => { return invalid("xhttp mode `stream-one` cannot be used with download-settings"); @@ -3025,8 +3018,8 @@ impl RawXHttpSettings { .. } = self; let host = host.filter(|value| !value.is_empty()); - if path.is_empty() - || path.len() > 2_048 + let path = if path.is_empty() { "/".into() } else { path }; + if path.len() > 2_048 || !path.starts_with('/') || path.parse::().is_err() { @@ -3165,6 +3158,7 @@ impl RawXHttpDownloadSettings { let server_name = servername .or_else(|| default_explicit_server_name.map(str::to_owned)) + .filter(|name| !name.is_empty()) .unwrap_or_else(|| address.clone()); validate_host(&server_name, "XHTTP download servername")?; let host = host @@ -3314,9 +3308,10 @@ impl RawXHttpDownloadSettings { return invalid("XHTTP HTTP/3 download requires standard TLS"); } - let path = path.unwrap_or_else(|| default_path.to_owned()); - if path.is_empty() - || path.len() > 2_048 + let path = path + .map(|path| if path.is_empty() { "/".into() } else { path }) + .unwrap_or_else(|| default_path.to_owned()); + if path.len() > 2_048 || !path.starts_with('/') || path.parse::().is_err() { @@ -4659,19 +4654,18 @@ outbounds: [] #[test] fn validates_listener_and_tun_constraints() { - for yaml in [ - CURRENT_TLS.replace("mtu: 1500", "mtu: 1279"), - CURRENT_TLS - .replace("enable: true", "enable: false") - .replace( - "mixed-port: 1080 -authentication: - - measure:secret\n", - "", - ), - ] { - assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); - } + assert!( + Config::parse_yaml(CURRENT_TLS.replace("mtu: 1500", "mtu: 1279").as_bytes()).is_err() + ); + let disabled = CURRENT_TLS + .replace("enable: true", "enable: false") + .replace( + "mixed-port: 1080\nauthentication:\n - measure:secret\n", + "", + ); + let config = Config::parse_yaml(disabled.as_bytes()).unwrap(); + assert!(config.inbounds.is_empty()); + assert!(!config.tun.enable); } #[test] @@ -4726,6 +4720,9 @@ tun: .unwrap(); assert!(config.external_controller.is_some()); assert!(config.proxies.is_empty()); + let disabled = + Config::parse_yaml(b"external-controller: \"\"\nrules: [\"MATCH,DIRECT\"]\n").unwrap(); + assert!(disabled.external_controller.is_none()); } #[test] @@ -4907,7 +4904,13 @@ authentication: assert!(!mixed.access.allow_lan); assert!(mixed.access.ipv6); } - for fields in ["tun: {enable: true}", "mixed-port: 0\ntun: {enable: true}"] { + for fields in [ + "", + "mixed-port: 0", + "tun: {enable: false}", + "tun: {enable: true}", + "mixed-port: 0\ntun: {enable: true}", + ] { let config = Config::parse_yaml(current_yaml(fields).as_bytes()).unwrap(); assert_eq!(config.mixed_port, None); assert!( @@ -4940,7 +4943,6 @@ authentication: .matches("用户".as_bytes(), "密:码".as_bytes()) ); for fields in [ - "mixed-port: 0", "mixed-port: -1", "mixed-port: 65536", "mixed-port: 1.0", @@ -5111,7 +5113,7 @@ authentication: &format!(" password: private-anytls-password\n {fields}"), ) }; - for fields in ["alpn: []", "skip-cert-verify: false"] { + for fields in ["alpn: []", "skip-cert-verify: false", "fingerprint: ''"] { let config = Config::parse_yaml(configured(fields).as_bytes()).unwrap(); let ProxyProtocol::AnyTls(anytls) = &config.proxies[0].protocol else { panic!() @@ -5145,7 +5147,6 @@ authentication: "skip-cert-verify: null", "skip-cert-verify: 'true'", "fingerprint: null", - "fingerprint: ''", "fingerprint: chrome", "fingerprint: aa:bb", ] { @@ -5614,10 +5615,8 @@ authentication: } #[test] - fn current_config_requires_listener_and_valid_references() { + fn current_config_requires_valid_references() { for yaml in [ - current_yaml("mixed-port: 0\nauthentication:\n - measure:secret"), - current_yaml("tun:\n enable: false"), current_yaml("tun:\n enable: true\n mtu: 1279"), current_yaml( "mixed-port: 1080 @@ -5816,6 +5815,18 @@ authentication: Some(RouteTargetId::Proxy(second)) ); assert_eq!(config.proxies[1].dialer_proxy, None); + let direct = Config::parse_yaml( + yaml.replace("dialer-proxy: socks-hop", "dialer-proxy: DIRECT") + .as_bytes(), + ) + .unwrap(); + assert_eq!(direct.proxies[0].dialer_proxy, None); + let default = Config::parse_yaml( + yaml.replace("dialer-proxy: socks-hop", "dialer-proxy: \"\"") + .as_bytes(), + ) + .unwrap(); + assert_eq!(default.proxies[0].dialer_proxy, None); assert_eq!( config.rules[0].action, RuleAction::Route(RouteTargetId::Proxy(first)) @@ -6160,7 +6171,7 @@ authentication: ); assert!(Config::parse_yaml(unknown.as_bytes()).is_err()); - for invalid_reference in ["''", "null", "'bad#name'"] { + for invalid_reference in ["null", "'bad#name'"] { let yaml = two_proxy_yaml( "mixed-port: 1080 authentication: diff --git a/src/config/providers.rs b/src/config/providers.rs index 751714e..dc8c782 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -354,6 +354,9 @@ impl ProviderTemplate { map.insert("url".into(), url.clone().into()); } let dynamic = !used.is_empty() || all_proxies || all_providers; + if declared.is_empty() && !dynamic { + return invalid("proxy group requires proxies or use"); + } let mut names = declared; if all_proxies { for name in &static_names { @@ -387,9 +390,6 @@ impl ProviderTemplate { } let mut names = selected; if names.is_empty() { - if !dynamic { - return invalid("proxy group requires proxies or use"); - } names.push(fallback.clone()); } if let Some(default) = map.get("default-selected").and_then(Value::as_str) @@ -731,6 +731,26 @@ fn apply_overrides( #[cfg(test)] mod tests { use super::*; + #[test] + fn static_members_filtered_to_empty_use_the_declared_fallback() { + for (fallback, expected) in [ + ("", "COMPATIBLE"), + (", empty-fallback: DIRECT", "DIRECT"), + (", empty-fallback: edge", "edge"), + ] { + let yaml = format!( + "mixed-port: 1080\nproxies: [{{name: edge, type: socks5, server: 192.0.2.1, port: 1080}}]\nproxy-groups: [{{name: filtered, type: select, proxies: [edge], exclude-filter: '^edge'{fallback}}}]\nrules: ['MATCH,filtered']\n" + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!(config.proxy_groups[0].members.len(), 1); + assert_eq!(config.proxy_groups[0].members[0].name, expected); + assert!( + Config::parse_yaml(yaml.replace("proxies: [edge]", "proxies: []").as_bytes()) + .is_err() + ); + } + } + #[test] fn provider_and_group_name_expressions_materialize_real_templates() { let config = Config::parse_yaml( diff --git a/src/config/shadowsocks.rs b/src/config/shadowsocks.rs index b2a945a..d304067 100644 --- a/src/config/shadowsocks.rs +++ b/src/config/shadowsocks.rs @@ -87,7 +87,7 @@ pub(super) fn plugin( options: Option, fingerprint: Option, ) -> Result> { - let (name, raw) = match (name, options) { + let (name, raw) = match (name.filter(|name| !name.is_empty()), options) { (None, None) if fingerprint.is_none() => return Ok(None), (Some(name), Some(raw)) => (name, raw), _ => return invalid("ShadowTLS plugin and plugin-opts must be provided together"), @@ -105,13 +105,9 @@ pub(super) fn plugin( .map(String::into_bytes) .collect(), certificate: super::TlsCertificatePolicy { - verification_name: raw.name_cert_verify, + verification_name: super::normalize_verification_name(raw.name_cert_verify)?, skip_cert_verify: raw.skip_cert_verify, - fingerprint: raw - .fingerprint - .as_deref() - .map(super::vless::parse_pin) - .transpose() + fingerprint: super::normalize_certificate_fingerprint(raw.fingerprint.as_deref()) .map_err(|_| { crate::VoleError::InvalidConfig("invalid ShadowTLS certificate pin".into()) })?, diff --git a/src/config/tls_policy_tests.rs b/src/config/tls_policy_tests.rs index 5808156..6cc0a51 100644 --- a/src/config/tls_policy_tests.rs +++ b/src/config/tls_policy_tests.rs @@ -14,12 +14,12 @@ fn node(protocol: &str) -> serde_json::Value { "name-cert-verify": "verified.invalid", "fingerprint": "ab".repeat(32), }); match protocol { - "anytls" | "trojan" => node["password"] = "synthetic-secret".into(), - "vmess" => { + "anytls" | "trojan" | "hysteria2" => node["password"] = "synthetic-secret".into(), + "vmess" | "vless" => { node["uuid"] = "00000000-0000-4000-8000-000000000001".into(); node["tls"] = true.into(); } - "socks5" => node["tls"] = true.into(), + "socks5" | "http" => node["tls"] = true.into(), _ => unreachable!(), } node @@ -80,9 +80,116 @@ fn common_tls_identity_rejects_unpaired_or_mismatched_keys() { let mut node = node(protocol); node["certificate"] = pem("CERTIFICATE", identity.cert.der()).into(); assert!(parse(node.clone()).is_err()); + node["private-key"] = "".into(); + assert!(parse(node.clone()).is_err()); node["private-key"] = pem("PRIVATE KEY", &other.signing_key.serialize_der()).into(); let error = parse(node).unwrap_err().to_string(); assert!(error.contains("invalid TLS client identity")); assert!(!error.contains("PRIVATE KEY")); } } + +#[test] +fn common_tls_empty_overrides_use_defaults_without_a_client_identity() { + for protocol in [ + "anytls", + "trojan", + "vmess", + "socks5", + #[cfg(feature = "outbound-http")] + "http", + #[cfg(feature = "outbound-vless")] + "vless", + #[cfg(feature = "outbound-hysteria2")] + "hysteria2", + ] { + for identity_fields in [ + serde_json::json!({"certificate": ""}), + serde_json::json!({"private-key": ""}), + serde_json::json!({"certificate": "", "private-key": ""}), + ] { + let mut node = node(protocol); + node["fingerprint"] = "".into(); + node["name-cert-verify"] = "".into(); + node.as_object_mut() + .unwrap() + .extend(identity_fields.as_object().unwrap().clone()); + if matches!(protocol, "vmess" | "vless") { + node["servername"] = "".into(); + } else if protocol != "socks5" { + node["sni"] = "".into(); + } + if matches!(protocol, "anytls" | "trojan" | "vmess" | "vless") { + node["client-fingerprint"] = "".into(); + } + let config = parse(node).unwrap(); + let (name, certificate, has_identity, has_profile) = match &config.proxies[0].protocol { + ProxyProtocol::AnyTls(node) => ( + node.server_name.as_str(), + &node.tls.certificate, + node.tls.identity.is_some(), + node.tls.client_fingerprint.is_some(), + ), + ProxyProtocol::Trojan(node) => ( + node.server_name.as_str(), + &node.tls.certificate, + node.tls.identity.is_some(), + node.tls.client_fingerprint.is_some(), + ), + ProxyProtocol::Vmess(node) => { + let tls = node.tls.as_ref().unwrap(); + ( + node.server_name.as_str(), + &tls.certificate, + tls.identity.is_some(), + tls.client_fingerprint.is_some(), + ) + } + ProxyProtocol::Socks5(node) => { + let tls = node.tls.as_ref().unwrap(); + ( + node.address.as_str(), + &tls.certificate, + tls.identity.is_some(), + tls.client_fingerprint.is_some(), + ) + } + ProxyProtocol::Http(node) => { + let tls = node.tls.as_ref().unwrap(); + ( + tls.server_name.as_str(), + &tls.certificate, + tls.identity.is_some(), + tls.client_fingerprint.is_some(), + ) + } + ProxyProtocol::Vless(node) => { + let SecurityConfig::Tls(tls) = &node.security else { + unreachable!() + }; + ( + tls.server_name.as_str(), + &tls.certificate, + tls.identity.is_some(), + tls.client_fingerprint.is_some(), + ) + } + ProxyProtocol::Hysteria2(node) => ( + node.tls.server_name.as_str(), + &node.tls.certificate, + node.tls.identity.is_some(), + node.tls.client_fingerprint.is_some(), + ), + _ => unreachable!(), + }; + assert_eq!(name, "cover.invalid", "{protocol}"); + assert_eq!(certificate.verification_name, None, "{protocol}"); + assert_eq!(certificate.fingerprint, None, "{protocol}"); + assert!(!has_identity && !has_profile, "{protocol}"); + assert_eq!( + (config.proxies[0].address(), config.proxies[0].port()), + ("cover.invalid", 443) + ); + } + } +} diff --git a/src/config/trojan.rs b/src/config/trojan.rs index 2c6b0c4..291250c 100644 --- a/src/config/trojan.rs +++ b/src/config/trojan.rs @@ -168,14 +168,14 @@ struct RawWebSocket { early_data_header_name: Option, } -fn uri(scheme: &str, server: &str, port: u16, path: &str) -> Result { +fn uri(scheme: &str, server: &str, path: &str) -> Result { if !path.starts_with('/') || path.contains(['\r', '\n', '#']) { return invalid("invalid Trojan transport path"); } let authority = if server.parse::().is_ok() { - format!("[{server}]:{port}") + format!("[{server}]") } else { - format!("{server}:{port}") + server.to_owned() }; let uri = http::Uri::builder() .scheme(scheme) @@ -272,7 +272,19 @@ impl RawTrojan { if self.password.is_empty() { return invalid("Trojan password must not be empty"); } - let transport = match (self.network.as_deref().unwrap_or("tcp"), self.ws, self.grpc) { + let server_name = self + .sni + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| self.server.clone()); + validate_host(&server_name, "Trojan sni")?; + let transport = match ( + self.network + .as_deref() + .filter(|network| !network.is_empty()) + .unwrap_or("tcp"), + self.ws, + self.grpc, + ) { ("tcp", None, None) => TrojanTransport::Tcp, ("ws", ws, None) => { let ws = ws.unwrap_or_default(); @@ -289,9 +301,11 @@ impl RawTrojan { )?, uri: uri( "wss", - &self.server, - self.port, - ws.path.as_deref().unwrap_or("/"), + &server_name, + ws.path + .as_deref() + .filter(|path| !path.is_empty()) + .unwrap_or("/"), )?, headers: ws.headers, max_early_data: ws.max_early_data, @@ -308,14 +322,12 @@ impl RawTrojan { ("grpc", None, grpc) => { let (path, options) = grpc.unwrap_or_default().normalize()?; TrojanTransport::Grpc { - uri: uri("https", &self.server, self.port, &path)?, + uri: uri("https", &server_name, &path)?, options, } } _ => return invalid("Trojan requires tcp, ws with ws-opts, or grpc with grpc-opts"), }; - let server_name = self.sni.unwrap_or_else(|| self.server.clone()); - validate_host(&server_name, "Trojan sni")?; let alpn: Vec> = self .alpn .unwrap_or_else(|| { @@ -340,22 +352,7 @@ impl RawTrojan { { return invalid("Trojan ALPN does not include the selected transport"); } - let fingerprint = self - .fingerprint - .map(|value| { - let hex: Vec = value.trim().bytes().filter(|byte| *byte != b':').collect(); - if hex.len() != 64 || !hex.iter().all(u8::is_ascii_hexdigit) { - return invalid("Trojan fingerprint must be a SHA-256 certificate digest"); - } - let mut pin = [0; 32]; - for (byte, pair) in pin.iter_mut().zip(hex.as_chunks::<2>().0) { - *byte = ((pair[0] as char).to_digit(16).unwrap() * 16 - + (pair[1] as char).to_digit(16).unwrap()) - as u8; - } - Ok(pin) - }) - .transpose()?; + let fingerprint = super::normalize_certificate_fingerprint(self.fingerprint.as_deref())?; Ok(( self.name, self.dialer_proxy, diff --git a/src/config/tuic.rs b/src/config/tuic.rs index 5a37bcc..724b54e 100644 --- a/src/config/tuic.rs +++ b/src/config/tuic.rs @@ -49,6 +49,7 @@ pub(super) struct RawTuic { server: String, port: u16, uuid: String, + #[serde(default)] password: String, #[serde(default)] udp: bool, @@ -99,7 +100,10 @@ impl RawTuic { congestion: self.congestion_controller, udp_mode: self.udp_relay_mode, tls: TlsConfig { - server_name: self.sni.unwrap_or(self.server), + server_name: self + .sni + .filter(|name| !name.is_empty()) + .unwrap_or(self.server), alpn: self .alpn .unwrap_or_else(|| vec!["h3".into()]) @@ -113,15 +117,10 @@ impl RawTuic { client_fingerprint: None, certificate: TlsCertificatePolicy { skip_cert_verify: self.skip_cert_verify, - verification_name: self.name_cert_verify, - fingerprint: self - .fingerprint - .as_deref() - .map(super::vless::parse_pin) - .transpose() - .map_err(|_| { - crate::VoleError::InvalidConfig("invalid TUIC certificate pin".into()) - })?, + verification_name: super::normalize_verification_name(self.name_cert_verify)?, + fingerprint: super::normalize_certificate_fingerprint( + self.fingerprint.as_deref(), + )?, }, }, }; diff --git a/src/config/vless.rs b/src/config/vless.rs index 2ba6d27..ec27ddc 100644 --- a/src/config/vless.rs +++ b/src/config/vless.rs @@ -160,7 +160,11 @@ impl RawVless { })?; VlessEncryption::MlKem768X25519Plus(self.encryption) }; - let network = self.network.as_deref().unwrap_or("tcp"); + let network = self + .network + .as_deref() + .filter(|network| !network.is_empty()) + .unwrap_or("tcp"); let vision = self.flow == "xtls-rprx-vision"; if self.jls_opts.is_some() && (vision || self.reality_opts.is_some()) { return invalid("JLS cannot be combined with REALITY or Vision"); @@ -218,6 +222,7 @@ impl RawVless { grpc, self.http, self.h2, + self.servername.as_deref().filter(|name| !name.is_empty()), )? }; let standard_options = self.skip_cert_verify.is_some() @@ -280,26 +285,15 @@ impl RawVless { { return invalid("invalid VLESS ALPN"); } - let explicit_name = self.servername.is_some(); - let server_name = self.servername.unwrap_or(fallback_name); + let servername = self.servername.filter(|name| !name.is_empty()); + let explicit_name = servername.is_some(); + let server_name = servername.unwrap_or(fallback_name); validate_host(&server_name, "VLESS servername")?; if ech.is_some() && server_name.parse::().is_ok() { return invalid("ECH requires a DNS servername"); } - if let Some(name) = &self.name_cert_verify { - validate_host(name, "VLESS certificate verification name")?; - } - let identity = match (self.certificate, self.private_key) { - (None, None) => None, - (Some(certificate), Some(private_key)) => { - validate_client_identity(&certificate, &private_key)?; - Some(TlsIdentityPem { - certificate, - private_key, - }) - } - _ => return invalid("VLESS certificate and private-key must be provided together"), - }; + let verification_name = normalize_verification_name(self.name_cert_verify)?; + let identity = normalize_client_identity(self.certificate, self.private_key)?; let security = match (self.reality_opts, self.jls_opts) { (Some(raw), None) => { let mut config = raw.normalize(server_name)?; @@ -328,12 +322,9 @@ impl RawVless { alpn, required_alpn: default_alpn.map(<[u8]>::to_vec), certificate: TlsCertificatePolicy { - verification_name: self.name_cert_verify, + verification_name, skip_cert_verify: self.skip_cert_verify.unwrap_or(false), - fingerprint: self - .fingerprint - .map(|value| parse_pin(&value)) - .transpose()?, + fingerprint: normalize_certificate_fingerprint(self.fingerprint.as_deref())?, }, identity, }), diff --git a/src/config/vmess.rs b/src/config/vmess.rs index 5306046..7eef90d 100644 --- a/src/config/vmess.rs +++ b/src/config/vmess.rs @@ -151,9 +151,10 @@ pub(super) struct RawHttp { #[serde(default)] headers: BTreeMap>, } -#[derive(Deserialize)] +#[derive(Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct RawH2 { + #[serde(default)] host: Vec, #[serde(default, deserialize_with = "deserialize_present_option")] path: Option, @@ -343,13 +344,20 @@ impl RawVmess { &self.server, self.port, self.tls, - self.network.as_deref().unwrap_or("tcp"), + self.network + .as_deref() + .filter(|network| !network.is_empty()) + .unwrap_or("tcp"), self.ws, self.grpc, self.http, self.h2, + self.servername.as_deref().filter(|name| !name.is_empty()), )?; - let server_name = self.servername.unwrap_or(fallback_name); + let server_name = self + .servername + .filter(|name| !name.is_empty()) + .unwrap_or(fallback_name); validate_host(&server_name, "VMess servername")?; let tls = if self.tls { let alpn: Vec> = self @@ -371,22 +379,8 @@ impl RawVmess { { return invalid("invalid VMess ALPN"); } - let fingerprint = self - .fingerprint - .map(|value| { - let hex: Vec<_> = value.trim().bytes().filter(|b| *b != b':').collect(); - if hex.len() != 64 || !hex.iter().all(u8::is_ascii_hexdigit) { - return invalid("invalid VMess certificate fingerprint"); - } - let mut pin = [0; 32]; - for (byte, pair) in pin.iter_mut().zip(hex.as_chunks::<2>().0) { - *byte = ((pair[0] as char).to_digit(16).unwrap() * 16 - + (pair[1] as char).to_digit(16).unwrap()) - as u8; - } - Ok(pin) - }) - .transpose()?; + let fingerprint = + super::normalize_certificate_fingerprint(self.fingerprint.as_deref())?; Some(super::TlsClientPolicy { client_fingerprint: super::parse_client_fingerprint( self.client_fingerprint.as_deref(), @@ -432,6 +426,7 @@ pub(super) fn normalize_transport( grpc: Option, http: Option, h2: Option, + grpc_authority: Option<&str>, ) -> Result<(StreamTransport, String)> { let authority = if server.parse::().is_ok() { format!("[{}]:{}", server, port) @@ -463,7 +458,10 @@ pub(super) fn normalize_transport( uri: uri( if tls { "wss" } else { "ws" }, &authority, - ws.path.as_deref().unwrap_or("/"), + ws.path + .as_deref() + .filter(|path| !path.is_empty()) + .unwrap_or("/"), )?, headers: ws.headers, max_early_data: ws.max_early_data, @@ -479,8 +477,19 @@ pub(super) fn normalize_transport( } ("grpc", None, grpc, None, None) => { let (path, options) = grpc.unwrap_or_default().normalize()?; + let grpc_authority = grpc_authority.map(|name| { + if name.parse::().is_ok() { + format!("[{name}]") + } else { + name.to_owned() + } + }); StreamTransport::Grpc { - uri: uri(scheme, &authority, &path)?, + uri: uri( + scheme, + grpc_authority.as_deref().unwrap_or(&authority), + &path, + )?, options, } } @@ -489,7 +498,10 @@ pub(super) fn normalize_transport( if http.path.is_empty() { http.path.push("/".into()); } - let method = http.method.unwrap_or_else(|| "GET".into()); + let method = http + .method + .filter(|method| !method.is_empty()) + .unwrap_or_else(|| "GET".into()); if method.parse::().is_err() || http.headers.values().any(Vec::is_empty) { return invalid("invalid HTTP transport options"); } @@ -510,7 +522,7 @@ pub(super) fn normalize_transport( .path .iter() .map(|path| { - if !path.starts_with('/') || path.contains(['\r', '\n']) { + if (!path.is_empty() && !path.starts_with('/')) || path.contains(['\r', '\n']) { return invalid("invalid HTTP transport path"); } let mut url = @@ -541,11 +553,22 @@ pub(super) fn normalize_transport( headers: http.headers, } } - ("h2", None, None, None, Some(h2)) if !h2.host.is_empty() => { + ("h2", None, None, None, h2) => { + let mut h2 = h2.unwrap_or_default(); + if h2.host.is_empty() { + h2.host.push("www.example.com".into()); + } let mut uris = Vec::new(); for host in h2.host { authority_host(&host)?; - uris.push(uri(scheme, &host, h2.path.as_deref().unwrap_or("/"))?); + uris.push(uri( + scheme, + &host, + h2.path + .as_deref() + .filter(|path| !path.is_empty()) + .unwrap_or("/"), + )?); } StreamTransport::H2 { uris } } diff --git a/src/config/xhttp.rs b/src/config/xhttp.rs index 244f387..1b86f4d 100644 --- a/src/config/xhttp.rs +++ b/src/config/xhttp.rs @@ -90,32 +90,35 @@ impl XHttpRequestOptions { return invalid("XHTTP generated content type conflicts with custom headers"); } if mode != super::XHttpMode::PacketUp - && (raw.seq_key.is_some() || raw.seq_placement.is_some()) + && (nonempty(raw.seq_key.as_deref()).is_some() + || nonempty(raw.seq_placement.as_deref()).is_some()) { return invalid("XHTTP sequence overrides require packet-up"); } let data = DataPlacement::normalize(raw)?; if mode != super::XHttpMode::PacketUp && (data != DataPlacement::Body - || raw.sc_max_each_post_bytes.is_some() - || raw.sc_min_posts_interval_ms.is_some()) + || range_value(raw.sc_max_each_post_bytes.as_deref()).is_some() + || range_value(raw.sc_min_posts_interval_ms.as_deref()).is_some()) { return invalid("XHTTP packet upload fields require packet-up"); } - let post_bytes = IntegerRange::parse( - raw.sc_max_each_post_bytes.as_deref().unwrap_or("1000000"), + let post_bytes = IntegerRange::parse_with_default( + raw.sc_max_each_post_bytes.as_deref(), + "1000000", 1, crate::limits::XHTTP_POST_BYTES, )?; - let post_interval_ms = IntegerRange::parse( - raw.sc_min_posts_interval_ms.as_deref().unwrap_or("30"), + let post_interval_ms = IntegerRange::parse_with_default( + raw.sc_min_posts_interval_ms.as_deref(), + "30", 0, 60_000, )?; if post_interval_ms.max == 0 { return invalid("XHTTP post interval must have a positive maximum"); } - let uplink_method = match raw.uplink_http_method.as_deref().unwrap_or("POST") { + let uplink_method = match nonempty(raw.uplink_http_method.as_deref()).unwrap_or("POST") { "POST" => http::Method::POST, "PUT" => http::Method::PUT, "PATCH" => http::Method::PATCH, @@ -252,9 +255,11 @@ pub(crate) enum DataPlacement { impl DataPlacement { fn normalize(raw: &super::RawXHttpSettings) -> Result { - let placement = raw.uplink_data_placement.as_deref().unwrap_or("body"); + let placement = nonempty(raw.uplink_data_placement.as_deref()).unwrap_or("body"); if matches!(placement, "body" | "auto") { - if raw.uplink_data_key.is_some() || raw.uplink_chunk_size.is_some() { + if nonempty(raw.uplink_data_key.as_deref()).is_some() + || range_value(raw.uplink_chunk_size.as_deref()).is_some() + { return invalid( "XHTTP body upload does not use payload keys or header chunk sizes", ); @@ -269,7 +274,7 @@ impl DataPlacement { }; validate_key(key)?; let mut size = - IntegerRange::parse(raw.uplink_chunk_size.as_deref().unwrap_or("0"), 0, 8192)?; + IntegerRange::parse_with_default(raw.uplink_chunk_size.as_deref(), "0", 0, 8192)?; if size.max == 0 { size = if placement == "cookie" { IntegerRange { @@ -309,6 +314,8 @@ impl MetaPlacement { header_default: &str, other_default: &str, ) -> Result { + let placement = nonempty(placement); + let key = nonempty(key); match placement.unwrap_or("path") { "path" if key.is_none() => Ok(Self::Path), "query" | "cookie" => { @@ -348,6 +355,7 @@ pub(crate) enum SessionId { impl SessionId { fn normalize(table: &str, length: Option<&str>) -> Result { + let length = range_value(length); if table.is_empty() || table == "uuid" { if length.is_some() { return invalid("XHTTP session-length requires a character table"); @@ -377,7 +385,7 @@ impl SessionId { bytes.push(byte); } } - let length = IntegerRange::parse(length.unwrap_or("16-32"), 1, 128)?; + let length = IntegerRange::parse_with_default(length, "16-32", 1, 128)?; let mut possibilities = 0u64; for n in length.min..=length.max { possibilities = @@ -447,7 +455,7 @@ pub(super) struct RawReuseConfig { impl RawReuseConfig { pub(super) fn normalize(self, version: XHttpVersion) -> Result { let range = |value: Option| { - IntegerRange::parse(value.as_deref().unwrap_or("0"), 0, i32::MAX as usize) + IntegerRange::parse_with_default(value.as_deref(), "0", 0, i32::MAX as usize) }; if version == XHttpVersion::Http1 && self.h_keep_alive_period != 0 { return invalid("XHTTP HTTP/1 does not support a keepalive period override"); @@ -464,12 +472,23 @@ impl RawReuseConfig { } impl IntegerRange { + fn parse_with_default( + value: Option<&str>, + fallback: &str, + minimum: usize, + maximum: usize, + ) -> Result { + Self::parse(range_value(value).unwrap_or(fallback), minimum, maximum) + } + pub(super) fn parse(value: &str, minimum: usize, maximum: usize) -> Result { + let value = value.trim(); let (left, right) = value.split_once('-').unwrap_or((value, value)); - if [left, right] - .iter() - .any(|part| part.is_empty() || !part.bytes().all(|b| b.is_ascii_digit())) - { + let (left, right) = (left.trim(), right.trim()); + if [left, right].iter().any(|part| { + let decimal = part.strip_prefix('+').unwrap_or(part); + decimal.is_empty() || !decimal.bytes().all(|b| b.is_ascii_digit()) + }) { return invalid("XHTTP range must be decimal N or N-M"); } let (Ok(min), Ok(max)) = (left.parse::(), right.parse::()) else { @@ -486,6 +505,14 @@ impl IntegerRange { } } +fn nonempty(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.is_empty()) +} + +fn range_value(value: Option<&str>) -> Option<&str> { + value.filter(|value| !value.trim().is_empty()) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum PaddingPlacement { QueryInHeader, @@ -520,26 +547,22 @@ impl Default for XHttpPadding { impl XHttpPadding { pub(super) fn normalize(raw: &super::RawXHttpSettings, headers: &XHttpHeaders) -> Result { - let mut padding = Self::default(); - if let Some(bytes) = &raw.x_padding_bytes { - padding.bytes = IntegerRange::parse(bytes, 1, MAX_PADDING_BYTES)?; - } + let mut padding = Self { + bytes: IntegerRange::parse_with_default( + raw.x_padding_bytes.as_deref(), + "100-1000", + 1, + MAX_PADDING_BYTES, + )?, + ..Self::default() + }; if !raw.x_padding_obfs_mode { - if raw - .x_padding_key - .as_deref() - .is_some_and(|value| value != "x_padding") - || raw - .x_padding_header - .as_deref() + if nonempty(raw.x_padding_key.as_deref()).is_some_and(|value| value != "x_padding") + || nonempty(raw.x_padding_header.as_deref()) .is_some_and(|value| !value.eq_ignore_ascii_case("referer")) - || raw - .x_padding_placement - .as_deref() + || nonempty(raw.x_padding_placement.as_deref()) .is_some_and(|value| value != "queryInHeader") - || raw - .x_padding_method - .as_deref() + || nonempty(raw.x_padding_method.as_deref()) .is_some_and(|value| value != "repeat-x") { return invalid( @@ -555,7 +578,7 @@ impl XHttpPadding { Some("cookie") => PaddingPlacement::Cookie, _ => return invalid("XHTTP obfuscated padding requires a supported placement"), }; - padding.tokenish = match raw.x_padding_method.as_deref().unwrap_or("repeat-x") { + padding.tokenish = match nonempty(raw.x_padding_method.as_deref()).unwrap_or("repeat-x") { "repeat-x" => false, "tokenish" => true, _ => return invalid("unsupported XHTTP padding method"), diff --git a/src/resource/http.rs b/src/resource/http.rs index d6c1a7f..6bff0d2 100644 --- a/src/resource/http.rs +++ b/src/resource/http.rs @@ -831,9 +831,9 @@ fn parse_response_head(block: &[u8]) -> Result { .next() .ok_or_else(|| DownloadError::Protocol("missing HTTP status line".to_owned()))?; let mut status_parts = status_line.splitn(3, ' '); - if status_parts.next() != Some("HTTP/1.1") { + if !matches!(status_parts.next(), Some("HTTP/1.0" | "HTTP/1.1")) { return Err(DownloadError::Protocol( - "server did not respond with HTTP/1.1".to_owned(), + "server did not respond with HTTP/1.0 or HTTP/1.1".to_owned(), )); } let status_text = status_parts @@ -1412,72 +1412,79 @@ mod tests { #[tokio::test] async fn not_modified_removes_the_unused_temporary_file() { let directory = tempdir().expect("tempdir"); - let temporary_path = directory.path().join("geoip.dat.new"); - let dispatcher = Arc::new(ScriptedDispatcher::new([ - b"HTTP/1.1 304 Not Modified\r\nETag: \"old\"\r\n\r\n".to_vec(), - ])); - let outcome = download_with_connector( - request( - dispatcher, - "https://rules.example.test/geoip.dat", - temporary_path.clone(), - ), - &PlainHttpsConnector, - ) - .await - .expect("conditional request succeeds"); - - assert_eq!( - outcome, - DownloadOutcome::NotModified { - subscription_info: None - } - ); - assert!(!temporary_path.exists()); + for version in ["HTTP/1.0", "HTTP/1.1"] { + let temporary_path = directory.path().join("geoip.dat.new"); + let dispatcher = Arc::new(ScriptedDispatcher::new([ + format!("{version} 304 Not Modified\r\nETag: \"old\"\r\nSubscription-Userinfo: upload=7\r\n\r\n").into_bytes(), + ])); + let outcome = download_with_connector( + request( + dispatcher, + "https://rules.example.test/geoip.dat", + temporary_path.clone(), + ), + &PlainHttpsConnector, + ) + .await + .expect("conditional request succeeds"); + assert_eq!( + outcome, + DownloadOutcome::NotModified { + subscription_info: Some(SubscriptionInfo { + upload: 7, + ..Default::default() + }) + } + ); + assert!(!temporary_path.exists()); + } } #[tokio::test] async fn supports_content_length_and_eof_delimited_bodies() { let directory = tempdir().expect("tempdir"); - let content_length_path = directory.path().join("content-length.new"); - let content_length_dispatcher = Arc::new(ScriptedDispatcher::new([ - b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n12345ignored".to_vec(), - ])); - let content_length = download_with_connector( - request( - content_length_dispatcher, - "https://rules.example.test/content-length", - content_length_path.clone(), - ), - &PlainHttpsConnector, - ) - .await - .expect("content-length body"); - assert!(matches!( - content_length, - DownloadOutcome::Downloaded { size: 5, .. } - )); - assert_eq!( - fs::read(content_length_path).expect("length file"), - b"12345" - ); - - let eof_path = directory.path().join("eof.new"); - let eof_dispatcher = Arc::new(ScriptedDispatcher::new([ - b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\neof-body".to_vec(), - ])); - let eof = download_with_connector( - request( - eof_dispatcher, - "https://rules.example.test/eof", - eof_path.clone(), - ), - &PlainHttpsConnector, - ) - .await - .expect("EOF body"); - assert!(matches!(eof, DownloadOutcome::Downloaded { size: 8, .. })); - assert_eq!(fs::read(eof_path).expect("EOF file"), b"eof-body"); + for (version, suffix) in [("HTTP/1.0", "1.0"), ("HTTP/1.1", "1.1")] { + for (name, headers, body, expected) in [ + ( + "length", + "Content-Length: 5\r\n", + "12345ignored", + b"12345".as_slice(), + ), + ( + "eof", + "Connection: close\r\n", + "eof-body", + b"eof-body".as_slice(), + ), + ] { + let path = directory.path().join(format!("{name}-{suffix}.new")); + let dispatcher = Arc::new(ScriptedDispatcher::new([ + format!("{version} 200 OK\r\n{headers}ETag: \"new\"\r\nSubscription-Userinfo: download=19\r\n\r\n{body}").into_bytes(), + ])); + let outcome = download_with_connector( + request(dispatcher, "https://rules.example.test/file", path.clone()), + &PlainHttpsConnector, + ) + .await + .expect("download body"); + let DownloadOutcome::Downloaded { + size, + sha256, + etag, + subscription_info, + .. + } = outcome + else { + panic!("download outcome") + }; + assert_eq!(size, expected.len() as u64); + assert_eq!(sha256.as_slice(), Sha256::digest(expected).as_slice()); + assert_eq!(etag.as_deref(), Some("\"new\"")); + assert_eq!(subscription_info.unwrap().download, 19); + assert_eq!(fs::read(path).expect("downloaded file"), expected); + } + } } #[tokio::test] diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index a9871b3..6adbc1e 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -2389,7 +2389,7 @@ async fn synthetic_fd_with_dns_disabled_dispatches_tcp_and_reuses_udp_associatio assert_eq!( dispatcher.tcp_sessions.lock().unwrap()[0], StreamSession { - metadata: Default::default(), + metadata: crate::session::InboundMetadata::new(tcp_destination.port()), inbound: InboundKind::Tun, source: tcp_source, destination: Destination::Ip(tcp_destination), @@ -2498,7 +2498,7 @@ async fn unconfigured_tls_port_dispatches_without_reading_a_prefix() { assert_eq!( dispatcher.tcp_sessions.lock().unwrap()[0], StreamSession { - metadata: Default::default(), + metadata: crate::session::InboundMetadata::new(destination.port()), inbound: InboundKind::Tun, source, destination: Destination::Ip(destination), diff --git a/tests/hysteria2_config.rs b/tests/hysteria2_config.rs index 4349332..99f7166 100644 --- a/tests/hysteria2_config.rs +++ b/tests/hysteria2_config.rs @@ -61,6 +61,11 @@ fn hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed() { node }; let defaults = normalized(base.clone()); + for field in ["obfs", "ports"] { + let mut raw = base.clone(); + raw[field] = json!(""); + assert_eq!(normalized(raw), defaults, "empty {field} uses defaults"); + } for field in [ "name", "server", @@ -70,6 +75,7 @@ fn hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed() { "sni", "alpn", "skip-cert-verify", + "name-cert-verify", "fingerprint", "certificate", "private-key", @@ -96,6 +102,17 @@ fn hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed() { assert_eq!(defaults.tls.server_name, "localhost"); assert_eq!(defaults.tls.alpn, vec![b"h3".to_vec()]); assert!(defaults.tls.tls13_only); + let mut raw = base.clone(); + raw["sni"] = json!("public.example.com"); + raw["name-cert-verify"] = json!("verified.example.com"); + let node = normalized(raw.clone()); + assert_eq!(node.tls.server_name, "public.example.com"); + assert_eq!( + node.tls.certificate.verification_name.as_deref(), + Some("verified.example.com") + ); + raw["name-cert-verify"] = json!("bad\nname"); + assert!(parse(raw).is_err()); for (value, expected) in [ (json!(2), 250000), (json!("2"), 250000), @@ -158,7 +175,6 @@ fn hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed() { ( "ports", vec![ - json!(""), json!("1,,2"), json!("2-1"), json!("0"), @@ -199,7 +215,6 @@ fn hysteria2_fields_are_strict_normalized_and_credentials_are_not_trimmed() { ("tls", json!(false)), ("network", json!("tcp")), ("client-fingerprint", json!("chrome")), - ("name-cert-verify", json!("localhost")), ("certificate", json!("bad")), ("private-key", json!("bad")), ("obfs", json!("salamander")), diff --git a/tests/proxy_group_compat.rs b/tests/proxy_group_compat.rs index b264b54..f3e4ffc 100644 --- a/tests/proxy_group_compat.rs +++ b/tests/proxy_group_compat.rs @@ -71,31 +71,24 @@ rules: } #[test] -fn keeps_vole_stricter_than_mihomo_at_the_documented_boundaries() { - for groups in [ - r#" -proxy-groups: - - name: primary - type: select - proxies: [node-a] - default-selected: missing -"#, - r#" -proxy-groups: - - name: primary - type: select - proxies: [node-a] - url: https://example.com/generate_204 -"#, - r#" -proxy-groups: - - name: primary - type: url-test - proxies: [node-a] -"#, +fn validates_group_selection_and_health_defaults() { + let yaml = format!( + "{COMMON_PREFIX}proxy-groups:\n - name: primary\n type: select\n proxies: [node-a]\n default-selected: missing\nrules: ['MATCH,primary']\n" + ); + assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); + for (kind, url, expected) in [ + ( + "select", + " url: https://example.com/generate_204\n", + "https://example.com/generate_204", + ), + ("url-test", "", "https://www.gstatic.com/generate_204"), ] { - let yaml = format!("{COMMON_PREFIX}{groups}rules:\n - MATCH,primary\n"); - assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); + let yaml = format!( + "{COMMON_PREFIX}proxy-groups:\n - name: primary\n type: {kind}\n proxies: [node-a]\n{url}rules: ['MATCH,primary']\n" + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!(config.proxy_groups[0].health.url, expected); } } diff --git a/tests/shadowtls_config.rs b/tests/shadowtls_config.rs index 7456b0c..55741f2 100644 --- a/tests/shadowtls_config.rs +++ b/tests/shadowtls_config.rs @@ -44,12 +44,29 @@ fn shadowtls_preserves_policy_values_and_shared_profile_aliases() { #[cfg(feature = "interop-test")] let _case = vole::resources::case_events::Case::new("SHADOWTLS-CFG", "policy"); let config = policy(node()); + let mut disabled = node(); + disabled.as_object_mut().unwrap().remove("plugin-opts"); + disabled["plugin"] = json!(""); + let disabled = parse(disabled).unwrap(); + let ProxyProtocol::Shadowsocks(plain) = &disabled.proxies[0].protocol else { + panic!() + }; + assert!(plain.shadow_tls.is_none()); + assert_eq!(plain.address, "fixture.invalid"); + assert_eq!(plain.port, 443); assert_eq!(config.password, " synthetic-private-marker "); assert_eq!(config.alpn, [b"h2".to_vec(), b"http/1.1".to_vec()]); assert!(!config.certificate.skip_cert_verify); assert!(config.certificate.fingerprint.is_none()); assert!(config.certificate.verification_name.is_none()); assert_eq!(config.client_fingerprint, None); + let mut empty_overrides = node(); + empty_overrides["plugin-opts"]["fingerprint"] = json!(""); + empty_overrides["plugin-opts"]["name-cert-verify"] = json!(""); + let defaults = policy(empty_overrides); + assert_eq!(defaults.certificate, config.certificate); + assert_eq!(defaults.server_name, config.server_name); + assert_eq!(defaults.password, config.password); for (name, expected) in [ ("", None), ("none", None), @@ -166,12 +183,9 @@ fn shadowtls_rejects_invalid_types_bounds_unknown_fields_and_nulls_without_secre ("skip-cert-verify", vec![json!(1), json!("true")]), ( "fingerprint", - vec![json!(""), json!("ff"), json!("z".repeat(64)), json!(true)], - ), - ( - "name-cert-verify", - vec![json!(""), json!("bad name"), json!(false)], + vec![json!("ff"), json!("z".repeat(64)), json!(true)], ), + ("name-cert-verify", vec![json!("bad name"), json!(false)]), ("strict-mode", vec![json!(true), json!(false)]), ("unknown", vec![json!("synthetic-private-marker")]), ] { diff --git a/tests/trojan_config.rs b/tests/trojan_config.rs index 37ca98e..f5b8030 100644 --- a/tests/trojan_config.rs +++ b/tests/trojan_config.rs @@ -81,7 +81,6 @@ fn trojan_invalid_configuration_is_rejected_without_exposing_credentials() { json!({"server":null}), json!({"network":"invalid"}), json!({"network":null}), - json!({"sni":""}), json!({"sni":"https://localhost"}), json!({"sni":null}), json!({"alpn":null}), @@ -140,6 +139,10 @@ fn trojan_ws_and_grpc_configuration_applies_transport_specific_defaults() { }; assert_eq!(node.tls.alpn, [b"http/1.1".to_vec()]); assert_eq!(node.server_name, "localhost"); + let vole::config::TrojanTransport::WebSocket { uri, .. } = &node.transport else { + unreachable!() + }; + assert_eq!(uri, "wss://localhost/"); let config = Config::parse_yaml(&document(json!({"network":"ws","ws-opts":{"path":"/edge?q=1","headers":{"Host":"cover.example:443","X-Fixture":"yes"},"max-early-data":2048}}))).unwrap(); let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { unreachable!() @@ -156,6 +159,10 @@ fn trojan_ws_and_grpc_configuration_applies_transport_specific_defaults() { unreachable!() }; assert_eq!(node.tls.alpn, [b"h2".to_vec()]); + let vole::config::TrojanTransport::Grpc { uri, .. } = &node.transport else { + unreachable!() + }; + assert_eq!(uri, "https://localhost/edge/Tun"); for service in ["Edge", "/custom/Tun"] { assert!( Config::parse_yaml(&document( @@ -166,6 +173,65 @@ fn trojan_ws_and_grpc_configuration_applies_transport_specific_defaults() { } } +#[test] +fn trojan_empty_defaults_and_grpc_authority_match_the_reference() { + use vole::config::TrojanTransport; + let config = Config::parse_yaml(&document(json!({"network":"","sni":""}))).unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert!(matches!(node.transport, TrojanTransport::Tcp)); + assert_eq!(node.server_name, "localhost"); + let config = + Config::parse_yaml(&document(json!({"network":"ws","ws-opts":{"path":""}}))).unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let TrojanTransport::WebSocket { uri, .. } = &node.transport else { + unreachable!() + }; + assert_eq!(uri, "wss://localhost/"); + for (server, sni, authority) in [ + ("127.0.0.1", "", "127.0.0.1"), + ("::1", "", "[::1]"), + ("127.0.0.1", "cover.invalid", "cover.invalid"), + ("127.0.0.1", "2001:db8::1", "[2001:db8::1]"), + ] { + let config = Config::parse_yaml(&document(json!({ + "server":server, "sni":sni, "network":"grpc", + "name-cert-verify":"verify.invalid", "grpc-opts":{"grpc-service-name":"edge"} + }))) + .unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let TrojanTransport::Grpc { uri, .. } = &node.transport else { + unreachable!() + }; + assert_eq!(uri, &format!("https://{authority}/edge/Tun")); + assert_eq!(node.server_name, if sni.is_empty() { server } else { sni }); + assert_eq!((node.address.as_str(), node.port), (server, 443)); + } + let config = Config::parse_yaml(&document(json!({ + "server":"127.0.0.1", "port":8443, "sni":"cover.invalid", "network":"ws", + "ws-opts":{"headers":{"Host":"request.invalid:7443"}} + }))) + .unwrap(); + let ProxyProtocol::Trojan(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let TrojanTransport::WebSocket { uri, headers, .. } = &node.transport else { + unreachable!() + }; + assert_eq!(uri, "wss://cover.invalid/"); + assert_eq!( + headers.get("Host").map(String::as_str), + Some("request.invalid:7443") + ); + assert_eq!(node.server_name, "cover.invalid"); + assert_eq!((node.address.as_str(), node.port), ("127.0.0.1", 8443)); +} + #[test] fn trojan_transport_boundaries_fail_before_runtime_io() { #[cfg(feature = "interop-test")] diff --git a/tests/tuic_config.rs b/tests/tuic_config.rs index 3e73914..9ea662f 100644 --- a/tests/tuic_config.rs +++ b/tests/tuic_config.rs @@ -15,7 +15,7 @@ fn parse(node: Value) -> vole::Result { } #[test] -fn explicit_v5_identity_accepts_empty_password_without_enabling_business_udp() { +fn v5_identity_defaults_password_without_enabling_business_udp() { #[cfg(feature = "interop-test")] let _case = vole::resources::case_events::Case::new("TUIC-CFG", "identity"); if !cfg!(feature = "outbound-tuic") { @@ -25,11 +25,17 @@ fn explicit_v5_identity_accepts_empty_password_without_enabling_business_udp() { let c = parse(node()).unwrap(); assert!(!c.proxies[0].udp); assert_eq!(c.proxies[0].address(), "peer.invalid"); - for field in ["uuid", "password"] { - let mut n = node(); - n.as_object_mut().unwrap().remove(field); - assert!(parse(n).is_err()); - } + let mut n = node(); + n.as_object_mut().unwrap().remove("password"); + let c = parse(n).unwrap(); + let vole::config::ProxyProtocol::Tuic(t) = &c.proxies[0].protocol else { + panic!("protocol"); + }; + assert_eq!(t.password, ""); + assert!(!c.proxies[0].udp); + let mut n = node(); + n.as_object_mut().unwrap().remove("uuid"); + assert!(parse(n).is_err()); } #[test] @@ -87,6 +93,14 @@ fn strict_v5_tls_and_identity_bounds_preserve_values() { assert_eq!(t.tls.alpn, [b"h3".to_vec()]); assert!(t.tls.tls13_only); assert_eq!(t.congestion, TuicCongestion::Cubic); + let mut n = node(); + n["sni"] = json!(""); + n["name-cert-verify"] = json!(""); + n["fingerprint"] = json!(""); + let t = extract(n); + assert_eq!(t.tls.server_name, "peer.invalid"); + assert_eq!(t.tls.certificate.verification_name, None); + assert_eq!(t.tls.certificate.fingerprint, None); for password in [String::new(), " 原样\t\0 ".to_string(), "x".repeat(65535)] { let mut n = node(); n["password"] = json!(password); @@ -129,7 +143,6 @@ fn strict_v5_tls_and_identity_bounds_preserve_values() { ("port", json!(0)), ("port", json!(65536)), ("server", json!("")), - ("sni", json!("")), ("name-cert-verify", json!("bad\nname")), ("alpn", json!([])), ("alpn", json!([""])), diff --git a/tests/vless_config.rs b/tests/vless_config.rs index 9d1b7c3..2b7215b 100644 --- a/tests/vless_config.rs +++ b/tests/vless_config.rs @@ -174,18 +174,75 @@ fn stream_transports_and_explicit_tls_policy_have_strict_public_fields() { } for fields in [ json!({"skip-cert-verify":false}), + json!({"tls":false,"servername":"","fingerprint":"","certificate":"","private-key":""}), + json!({"tls":true,"reality-opts":{},"client-fingerprint":""}), json!({"tls":true,"fingerprint":"invalid"}), - json!({"tls":true,"name-cert-verify":""}), + json!({"tls":true,"name-cert-verify":"bad\nname"}), json!({"tls":true,"certificate":"bad"}), json!({"tls":true,"certificate":"bad","private-key":"bad"}), json!({"network":"tcp","ws-opts":{}}), - json!({"network":"h2","h2-opts":{"host":[]}}), json!({"network":"grpc","grpc-opts":{"grpc-service-name":"vless","future":1}}), ] { assert!(Config::parse_yaml(&document(fields)).is_err()); } } +#[test] +fn empty_network_and_servername_keep_transport_fallbacks_and_grpc_authority() { + use vole::config::{ProxyProtocol, SecurityConfig, StreamTransport, VlessTransport}; + let config = + Config::parse_yaml(&document(json!({"network":"","tls":true,"servername":""}))).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert!(matches!( + node.transport, + VlessTransport::Stream(StreamTransport::Tcp) + )); + for (server, servername, authority) in [ + ("127.0.0.1", "", "127.0.0.1:443"), + ("::1", "", "[::1]:443"), + ("127.0.0.1", "cover.invalid", "cover.invalid"), + ("127.0.0.1", "2001:db8::1", "[2001:db8::1]"), + ] { + let config = Config::parse_yaml(&document(json!({ + "server":server, "servername":servername, "tls":true, "network":"grpc", + "name-cert-verify":"verify.invalid", "grpc-opts":{"grpc-service-name":"edge"} + }))) + .unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let VlessTransport::Stream(StreamTransport::Grpc { uri, .. }) = &node.transport else { + unreachable!() + }; + let SecurityConfig::Tls(tls) = &node.security else { + unreachable!() + }; + assert_eq!(uri, &format!("https://{authority}/edge/Tun")); + assert_eq!( + tls.server_name, + if servername.is_empty() { + server + } else { + servername + } + ); + assert_eq!((node.address.as_str(), node.port), (server, 443)); + } + let config = Config::parse_yaml(&document(json!({ + "tls":true,"servername":"","network":"ws","ws-opts":{"headers":{"Host":"cover.invalid:443"}} + }))) + .unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let SecurityConfig::Tls(tls) = &node.security else { + unreachable!() + }; + assert_eq!(tls.server_name, "cover.invalid"); +} + #[test] fn extended_ws_and_grpc_fields_are_scoped_and_bounded() { #[cfg(feature = "interop-test")] @@ -209,3 +266,33 @@ fn extended_ws_and_grpc_fields_are_scoped_and_bounded() { assert!(Config::parse_yaml(&document(fields)).is_err()); } } + +#[test] +fn h2_omitted_and_empty_options_use_the_reference_authority() { + use vole::config::{ProxyProtocol, StreamTransport, VlessTransport}; + for tls in [false, true] { + for options in [ + json!({}), + json!({"h2-opts":{}}), + json!({"h2-opts":{"host":[],"path":""}}), + ] { + let mut fields = options; + fields["network"] = json!("h2"); + fields["tls"] = json!(tls); + let config = Config::parse_yaml(&document(fields)).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let VlessTransport::Stream(StreamTransport::H2 { uris }) = &node.transport else { + unreachable!() + }; + assert_eq!( + uris, + &[format!( + "{}://www.example.com/", + if tls { "https" } else { "http" } + )] + ); + } + } +} diff --git a/tests/vmess_config.rs b/tests/vmess_config.rs index d13bef7..7bbab8f 100644 --- a/tests/vmess_config.rs +++ b/tests/vmess_config.rs @@ -70,14 +70,12 @@ fn vmess_transport_and_security_combinations_are_strict() { json!({"cipher":"none","global-padding":true}), json!({"cipher":"zero","authenticated-length":true}), json!({"network":"bogus"}), - json!({"network":"h2"}), json!({"tls":false,"servername":"secret.invalid"}), json!({"tls":false,"skip-cert-verify":false}), json!({"tls":false,"alpn":[]}), json!({"packet-encoding":"packet"}), json!({"ws-opts":{}}), json!({"http-opts":{}}), - json!({"network":"h2","h2-opts":{"host":[]}}), json!({"network":"http","http-opts":{"method":"GET\r\n"}}), json!({"network":"http","http-opts":{"headers":{"Content-Length":["1"]}}}), json!({"network":"http","http-opts":{"headers":{"X-Test":[]}}}), @@ -309,3 +307,66 @@ async fn vmess_normalized_websocket_fields_preserve_headers_and_early_data_order reader.await.unwrap(); } } + +#[test] +fn stream_defaults_and_grpc_authority_match_the_reference() { + use vole::config::{ProxyProtocol, StreamTransport}; + for tls in [false, true] { + for options in [ + json!({}), + json!({"h2-opts":{}}), + json!({"h2-opts":{"host":[],"path":""}}), + ] { + let mut fields = options; + fields["network"] = json!("h2"); + fields["tls"] = json!(tls); + let config = Config::parse_yaml(&document(fields)).unwrap(); + let ProxyProtocol::Vmess(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let StreamTransport::H2 { uris } = &node.transport else { + unreachable!() + }; + assert_eq!( + uris, + &[format!( + "{}://www.example.com/", + if tls { "https" } else { "http" } + )] + ); + } + } + for (name, authority) in [ + (None, "example.com:443"), + (Some(""), "example.com:443"), + (Some("cover.example"), "cover.example"), + (Some("2001:db8::1"), "[2001:db8::1]"), + ] { + let mut fields = json!({"network":"grpc","tls":true,"name-cert-verify":"verified.example"}); + if let Some(name) = name { + fields["servername"] = json!(name); + } + let config = Config::parse_yaml(&document(fields)).unwrap(); + let ProxyProtocol::Vmess(node) = &config.proxies[0].protocol else { + unreachable!() + }; + let StreamTransport::Grpc { uri, .. } = &node.transport else { + unreachable!() + }; + assert_eq!( + uri.parse::() + .unwrap() + .authority() + .unwrap() + .as_str(), + authority + ); + } + for fields in [ + json!({"network":""}), + json!({"network":"ws","ws-opts":{"path":""}}), + json!({"network":"http","http-opts":{"method":""}}), + ] { + Config::parse_yaml(&document(fields)).unwrap(); + } +} diff --git a/tests/xhttp_config.rs b/tests/xhttp_config.rs index 4e5b339..bba219a 100644 --- a/tests/xhttp_config.rs +++ b/tests/xhttp_config.rs @@ -1,7 +1,7 @@ #![cfg(feature = "outbound-vless")] use serde_json::{Value, json}; -use vole::config::{Config, ProxyProtocol}; +use vole::config::{Config, ProxyProtocol, XHttpMode}; fn document(options: Value) -> Vec { serde_json::to_vec(&json!({ @@ -131,7 +131,13 @@ fn xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io() { ), ("session-length", 10, 128, json!({"session-table":"number"})), ] { - for value in [low.to_string(), high.to_string(), format!("{low}-{high}")] { + for value in [ + low.to_string(), + high.to_string(), + format!("{low}-{high}"), + format!(" +{low} "), + format!(" +{low} - +{high} "), + ] { let mut options = extra.clone(); options[field] = json!(value); assert!( @@ -144,9 +150,7 @@ fn xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io() { "-1".into(), "1-0".into(), "1.0".into(), - " 1".into(), "1-2-3".into(), - String::new(), ] { let mut options = extra.clone(); options[field] = json!(value); @@ -200,6 +204,91 @@ fn xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io() { } } +#[test] +fn xhttp_empty_mode_path_and_download_name_use_each_legs_defaults() { + let config = Config::parse_yaml(&document(json!({"mode":"", "path":""}))).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.xhttp().unwrap().mode, XHttpMode::PacketUp); + assert_eq!(node.xhttp().unwrap().path, "/"); + + let mut input: Value = serde_json::from_slice(&document(json!({ + "path":"/upload", + "download-settings":{"server":"download.example.com", "path":"", "servername":""} + }))) + .unwrap(); + input["proxies"][0]["servername"] = json!("parent.invalid"); + let config = Config::parse_yaml(&serde_json::to_vec(&input).unwrap()).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert_eq!(node.xhttp().unwrap().path, "/upload"); + assert_eq!(node.xhttp().unwrap().host, "parent.invalid"); + let download = node.download().unwrap(); + assert_eq!(download.path, "/"); + assert_eq!(download.security.server_name(), "download.example.com"); + assert_eq!(download.host, "download.example.com"); +} + +#[test] +fn xhttp_empty_request_and_pool_fields_restore_their_defaults() { + let expected = Config::parse_yaml(&document(json!({}))).unwrap(); + let ProxyProtocol::Vless(expected_node) = &expected.proxies[0].protocol else { + unreachable!() + }; + let actual = Config::parse_yaml(&document(json!({ + "uplink-http-method":"", + "session-placement":"", "session-key":"", "session-length":" \t ", + "seq-placement":"", "seq-key":"", + "uplink-data-placement":"", "uplink-data-key":"", "uplink-chunk-size":" \t ", + "x-padding-bytes":" \t ", "x-padding-key":"", "x-padding-header":"", + "x-padding-placement":"", "x-padding-method":"", + "sc-max-each-post-bytes":" \t ", "sc-min-posts-interval-ms":"" + }))) + .unwrap(); + let ProxyProtocol::Vless(actual_node) = &actual.proxies[0].protocol else { + unreachable!() + }; + assert_eq!( + expected_node.xhttp().unwrap().request, + actual_node.xhttp().unwrap().request + ); + for download in [false, true] { + let expected_options = if download { + json!({"download-settings":{"reuse-settings":{}}}) + } else { + json!({"reuse-settings":{}}) + }; + let mut options = json!({"reuse-settings":{ + "max-concurrency":"", "max-connections":" \t ", "c-max-reuse-times":"", + "h-max-request-times":"", "h-max-reusable-secs":" \t " + }}); + if download { + options = json!({"download-settings":options}); + } + let expected = Config::parse_yaml(&document(expected_options)).unwrap(); + let actual = Config::parse_yaml(&document(options)).unwrap(); + let (ProxyProtocol::Vless(expected), ProxyProtocol::Vless(actual)) = + (&expected.proxies[0].protocol, &actual.proxies[0].protocol) + else { + unreachable!() + }; + let (expected, actual) = if download { + ( + &expected.download().unwrap().reuse, + &actual.download().unwrap().reuse, + ) + } else { + ( + &expected.xhttp().unwrap().reuse, + &actual.xhttp().unwrap().reuse, + ) + }; + assert_eq!(expected, actual); + } +} + #[test] fn empty_http_authority_falls_back_to_each_legs_authentication_name() { #[cfg(feature = "interop-test")] From c59b7791f4f167c9af75232a35240f00122c47fd Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 16:05:43 +0800 Subject: [PATCH 043/445] fix: start ASN updates and honor provider refresh deadlines --- docs/config.yaml | 10 ++--- src/resource/cache.rs | 32 ++++++++++----- src/resource/cache_tests.rs | 81 +++++++++++++++++++++++++++++++++++++ src/runtime.rs | 3 ++ src/runtime/providers.rs | 30 ++++++++++++-- 5 files changed, 137 insertions(+), 19 deletions(-) diff --git a/docs/config.yaml b/docs/config.yaml index 1e4ec4c..74bed1a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -233,7 +233,7 @@ proxies: udp: true # dialer-proxy: upstream-select # 上游必须提供足够的双向 UDP 预算 - # TUIC v5:固定 QUIC/TLS1.3;必填 UUID 和原样 password(可空,最多 65535 UTF-8 字节)。 + # TUIC v5:固定 QUIC/TLS1.3;UUID 必填,password 缺省为空,原样使用(最多 65535 UTF-8 字节)。 # 不接受 v4、token、0-RTT、client-fingerprint、mTLS、ECH 或其他 TLS 后端选项。 - name: tuic-edge type: tuic @@ -308,7 +308,7 @@ proxies: # 每次握手各选一个路径与每个头的一个值;禁止 Content-Length/Transfer-Encoding。 # network: h2 # h2-opts: # 省略/host: [] 使用 www.example.com;path 省略/空为 / - # host: [cover.example:443] # 必填、非空;每流选一个,不改变 TLS 认证名 + # host: [cover.example:443] # 每流选一个;缺省/空列表用 www.example.com,不改变 TLS 认证名 # path: /cover # 默认 /;普通 PUT body,不使用 gRPC framing # Trojan(outbound-trojan feature):固定 TLS,不接受 tls 开关。 @@ -437,7 +437,7 @@ proxies: password: password udp: true - # AnyTLS 只提供客户端出站,配置只接受以下字段和可选 dialer-proxy。 + # AnyTLS 只提供客户端出站,接受以下协议字段和节点通用字段。 # sni 省略时使用 server。TLS 1.3/1.2 默认 WebPKI,alpn 省略/[] 时不发送。 # skip-cert-verify 不能覆盖 fingerprint;pin 是证书 SHA-256,不是客户端伪造指纹。 # 叶 pin 直接决定信任;非叶 pin 作为根继续验证链和名称。所有策略均验证握手签名。 @@ -493,7 +493,7 @@ proxies: # 反引号分隔的多表达式按匹配顺序排列。节点改名使用 $1 / ${name} / $$ 等 .NET replacement。 # 复杂表达式有内部编译/执行预算;失败候选保留旧资源,不静默当不匹配。 # 静态成员保留顺序与重复项。 -# 动态组为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 +# 过滤后组成员为空时使用 empty-fallback(缺省 COMPATIBLE,即 DIRECT),可指定节点或内置目标。 # disable-udp 缺省 false;true 禁用该组的业务 UDP。规则模式跳过不支持 UDP 的目标, # 继续匹配后续规则;global 模式及显式 DNS 出口仍会拒绝。hidden/icon 为 Controller 元数据。 # 节点与组的完整混合图必须无环。资源更新后保留仍存在的当前选择,否则使用默认/首成员。 @@ -549,7 +549,7 @@ proxy-groups: # proxy provider 文件为 {proxies: [...]};可用 filter/exclude-filter/exclude-type。 # provider 的 dialer-proxy 覆盖每个节点的同名字段,override 最后应用; # 它只控制节点上游,资源下载仍使用独立的 proxy 字段。 -# override 支持 udp、udp-over-tcp、up/down、dialer-proxy、skip-cert-verify、name-cert-verify、 +# override 支持 udp、udp-over-tcp、up/down、dialer-proxy、ip-version、skip-cert-verify、name-cert-verify、 # additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]; # 协议专属覆盖只用于支持该字段的节点。未实现的覆盖字段严格拒绝。 # proxy providers 的 health-check 接受 enable/url/interval/timeout/lazy/expected-status; diff --git a/src/resource/cache.rs b/src/resource/cache.rs index fc142c3..e9c237c 100644 --- a/src/resource/cache.rs +++ b/src/resource/cache.rs @@ -177,13 +177,22 @@ impl ProviderCache { }) } pub fn next_due(&self) -> Option { - self.entries.iter().position(|entry| { - entry.spec.kind != ProviderType::Inline - && !(entry.spec.kind == ProviderType::Http - && entry.spec.interval == 0 - && entry.state.downloaded) - && entry.next <= tokio::time::Instant::now() - }) + self.next_refresh() + .filter(|(_, deadline)| *deadline <= tokio::time::Instant::now()) + .map(|(index, _)| index) + } + pub fn next_refresh(&self) -> Option<(usize, tokio::time::Instant)> { + self.entries + .iter() + .enumerate() + .filter(|(_, entry)| { + entry.spec.kind != ProviderType::Inline + && !(entry.spec.kind == ProviderType::Http + && entry.spec.interval == 0 + && entry.state.downloaded) + }) + .min_by_key(|(_, entry)| entry.next) + .map(|(index, entry)| (index, entry.next)) } pub async fn fetch( &mut self, @@ -366,11 +375,14 @@ impl ProviderCache { } fn schedule(entry: &mut ResourceEntry) { entry.failures = 0; - entry.next = if entry.spec.kind == ProviderType::File { - tokio::time::Instant::now() + Duration::from_secs(1) + let seconds = if entry.spec.kind == ProviderType::File { + 1 + } else if entry.spec.interval == 0 { + 365 * 24 * 3600 } else { - next_update(entry.spec.interval, unix_time(SystemTime::now())) + entry.spec.interval.min(i64::MAX as u64 / 1_000_000_000) }; + entry.next = tokio::time::Instant::now() + Duration::from_secs(seconds); } } fn content_len(content: &ProviderContent) -> usize { diff --git a/src/resource/cache_tests.rs b/src/resource/cache_tests.rs index 3028786..e26e9ab 100644 --- a/src/resource/cache_tests.rs +++ b/src/resource/cache_tests.rs @@ -88,6 +88,87 @@ fn commit_valid(cache: &mut ProviderCache, candidate: Candidate) { .unwrap(); } +#[tokio::test(start_paused = true)] +async fn refresh_deadline_preserves_fetch_completion_phase_and_zero_disables_polling() { + let root = tempfile::tempdir().unwrap(); + let mut cache = ProviderCache::open(root.path(), template()).unwrap(); + cache.entries[0].spec.interval = 1; + let started = tokio::time::Instant::now(); + let candidate = fetch( + &mut cache, + response( + 200, + "", + "proxies: [{name: edge, type: socks5, server: 192.0.2.1, port: 1080}]", + ), + ) + .await + .unwrap() + .unwrap(); + // Publication completes just after a fixed one-second poll boundary. + tokio::time::advance(Duration::from_millis(20)).await; + commit_valid(&mut cache, candidate); + let deadline = started + Duration::from_millis(1020); + assert_eq!(cache.next_refresh(), Some((0, deadline))); + tokio::time::advance(Duration::from_millis(980)).await; + assert_eq!(cache.next_due(), None); + tokio::time::sleep_until(cache.next_refresh().unwrap().1).await; + assert_eq!(tokio::time::Instant::now(), deadline); + assert_eq!(cache.next_due(), Some(0)); + assert!( + fetch(&mut cache, response(304, "", "")) + .await + .unwrap() + .is_none() + ); + assert_eq!( + cache.next_refresh(), + Some((0, deadline + Duration::from_secs(1))) + ); + cache.entries[0].spec.interval = 0; + assert_eq!(cache.next_refresh(), None); + assert_eq!(cache.next_due(), None); +} + +#[tokio::test(start_paused = true)] +async fn refresh_deadline_selects_oldest_due_provider_and_keeps_failure_backoff() { + let root = tempfile::tempdir().unwrap(); + let template = Config::parse_yaml( + br#" +mixed-port: 1080 +proxy-providers: + a: {type: http, url: 'http://subscription.invalid/a', interval: 1} + b: {type: http, url: 'http://subscription.invalid/b', interval: 1} +proxy-groups: + - {name: choice, type: select, use: [a, b]} +"#, + ) + .unwrap() + .providers + .unwrap(); + let mut cache = ProviderCache::open(root.path(), template).unwrap(); + let started = tokio::time::Instant::now(); + cache.entries[0].next = started + Duration::from_secs(2); + cache.entries[1].next = started + Duration::from_secs(1); + assert_eq!( + cache.next_refresh(), + Some((1, started + Duration::from_secs(1))) + ); + tokio::time::advance(Duration::from_secs(2)).await; + assert_eq!(cache.next_due(), Some(1)); + cache.reject(1); + assert_eq!(cache.next_due(), Some(0)); + cache.entries[0].state.downloaded = true; + cache.entries[0].spec.interval = 0; + assert_eq!(cache.next_due(), None); + assert_eq!( + cache.next_refresh(), + Some((1, started + Duration::from_secs(12))) + ); + cache.entries[1].spec.kind = ProviderType::Inline; + assert_eq!(cache.next_refresh(), None); +} + #[tokio::test] async fn subscription_metadata_follows_successful_candidates_304_and_verified_disk_cache() { let root = tempfile::tempdir().unwrap(); diff --git a/src/runtime.rs b/src/runtime.rs index 2b62677..32e7015 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -416,6 +416,7 @@ impl PreparedCore { if let Some(update) = self.config.geodata_update.as_ref().filter(|_| { report.geosite.required || report.geoip.required + || report.asn.required || self .config .providers @@ -2439,6 +2440,8 @@ geo-update-interval: 24 let missing_config_with_demand = config_with_rules("rules:\n - GEOIP,cn,proxy\n - MATCH,proxy\n"); assert!(updater_for(&missing_config_with_demand).await.is_some()); + let asn_only = config_with_rules("rules:\n - IP-ASN,0,proxy\n - MATCH,proxy\n"); + assert!(updater_for(&asn_only).await.is_some()); assert!(updater_for(&configured(false, true)).await.is_some()); assert!(updater_for(&configured(true, false)).await.is_none()); diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 9ea6b4b..7daf87a 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -214,8 +214,16 @@ impl ProviderRuntime { .await .take() .ok_or_else(|| io::Error::other("provider worker already running"))?; - let mut tick = tokio::time::interval(Duration::from_secs(1)); + let mut maintenance = tokio::time::interval(Duration::from_secs(1)); loop { + let deadline = self.cache.lock().await.next_refresh().map(|(_, at)| at); + let refresh = async { + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline).await; + } else { + std::future::pending::<()>().await; + } + }; tokio::select! { biased; ()=parent.cancelled()=>{self.begin_shutdown();return Ok(());} @@ -225,15 +233,29 @@ impl ProviderRuntime { let result=match index {Some(index)=>self.update(index).await,None=>Err("unknown provider")}; let _=request.reply.send(result); }, - _=tick.tick()=>{ + ()=refresh=>{ let due=self.cache.lock().await.next_due(); if let Some(index)=due { let _=self.update(index).await; } - self.reap().await; - } + }, + _=maintenance.tick()=>self.reap().await, } } } async fn update(self: &Arc, index: usize) -> Result<(), &'static str> { + let result = self.update_inner(index).await; + if result.is_err() && !self.cancellation.is_cancelled() { + let mut cache = self.cache.lock().await; + // Fetch/parse and ordinary candidate failures already schedule + // backoff. Cover later preparation/publication errors that left an + // expired deadline so the exact timer cannot spin immediately. + if cache.entries[index].next <= tokio::time::Instant::now() { + cache.reject(index); + self.record_states(&cache); + } + } + result + } + async fn update_inner(self: &Arc, index: usize) -> Result<(), &'static str> { let mut cache = self.cache.lock().await; let proxy = cache.entries[index].spec.proxy.clone(); let generation = self.current.load_full(); From eedd2ae0d6e8f6bd65ccca9eab63d791e2cf252f Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 16:30:46 +0800 Subject: [PATCH 044/445] fix: preserve public drop counters in foreground logs --- src/invoke/foreground.rs | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/invoke/foreground.rs b/src/invoke/foreground.rs index 738bf79..a21ee72 100644 --- a/src/invoke/foreground.rs +++ b/src/invoke/foreground.rs @@ -634,6 +634,7 @@ fn public_numeric_field(name: &str) -> bool { "_peak", "_count", "_dropped", + "_drops", "_bytes", "_capacity", "_limit", @@ -946,4 +947,57 @@ mod tests { assert!(line.len <= LOG_BYTES); assert!(!line.as_str().is_empty()); } + + #[test] + fn stderr_events_preserve_drop_counts_and_filter_private_values() { + struct Capture(Arc>); + impl Subscriber for Capture { + fn enabled(&self, _: &Metadata<'_>) -> bool { + true + } + fn new_span(&self, _: &Attributes<'_>) -> Id { + Id::from_u64(1) + } + fn record(&self, _: &Id, _: &Record<'_>) {} + fn record_follows_from(&self, _: &Id, _: &Id) {} + fn enter(&self, _: &Id) {} + fn exit(&self, _: &Id) {} + fn event(&self, event: &Event<'_>) { + let mut line = LogLine::new(); + event.record(&mut NumericFields(&mut line)); + self.0.lock().unwrap().push_str(line.as_str()); + } + } + let rendered = Arc::new(Mutex::new(String::new())); + tracing::subscriber::with_default(Capture(rendered.clone()), || { + tracing::info!( + packet_queue_drops = 1_u64, + udp_queue_drops = 2_u64, + udp_association_queue_drops = 3_u64, + udp_response_queue_drops = 4_u64, + dns_queue_drops = 5_u64, + udp_drops = 6_u64, + mtu = 1500_u64, + destination = "private destination", + uuid = 123_u64, + password_bytes = "private credential", + "private message" + ); + }); + let rendered = rendered.lock().unwrap(); + for (field, count) in [ + ("packet_queue_drops", 1), + ("udp_queue_drops", 2), + ("udp_association_queue_drops", 3), + ("udp_response_queue_drops", 4), + ("dns_queue_drops", 5), + ("udp_drops", 6), + ] { + assert!(rendered.contains(&format!(" {field}={count}"))); + } + assert!(rendered.contains(" mtu=1500")); + for private in ["private", "destination", "uuid", "password", "message"] { + assert!(!rendered.contains(private)); + } + } } From 7eb7dbd3c0b033fe7de75253d4808f70bafdfb75 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 17:09:28 +0800 Subject: [PATCH 045/445] fix: disable Nagle for mixed TCP connections --- docs/inbounds.md | 1 + src/dialer_connections.rs | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/docs/inbounds.md b/docs/inbounds.md index a6b6dbb..148cda8 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -28,6 +28,7 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 使用自己的 Bearer 认证,不随共享开关开放。 - 不设置全局业务连接/关联准入数。Stop 取消并等待全部连接、relay 和接收任务, 返回后端口可重新绑定。 +- 接收的 TCP 连接启用 `TCP_NODELAY`,小包应答不等待 Nagle 聚合。 ## 选路元数据 diff --git a/src/dialer_connections.rs b/src/dialer_connections.rs index f161707..0449e28 100644 --- a/src/dialer_connections.rs +++ b/src/dialer_connections.rs @@ -58,6 +58,9 @@ where impl crate::config::ConnectionSettings { pub(crate) fn apply(self, socket: &socket2::SockRef<'_>) -> std::io::Result<()> { + // Apply to accepted mixed connections as well as physical dials: + // small proxy writes must not wait on Nagle and delayed ACKs. + socket.set_tcp_nodelay(true)?; // Mihomo deliberately disables TCP probes on Android. if self.disable_keep_alive || cfg!(target_os = "android") { return socket.set_keepalive(false); @@ -137,6 +140,7 @@ mod tests { ..Default::default() }; options.apply(&socket2::SockRef::from(&socket)).unwrap(); + assert!(socket.tcp_nodelay().unwrap()); assert_eq!(socket.keepalive().unwrap(), !cfg!(target_os = "android")); #[cfg(any(target_os = "linux", target_os = "macos"))] { @@ -155,6 +159,7 @@ mod tests { } .apply(&socket2::SockRef::from(&socket)) .unwrap(); + assert!(socket.tcp_nodelay().unwrap()); assert!(!socket.keepalive().unwrap()); } } From 43121b05d9cb7ce9d7a6727fe94c97773abbee87 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 18:18:04 +0800 Subject: [PATCH 046/445] fix: align supported configuration inputs with mihomo --- ...t-proxy-groups-as-session-route-targets.md | 2 +- ...roxy-group-selection-through-controller.md | 2 +- docs/cli.md | 1 + docs/config.yaml | 35 ++-- docs/controller-api.md | 20 +- docs/inbounds.md | 4 +- docs/invoke-api.md | 5 +- docs/mihomo-compatibility.md | 6 +- docs/outbounds.md | 14 +- docs/tls-client-fingerprint.md | 9 +- docs/tun-icmp-dns.md | 4 +- docs/vless.md | 4 +- docs/windows-vpn.md | 4 +- docs/xhttp.md | 2 +- src/config/fallback.rs | 7 - src/config/groups.rs | 1 + src/config/identity_files.rs | 115 +++++++++++ src/config/measure.rs | 18 +- src/config/mod.rs | 193 ++++++++++-------- src/config/providers.rs | 8 + src/config/scalar.rs | 109 ++++++++++ src/config/tls_policy_tests.rs | 153 ++++++++++++++ src/config/vless.rs | 9 +- src/config/wireguard.rs | 145 ++++++++++++- src/controller.rs | 67 ++++-- src/invoke/foreground.rs | 45 +++- src/invoke/measure_delay.rs | 9 +- src/invoke/mod.rs | 11 +- tests/proxy_group_compat.rs | 40 +++- tests/vless_config.rs | 41 +++- tests/xhttp_config.rs | 53 ++++- 31 files changed, 955 insertions(+), 181 deletions(-) create mode 100644 src/config/identity_files.rs create mode 100644 src/config/scalar.rs diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index 43a43f5..8c90f99 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -4,7 +4,7 @@ status: accepted # ADR 0005:将 select 代理组建模为会话路由与上游目标 -`select` 是普通规则、`MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;省略 `default-selected` 时选择第一项,显式值必须精确命中一个直接成员。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 +`select` 是普通规则、`MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;`default-selected` 精确匹配直接成员,省略、空串或未命中时选择第一项。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 `mode` 支持 rule、global、direct;rule 模式允许空规则和任意位置的 MATCH,未命中直连。global 使用 GLOBAL 组;未显式声明时自动生成,成员顺序为 DIRECT、REJECT、全部节点、全部声明组,默认 DIRECT。该组与普通 select 共用运行时选择和 Controller 接口。 diff --git a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md index de0132b..607d6be 100644 --- a/docs/adr/0006-control-live-proxy-group-selection-through-controller.md +++ b/docs/adr/0006-control-live-proxy-group-selection-through-controller.md @@ -8,6 +8,6 @@ Vole 首版只通过 Controller 暴露代理组的实时选择,不新增 Invok Windows WinRT VPN 的实际运行时位于独立 Session Host,桌面 Wintun、Apple 与 Android 的运行时位于 Invoke owner 背后;Controller 是现有平台中唯一能统一到达实际运行会话的 Adapter。Controller 也可用于合法 mixed-only 静态节点或纯 DIRECT 配置,无需额外开启 TUN 或显式代理组;组路由可以在无 TUN 的本地代理运行时中使用,`/traffic` 仍只在存在 TUN 统计时提供。 -配置同时包含代理组和 Controller 时,必须为整个 Controller 配置 Bearer secret。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;Core 按 `profile.store-selected`(默认 true)保存 Controller 的显式选择;下次启动优先恢复仍存在的缓存成员,否则使用配置默认或首成员。持久化失败不回滚已经成功的运行时选择。 +Controller 的监听地址和可选 `secret` 遵循 Mihomo 的配置语义:支持 IP、`localhost` 或空 host 与数值端口,端口 0 由系统选择;省略或空串 `secret` 表示不要求认证,非空值统一启用 Bearer 鉴权,代理组不增加认证要求。请求使用精确名称和严格、有界的 JSON,任何失败都不得改变选择。没有 Controller 的代理组配置仍然有效,但本次会话中没有公开的实时切换入口;Core 按 `profile.store-selected`(默认 true)保存 Controller 的显式选择;下次启动优先恢复仍存在的缓存成员,否则使用配置默认或首成员。持久化失败不回滚已经成功的运行时选择。 Controller 同时展示动态 provider、自动组和节点健康状态;组选择与探测任务均位于实际运行会话。Controller 的节点/组 delay 与 Provider healthcheck 在当前资源代际中共用自动健康探测器、DNS、Dialer 和停止屏障;每节点手动历史有界,实际内存仅在请求时采集。独立 measureDelay 保持 node-only Invoke 接口,复用内部 HTTP/TLS 探测能力。 diff --git a/docs/cli.md b/docs/cli.md index 2b771cb..446848d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,6 +28,7 @@ cat config.yaml | vole -f - Unix 用户目录取 `HOME`,Windows 取 `USERPROFILE`,缺失时使用工作目录。 默认 `.config/vole` 不存在或不可访问时,若设置 `XDG_CONFIG_HOME`,使用其中的 `vole` 目录。 配置文件必须是普通文件,文件和标准输入上限均为 8 MiB,CLI 不写回配置。 +客户端 certificate/private-key 可引用 PEM 文件,相对路径从 -d 的数据目录解析;-t 同样读取并校验身份,仍不初始化目录。 正常运行执行 `initialize → createInstance → start(configYaml)`,等待退出后 `stop → destroyInstance`。Unix 支持 SIGINT/SIGTERM,Windows 支持 Ctrl+C/Ctrl+Break; diff --git a/docs/config.yaml b/docs/config.yaml index 74bed1a..797c8c1 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -7,6 +7,7 @@ # 库宿主先通过 Invoke API 调用 initialize({dataDir}),再通过 configYaml 内联 # 传入配置;CLI 使用 -f 指定文件,-d 指定数据目录,省略时读取数据目录下的 config.yaml。 # GeoData 固定由 Vole 管理在 dataDir/geodata 中;CLI 的 -t 只校验,不创建目录或联网。 +# certificate/private-key 可为成对 PEM 或文件路径;相对路径从 dataDir 解析,校验时读取并验证。 # 所有平台均不设 GeoData 条数上限,不按数量截断,也不设总内存预算; # 仅加载当前业务规则和 DNS policy 引用的分类/属性交集,详见 docs/geodata.md。 @@ -71,10 +72,9 @@ skip-auth-prefixes: [] # 仅跳过认证,仍执行来源访问控制 authentication: - "probe-user:probe-password" -# 可选的运行时 Controller,地址必须是回环 IP 和显式非零端口;合法 mixed-only +# 可选的运行时 Controller,接受 IP:port、localhost:port 或 :port;端口 0 由系统分配。 # 静态节点、纯 DIRECT 及未启用代理入口的配置也可使用。 -# 同时显式配置 proxy-groups 时 secret 必填;没有显式代理组时可省略。 -# secret 出现时必须为 1–255 UTF-8 字节,并作为全部 Controller 路由的 Bearer token。 +# secret 省略或空串时免认证;非空时保护全部 Controller 路由,不因代理组强制要求。 # Controller 提供 GET /traffic、GET /group、GET /group/{name}、 # GET /proxies/{name} 和 PUT /proxies/{name};另含连接查询/关闭、有效配置、规则 # 与 A/AAAA DNS 查询、节点/组 delay、Provider healthcheck、实际进程内存; @@ -149,7 +149,7 @@ sniffer: # TCP 支持明文或 TLS 1.2/1.3;XHTTP H1/H2 支持明文、TLS1.3 或 REALITY。 # - encryption 省略/空/none,或 mlkem768x25519plus.{native,xorpub,random}.{1rtt,0rtt}.…; # key/padding 链、上限和恢复规则见 vless.md;该层独立于外层 TLS/传输。 -# flow 省略/空/xtls-rprx-vision;Vision 仅 TCP + TLS1.3/REALITY 或 Encryption, +# flow 省略/null/空串/xtls-rprx-vision;Vision 仅 TCP + TLS1.3/REALITY 或 Encryption, # packet-encoding 必须 xudp(即使 udp=false); # - servername 可省略(WS Host 去端口后优先于 server);TCP alpn 默认空, # WS 默认 [http/1.1],gRPC/H2 默认 [h2];XHTTP [http/1.1] 选择 H1, @@ -177,7 +177,7 @@ sniffer: # 同时关闭下载腿,不提供 TCP 半关闭后继续读取尾包的保证;packet-up 的待完成 # 上传最多等待 1 秒。需要完整响应的应用须在关闭前读完响应。 # - 标准 TLS 支持 skip-cert-verify/fingerprint/name-cert-verify,以及配对的 -# 内联 PEM certificate/private-key;不能混用 REALITY。完整策略见 vless.md。 +# 内联 PEM 或文件路径 certificate/private-key;不能混用 REALITY。完整策略见 vless.md。 # - WS/gRPC/HTTP/H2 复用共享传输。WS 可 v2ray-http-upgrade 及其 fast-open; # gRPC 与 Trojan/VMess 共用 UA、空闲 PING、max-connections/min-streams/max-streams 池阈值。 # - 公共节点 smux 支持 h2mux/smux/yamux、padding/only-tcp 与池阈值;与 Vision 互斥。 @@ -200,7 +200,7 @@ sniffer: # HTTP(S) CONNECT 示例:type: http,必填 name/server/port,tls 默认 false。 # username/password 均非空才认证;headers 为字符串映射;仅 TCP,不支持 udp: true。 # tls: true 时支持 sni/skip-cert-verify/name-cert-verify/fingerprint, -# 以及成对的内联 certificate/private-key。支持 dialer-proxy;详见 outbounds.md。 +# 以及成对的 certificate/private-key(PEM 或文件路径)。支持 dialer-proxy;详见 outbounds.md。 proxies: # 标准 WireGuard:用户态内层 TCP/UDP,不创建系统接口/路由/NAT。 # 密钥为公开测试值,不可用于真实连接。外层始终需要双向 UDP;业务 udp 默认 false。 @@ -219,7 +219,7 @@ proxies: mtu: 1408 persistent-keepalive: 0 # uint16 秒;0 不定期保活 allowed-ips: [] # 无 peers 时忽略;各本地地址族使用全地址路由 - reserved: [] # 空或三个字节;peer 缺省继承顶层值 + reserved: [] # 空、三字节数组或标准 Base64 字符串(如 "U4An");peer 缺省继承顶层值 remote-dns-resolve: false # true 且 dns 非空时,业务域名通过本节点查询 dns: [] # 支持 Core DNS 上游;上游域名仍由 Core 代理 DNS bootstrap refresh-server-ip-interval: 0 # 秒;0 不更新,新业务触发到期检查 @@ -263,7 +263,7 @@ proxies: skip-cert-verify: false # name-cert-verify: verified.example.com # 独立证书验证名,不改变 SNI # fingerprint: "" - # certificate/private-key: 成对内联 PEM 客户端身份 + # certificate/private-key: 成对内联 PEM 或文件路径 客户端身份 udp: true up: 0 # 裸数单位 Mbps;0 自动,也可 "125 KBps",b=bit/B=byte down: 0 @@ -280,7 +280,7 @@ proxies: # network 默认 tcp,支持 tcp/ws/grpc/http/h2。tls 默认 false;关闭时 # 禁止 servername/alpn/fingerprint/skip-cert-verify 等 TLS 专属字段。 # servername 优先显式值,其次 WS Host 去端口,否则 server。 - # TLS 支持 name-cert-verify 和成对的内联 PEM certificate/private-key; + # TLS 支持 name-cert-verify 和成对的内联 PEM 或文件路径 certificate/private-key; # 验证名不改变 servername,客户端身份不增加服务端信任根。 # ws-opts/grpc-opts 与下面 Trojan 的结构相同;HTTP/H2 选项见示例注释。 # UDP 默认 false;packet-encoding 默认为空(raw),或 xudp/packetaddr。 @@ -319,7 +319,7 @@ proxies: # Stop / 取消仍关闭全部会话及自有 driver,不更改 XHTTP 的全关闭约定。 # skip-cert-verify 默认 false;fingerprint 沿用 AnyTLS 的证书 SHA-256 # pin 语义,pin 与 skip 同时存在仍必须匹配 pin。udp 默认 false。 - # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 客户端身份。 + # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 或文件路径 客户端身份。 # UDP 在 TLS 流内传输;单包负载最多 8192 字节并与入口预算取较小值, # 不拆包或截断。域名地址保持域名交由服务端处理;Mihomo v1.19.31 的 # Trojan listener 不接收域名 UDP 帧,使用该对端时需要 IP 业务目标。 @@ -370,7 +370,7 @@ proxies: path: /onev mode: auto headers: {X-Transport: edge} - # 以下范围均为字符串 N 或 N-M。所有请求字段与合法配套见 docs/xhttp.md。 + # 范围用字符串 N 或 N-M;uplink-chunk-size/sc-max-each-post-bytes/sc-min-posts-interval-ms 也接受整数。 x-padding-bytes: "100-1000" sc-max-each-post-bytes: "1000000" # packet-up,最多16MiB,非预分配大小 sc-min-posts-interval-ms: "30" # packet-up,0–60000且最大值为正 @@ -396,8 +396,8 @@ proxies: fingerprint: "" # 清除继承的 pin client-fingerprint: "" # 清除继承的 ClientHello profile name-cert-verify: "" # 恢复下载认证名 - # certificate: # 与 private-key 成对;禁止文件路径 - # private-key: + # certificate: ./client.crt # 与 private-key 成对,或两者都使用内联 PEM + # private-key: ./client.key # reality-opts: {} # 清除继承的 REALITY;非空对象必须提供 public-key # jls-opts: {} # 清除继承的 JLS;非空对象必须提供 username/password smux: @@ -427,7 +427,7 @@ proxies: # SOCKS5 出站支持 CONNECT 和 UDP ASSOCIATE。username/password 必须同时省略 # 或同时出现;tls 默认 false,true 使用 server 作为 TLS 名称,不接受 sni/alpn/client-fingerprint。 - # TLS 支持 skip-cert-verify/name-cert-verify/fingerprint 和成对内联 PEM certificate/private-key。 + # TLS 支持 skip-cert-verify/name-cert-verify/fingerprint 和成对内联 PEM 或文件路径 certificate/private-key。 # TLS 只保护 TCP 控制连接,UDP relay 数据不经 TLS;udp 控制 UDP ASSOCIATE。 - name: socks-hop type: socks5 @@ -441,7 +441,7 @@ proxies: # sni 省略时使用 server。TLS 1.3/1.2 默认 WebPKI,alpn 省略/[] 时不发送。 # skip-cert-verify 不能覆盖 fingerprint;pin 是证书 SHA-256,不是客户端伪造指纹。 # 叶 pin 直接决定信任;非叶 pin 作为根继续验证链和名称。所有策略均验证握手签名。 - # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 客户端身份。 + # name-cert-verify 只改变证书验证名;certificate/private-key 为成对内联 PEM 或文件路径 客户端身份。 # 支持共享 client-fingerprint;不支持 REALITY、ECH 或 session/padding 调参字段,详见 outbounds.md#anytls。 # TCP 使用 AnyTLS session;udp: true 使用 UoT v2 datagram mode。 - name: anytls-edge @@ -500,14 +500,14 @@ proxies: # 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 # 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 # -# select 的 default-selected 省略时选中第一项;有效缓存选择优先。名称精确匹配。 +# select 的 default-selected 省略、空串或未命中时选中第一项;有效缓存选择优先。名称精确匹配。 # url-test 按 URL 探测延迟选择,tolerance(毫秒,默认 0)抑制小幅切换;fallback # 按声明顺序选择首个健康成员,恢复后可回到前面的成员。全部失败时返回首成员。 # 四种组均接受 url/interval/timeout/lazy/expected-status/max-failed-times。 # url 缺省使用首个 provider 的健康 URL,否则 https://www.gstatic.com/generate_204。 # interval 单位秒,自动组缺省/0 为 300;select 缺省 0,不主动周期探测。 # timeout 单位毫秒,缺省/0 为 5000;lazy 默认 true,无近期使用时跳过周期探测。 -# expected-status 省略/空/* 接受所有最终 HTTP 状态,可用 200/204 或 200-299。 +# expected-status 省略/空/* 接受所有最终 HTTP 状态;单值可写整数 204 或字符串 "204",区间用 "200-299"。 # max-failed-times 缺省/0 为 5,建链失败达到阈值后触发探测,不重试同一次业务连接。 # HTTP 探测结果不代表 UDP 可用性,业务 UDP 仍检查节点能力与 disable-udp。 # Controller 可手动固定 url-test/fallback 成员,成员不可用时恢复自动选择。 @@ -626,7 +626,6 @@ dns: geoip-code: CN ipcidr: [] # 任意返回地址命中时使用 fallback domain: [] # Clash 域名通配,匹配后只查询 fallback - geosite: [] # 共用 GeoData,匹配后只查询 fallback enhanced-mode: redir-host # 或 fake-ip;内部节点/直连解析始终取得真实地址 fake-ip-range: 198.18.0.1/16 fake-ip-range6: "" # 设置 IPv6 CIDR 且启用 IPv6 后分配 AAAA diff --git a/docs/controller-api.md b/docs/controller-api.md index adec049..70bd736 100644 --- a/docs/controller-api.md +++ b/docs/controller-api.md @@ -1,6 +1,6 @@ # 运行时 Controller API -Controller 是当前 Running Session 的回环 HTTP 接口。它提供业务连接管理、有效设置与规则查询、一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback`、`load-balance` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 +Controller 是当前 Running Session 的 HTTP 接口。它提供业务连接管理、有效设置与规则查询、一次性 TUN 流量快照,以及静态与动态 `select`、`url-test`、`fallback`、`load-balance` 代理组的状态查询和实时选择;它不是 Invoke API,也不承诺完整 Mihomo Dashboard 兼容。 ## 配置与生命周期 @@ -9,9 +9,10 @@ external-controller: 127.0.0.1:9090 secret: "vole-runtime-secret" ``` -- 省略或空串 `external-controller` 表示不启动 Controller,也不采集业务连接、规则命中和建链诊断,此时不能单独配置 `secret`。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 -- `external-controller` 只接受带显式非零端口的回环 IP 地址。静态节点、纯 DIRECT 及关闭业务入口的配置均可使用。 -- 只要同时显式配置代理组与 Controller,`secret` 就必填;没有显式代理组时可省略。`secret` 出现时必须为 1–255 UTF-8 字节,并保护本文定义的全部路由。 +- 省略或空串 `external-controller` 表示不启动 Controller,也不采集业务连接、规则命中和建链诊断;配置中的 `secret` 可保留。代理组选择、健康检查、资源更新与 TUN 原始流量统计独立运行。 +- `external-controller` 接受 IP、`localhost` 或空 host 与数值端口组成的地址,如 `127.0.0.1:9090`、`0.0.0.0:9090`、`[::]:9090`、`localhost:9090`、`:9090`。端口范围为 0–65535,0 由系统选择端口;`localhost` 使用 IPv4 回环。其他域名和命名服务端口不在当前范围内。静态节点、纯 DIRECT 及关闭业务入口的配置均可使用。 +- 空 host、`0.0.0.0` 和 `[::]` 均优先使用允许 IPv4 映射连接的双栈通配监听,仅地址族不可用时改用 IPv4。Controller 的地址族选择独立于顶层 `ipv6`。 +- `secret` 始终可选,省略或空串表示不要求认证;配置非空值时保护本文定义的全部路由。配置仅受整体 YAML 大小预算约束,HTTP 请求头仍限 32 KiB。 - `measureDelay` 的 node-only 配置不能包含 Controller 或代理组字段。 - `validateConfig` 只校验字段;`start` 绑定端口,绑定失败则启动失败。`stop` 和 `destroyInstance` 关闭监听器。 @@ -19,7 +20,7 @@ Controller 与公共运行时 session 同生共灭。`profile.store-selected` ## Bearer 鉴权 -配置 `secret` 后,每个请求都必须携带: +配置非空 `secret` 后,每个请求都必须携带: ```http Authorization: Bearer vole-runtime-secret @@ -30,8 +31,8 @@ Authorization: Bearer vole-runtime-secret - scheme 必须精确为 `Bearer`,token 必须与当前 session 配置完全一致; - 缺失、重复、格式错误或不匹配都返回 `401 Unauthorized`; - 比较过程不能通过日志、响应或明显的提前返回时序泄漏 token; -- 未配置 `secret` 时不要求 `Authorization`;Controller 不使用 HTTP Basic,顶层 `authentication` 只属于 HTTP/SOCKS5 业务代理。 -- `allow-lan` 只控制业务代理监听,不改变 Controller 的回环限制或 Bearer 鉴权。 +- 省略或空串 `secret` 时不要求 `Authorization`;Controller 不使用 HTTP Basic,顶层 `authentication` 只属于 HTTP/SOCKS5 业务代理。 +- `allow-lan` 只控制业务代理监听,不改变 Controller 的监听地址或 Bearer 鉴权。 ## `GET /traffic` @@ -223,7 +224,7 @@ Expire 为 Unix 秒。未知时省略该对象,缺失子项为 0;不以本 policy 与 fallback 仍生效。DNS 关闭或查询失败返回 500。这里只输出地址/CNAME 答案, 不提供其他 qtype、Authority/Additional 展开或后台 DNS 查询日志。 -这些接口复用 Controller 的回环限制和 Bearer 鉴权。目标与 DNS 路径只通过显式查询返回, +这些接口复用 Controller 的 Bearer 鉴权。目标与 DNS 路径只通过显式查询返回, 不会写入普通日志。`/connections` 是业务负载统计,`/traffic` 继续表示 TUN 原始 IP 包流量, `/proxies` 的节点健康数据继续表示探测结果,三者不可相互替代。 @@ -239,7 +240,6 @@ policy 与 fallback 仍生效。DNS 关闭或查询失败返回 500。这里只 | `204 No Content` | 选择、刷新或清理成功,正文为空 | | `400 Bad Request` | 路径、JSON、长度格式或成员无效 | | `401 Unauthorized` | Bearer 鉴权失败 | -| `403 Forbidden` | 请求来源不是回环地址 | | `404 Not Found` | 路由、组或当前 session 中的 TUN 流量资源不存在 | | `405 Method Not Allowed` | 路由存在但方法不允许 | | `408 Request Timeout` | header 或 body 读取超时 | @@ -252,6 +252,6 @@ policy 与 fallback 仍生效。DNS 关闭或查询失败返回 500。这里只 当前不提供配置修改、WebSocket 推送、Controller 版本协商或完整 Dashboard response。除本文列出的连接、规则、配置、DNS、主动探测、内存和 provider 路由、`GET /proxies`、`GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}` 外,其他路径和方法均不属于公共协议。 Windows WinRT VPN 的完整运行时和 Controller 位于独立 Session Host;桌面 Wintun 的 -Controller 位于普通业务运行时。宿主通过回环 HTTP 直接访问实际 Running Session; +Controller 位于普通业务运行时。宿主通过配置的 HTTP 地址直接访问实际 Running Session; 组查询和切换不经过 `VoleInvoke`、Windows bridge 或 Provider 控制管道, 不携带 `instanceId`,也不占用 Invoke 命令锁。 diff --git a/docs/inbounds.md b/docs/inbounds.md index 148cda8..4497b99 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -24,8 +24,8 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 非空接受多个 `user:password`,相同用户名以最后一项为准, 按第一个冒号拆分,两项各 1–255 UTF-8 字节,不 trim。null 不视为省略。 配置凭据后 HTTP/SOCKS5 均校验凭据;混合入口关闭时允许保留合法的共享和认证配置。 -- Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 仍独立回环监听, - 使用自己的 Bearer 认证,不随共享开关开放。 +- Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 按 external-controller 独立监听, + 使用自己的可选 Bearer 认证,不随共享开关改变地址或认证。 - 不设置全局业务连接/关联准入数。Stop 取消并等待全部连接、relay 和接收任务, 返回后端口可重新绑定。 - 接收的 TCP 连接启用 `TCP_NODELAY`,小包应答不等待 Nagle 聚合。 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index 53727c9..b2f4b9e 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -226,7 +226,7 @@ stopped -> preparing -> prepared -> starting -> running } ``` -无需 `initialize`,完成大小、YAML、结构、共享 route-target 命名空间、引用和字段组合校验。节点的 `dialer-proxy` 可指定 DIRECT 或引用节点、组;全部上游和组成员边(包括未选成员)统一做无环校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。 +无需 `initialize`,完成大小、YAML、结构、共享 route-target 命名空间、引用和字段组合校验。节点的 `dialer-proxy` 可指定 DIRECT 或引用节点、组;全部上游和组成员边(包括未选成员)统一做无环校验。不创建实例、不解析远端域名、不联网,也不读取 GeoData。资产缺失不影响纯配置校验。显式引用的客户端证书与密钥文件在校验时读取;相对路径使用已初始化的 dataDir,未初始化时使用当前工作目录。 ### `start` @@ -307,6 +307,7 @@ Stop 关闭本次持有的 fd、session 和原生句柄;Wintun 不承诺移除 配置路径分别从启动工作目录解析并折叠 `.`/`..`,绝对路径原样保留; 配置默认 `/config.yaml`。 `configPath: "-"` 从标准输入读取。文件必须是普通文件,文件和标准输入均最多 8 MiB; +证书/密钥相对路径从选定 dataDir 解析,validate 也读取身份文件但不初始化目录。 不会下载、创建模板或改写配置。详细命令行映射见 [CLI](cli.md)。 普通 Unicode 路径使用 JSON 字符串。需要保留平台原生编码时,Unix 可使用 @@ -424,7 +425,7 @@ ProtectFd(fd) -> bool ## Controller -配置 `external-controller` 后,运行时可提供回环 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的 `mixed-port` 配置中运行;此时 `/traffic` 不存在。只要 Controller 管理代理组,`secret` 就必填并保护全部路由。 +配置 `external-controller` 后,运行时可提供 HTTP `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 和 `PUT /proxies/{name}`。代理组 Controller 可以在非 TUN 的 `mixed-port` 配置中运行;此时 `/traffic` 不存在。`secret` 省略或空串时免认证;非空时保护全部路由,监听地址由配置决定。 静态成员来自配置,动态成员随 provider 成功更新;显式选择按 `profile.store-selected` 保存。成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool。自动组根据健康状态处理后续连接的选择与恢复;select 的连接失败按原样返回。已认证 Hysteria2 会话的后续逻辑流和跳端口 socket 保留初始上游选择;新的认证会话才读取新选择。Controller 查询不携带 `instanceId`,不进入 Invoke 命令锁;完整语义见 [Controller API](controller-api.md)。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 364179f..d7aa96a 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -2,7 +2,7 @@ 配置名称以 [Mihomo v1.19.32](https://github.com/MetaCubeX/mihomo/tree/88dcbf7f1614a67c3b36b848ee3592dfa92ada36) 为参考。 Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配置子集不等于完整替代 Mihomo。 -未知字段和不支持的组合明确拒绝,不静默修改输入。 +未知字段和不支持的组合明确拒绝,不静默修改输入。已废弃的 dns.fallback-filter.geosite 不接受,使用 nameserver-policy 的 geosite 选择器。 | 范围 | 当前支持 | | --- | --- | @@ -31,12 +31,12 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - 订阅元信息只在成功候选或合法 304 后发布,失败响应或坏候选保留旧值; 与 Mihomo 在读取响应头时更新的时点不同。数值仅接受有限且可表示的十进制数, 不保留非有限/溢出浮点转换的参考行为。 -- WireGuard 使用标准用户态单/多 peer、最长前缀选择、reserved、节点远端 DNS 与按需端点更新; +- WireGuard 使用标准用户态单/多 peer、最长前缀选择、reserved(数组或 Base64)、节点远端 DNS 与按需端点更新; 每个本地地址族最多一个地址,MTU 至少1280。无 peers 时顶层 allowed-ips 与参考一致地忽略。 peer 仅在实际使用时解析/建链;Mihomo 在节点初始化时解析所有 peer。 - 节点 UDP 能力按 [出站契约](outbounds.md) 声明;rule 模式跳过不支持 UDP 的目标,继续匹配后续规则。 global 模式和显式 DNS 出口不执行此跳过。负载均衡组按其声明能力参与规则匹配,实际选中成员仍须支持 UDP。 -- 未启用 mixed-port 或 TUN 时仍可运行 Controller 和资源任务。Controller 沿用回环地址、secret 准入,见 [Controller API](controller-api.md)。 +- 未启用 mixed-port 或 TUN 时仍可运行 Controller 和资源任务。Controller 接受通配 IP、localhost 和空 host;secret 可选,见 [Controller API](controller-api.md)。 /dns/query 查询真实上游;连接表按 UDP 目标流管理,memory 返回当前进程 resident bytes,平台口径不同。 - fake-IP TTL 最小一秒;映射生命周期独立于 TTL。本地选择和映射存储不保证与 Mihomo 数据库互读。 - Apple、Android、Windows UWP 的网络设置、权限和 App 生命周期仍归宿主;Windows Wintun 使用外置 DLL。 diff --git a/docs/outbounds.md b/docs/outbounds.md index 5868859..0dd1306 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -35,7 +35,7 @@ VLESS/Vision/Encryption/JLS/ECH 见 [VLESS](vless.md),XHTTP/sing-mux 见[专 `type: http` 提供 TCP CONNECT;`tls: true` 在代理链路使用共享 TLS 与证书策略。 支持 `username/password`、`headers`、`sni`、`skip-cert-verify`、`name-cert-verify`、 -`fingerprint` 及成对的内联 `certificate/private-key`。用户名和密码均非空时发送 Basic 认证。 +`fingerprint` 及成对的 `certificate/private-key`(PEM 或文件路径)。用户名和密码均非空时发送 Basic 认证。 HTTP CONNECT 本身不提供 UDP,也不接受 `udp` 字段。公共 smux 可承载 UDP。 握手沿用原期限,失败关闭, 成功后保留响应头后的业务字节。启用此协议需要 `outbound-http` feature。 @@ -46,7 +46,7 @@ HTTP CONNECT 本身不提供 UDP,也不接受 `udp` 字段。公共 smux 可 TCP 保留域名交由上游解析;UDP 由控制连接拥有,校验 relay 来源并保留实际回包目标。 上游为 select 组时,控制连接和后续 UDP 数据报使用同次建链快照。 `tls: true` 使用共享 TLS、`skip-cert-verify`、`name-cert-verify`、`fingerprint` 和 -成对的内联 `certificate/private-key`。TLS 名称固定使用 server;不接受 sni、alpn +成对的 `certificate/private-key`(PEM 或文件路径)。TLS 名称固定使用 server;不接受 sni、alpn 或 ClientHello 模板。TLS 保护 CONNECT / UDP ASSOCIATE 控制连接,普通 UDP relay 数据不经过 TLS;预算独立于 TUN MTU。TLS 关闭时不能配置证书字段。 @@ -54,7 +54,7 @@ TCP 保留域名交由上游解析;UDP 由控制连接拥有,校验 relay 支持 v2、兼容 v1 的会话、内置填充与 UoT v2。password 为 1–1024 UTF-8 字节; SNI 默认 server;ALPN 省略/空列表不发送,非空保持顺序,不强制选中某协议。 -支持独立的 `name-cert-verify` 和成对内联 PEM `certificate/private-key` 客户端身份; +支持独立的 `name-cert-verify` 和成对内联 PEM 或文件路径 `certificate/private-key` 客户端身份; 不开放 TLS 版本、ECH、REALITY、填充和会话参数。命名指纹沿用共享 TLS。 连接先发送 SHA-256 密码摘要、认证填充和首个会话批次。Settings 固定声明 v=2 和 @@ -87,7 +87,7 @@ begin_shutdown 同步禁止新流、清空空闲并取消所有会话;shutdown SNI 默认 server;WS Host 只覆盖 HTTP authority,不作为 SNI 回退。ALPN 默认 TCP 空、 WS http/1.1、gRPC h2,显式列表及实际协商均须满足传输,不能静默降级。 -`name-cert-verify` 只覆盖证书验证名;成对的内联 PEM `certificate/private-key` +`name-cert-verify` 只覆盖证书验证名;成对的内联 PEM 或文件路径 `certificate/private-key` 用于客户端 mTLS 身份,共用标准 TLS 的验证、指纹与恢复策略。 WS path 默认 /,可带 query;头字段不得覆盖握手保留头或大小写重复。early-data @@ -125,7 +125,7 @@ none 只关闭 body 加密,仍验证响应身份;不能启用 global-padding network 省略/空串为 tcp,另支持 ws/grpc/http/h2;均可明文或标准 TLS。tls 默认 false, 关闭时禁止 TLS 字段,即便空串、空列表或 false。认证名依次为 servername、WS Host 去端口、server;HTTP 伪装 Host/H2 authority 不替代认证名。WS 需 http/1.1,gRPC/H2 需 h2。 -TLS 支持独立的 `name-cert-verify` 和成对内联 PEM `certificate/private-key` 客户端身份。 +TLS 支持独立的 `name-cert-verify` 和成对内联 PEM 或文件路径 `certificate/private-key` 客户端身份。 - WS ED/路径/头及 HTTPUpgrade 普通/fast-open 约束同 Trojan。 - gRPC 选项、默认值和节点连接池与 Trojan/VLESS 共用,每条逻辑流独立生成 VMess AEAD 认证;关闭逻辑流不关闭物理连接或兄弟流。 @@ -160,7 +160,7 @@ gRPC 池与 legacy H2 driver 由节点拥有并同步退出,无全局会话额 始终 QUIC/TLS 1.3,使用官方 rustls;不接受 tls/network/client-fingerprint/REALITY。 port 为 1–65535,仅提供 ports 时可省略;显式非法 port 仍拒绝。password 默认空、 保留原值,须为最长 8192 字节的合法 HTTP 头值。SNI 默认 server;ALPN 省略/空为 h3。 -支持共享证书 pin/skip、独立 `name-cert-verify` 和成对内联 PEM mTLS; +支持共享证书 pin/skip、独立 `name-cert-verify` 和成对内联 PEM 或文件路径 mTLS; 验证名不改变 SNI。udp 只控制业务 UDP,不关闭 QUIC 载体。 - up/down 独立非负整数或十进制整数字符串,裸值 Mbit/s;单位 [KMGT]?[bB]ps 使用 @@ -334,7 +334,7 @@ private-key 必填,ip/ipv6 至少提供一个本地地址;地址接受 IP 发送目的地址与解密包的来源地址使用同一不可变最长前缀表;相同前缀最后声明的 peer 优先。同一公钥的重复声明更新端点,保留未覆盖的 PSK,并追加允许前缀。 -reserved 缺省为空(全零),否则必须为三个字节。peer 缺省继承顶层值;发送时写入 +reserved 缺省为空(全零),否则接受三字节数组或解码后恰好三字节的标准 Base64 字符串。peer 缺省继承顶层值;发送时写入 WireGuard 消息头的保留字节,接收时清零后验证 Noise。MTU 省略/0 为 1408,范围 1280–65503;外层实际双向 UDP 预算必须至少为 MTU+32,不足时关闭上游,不静默缩小。 udp 缺省 false,只控制业务 UDP;外层始终需要双向数据报。persistent-keepalive 为 diff --git a/docs/tls-client-fingerprint.md b/docs/tls-client-fingerprint.md index a3dbd38..37c7d34 100644 --- a/docs/tls-client-fingerprint.md +++ b/docs/tls-client-fingerprint.md @@ -46,12 +46,17 @@ client-fingerprint: chrome - 命名指纹的标准 TLS,以及所有 REALITY/JLS,使用自有 boring fork;开发及 PR/发布的来源规则见 [TLS 依赖](tls-dependencies.md)。 - 普通 TLS 两条路径共用同一个 WebPKI 证书验证器和发布信任根;叶 pin、非叶信任锚、 独立验证名、skip 优先级不变。TLS 握手签名仍由各自后端强制验证。 -- HTTP、SOCKS5、AnyTLS、Trojan、VMess、VLESS 与 Hysteria2 的 mTLS 使用配对内联 PEM, - 密钥匹配在使用 IO 前检查;客户端证书不增加服务端信任根,profile 不修改身份。 +- HTTP、SOCKS5、AnyTLS、Trojan、VMess、VLESS 与 Hysteria2 的 mTLS 使用配对内联 PEM 或文件路径, + 配置校验与资源生成时读取文件并检查密钥匹配;客户端证书不增加服务端信任根,profile 不修改身份。 - REALITY 仅允许自身临时证书认证,不能混用 pin/skip/mTLS 或降级到普通站点证书。 - [JLS](vless.md#jls) 使用共享凭据身份和完整原生 TLS1.3,不启用恢复或 PKI 回退;profile 不改变身份。 - 连接器只包装 Dialer/上游交付的 IO,不解析 DNS、不创建 socket、不绕过平台 protect。 +证书与密钥的相对路径从初始化的 `dataDir` 解析;CLI 校验模式使用 `-d` 的目录而不初始化它。 +未初始化的普通 `validateConfig` 和直接 Rust `Config::parse_yaml` 使用当前工作目录,绝对路径原样使用。 +每个资源生成过程读取的身份文件合计最多 8 MiB,只接受普通 UTF-8 PEM 文件;错误不回显路径或内容。 +载入后的身份不可变,不在 TLS 连接时读取或自动监测文件变化。 + ## 四模板边界 模板由 fork 固定密码套件、组/share、签名算法、扩展排列及受限扩展编码。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index e64ad71..7eb74e4 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -166,8 +166,8 @@ Apple 使用宿主快照和 `IP_BOUND_IF`/`IPV6_BOUND_IF`;App 负责取得真 ## Fallback `fallback` 只作用于主 resolver 的 A/AAAA。先应用 nameserver-policy;命中的 policy -独立查询自己的上游,不再进入 fallback。否则,`fallback-filter.domain` 的 Clash 通配或 -`geosite` 命中时直接查询 fallback,不启动主组。 +独立查询自己的上游,不再进入 fallback。否则,`fallback-filter.domain` 的 Clash 通配 +命中时直接查询 fallback,不启动主组。GeoSite 分流使用 `nameserver-policy` 配置。 其余查询先取主组结果。主组失败、没有地址,或任一地址命中 `ipcidr` / GeoIP 条件时 使用 fallback 的结果。`geoip` 默认 true、`geoip-code` 默认 CN:非本地且不属于该分类的 diff --git a/docs/vless.md b/docs/vless.md index b84f094..62f15ca 100644 --- a/docs/vless.md +++ b/docs/vless.md @@ -5,10 +5,10 @@ ## 配置与传输 -`type: vless`,`server`、非零 `port` 和标准格式 `uuid` 必填。`udp` 默认 false;`network` 默认 `tcp`,`tls` 默认 false。`encryption` 省略、空或 `none` 关闭该层;其他值见下文。`flow` 省略、空或 `xtls-rprx-vision`。未知字段、null、错型或不匹配的传输选项失败,不隐式迁移配置。 +`type: vless`,`server`、非零 `port` 和标准格式 `uuid` 必填。`udp` 默认 false;`network` 默认 `tcp`,`tls` 默认 false。`encryption` 省略、空或 `none` 关闭该层;其他值见下文。`flow` 省略、null 或空串时关闭,也接受 `xtls-rprx-vision`。其余不接受 null 的字段、未知字段、错型或不匹配的传输选项失败,不隐式迁移配置。 - TCP/WS/gRPC/HTTP 首包伪装/legacy H2 可明文或标准 TLS。TLS 支持 1.2/1.3,TCP 默认不发送 ALPN,WS 默认 `[http/1.1]`,gRPC/H2 默认 `[h2]`。有序 ALPN 保留且必须包含传输要求值;标准 TLS 检查协商结果。`servername` 优先显式值、WS Host 去端口、server;不会改变拨号地址。TLS 关闭时禁止安全选项。 -- 标准 TLS 支持 `skip-cert-verify`、SHA-256 `fingerprint`、独立 `name-cert-verify`;沿用共享证书验证器的叶 pin / 非叶信任锚优先级。`certificate` / `private-key` 是配对的内联 PEM,解析或密钥不匹配在 IO 前失败,禁止文件路径。REALITY 不能混用这些标准证书策略。 +- 标准 TLS 支持 `skip-cert-verify`、SHA-256 `fingerprint`、独立 `name-cert-verify`;沿用共享证书验证器的叶 pin / 非叶信任锚优先级。`certificate` / `private-key` 接受配对的内联 PEM 或文件路径,相对路径从数据目录解析,解析或密钥不匹配在网络 IO 前失败。REALITY 不能混用这些标准证书策略。 - `client-fingerprint` 使用 [TLS 指纹](tls-client-fingerprint.md)的七值/四模板,适用于 TCP 上的标准 TLS/REALITY(含 Vision 和 XHTTP H1/H2),与证书 pin 独立;`none` / 空串关闭,H3 拒绝已启用的 profile。 - WS 的 ED 支持默认头、自定义头或路径(上限 2048 字节)。`v2ray-http-upgrade` 开启升级后裸流;`v2ray-http-upgrade-fast-open` 必须与前者一起启用,只允许默认 ED 头。fast-open 提前发协议首包但仍等待合法 101,拒绝不返回连接成功。 - gRPC 与 Trojan/VMess 共用配置:省略或空 `grpc-service-name` 默认 `GunService`,普通名映射 `/name/Tun`,`/` 开头使用完整路径;省略或空 `grpc-user-agent` 默认 `grpc-go/1.36.0`。`ping-interval` 为带符号秒数,0 不主动 PING,负值明确拒绝;三项池阈值接受带符号整数,负阈值禁用相应分支。空闲连接总是复用;max-connections>0 时按连接上限/min-streams 扩容,否则按 max-streams 复用阈值扩容。三项字面全零归一 max-connections=1;正 max-connections 优先于 max-streams。阈值不是硬流数上限。物理连接归节点所有,关闭逻辑流不会杀死兄弟流;复用不重选上游组。 diff --git a/docs/windows-vpn.md b/docs/windows-vpn.md index fef002c..df60384 100644 --- a/docs/windows-vpn.md +++ b/docs/windows-vpn.md @@ -339,10 +339,10 @@ Stopped { version, packetCounters } ## Controller 与本地 SOCKS -- Controller 由 Session Host 监听完全信任的回环地址; +- Controller 由 Session Host 按 YAML 的 `external-controller` 地址监听; - `RuntimeReady` 前必须完成绑定,失败则连接失败; - 前台宿主持有配置中的端口和 secret,并调用 `GET /traffic`、`GET /group`、`GET /group/{name}`、`GET /proxies/{name}` 或 `PUT /proxies/{name}`; -- 配置代理组与 Controller 时 secret 必填并保护全部路由;仅有 TUN 流量 Controller 时 secret 仍可省略; +- `secret` 可省略或为空;配置非空 `secret` 时保护全部路由,与是否配置代理组无关; - 代理组选择属于 Session Host 中实际 Running Session 的内存状态,不经过 Windows bridge、Provider 控制管道或 Session Snapshot 写回; - 成功切换只影响之后新建的物理 TCP、UDP 和 DNS transport,不迁移既有连接、UDP association、DNS 状态或 TCP pool,也不执行自动 failover; - 宿主如需跨 VPN session 保留选择,必须自行持久化,并在下次 `startVpn` 的 YAML 中注入对应 `default-selected`; diff --git a/docs/xhttp.md b/docs/xhttp.md index 9c39fa7..4338178 100644 --- a/docs/xhttp.md +++ b/docs/xhttp.md @@ -13,7 +13,7 @@ H1/H2 可使用明文、标准 TLS 1.3、REALITY(经典默认、显式混合 ## 请求字段 -区间字段均为字符串 `"N"` 或 `"N-M"`;空串或纯空白使用字段默认,数字及区间两端允许外围空白和正号。不接受数字类型、负数、倒置区间或溢出。每次采样保持在闭区间内。 +区间字段接受字符串 `"N"` 或 `"N-M"`;`uplink-chunk-size`、`sc-max-each-post-bytes`、`sc-min-posts-interval-ms` 还接受 YAML 整数,使用相同的范围与配套校验。空串或纯空白使用字段默认,字符串中的数字及区间两端允许外围空白和正号。其他区间字段不接受数字类型;所有字段均拒绝 null、浮点数、布尔值、负数、倒置区间或溢出。每次采样保持在闭区间内。 | 字段 | 默认及约束 | | --- | --- | diff --git a/src/config/fallback.rs b/src/config/fallback.rs index 67ca942..029ec42 100644 --- a/src/config/fallback.rs +++ b/src/config/fallback.rs @@ -16,7 +16,6 @@ pub(super) struct RawFallbackFilter { geoip_code: String, ipcidr: Vec, domain: Vec, - geosite: Vec, } impl Default for RawFallbackFilter { fn default() -> Self { @@ -25,7 +24,6 @@ impl Default for RawFallbackFilter { geoip_code: "CN".into(), ipcidr: Vec::new(), domain: Vec::new(), - geosite: Vec::new(), } } } @@ -43,11 +41,6 @@ impl RawFallbackFilter { } domains.push(selector); } - for code in self.geosite { - domains.push(DnsPolicySelector::GeoSite( - vec![normalize_geo_code(&code, "fallback-filter.geosite")?].into_boxed_slice(), - )); - } Ok(DnsFallbackConfig { nameservers, geoip: self diff --git a/src/config/groups.rs b/src/config/groups.rs index dd67cb7..6a191b7 100644 --- a/src/config/groups.rs +++ b/src/config/groups.rs @@ -41,6 +41,7 @@ pub struct HealthCheckConfig { pub interval: u64, pub timeout: u64, pub lazy: bool, + #[serde(deserialize_with = "super::scalar::string_or_integer")] pub expected_status: String, } impl Default for HealthCheckConfig { diff --git a/src/config/identity_files.rs b/src/config/identity_files.rs new file mode 100644 index 0000000..d211d17 --- /dev/null +++ b/src/config/identity_files.rs @@ -0,0 +1,115 @@ +use std::{fs::OpenOptions, io::Read, path::Path}; + +use serde_yaml_ng::{Mapping, Value}; + +use super::MAX_CONFIG_BYTES; +use crate::{Result, VoleError}; + +/// Resolve identities before normalization, keeping file I/O out of TLS clients. +/// One resource generation has a shared, bounded file-content budget. +pub(super) struct IdentityFiles<'a> { + directory: Option<&'a Path>, + remaining: usize, +} + +impl<'a> IdentityFiles<'a> { + pub(super) fn new(directory: Option<&'a Path>) -> Self { + Self { + directory, + remaining: MAX_CONFIG_BYTES, + } + } + + pub(super) fn inline_node(&mut self, node: &mut Value) -> Result<()> { + let Some(map) = node.as_mapping_mut() else { + return Ok(()); // The strict node parser reports the invalid shape. + }; + if !matches!( + map.get("type").and_then(Value::as_str), + Some("http" | "socks5" | "anytls" | "trojan" | "vmess" | "vless" | "hysteria2") + ) { + return Ok(()); + } + self.inline_pair(map)?; + if let Some(download) = map + .get_mut("xhttp-opts") + .and_then(Value::as_mapping_mut) + .and_then(|xhttp| xhttp.get_mut("download-settings")) + .and_then(Value::as_mapping_mut) + { + self.inline_pair(download)?; + } + Ok(()) + } + + fn inline_pair(&mut self, map: &mut Mapping) -> Result<()> { + let (Some(certificate), Some(private_key)) = ( + map.get("certificate").and_then(Value::as_str), + map.get("private-key").and_then(Value::as_str), + ) else { + return Ok(()); // Pair presence and types remain strict-schema checks. + }; + if certificate.is_empty() || private_key.is_empty() { + return Ok(()); + } + if crate::security::TlsClientIdentity::from_pem(certificate, private_key).is_ok() { + return Ok(()); + } + let certificate = self.read(certificate)?; + let private_key = self.read(private_key)?; + crate::security::TlsClientIdentity::from_pem(&certificate, &private_key) + .map_err(|_| invalid_identity())?; + map.insert("certificate".into(), certificate.into()); + map.insert("private-key".into(), private_key.into()); + Ok(()) + } + + fn read(&mut self, value: &str) -> Result { + let path = Path::new(value); + let path = self + .directory + .filter(|_| path.is_relative()) + .map_or_else(|| path.to_path_buf(), |directory| directory.join(path)); + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NONBLOCK); + } + let file = options.open(path).map_err(|_| invalid_identity())?; + if !file.metadata().map_err(|_| invalid_identity())?.is_file() { + return Err(invalid_identity()); + } + let mut content = String::new(); + file.take(self.remaining as u64 + 1) + .read_to_string(&mut content) + .map_err(|_| invalid_identity())?; + if content.len() > self.remaining { + return Err(invalid_identity()); + } + self.remaining -= content.len(); + Ok(content) + } +} + +fn invalid_identity() -> VoleError { + VoleError::InvalidConfig("invalid TLS client identity file".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_budget_is_shared_and_rejects_non_regular_files() { + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("first"), "ab").unwrap(); + std::fs::write(directory.path().join("second"), "cd").unwrap(); + let mut files = IdentityFiles::new(Some(directory.path())); + files.remaining = 3; + assert_eq!(files.read("first").unwrap(), "ab"); + assert!(files.read("second").is_err()); + assert!(files.read(".").is_err()); + } +} diff --git a/src/config/measure.rs b/src/config/measure.rs index ae703e2..5f8a2f7 100644 --- a/src/config/measure.rs +++ b/src/config/measure.rs @@ -23,7 +23,23 @@ struct RawMeasureConfig { impl MeasureConfig { pub(crate) fn parse_yaml(input: &[u8]) -> Result { - let yaml = super::yaml::parse(input)?; + Self::parse_yaml_with_directory(input, None) + } + + pub(crate) fn parse_yaml_with_directory( + input: &[u8], + directory: Option<&std::path::Path>, + ) -> Result { + let mut yaml = super::yaml::parse(input)?; + if let Some(nodes) = yaml + .get_mut("proxies") + .and_then(serde_yaml_ng::Value::as_sequence_mut) + { + let mut identities = super::identity_files::IdentityFiles::new(directory); + for node in nodes { + identities.inline_node(node)?; + } + } let raw: RawMeasureConfig = serde_yaml_ng::from_value(yaml) .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; raw.normalize() diff --git a/src/config/mod.rs b/src/config/mod.rs index fb67717..231a4af 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -35,10 +35,12 @@ use uuid::Uuid; use crate::{Result, VoleError}; mod dns; +mod identity_files; mod name_regex; pub mod providers; mod rule_provider; pub(crate) mod rules; +mod scalar; pub use rule_provider::RuleCollection; mod yaml; use rules::parse_rule; @@ -90,7 +92,6 @@ pub const MAX_RULE_BYTES: usize = 16 * 1024; pub const MAX_RULES_TOTAL_BYTES: usize = MAX_CONFIG_BYTES; pub const MAX_SNIFFER_PORT_ITEMS: usize = 64; pub const MAX_GEOX_URL_BYTES: usize = 4_096; -pub const MAX_CONTROLLER_SECRET_BYTES: usize = 255; pub const MAX_ANYTLS_PASSWORD_BYTES: usize = 1_024; pub const GEO_UPDATE_INTERVAL_HOURS: u64 = 24; @@ -1142,7 +1143,11 @@ struct RawProxyGroup { timeout: u64, #[serde(default = "default_true")] lazy: bool, - #[serde(rename = "expected-status", default)] + #[serde( + rename = "expected-status", + default, + deserialize_with = "scalar::string_or_integer" + )] expected_status: String, #[serde(default)] tolerance: u16, @@ -1748,19 +1753,19 @@ struct RawXHttpSettings { #[serde( rename = "uplink-chunk-size", default, - deserialize_with = "deserialize_present_option" + deserialize_with = "scalar::optional_string_or_integer" )] uplink_chunk_size: Option, #[serde( rename = "sc-max-each-post-bytes", default, - deserialize_with = "deserialize_present_option" + deserialize_with = "scalar::optional_string_or_integer" )] sc_max_each_post_bytes: Option, #[serde( rename = "sc-min-posts-interval-ms", default, - deserialize_with = "deserialize_present_option" + deserialize_with = "scalar::optional_string_or_integer" )] sc_min_posts_interval_ms: Option, #[serde( @@ -1992,8 +1997,17 @@ where impl Config { pub fn parse_yaml(input: &[u8]) -> Result { + Self::parse_yaml_with_directory(input, None) + } + + pub(crate) fn parse_yaml_with_directory( + input: &[u8], + directory: Option<&std::path::Path>, + ) -> Result { let yaml = yaml::parse(input)?; - let template = std::sync::Arc::new(providers::ProviderTemplate::extract(yaml)?); + let mut template = providers::ProviderTemplate::extract(yaml)?; + template.identity_directory = directory.map(std::path::Path::to_path_buf); + let template = std::sync::Arc::new(template); template.materialize(&providers::ProviderContents::new()) } #[cfg(any(feature = "invoke", test))] @@ -2072,7 +2086,6 @@ impl RawVoleConfig { RawSnifferConfig::normalize, )?; - let has_explicit_groups = !self.proxy_groups.is_empty(); let mut raw_groups = self.proxy_groups; let generated_global = !raw_groups.iter().any(|group| group.name == "GLOBAL"); if generated_global { @@ -2114,11 +2127,8 @@ impl RawVoleConfig { let route_targets = collect_route_targets(&proxy_ids, &proxy_group_ids); let proxy_groups = normalize_proxy_groups(raw_proxy_groups, &route_targets)?; proxy_graph_order(&proxies, &proxy_groups)?; - let external_controller = normalize_external_controller( - self.external_controller, - self.secret, - has_explicit_groups, - )?; + let external_controller = + normalize_external_controller(self.external_controller, self.secret)?; let mut dns = self .dns @@ -2185,36 +2195,37 @@ impl RawVoleConfig { fn normalize_external_controller( listen: Option, secret: Option, - has_proxy_groups: bool, ) -> Result> { let Some(listen) = listen.filter(|listen| !listen.is_empty()) else { - if secret.is_some() { - return invalid("secret requires external-controller"); - } return Ok(None); }; - if has_proxy_groups && secret.is_none() { - return invalid("secret is required when external-controller manages proxy-groups"); - } - let listen = listen.parse::().map_err(|_| { - VoleError::InvalidConfig( - "external-controller must be an IP socket address with an explicit port".to_owned(), - ) - })?; - if !listen.ip().is_loopback() { - return invalid("external-controller must listen on a loopback address"); - } - if listen.port() == 0 { - return invalid("external-controller port must be between 1 and 65535"); - } - if let Some(secret) = &secret - && !(1..=MAX_CONTROLLER_SECRET_BYTES).contains(&secret.len()) - { - return invalid(format!( - "secret must contain between 1 and {MAX_CONTROLLER_SECRET_BYTES} UTF-8 bytes" - )); - } - Ok(Some(ExternalControllerConfig { listen, secret })) + // Mihomo's tcp preResolve accepts literal IPs, localhost and an empty host + // without consulting DNS. + let listen = if let Ok(address) = listen.parse::() { + address + } else { + let address = listen + .strip_prefix("localhost:") + .map(|port| (IpAddr::V4(Ipv4Addr::LOCALHOST), port)) + .or_else(|| { + listen + .strip_prefix(':') + .map(|port| (IpAddr::V6(Ipv6Addr::UNSPECIFIED), port)) + }); + let Some((host, port)) = address else { + return invalid( + "external-controller requires an IP, localhost or empty host and a numeric port", + ); + }; + let port = port.parse::().map_err(|_| { + VoleError::InvalidConfig("external-controller port must be between 0 and 65535".into()) + })?; + SocketAddr::new(host, port) + }; + Ok(Some(ExternalControllerConfig { + listen, + secret: secret.filter(|secret| !secret.is_empty()), + })) } fn normalize_geodata_update( @@ -2586,18 +2597,10 @@ fn normalize_proxy_groups( Ok(ProxyGroupMemberConfig { name, target }) }) .collect::>>()?; - let initial_member = match group.default_selected { - Some(default) => members - .iter() - .position(|member| member.name == default) - .ok_or_else(|| { - VoleError::InvalidConfig(format!( - "proxy group `{}` default-selected `{default}` must name a direct member", - group.name - )) - })?, - None => 0, - }; + let initial_member = group + .default_selected + .and_then(|default| members.iter().position(|member| member.name == default)) + .unwrap_or(0); let health = HealthCheckConfig { enable: true, url: if group.url.is_empty() { "https://www.gstatic.com/generate_204".into() } else { group.url }, @@ -4701,6 +4704,28 @@ tun: assert_eq!(controller.listen, "[::1]:19091".parse().unwrap()); assert!(controller.secret.is_none()); + for (address, expected) in [ + ("0.0.0.0:9090", "0.0.0.0:9090"), + ("[::]:9090", "[::]:9090"), + ("192.0.2.1:9090", "192.0.2.1:9090"), + ("127.0.0.1:0", "127.0.0.1:0"), + ("localhost:0", "127.0.0.1:0"), + (":0", "[::]:0"), + ] { + let fields = format!("external-controller: '{address}'\nsecret: ''"); + let config = Config::parse_yaml(current_yaml(&fields).as_bytes()).unwrap(); + let controller = config.external_controller.unwrap(); + assert_eq!(controller.listen, expected.parse().unwrap()); + assert!(controller.secret.is_none()); + } + let secret = "s".repeat(crate::inbound::DEFAULT_HEADER_LIMIT + 1); + let fields = format!("external-controller: '127.0.0.1:0'\nsecret: '{secret}'"); + let config = Config::parse_yaml(current_yaml(&fields).as_bytes()).unwrap(); + assert_eq!( + config.external_controller.unwrap().secret.as_deref(), + Some(secret.as_str()) + ); + let debug = format!( "{:?}", ExternalControllerConfig { @@ -4720,47 +4745,30 @@ tun: .unwrap(); assert!(config.external_controller.is_some()); assert!(config.proxies.is_empty()); - let disabled = - Config::parse_yaml(b"external-controller: \"\"\nrules: [\"MATCH,DIRECT\"]\n").unwrap(); - assert!(disabled.external_controller.is_none()); + for controller in ["", "external-controller: \"\"\n"] { + let yaml = format!("{controller}secret: token\nrules: ['MATCH,DIRECT']\n"); + let disabled = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert!(disabled.external_controller.is_none()); + } } #[test] - fn rejects_unsafe_or_misplaced_traffic_controller_config() { - for (fields, expected) in [ - ( - "mixed-port: 1080\nauthentication:\n - measure:secret\nsecret: token", - "secret requires external-controller", - ), - ( - "external-controller: \"0.0.0.0:19090\"\ntun:\n enable: true", - "loopback", - ), - ( - "external-controller: \"127.0.0.1:0\"\ntun:\n enable: true", - "between 1 and 65535", - ), - ( - "external-controller: localhost:19090\ntun:\n enable: true", - "IP socket address", - ), - ( - "external-controller: \"127.0.0.1:19090\"\nsecret: \"\"\ntun:\n enable: true", - "between 1 and 255", - ), + fn rejects_malformed_traffic_controller_addresses() { + for address in [ + "127.0.0.1", + "127.0.0.1:65536", + "localhost:not-a-port", + "controller.example:9090", + "::1:9090", ] { - let error = Config::parse_yaml(current_yaml(fields).as_bytes()).unwrap_err(); + let fields = format!("external-controller: '{address}'"); + let error = Config::parse_yaml(current_yaml(&fields).as_bytes()).unwrap_err(); assert!( - error.to_string().contains(expected), - "unexpected error for {fields:?}: {error}" + error.to_string().contains("external-controller"), + "unexpected error for {address:?}: {error}" ); } - let oversized = current_yaml(&format!( - "external-controller: \"127.0.0.1:19090\"\nsecret: \"{}\"\ntun:\n enable: true", - "s".repeat(MAX_CONTROLLER_SECRET_BYTES + 1) - )); - assert!(Config::parse_yaml(oversized.as_bytes()).is_err()); assert!( Config::parse_yaml( current_yaml("external-controller: null\ntun:\n enable: true").as_bytes() @@ -5937,10 +5945,6 @@ rules: proxies: []"#, r#" - name: 主线路 type: select - proxies: [proxy] - default-selected: missing"#, - r#" - name: 主线路 - type: select proxies: [missing]"#, r#" - name: 主线路 type: select @@ -6028,8 +6032,8 @@ rules: assert!(Config::parse_yaml(controller.as_bytes()).is_ok()); let missing_secret = controller.replace("secret: token\n", ""); - let error = Config::parse_yaml(missing_secret.as_bytes()).unwrap_err(); - assert!(error.to_string().contains("secret is required"), "{error}"); + let config = Config::parse_yaml(missing_secret.as_bytes()).unwrap(); + assert!(config.external_controller.unwrap().secret.is_none()); } #[test] @@ -6461,6 +6465,17 @@ authentication: ); } + #[test] + fn rejects_deprecated_fallback_filter_geosite() { + for enable in [false, true] { + let yaml = format!( + "rules: ['MATCH,DIRECT']\ndns:\n enable: {enable}\n fallback-filter:\n geosite: []\n" + ); + let error = Config::parse_yaml(yaml.as_bytes()).unwrap_err(); + assert!(error.to_string().contains("geosite"), "{error}"); + } + } + #[test] fn current_config_parses_ordered_geosite_nameserver_policies() { let yaml = current_yaml( diff --git a/src/config/providers.rs b/src/config/providers.rs index dc8c782..02219c9 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -165,6 +165,7 @@ impl ProviderSpec { #[derive(Clone, PartialEq, Eq)] pub struct ProviderTemplate { base: Value, + pub(super) identity_directory: Option, pub proxies: Vec<(String, ProviderSpec)>, pub rules: Vec<(String, ProviderSpec)>, } @@ -218,6 +219,7 @@ impl ProviderTemplate { let rules = take("rule-providers", true)?; Ok(Self { base, + identity_directory: None, proxies, rules, }) @@ -412,6 +414,12 @@ impl ProviderTemplate { } } } + let mut identities = + super::identity_files::IdentityFiles::new(self.identity_directory.as_deref()); + for node in &mut proxies { + budget.check()?; + identities.inline_node(node)?; + } base.insert("proxies".into(), Value::Sequence(proxies)); let raw: RawVoleConfig = serde_yaml_ng::from_value(value) .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; diff --git a/src/config/scalar.rs b/src/config/scalar.rs new file mode 100644 index 0000000..b854951 --- /dev/null +++ b/src/config/scalar.rs @@ -0,0 +1,109 @@ +//! String-backed numeric fields retain their normalizer's syntax and bounds. +use serde::{Deserializer, de::Visitor}; + +pub(super) fn string_or_integer<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result { + struct Scalar; + + impl Visitor<'_> for Scalar { + type Value = String; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a string or integer") + } + + fn visit_str(self, value: &str) -> Result { + Ok(value.to_owned()) + } + + fn visit_string(self, value: String) -> Result { + Ok(value) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(value.to_string()) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(value.to_string()) + } + } + + deserializer.deserialize_any(Scalar) +} + +pub(super) fn optional_string_or_integer<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + string_or_integer(deserializer).map(Some) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::config::Config; + + fn health_document(status: Value) -> Vec { + serde_json::to_vec(&json!({ + "mixed-port": 1080, + "proxy-groups": [{ + "name": "pool", "type": "select", "proxies": ["DIRECT"], + "expected-status": status + }], + "proxy-providers": {"local": { + "type": "inline", "payload": [], + "health-check": {"expected-status": status} + }} + })) + .unwrap() + } + + #[test] + fn health_status_accepts_integer_and_string_for_groups_and_providers() { + for value in [0, 204, 65535] { + let numeric = Config::parse_yaml(&health_document(json!(value))).unwrap(); + let quoted = Config::parse_yaml(&health_document(json!(value.to_string()))).unwrap(); + assert_eq!( + numeric.proxy_groups[0].health, + quoted.proxy_groups[0].health + ); + assert_eq!( + numeric.providers.as_ref().unwrap().proxies[0] + .1 + .health_check, + quoted.providers.as_ref().unwrap().proxies[0].1.health_check + ); + assert_eq!( + numeric.proxy_groups[0].health.expected_status, + value.to_string() + ); + } + let ranged = Config::parse_yaml(&health_document(json!("200-299/304"))).unwrap(); + assert_eq!(ranged.proxy_groups[0].health.expected_status, "200-299/304"); + } + + #[test] + fn health_status_retains_type_and_status_bounds_in_both_locations() { + for value in [ + json!(null), + json!(true), + json!(204.0), + json!(-1), + json!(65536), + ] { + for provider in [false, true] { + let mut document: Value = + serde_json::from_slice(&health_document(json!(204))).unwrap(); + let field = if provider { + &mut document["proxy-providers"]["local"]["health-check"]["expected-status"] + } else { + &mut document["proxy-groups"][0]["expected-status"] + }; + *field = value.clone(); + assert!(Config::parse_yaml(&serde_json::to_vec(&document).unwrap()).is_err()); + } + } + } +} diff --git a/src/config/tls_policy_tests.rs b/src/config/tls_policy_tests.rs index 6cc0a51..f8d691a 100644 --- a/src/config/tls_policy_tests.rs +++ b/src/config/tls_policy_tests.rs @@ -43,6 +43,159 @@ fn policy(config: &Config) -> &TlsClientPolicy { } } +fn client_identity(config: &Config) -> &TlsIdentityPem { + let identity = match &config.proxies[0].protocol { + ProxyProtocol::AnyTls(config) => &config.tls.identity, + ProxyProtocol::Trojan(config) => &config.tls.identity, + ProxyProtocol::Vmess(config) => &config.tls.as_ref().unwrap().identity, + ProxyProtocol::Socks5(config) => &config.tls.as_ref().unwrap().identity, + ProxyProtocol::Http(config) => &config.tls.as_ref().unwrap().identity, + ProxyProtocol::Hysteria2(config) => &config.tls.identity, + ProxyProtocol::Vless(config) => match &config.security { + SecurityConfig::Tls(tls) => &tls.identity, + _ => unreachable!(), + }, + _ => unreachable!(), + }; + identity.as_ref().unwrap() +} + +#[test] +fn common_tls_identity_files_use_the_configuration_directory_and_inline_validation() { + let directory = tempfile::tempdir().unwrap(); + let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); + let certificate = pem("CERTIFICATE", identity.cert.der()); + let private_key = pem("PRIVATE KEY", &identity.signing_key.serialize_der()); + std::fs::write(directory.path().join("client.crt"), &certificate).unwrap(); + std::fs::write(directory.path().join("client.key"), &private_key).unwrap(); + + for protocol in [ + "socks5", + "anytls", + "trojan", + "vmess", + #[cfg(feature = "outbound-http")] + "http", + #[cfg(feature = "outbound-vless")] + "vless", + #[cfg(feature = "outbound-hysteria2")] + "hysteria2", + ] { + let mut node = node(protocol); + node["certificate"] = "./client.crt".into(); + node["private-key"] = "./client.key".into(); + let yaml = serde_yaml_ng::to_string(&serde_json::json!({ + "mixed-port": 1080, "proxies": [node], + })) + .unwrap(); + let config = + Config::parse_yaml_with_directory(yaml.as_bytes(), Some(directory.path())).unwrap(); + let loaded = client_identity(&config); + assert_eq!(loaded.certificate, certificate); + assert_eq!(loaded.private_key, private_key); + assert!(!format!("{config:?}").contains("PRIVATE KEY")); + crate::security::TlsClientIdentity::from_pem(&loaded.certificate, &loaded.private_key) + .unwrap(); + } + + let mut absolute = node("anytls"); + absolute["certificate"] = directory.path().join("client.crt").to_str().unwrap().into(); + absolute["private-key"] = directory.path().join("client.key").to_str().unwrap().into(); + assert_eq!( + client_identity(&parse(absolute).unwrap()).certificate, + certificate + ); + + let mut missing = node("anytls"); + missing["certificate"] = directory + .path() + .join("missing.crt") + .to_str() + .unwrap() + .into(); + missing["private-key"] = directory.path().join("client.key").to_str().unwrap().into(); + let error = parse(missing).unwrap_err().to_string(); + assert!(!error.contains("missing.crt") && !error.contains("PRIVATE KEY")); + + std::fs::write(directory.path().join("client.crt"), "").unwrap(); + std::fs::write(directory.path().join("client.key"), "").unwrap(); + let mut empty_files = node("anytls"); + empty_files["certificate"] = "client.crt".into(); + empty_files["private-key"] = "client.key".into(); + let yaml = serde_json::to_vec(&serde_json::json!({"proxies": [empty_files]})).unwrap(); + let error = Config::parse_yaml_with_directory(&yaml, Some(directory.path())) + .unwrap_err() + .to_string(); + assert!(!error.contains("client.crt") && !error.contains("client.key")); +} + +#[test] +#[cfg(all(feature = "outbound-http", feature = "outbound-vless"))] +fn provider_and_xhttp_download_identity_files_share_the_data_directory() { + let directory = tempfile::tempdir().unwrap(); + let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); + let certificate = pem("CERTIFICATE", identity.cert.der()); + std::fs::write(directory.path().join("client.crt"), &certificate).unwrap(); + std::fs::write( + directory.path().join("client.key"), + pem("PRIVATE KEY", &identity.signing_key.serialize_der()), + ) + .unwrap(); + let mut proxy = node("http"); + proxy["certificate"] = "client.crt".into(); + proxy["private-key"] = "client.key".into(); + let yaml = serde_yaml_ng::to_string(&serde_json::json!({ + "mixed-port": 1080, + "proxy-providers": {"local": {"type": "inline", "payload": [proxy]}}, + "proxy-groups": [{"name": "selected", "type": "select", "use": ["local"]}], + })) + .unwrap(); + let config = + Config::parse_yaml_with_directory(yaml.as_bytes(), Some(directory.path())).unwrap(); + assert_eq!(client_identity(&config).certificate, certificate); + + let other = rcgen::generate_simple_self_signed(vec!["download.invalid".into()]).unwrap(); + let other_certificate = pem("CERTIFICATE", other.cert.der()); + let other_key = pem("PRIVATE KEY", &other.signing_key.serialize_der()); + std::fs::write( + directory.path().join("download-----BEGIN.crt"), + &other_certificate, + ) + .unwrap(); + std::fs::write(directory.path().join("download.key"), &other_key).unwrap(); + let mut proxy = node("vless"); + proxy["network"] = "xhttp".into(); + proxy["certificate"] = "client.crt".into(); + proxy["private-key"] = "client.key".into(); + proxy["xhttp-opts"] = serde_json::json!({"download-settings": { + "certificate": "download-----BEGIN.crt", "private-key": "download.key", + }}); + let yaml = serde_json::to_vec(&serde_json::json!({"proxies": [proxy]})).unwrap(); + let xhttp = Config::parse_yaml_with_directory(&yaml, Some(directory.path())).unwrap(); + assert_eq!(client_identity(&xhttp).certificate, certificate); + let ProxyProtocol::Vless(proxy) = &xhttp.proxies[0].protocol else { + unreachable!() + }; + let SecurityConfig::Tls(download) = &proxy.download().unwrap().security else { + unreachable!() + }; + assert_eq!( + download.identity.as_ref().unwrap().certificate, + other_certificate + ); + + std::fs::write(directory.path().join("client.crt"), &other_certificate).unwrap(); + std::fs::write(directory.path().join("client.key"), &other_key).unwrap(); + let next = config + .providers + .as_ref() + .unwrap() + .materialize(&providers::ProviderContents::new()) + .unwrap(); + assert_eq!(client_identity(&next).certificate, other_certificate); + assert_eq!(client_identity(&config).certificate, certificate); +} + #[test] fn common_tls_policy_keeps_verified_identity_separate_from_server_name() { let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); diff --git a/src/config/vless.rs b/src/config/vless.rs index ec27ddc..d36678b 100644 --- a/src/config/vless.rs +++ b/src/config/vless.rs @@ -47,7 +47,7 @@ pub(super) struct RawVless { #[serde(default)] encryption: String, #[serde(default)] - flow: String, + flow: Option, #[serde(default, deserialize_with = "deserialize_present_option")] servername: Option, #[serde(default, deserialize_with = "deserialize_present_option")] @@ -165,11 +165,12 @@ impl RawVless { .as_deref() .filter(|network| !network.is_empty()) .unwrap_or("tcp"); - let vision = self.flow == "xtls-rprx-vision"; + let flow = self.flow.unwrap_or_default(); + let vision = flow == "xtls-rprx-vision"; if self.jls_opts.is_some() && (vision || self.reality_opts.is_some()) { return invalid("JLS cannot be combined with REALITY or Vision"); } - if (!self.flow.is_empty() && !vision) + if (!flow.is_empty() && !vision) || (vision && (network != "tcp" || (!self.tls && matches!(&encryption, VlessEncryption::None)) @@ -356,7 +357,7 @@ impl RawVless { port: self.port, id, encryption, - flow: self.flow, + flow, security, transport, packet_encoding: self.packet_encoding, diff --git a/src/config/wireguard.rs b/src/config/wireguard.rs index 558f726..71a9f11 100644 --- a/src/config/wireguard.rs +++ b/src/config/wireguard.rs @@ -1,7 +1,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use base64::{Engine as _, engine::general_purpose::STANDARD}; -use serde::Deserialize; +use serde::{Deserialize, de::Visitor}; use zeroize::Zeroizing; use super::{ @@ -78,7 +78,7 @@ struct RawPeer { public_key: String, #[serde(default)] pre_shared_key: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_reserved")] reserved: Vec, #[serde(default)] allowed_ips: Vec, @@ -100,7 +100,7 @@ pub(super) struct RawWireGuard { public_key: String, #[serde(default)] pre_shared_key: String, - #[serde(default)] + #[serde(default, deserialize_with = "deserialize_reserved")] reserved: Vec, #[serde(default)] allowed_ips: Vec, @@ -132,6 +132,70 @@ pub(super) fn deserialize<'de, D: serde::Deserializer<'de>>( RawWireGuard::deserialize(d) .map_err(|_| serde::de::Error::custom("invalid WireGuard configuration fields")) } + +fn deserialize_reserved<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + struct Reserved; + + impl<'de> Visitor<'de> for Reserved { + type Value = Vec; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("three bytes as a byte array or standard Base64") + } + + fn visit_str(self, value: &str) -> std::result::Result, E> { + let error = || E::custom("WireGuard reserved requires three bytes"); + let mut encoded = [0; 4]; + let mut length = 0; + // Go's standard Base64 decoder ignores CR and LF. + for byte in value.bytes().filter(|byte| !matches!(*byte, b'\r' | b'\n')) { + if length == encoded.len() { + return Err(error()); + } + encoded[length] = byte; + length += 1; + } + if length == 0 { + return Ok(Vec::new()); + } + let mut decoded = [0; 3]; + if STANDARD + .decode_slice(&encoded[..length], &mut decoded) + .map_err(|_| error())? + != decoded.len() + { + return Err(error()); + } + Ok(decoded.to_vec()) + } + + fn visit_seq>( + self, + mut sequence: A, + ) -> std::result::Result, A::Error> { + let mut bytes = Vec::with_capacity(3); + while let Some(byte) = sequence.next_element::()? { + if bytes.len() == 3 { + return Err(serde::de::Error::custom( + "WireGuard reserved requires three bytes", + )); + } + bytes.push(byte); + } + if !bytes.is_empty() && bytes.len() != 3 { + return Err(serde::de::Error::custom( + "WireGuard reserved requires three bytes", + )); + } + Ok(bytes) + } + } + + deserializer.deserialize_any(Reserved) +} + fn reserved(value: Vec, default: [u8; 3]) -> Result<[u8; 3]> { if value.is_empty() { Ok(default) @@ -356,3 +420,78 @@ impl WireGuardOutboundConfig { Ok(()) } } + +#[cfg(all(test, feature = "outbound-wireguard"))] +mod tests { + use serde_json::{Value, json}; + + use super::*; + use crate::config::Config; + + fn document(global: Value, peer: Option) -> Vec { + let mut node = json!({ + "name": "wg", "type": "wireguard", "server": "192.0.2.1", "port": 51820, + "ip": "10.0.0.2", "private-key": STANDARD.encode([1; 32]), + "public-key": STANDARD.encode([2; 32]), "reserved": global + }); + if let Some(reserved) = peer { + node["peers"] = json!([{ + "server": "192.0.2.1", "port": 51820, "public-key": STANDARD.encode([2; 32]), + "allowed-ips": ["0.0.0.0/0"], "reserved": reserved + }]); + } + serde_json::to_vec(&json!({"mixed-port": 1080, "proxies": [node]})).unwrap() + } + + #[test] + fn reserved_base64_and_bytes_match_and_empty_peer_inherits() { + for peer in [None, Some(json!([])), Some(json!([0, 1, 255]))] { + let base64_peer = peer.as_ref().map(|value| { + if value.as_array().unwrap().is_empty() { + json!("") + } else { + json!("AAH/") + } + }); + let bytes = Config::parse_yaml(&document(json!([83, 128, 39]), peer)).unwrap(); + let encoded = Config::parse_yaml(&document(json!("U4An"), base64_peer)).unwrap(); + assert_eq!(bytes.proxies[0].protocol, encoded.proxies[0].protocol); + } + let inherited = Config::parse_yaml(&document(json!("U4An"), Some(json!("")))).unwrap(); + let ProxyProtocol::WireGuard(config) = &inherited.proxies[0].protocol else { + panic!("expected WireGuard"); + }; + assert_eq!(config.peers[0].reserved, [83, 128, 39]); + let zeros = Config::parse_yaml(&document(json!("U4An"), Some(json!("AAAA")))).unwrap(); + let ProxyProtocol::WireGuard(config) = &zeros.proxies[0].protocol else { + panic!("expected WireGuard"); + }; + assert_eq!(config.peers[0].reserved, [0; 3]); + let default = Config::parse_yaml(&document(json!(""), None)).unwrap(); + let bytes = Config::parse_yaml(&document(json!([]), None)).unwrap(); + assert_eq!(default.proxies[0].protocol, bytes.proxies[0].protocol); + assert!(Config::parse_yaml(&document(json!("U4\r\nAn"), None)).is_ok()); + } + + #[test] + fn reserved_rejects_invalid_base64_lengths_and_byte_values_at_both_levels() { + for value in [ + json!("AA=="), + json!("AAE="), + json!("AAECAw=="), + json!("%%%"), + json!("____"), + json!([1]), + json!([1, 2, 3, 4]), + json!([0, 1, 256]), + json!([-1, 1, 2]), + json!([0, 1, 2.0]), + json!(null), + json!(true), + json!(42), + ] { + assert!(Config::parse_yaml(&document(value.clone(), None)).is_err()); + assert!(Config::parse_yaml(&document(json!([0, 0, 0]), Some(value))).is_err()); + } + } +} diff --git a/src/controller.rs b/src/controller.rs index a7b20d0..53d366b 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -1,6 +1,7 @@ use std::{io, net::SocketAddr, sync::Arc, time::Duration}; use serde::{Deserialize, Serialize}; +use socket2::{Domain, Protocol, Socket, Type}; use tokio::{ io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}, @@ -10,7 +11,7 @@ use tokio::{ use tokio_util::sync::CancellationToken; use crate::{ - config::{ExternalControllerConfig, MAX_CONTROLLER_SECRET_BYTES, validate_route_target_name}, + config::{ExternalControllerConfig, validate_route_target_name}, routing::{ProxyGroupError, ProxyGroupState, ProxyGroups}, traffic::TunTrafficStats, }; @@ -46,7 +47,7 @@ impl RuntimeController { proxy_groups: Arc, ) -> io::Result { Ok(Self { - listener: TcpListener::bind(config.listen).await?, + listener: bind_controller(config)?, secret: config.secret.as_deref().map(Arc::from), traffic, proxy_groups, @@ -83,7 +84,7 @@ impl RuntimeController { } } accepted = self.listener.accept(), if tasks.len() < MAX_CONTROLLER_CONNECTIONS => { - let (stream, peer) = accepted?; + let (stream, _) = accepted?; let secret = self.secret.clone(); let traffic = self.traffic.clone(); let proxy_groups = self.proxy_groups.clone(); @@ -92,7 +93,6 @@ impl RuntimeController { tasks.spawn(crate::resources::observation::task(async move { if let Err(error) = handle_connection( stream, - peer, secret, traffic, proxy_groups, @@ -116,19 +116,49 @@ impl RuntimeController { } } +fn bind_controller(config: &ExternalControllerConfig) -> io::Result { + let listen = if config.listen.ip().is_unspecified() { + SocketAddr::from(([0u16; 8], config.listen.port())) + } else { + config.listen + }; + match bind_controller_tcp(listen) { + Err(error) + if config.listen.ip().is_unspecified() + && crate::inbound::listen::address_family_unavailable(&error) => + { + bind_controller_tcp(SocketAddr::from(([0, 0, 0, 0], config.listen.port()))) + } + result => result, + } +} + +fn bind_controller_tcp(address: SocketAddr) -> io::Result { + let socket = Socket::new( + Domain::for_address(address), + Type::STREAM, + Some(Protocol::TCP), + )?; + if address.is_ipv6() { + // Go's tcp listener allows IPv4-mapped peers on an IPv6 wildcard. + socket.set_only_v6(false)?; + } + #[cfg(unix)] + socket.set_reuse_address(true)?; + socket.set_nonblocking(true)?; + socket.bind(&address.into())?; + socket.listen(128)?; + TcpListener::from_std(socket.into()) +} + async fn handle_connection( mut stream: TcpStream, - peer: SocketAddr, secret: Option>, traffic: Option>, proxy_groups: Arc, resources: Option>, cancellation: CancellationToken, ) -> io::Result<()> { - if !peer.ip().is_loopback() { - return write_error(&mut stream, 403, "Forbidden", &[]).await; - } - let request = tokio::select! { biased; () = cancellation.cancelled() => return Ok(()), @@ -654,8 +684,12 @@ fn authorized(headers: &[(String, String)], secret: Option<&str>) -> bool { } fn controller_token_eq(candidate: &[u8], secret: &[u8]) -> bool { + let limit = crate::inbound::DEFAULT_HEADER_LIMIT; + if candidate.len() > limit || secret.len() > limit { + return false; + } let mut difference = candidate.len() ^ secret.len(); - for index in 0..MAX_CONTROLLER_SECRET_BYTES { + for index in 0..limit { let candidate = candidate.get(index).copied().unwrap_or_default(); let secret = secret.get(index).copied().unwrap_or_default(); difference |= usize::from(candidate ^ secret); @@ -835,7 +869,6 @@ async fn write_response( 204 => "No Content", 400 => "Bad Request", 401 => "Unauthorized", - 403 => "Forbidden", 404 => "Not Found", 405 => "Method Not Allowed", 408 => "Request Timeout", @@ -978,8 +1011,16 @@ mod tests { )); assert!(!controller_token_eq(b"toke", b"token")); assert!(!controller_token_eq(b"token-longer", b"token")); - let longest = vec![b'x'; MAX_CONTROLLER_SECRET_BYTES]; - assert!(controller_token_eq(&longest, &longest)); + let long_secret = "x".repeat(512); + assert!(authorized( + &[("Authorization".to_owned(), format!("Bearer {long_secret}"))], + Some(&long_secret), + )); + let mut different = long_secret.as_bytes().to_vec(); + *different.last_mut().unwrap() = b'y'; + assert!(!controller_token_eq(&different, long_secret.as_bytes())); + let oversized = vec![b'x'; crate::inbound::DEFAULT_HEADER_LIMIT + 1]; + assert!(!controller_token_eq(&oversized, &oversized)); } #[test] diff --git a/src/invoke/foreground.rs b/src/invoke/foreground.rs index a21ee72..552d645 100644 --- a/src/invoke/foreground.rs +++ b/src/invoke/foreground.rs @@ -173,7 +173,7 @@ pub(super) fn execute(payload: ForegroundPayload) -> Result Result) -> Result<(), InvokeFailure> { +fn validate_bytes(bytes: Vec, directory: &Path) -> Result<(), InvokeFailure> { let yaml = String::from_utf8(bytes).map_err(|_| failure("configuration is not UTF-8"))?; - super::validate_config(yaml).map_err(|_| failure("invalid configuration")) + let config = super::parse_config_yaml_with_directory(&yaml, Some(directory)) + .map_err(|_| failure("invalid configuration"))?; + crate::runtime::PreparedCore::validate_config(config) + .map_err(|_| failure("invalid configuration")) } struct ForegroundInstance(Option>); impl ForegroundInstance { @@ -804,6 +807,42 @@ mod tests { assert_eq!(execute(payload).unwrap()["output"], "configuration valid\n"); assert!(!data.exists()); } + #[test] + #[cfg(feature = "outbound-http")] + fn validation_resolves_identity_files_from_data_dir_without_initializing_it() { + use base64::Engine as _; + let pem = |kind: &str, der: &[u8]| { + format!( + "-----BEGIN {kind}-----\n{}\n-----END {kind}-----\n", + base64::engine::general_purpose::STANDARD.encode(der) + ) + }; + let directory = tempfile::tempdir().unwrap(); + let data = directory.path().join("state"); + fs::create_dir(&data).unwrap(); + let identity = rcgen::generate_simple_self_signed(vec!["client.invalid".into()]).unwrap(); + fs::write( + data.join("client.crt"), + pem("CERTIFICATE", identity.cert.der()), + ) + .unwrap(); + fs::write( + data.join("client.key"), + pem("PRIVATE KEY", &identity.signing_key.serialize_der()), + ) + .unwrap(); + let config = directory.path().join("config.yaml"); + fs::write(&config, "proxies:\n - name: edge\n type: http\n server: proxy.invalid\n port: 443\n tls: true\n certificate: client.crt\n private-key: client.key\n").unwrap(); + let result = execute(ForegroundPayload { + action: ForegroundAction::Validate, + data_dir: Some(data.clone().into()), + config_path: Some(config.into()), + }) + .unwrap(); + assert_eq!(result["output"], "configuration valid\n"); + assert!(!data.join("geodata").exists() && !data.join("configs").exists()); + } + #[test] fn validation_failures_do_not_echo_config_or_initialize_data() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/invoke/measure_delay.rs b/src/invoke/measure_delay.rs index b8544c5..ca14adf 100644 --- a/src/invoke/measure_delay.rs +++ b/src/invoke/measure_delay.rs @@ -188,7 +188,14 @@ async fn measure_one( target: &Target, timeout: Duration, ) -> Result { - let config = MeasureConfig::parse_yaml(config_yaml.as_bytes()).map_err(InvokeFailure::from)?; + let directory = super::registry().data_directory().ok(); + let config = match directory { + Some(directory) => { + MeasureConfig::parse_yaml_with_directory(config_yaml.as_bytes(), Some(directory.root())) + } + None => MeasureConfig::parse_yaml(config_yaml.as_bytes()), + } + .map_err(InvokeFailure::from)?; drop(config_yaml); let prepared = PreparedMeasurement::prepare_config(config, &SystemResolver, ResourceLimits::default()) diff --git a/src/invoke/mod.rs b/src/invoke/mod.rs index 4f45fdd..2a5f80c 100644 --- a/src/invoke/mod.rs +++ b/src/invoke/mod.rs @@ -1240,10 +1240,19 @@ fn validate_config(config_yaml: String) -> Result<(), InvokeFailure> { } fn parse_config_yaml(config_yaml: &str) -> Result { + let directory = registry().data_directory().ok(); + parse_config_yaml_with_directory(config_yaml, directory.as_ref().map(|dir| dir.root())) +} + +fn parse_config_yaml_with_directory( + config_yaml: &str, + directory: Option<&Path>, +) -> Result { if config_yaml.is_empty() { return Err(InvokeFailure::invalid_request("configYaml is empty")); } - Config::parse_yaml(config_yaml.as_bytes()).map_err(InvokeFailure::from) + Config::parse_yaml_with_directory(config_yaml.as_bytes(), directory) + .map_err(InvokeFailure::from) } fn geodata_status_data(status: GeoDataStatus) -> Value { diff --git a/tests/proxy_group_compat.rs b/tests/proxy_group_compat.rs index f3e4ffc..049af4a 100644 --- a/tests/proxy_group_compat.rs +++ b/tests/proxy_group_compat.rs @@ -72,10 +72,42 @@ rules: #[test] fn validates_group_selection_and_health_defaults() { - let yaml = format!( - "{COMMON_PREFIX}proxy-groups:\n - name: primary\n type: select\n proxies: [node-a]\n default-selected: missing\nrules: ['MATCH,primary']\n" - ); - assert!(Config::parse_yaml(yaml.as_bytes()).is_err()); + let providers = r#" +proxy-providers: + nodes: + type: inline + payload: + - {name: provider-a, type: socks5, server: 192.0.2.3, port: 1080} + - {name: provider-b, type: socks5, server: 192.0.2.4, port: 1080} +"#; + for (members, first, second, provider_config) in [ + ("proxies: [node-a, node-b]", "node-a", "node-b", ""), + ("use: [nodes]", "provider-a", "provider-b", providers), + ] { + for (selection, expected) in [ + (String::new(), 0), + (" default-selected: \"\"\n".to_owned(), 0), + (" default-selected: missing\n".to_owned(), 0), + (format!(" default-selected: {second}\n"), 1), + ] { + let yaml = format!( + "{COMMON_PREFIX}{provider_config}proxy-groups:\n - name: primary\n type: select\n {members}\n{selection}rules: ['MATCH,primary']\n" + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + let group = &config.proxy_groups[0]; + assert_eq!(group.initial_member, expected, "{yaml}"); + assert_eq!( + group.members[expected].name, + if expected == 0 { first } else { second } + ); + } + let yaml = format!( + "{COMMON_PREFIX}{provider_config}proxy-groups:\n - name: primary\n type: select\n {members}\n default-selected: {first}\n exclude-filter: '^{first}$'\nrules: ['MATCH,primary']\n" + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!(config.proxy_groups[0].initial_member, 0); + assert_eq!(config.proxy_groups[0].members[0].name, second); + } for (kind, url, expected) in [ ( "select", diff --git a/tests/vless_config.rs b/tests/vless_config.rs index 2b7215b..d87e696 100644 --- a/tests/vless_config.rs +++ b/tests/vless_config.rs @@ -87,6 +87,45 @@ fn default_tcp_and_three_udp_encodings_are_accepted_without_io() { } } +#[test] +fn omitted_null_and_empty_flow_use_plain_grpc() { + use vole::config::{ProxyProtocol, StreamTransport, VlessTransport}; + let mut documents = [json!({}), json!({"flow": null}), json!({"flow": ""})] + .into_iter() + .map(|mut fields| { + fields["network"] = json!("grpc"); + fields["grpc-opts"] = json!({"grpc-service-name": "edge"}); + document(fields) + }) + .collect::>(); + documents.push( + br#"mixed-port: 1080 +proxies: + - name: edge + type: vless + server: example.com + port: 443 + uuid: 07070707-0707-0707-0707-070707070707 + flow: + network: grpc + grpc-opts: {grpc-service-name: edge} +rules: ['MATCH,edge'] +"# + .to_vec(), + ); + for yaml in documents { + let config = Config::parse_yaml(&yaml).unwrap(); + let ProxyProtocol::Vless(node) = &config.proxies[0].protocol else { + unreachable!() + }; + assert!(node.flow.is_empty()); + assert!(matches!( + node.transport, + VlessTransport::Stream(StreamTransport::Grpc { .. }) + )); + } +} + #[test] fn tcp_and_existing_xhttp_reject_mismatched_and_future_options() { #[cfg(feature = "interop-test")] @@ -102,6 +141,7 @@ fn tcp_and_existing_xhttp_reject_mismatched_and_future_options() { json!({"packet-encoding":""}), json!({"packet-encoding":"raw"}), json!({"flow":"invalid-flow"}), + json!({"flow":false}), json!({"encryption":"auto"}), json!({"future":true}), ] { @@ -115,7 +155,6 @@ fn tcp_and_existing_xhttp_reject_mismatched_and_future_options() { "network", "tls", "udp", - "flow", "encryption", "packet-encoding", "servername", diff --git a/tests/xhttp_config.rs b/tests/xhttp_config.rs index bba219a..d1b2d3e 100644 --- a/tests/xhttp_config.rs +++ b/tests/xhttp_config.rs @@ -85,7 +85,7 @@ fn xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io() { "download-settings", ]; for field in fields { - for value in [Value::Null, json!([]), json!(42)] { + for value in [Value::Null, json!([]), json!(42.5)] { assert!( Config::parse_yaml(&document(json!({field:value}))).is_err(), "accepted invalid type for {field}: {value}" @@ -204,6 +204,57 @@ fn xhttp_fields_reject_wrong_types_null_and_every_range_boundary_before_io() { } } +#[test] +fn xhttp_numeric_upload_fields_match_strings_and_retain_bounds() { + let numeric = json!({ + "uplink-data-placement": "header", "uplink-data-key": "data", + "uplink-chunk-size": 1024, + "sc-max-each-post-bytes": 4096, + "sc-min-posts-interval-ms": 1 + }); + let fields = [ + "uplink-chunk-size", + "sc-max-each-post-bytes", + "sc-min-posts-interval-ms", + ]; + let mut quoted = numeric.clone(); + for field in fields { + quoted[field] = json!(quoted[field].as_u64().unwrap().to_string()); + } + let numeric_config = Config::parse_yaml(&document(numeric.clone())).unwrap(); + let quoted_config = Config::parse_yaml(&document(quoted)).unwrap(); + assert_eq!( + numeric_config.proxies[0].protocol, + quoted_config.proxies[0].protocol + ); + + for (field, low, high) in [ + ("uplink-chunk-size", 64, 8192), + ("sc-max-each-post-bytes", 1, 16 * 1024 * 1024), + ("sc-min-posts-interval-ms", 1, 60000), + ] { + for value in [low, high] { + let mut options = numeric.clone(); + options[field] = json!(value); + assert!(Config::parse_yaml(&document(options)).is_ok()); + } + for value in [ + json!(low - 1), + json!(high + 1), + json!(-1), + json!(u64::MAX), + json!(true), + ] { + let mut options = numeric.clone(); + options[field] = value; + assert!(Config::parse_yaml(&document(options)).is_err()); + } + } + let mut adaptive = numeric; + adaptive["uplink-chunk-size"] = json!(0); + assert!(Config::parse_yaml(&document(adaptive)).is_ok()); +} + #[test] fn xhttp_empty_mode_path_and_download_name_use_each_legs_defaults() { let config = Config::parse_yaml(&document(json!({"mode":"", "path":""}))).unwrap(); From 6b6214c5de56a3ebb27657c9a14908845b8ca10c Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 20:56:39 +0800 Subject: [PATCH 047/445] feat: align mixed authentication and LAN hosts --- Cargo.lock | 1 + Cargo.toml | 1 + ...t-proxy-groups-as-session-route-targets.md | 2 +- docs/config.yaml | 12 +- docs/inbounds.md | 6 +- docs/mihomo-compatibility.md | 2 +- docs/tun-icmp-dns.md | 6 +- src/config/credentials.rs | 62 ++++++-- src/config/dns.rs | 7 +- src/config/mod.rs | 123 +++++++++------- src/dns/hosts.rs | 135 +++++++++++++++++- src/dns/runtime.rs | 5 + src/inbound/http/server.rs | 8 +- src/runtime.rs | 1 + src/runtime/providers.rs | 3 + tests/README.md | 2 + 16 files changed, 291 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6111ee6..6c966c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2965,6 +2965,7 @@ dependencies = [ "fancy-regex", "foreign-types", "futures-util", + "getifaddrs", "h2", "h3", "h3-quinn", diff --git a/Cargo.toml b/Cargo.toml index 69c8d7e..a74274d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,7 @@ bytes = "1" crc32fast = { version = "1.5.2", optional = true } fancy-regex = { version = "=0.19.2", default-features = false, features = ["std", "unicode"] } futures-util = { version = "0.3", features = ["sink"] } +getifaddrs = "=0.6.2" h2 = "0.4.19" h3 = { version = "=0.0.8", default-features = false, optional = true } h3-quinn = { version = "=0.0.10", default-features = false, optional = true } diff --git a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md index 8c90f99..7af4674 100644 --- a/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md +++ b/docs/adr/0005-model-select-proxy-groups-as-session-route-targets.md @@ -4,7 +4,7 @@ status: accepted # ADR 0005:将 select 代理组建模为会话路由与上游目标 -`select` 是普通规则、`MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;`default-selected` 精确匹配直接成员,省略、空串或未命中时选择第一项。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 +`select` 是普通规则、`MATCH`、DNS 与 `dialer-proxy` 共享的命名目标。组拥有一个有序、非空的直接成员列表,成员可以是代理节点、嵌套 `select` 组、`DIRECT` 或 `REJECT`;`default-selected` 精确匹配直接成员,省略、null、空串或未命中时选择第一项。节点上游和全部组成员边统一为无环图,包括未选成员;独立 `measureDelay` 仍只接受具体节点链。 `mode` 支持 rule、global、direct;rule 模式允许空规则和任意位置的 MATCH,未命中直连。global 使用 GLOBAL 组;未显式声明时自动生成,成员顺序为 DIRECT、REJECT、全部节点、全部声明组,默认 DIRECT。该组与普通 select 共用运行时选择和 Controller 接口。 diff --git a/docs/config.yaml b/docs/config.yaml index 797c8c1..329dfa1 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -57,15 +57,15 @@ geo-update-interval: 24 # 构建必须同时包含 inbound-http 和 inbound-socks5;缺少任一 feature 则拒绝启用入口。 # allow-lan 默认 false,绑定回环;true 时绑定通配地址,与 authentication 独立。 # ipv6: true 时同时绑定同端口 IPv6(v6-only);false 时只监听 IPv4。 -# authentication 省略或 [] 表示任何绑定地址均免认证;非空支持多个 user:password, -# 按首个冒号分隔,用户名 / 密码各 1–255 UTF-8 字节,HTTP/SOCKS5 共用。 +# authentication 省略、null 或 [] 表示任何绑定地址均免认证;非空支持多个 user:password, +# 按首个冒号分隔,不 trim,允许空用户名或密码。HTTP 受 32 KiB 总头预算约束;SOCKS5 各字段最多 255 字节。 # mixed-port 关闭时可以保留合法的 allow-lan 和 authentication,供之后启用入口使用。 # HTTP 逐请求认证、选路,支持流式正文 / 分块、Keep-Alive、CONNECT 与 Upgrade。 # 共享只适合可信网络,Basic 不加密链路;详细边界见 docs/inbounds.md#http。 # UDP 必须属于有效的 TCP 控制连接,源 IP/端口校验和资源上限见 inbounds.md#socks5。 mixed-port: 1080 allow-lan: false -bind-address: "*" # allow-lan=true 时可指定单个 IP +bind-address: "*" # allow-lan=true 时可指定单个 IP;localhost 始终绑定回环 lan-allowed-ips: [0.0.0.0/0, "::/0"] lan-disallowed-ips: [] # 拒绝优先 skip-auth-prefixes: [] # 仅跳过认证,仍执行来源访问控制 @@ -500,7 +500,7 @@ proxies: # 未显式定义 GLOBAL 时,自动追加一个 select 组;成员依次为 DIRECT、REJECT、 # 所有节点和声明的组,默认 DIRECT。可显式声明 GLOBAL 覆盖其成员及初始选择。 # -# select 的 default-selected 省略、空串或未命中时选中第一项;有效缓存选择优先。名称精确匹配。 +# select 的 default-selected 省略、null、空串或未命中时选中第一项;有效缓存选择优先。名称精确匹配。 # url-test 按 URL 探测延迟选择,tolerance(毫秒,默认 0)抑制小幅切换;fallback # 按声明顺序选择首个健康成员,恢复后可回到前面的成员。全部失败时返回首成员。 # 四种组均接受 url/interval/timeout/lazy/expected-status/max-failed-times。 @@ -584,7 +584,9 @@ rule-providers: # DNS policy 支持 rule-set:local-domains(逗号分隔多个集合);只接受 domain/classical, # classical 在 DNS 场景仅使用域名与 GeoSite 条件。 -# hosts 支持单 IP、IP 数组、域名别名与 Clash 域名通配;不支持动态 lan 展开。 +# hosts 支持单 IP、IP 数组、域名别名与 Clash 域名通配。 +# 值恰好为 lan 或 [lan] 时,启动准备捕获接口 IP,排除回环和 link-local;空快照使启动失败。 +# 校验不读取网卡;快照不随地址变化或 Provider 更新刷新,不限于私有网段。 hosts: "internal.example": [192.0.2.10] "alias.example": internal.example diff --git a/docs/inbounds.md b/docs/inbounds.md index 4497b99..1c2d314 100644 --- a/docs/inbounds.md +++ b/docs/inbounds.md @@ -16,13 +16,15 @@ HTTP 与 SOCKS5 共用一个混合入口,并和 TUN 共用 Dispatcher、规则 IPv6-only 回环/通配 socket。仅系统明确不支持地址族时可省略 IPv6,其余绑定错误 整体回滚。取得 Controller 和全部业务 socket 后才启动接收任务。 - `bind-address` 默认为 `*`;`allow-lan: true` 时可指定单个 IPv4/IPv6 地址,false 仍绑定回环。 + `localhost` 始终使用回环策略,并随 `ipv6` 控制 IPv6 回环 socket。 `lan-allowed-ips` 默认允许全部 IPv4/IPv6,`lan-disallowed-ips` 的拒绝规则优先。 两者作用于 TCP 来源;SOCKS5 UDP 必须属于已获准的 TCP 关联。 `skip-auth-prefixes` 只跳过匹配来源的认证,不跳过来源访问控制。 - `allow-lan` 与 `authentication` 独立;通配绑定不强制认证。 - `authentication` 省略或空列表时,HTTP/SOCKS5 在任何绑定地址均免认证; + `authentication` 省略、null 或空列表时,HTTP/SOCKS5 在任何绑定地址均免认证; 非空接受多个 `user:password`,相同用户名以最后一项为准, - 按第一个冒号拆分,两项各 1–255 UTF-8 字节,不 trim。null 不视为省略。 + 按第一个冒号拆分,不 trim,允许空用户名或密码。配置和 HTTP 不套用 SOCKS5 的 + 长度限制;HTTP 凭据受整个 32 KiB 请求头预算约束,SOCKS5 各字段仍使用一字节 wire 长度。 配置凭据后 HTTP/SOCKS5 均校验凭据;混合入口关闭时允许保留合法的共享和认证配置。 - Basic / SOCKS 用户密码没有链路加密,只适合可信网络。Controller 按 external-controller 独立监听, 使用自己的可选 Bearer 认证,不随共享开关改变地址或认证。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index d7aa96a..e2bae66 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -9,7 +9,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 | 配置与选路 | YAML anchor/merge;rule/global/direct;内置 DIRECT/REJECT;域名、地址、端口、网络、GeoData、RULE-SET 和 AND/OR/NOT | | 资源 | HTTP/file/inline proxy/rule providers;动态成员、筛选、覆盖、健康检查;Provider dialer-proxy 与 HTTP 订阅信息;YAML/Text 与 domain/ipcidr MRS;失败保留旧代 | | 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | -| DNS | hosts、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | +| DNS | hosts(含启动 LAN 地址快照)、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | | mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | | 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022、标准 WireGuard 单/多 peer;依赖启用的 Cargo feature | | 嗅探 | TUN/mixed TCP 的 HTTP/TLS;TUN UDP 的 QUIC;域名/地址排除、强制条件和目标覆盖 | diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 7eb74e4..365f655 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -241,6 +241,10 @@ fake-IP 映射、活动业务会话、在途查询和上游连接池继续保留 `hosts` 支持 IP、IP 数组和域名别名,匹配规则沿用 Clash 域名通配;默认包含 localhost,别名循环在配置阶段拒绝。`use-hosts`、`use-system-hosts` 独立控制配置与系统 hosts,默认均为 true;系统 hosts 在运行时创建时读取。TCP、UDP 节点的 TLS SNI 和逻辑目的名称不因解析为 IP 而改变。 +值恰好为 `lan` 或 `[lan]` 时,启动准备捕获全部接口 IP,排除回环和 link-local, +包含可用的公网与 TUN 接口地址,不仅限私有网段。校验只保留内部标记,不读取网卡。 +无可用地址或枚举失败使启动失败;运行期间及 Provider 更新均保留启动快照。 + `default-nameserver` 仅引导上游域名。`proxy-server-nameserver` 与其 policy 用于节点解析;`direct-nameserver` 用于直连,`direct-nameserver-follow-policy` 决定是否使用业务 policy。各角色拥有独立 cache/singleflight,未配置节点或直连 resolver 时复用主 resolver。有限并发、取消与连接池适用于各角色,依赖循环不会等待自身的 singleflight。 启用有效 IPv6 时,内部地址解析并发请求 A/AAAA,结果按 IPv4、IPv6 排列。 @@ -305,7 +309,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; - DoQ、DoH HTTP/2/3; -- 动态 hosts: lan; +- 运行中随接口变化自动刷新 hosts: lan; - 用户可配置的缓存容量、查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/src/config/credentials.rs b/src/config/credentials.rs index ab77efc..1cb9874 100644 --- a/src/config/credentials.rs +++ b/src/config/credentials.rs @@ -20,14 +20,6 @@ impl ProxyCredentials { pub fn new(username: impl Into, password: impl Into) -> io::Result { let username = username.into(); let password = password.into(); - for (kind, value) in [("user", &username), ("password", &password)] { - if !(1..=255).contains(&value.len()) { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - format!("authentication {kind} must contain between 1 and 255 UTF-8 bytes"), - )); - } - } if username.contains(':') { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -49,7 +41,14 @@ impl ProxyCredentials { for (expected_user, expected_password) in &self.entries { let mut difference = username.len() ^ expected_user.len(); difference |= password.len() ^ expected_password.len(); - for index in 0..255 { + // Cover both complete fields without an early exit on content. + // HTTP credentials are not limited by SOCKS5's one-byte lengths. + let length = username + .len() + .max(expected_user.len()) + .max(password.len()) + .max(expected_password.len()); + for index in 0..length { difference |= usize::from( username.get(index).copied().unwrap_or(0) ^ expected_user.as_bytes().get(index).copied().unwrap_or(0), @@ -104,7 +103,9 @@ impl ProxyCredentials { let (Some(scheme), Some(encoded), None) = (parts.next(), parts.next(), parts.next()) else { return None; }; - if !scheme.eq_ignore_ascii_case("basic") || encoded.len() > 684 { + if !scheme.eq_ignore_ascii_case("basic") + || encoded.len() > crate::inbound::DEFAULT_HEADER_LIMIT + { return None; } let Ok(decoded) = STANDARD.decode(encoded) else { @@ -115,6 +116,47 @@ impl ProxyCredentials { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_credentials_cover_empty_values_and_complete_long_fields() { + for (user, password) in [("", ""), ("", "secret"), ("user", "")] { + let auth = ProxyCredentials::new(user, password).unwrap(); + assert!(auth.verifies(&[( + "Proxy-Authorization".into(), + auth.authorization_header_value(), + )])); + } + let user = "u".repeat(600); + let password = "p".repeat(600); + let auth = ProxyCredentials::new(&user, &password).unwrap(); + assert!(auth.verifies(&[( + "Proxy-Authorization".into(), + auth.authorization_header_value(), + )])); + let mut wrong_user = user.into_bytes(); + let mut wrong_password = password.into_bytes(); + wrong_user[599] = b'x'; + assert!(!auth.matches(&wrong_user, &wrong_password)); + wrong_user[599] = b'u'; + wrong_password[599] = b'x'; + assert!(!auth.matches(&wrong_user, &wrong_password)); + assert!(!auth.matches(&wrong_user, &wrong_password[..599])); + } + + #[test] + fn http_authentication_keeps_the_shared_header_budget() { + let auth = ProxyCredentials::new("user", "p".repeat(crate::inbound::DEFAULT_HEADER_LIMIT)) + .unwrap(); + assert!(!auth.verifies(&[( + "Proxy-Authorization".into(), + auth.authorization_header_value(), + )])); + } +} + /// A fixed client-oriented listener policy, not an arbitrary address list. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProxyAccess { diff --git a/src/config/dns.rs b/src/config/dns.rs index b0fa506..d879e58 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -285,10 +285,9 @@ pub(super) fn normalize_hosts( RawPolicyNameservers::One(value) => vec![value], RawPolicyNameservers::Many(values) => values, }; - let value = if values.len() == 1 && values[0].parse::().is_err() { - if values[0] == "lan" { - return invalid("hosts dynamic lan expansion is not supported"); - } + let value = if values.as_slice() == ["lan"] { + HostValue::Lan + } else if values.len() == 1 && values[0].parse::().is_err() { HostValue::Alias( crate::dns::canonicalize_name(&values[0]) .map_err(|_| VoleError::InvalidConfig("invalid hosts alias".into()))?, diff --git a/src/config/mod.rs b/src/config/mod.rs index 231a4af..af3996e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -325,6 +325,8 @@ pub struct HostEntry { pub enum HostValue { Addresses(Vec), Alias(String), + /// Interface addresses are captured during runtime preparation, never validation. + Lan, } #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -1164,11 +1166,7 @@ struct RawProxyGroup { #[serde(rename = "type")] kind: GroupKind, proxies: Vec, - #[serde( - rename = "default-selected", - default, - deserialize_with = "deserialize_present_option" - )] + #[serde(rename = "default-selected", default)] default_selected: Option, } @@ -1986,13 +1984,9 @@ fn deserialize_present_credentials<'de, D>( where D: Deserializer<'de>, { - deserialize_non_null_vec(deserializer) - .map(Some) - .map_err(|_| { - serde::de::Error::custom( - "authentication must be a non-null sequence of credential strings", - ) - }) + Option::>::deserialize(deserializer).map_err(|_| { + serde::de::Error::custom("authentication must be a sequence of credential strings or null") + }) } impl Config { @@ -2055,10 +2049,15 @@ impl RawVoleConfig { )?; let mixed_port = self.mixed_port.filter(|port| *port != 0); + let allow_lan = self.allow_lan && self.bind_address.as_deref() != Some("localhost"); let bind_address = match self.bind_address.as_deref() { - None | Some("" | "*") => None, + None | Some("" | "*" | "localhost") => None, Some(value) => Some(value.trim_matches(['[', ']']).parse::().map_err( - |_| VoleError::InvalidConfig("bind-address must be an IP address or *".into()), + |_| { + VoleError::InvalidConfig( + "bind-address must be an IP address, localhost or *".into(), + ) + }, )?), }; let cidrs = |values: Vec| { @@ -2148,7 +2147,7 @@ impl RawVoleConfig { port, access: ProxyAccess { bind_address, - allow_lan: self.allow_lan, + allow_lan, ipv6: self.ipv6, }, auth: authentication, @@ -4796,48 +4795,66 @@ authentication: .matches("用户".as_bytes(), "密:码".as_bytes()) ); - let max_user = "u".repeat(usize::from(u8::MAX)); - let max_password = "p".repeat(usize::from(u8::MAX)); - let max = current_yaml(&format!( - "mixed-port: 1080\nauthentication:\n - \"{max_user}:{max_password}\"" - )); - assert!(Config::parse_yaml(max.as_bytes()).is_ok()); - - for (fields, expected) in [ - ( - "mixed-port: 1080\nauthentication:\n - missing-separator", - "authentication entry must use user:password", - ), - ( - "mixed-port: 1080\nauthentication:\n - \":secret\"", - "authentication user must contain between 1 and 255", - ), - ( - "mixed-port: 1080\nauthentication:\n - \"measure:\"", - "authentication password must contain between 1 and 255", - ), + for (user, password) in [ + ("", ""), + ("", "secret"), + ("user", ""), + (&"u".repeat(600), &"p".repeat(600)), ] { - let error = Config::parse_yaml(current_yaml(fields).as_bytes()).unwrap_err(); + let config = Config::parse_yaml( + current_yaml(&format!( + "mixed-port: 1080\nauthentication: ['{user}:{password}']" + )) + .as_bytes(), + ) + .unwrap(); + let [InboundConfig::Mixed(mixed)] = config.inbounds.as_slice() else { + panic!("missing mixed inbound"); + }; assert!( - error.to_string().contains(expected), - "unexpected error for {fields:?}: {error}" + mixed + .auth + .as_ref() + .unwrap() + .matches(user.as_bytes(), password.as_bytes()) ); } + let error = Config::parse_yaml( + current_yaml("mixed-port: 1080\nauthentication: [missing-separator]").as_bytes(), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("authentication entry must use user:password") + ); + assert!( + Config::parse_yaml( + current_yaml("mixed-port: 1080\nauthentication: measure:secret").as_bytes() + ) + .is_err() + ); + } - for yaml in [ - current_yaml("mixed-port: 1080\nauthentication: null"), - current_yaml("mixed-port: 1080\nauthentication: measure:secret"), - current_yaml(&format!( - "mixed-port: 1080\nauthentication:\n - \"{}:secret\"", - "u".repeat(usize::from(u8::MAX) + 1) - )), - current_yaml(&format!( - "mixed-port: 1080\nauthentication:\n - \"measure:{}\"", - "p".repeat(usize::from(u8::MAX) + 1) - )), - ] { - assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); + #[test] + fn mixed_localhost_and_null_inputs_keep_reference_defaults() { + for authentication in ["null", "[]"] { + let config = Config::parse_yaml(current_yaml(&format!( + "mixed-port: 1080\nallow-lan: true\nbind-address: localhost\nauthentication: {authentication}" + )).as_bytes()).unwrap(); + let [InboundConfig::Mixed(mixed)] = config.inbounds.as_slice() else { + panic!("missing mixed inbound"); + }; + assert!(!mixed.access.allow_lan); + assert!(mixed.access.bind_address.is_none()); + assert!(mixed.auth.is_none()); } + let yaml = with_proxy_groups( + " - name: choice\n type: select\n proxies: [DIRECT, proxy]\n default-selected: null", + "rules: ['MATCH,choice']", + ); + let config = Config::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!(config.proxy_groups[0].initial_member, 0); } #[test] @@ -5949,10 +5966,6 @@ rules: r#" - name: 主线路 type: select proxies: [RULES]"#, - r#" - name: 主线路 - type: select - proxies: [proxy] - default-selected: null"#, ] { let yaml = with_proxy_groups(groups, fields); assert!(Config::parse_yaml(yaml.as_bytes()).is_err(), "{yaml}"); diff --git a/src/dns/hosts.rs b/src/dns/hosts.rs index 3cac498..7181e28 100644 --- a/src/dns/hosts.rs +++ b/src/dns/hosts.rs @@ -54,7 +54,19 @@ impl Hosts { } } if configured { - for entry in entries { + // PreparedCore has already captured LAN entries. Standalone resolver + // constructors capture their own snapshot and fail closed if unavailable. + let materialized = entries + .iter() + .any(|entry| matches!(entry.value, HostValue::Lan)) + .then(|| { + let mut entries = entries.to_vec(); + if materialize_lan(&mut entries).is_err() { + clear_lan_markers(&mut entries); + } + entries + }); + for entry in materialized.as_deref().unwrap_or(entries) { hosts.insert(&entry.pattern, entry.value.clone()); } } @@ -81,6 +93,7 @@ impl Hosts { return Ok(HostValue::Addresses(addresses.clone())); } HostValue::Alias(alias) => name.clone_from(alias), + HostValue::Lan => return Err(io::ErrorKind::InvalidInput.into()), } } Ok(HostValue::Alias(name)) @@ -88,9 +101,127 @@ impl Hosts { } pub(crate) fn validate(entries: &[HostEntry]) -> io::Result<()> { - let hosts = Hosts::new(entries, true, false); + let mut entries = entries.to_vec(); + clear_lan_markers(&mut entries); + let hosts = Hosts::new(&entries, true, false); for entry in entries { hosts.lookup(&entry.pattern)?; } Ok(()) } + +pub(crate) fn materialize_lan(entries: &mut [HostEntry]) -> io::Result<()> { + if !entries + .iter() + .any(|entry| matches!(entry.value, HostValue::Lan)) + { + return Ok(()); + } + let addresses = getifaddrs::getifaddrs() + .map_err(|_| io::Error::other("hosts LAN interface snapshot unavailable"))? + .filter_map(|interface| interface.address.ip_addr()) + .collect::>(); + apply_lan_snapshot(entries, &addresses) +} + +fn clear_lan_markers(entries: &mut [HostEntry]) { + for entry in entries { + if matches!(entry.value, HostValue::Lan) { + entry.value = HostValue::Addresses(Vec::new()); + } + } +} + +fn apply_lan_snapshot(entries: &mut [HostEntry], addresses: &[IpAddr]) -> io::Result<()> { + if !entries + .iter() + .any(|entry| matches!(entry.value, HostValue::Lan)) + { + return Ok(()); + } + let addresses = addresses + .iter() + .copied() + .map(|address| match address { + IpAddr::V6(address) => address + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(address)), + address => address, + }) + .filter(|address| { + !address.is_loopback() + && match address { + IpAddr::V4(address) => !address.is_link_local(), + IpAddr::V6(address) => !address.is_unicast_link_local(), + } + }) + .collect::>(); + if addresses.is_empty() { + return Err(io::Error::new( + io::ErrorKind::AddrNotAvailable, + "hosts LAN snapshot has no usable interface addresses", + )); + } + for entry in entries { + if matches!(entry.value, HostValue::Lan) { + entry.value = HostValue::Addresses(addresses.clone()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lan_hosts_use_a_fixed_injected_snapshot_without_interface_access() { + let config = crate::config::Config::parse_yaml( + b"hosts: {home.lan: lan, alias.lan: home.lan, public.lan: [lan]}\nrules: ['MATCH,DIRECT']", + ) + .unwrap(); + assert!(matches!(config.dns.hosts[1].value, HostValue::Alias(_))); + assert!( + config + .dns + .hosts + .iter() + .any(|entry| matches!(entry.value, HostValue::Lan)) + ); + let mut entries = config.dns.hosts; + let addresses = [ + "127.0.0.1", + "::1", + "169.254.1.2", + "fe80::1", + "192.168.1.2", + "2001:db8::2", + "203.0.113.4", + ] + .map(|address| address.parse().unwrap()); + apply_lan_snapshot(&mut entries, &addresses).unwrap(); + let hosts = Hosts::new(&entries, true, false); + let expected = HostValue::Addresses(addresses[4..].to_vec()); + assert_eq!(hosts.lookup("home.lan").unwrap(), expected); + assert_eq!(hosts.lookup("alias.lan").unwrap(), expected); + assert_eq!(hosts.lookup("public.lan").unwrap(), expected); + apply_lan_snapshot(&mut entries, &["192.168.2.2".parse().unwrap()]).unwrap(); + assert_eq!( + Hosts::new(&entries, true, false) + .lookup("home.lan") + .unwrap(), + expected + ); + let mut empty = vec![HostEntry { + pattern: "empty.lan".into(), + value: HostValue::Lan, + }]; + assert_eq!( + apply_lan_snapshot(&mut empty, &addresses[..4]) + .unwrap_err() + .kind(), + io::ErrorKind::AddrNotAvailable + ); + } +} diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index fdb8cf5..99f6b3a 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -1364,6 +1364,11 @@ impl RuntimeDns { return Ok(addresses); } HostValue::Alias(host) => host, + HostValue::Lan => { + return Err(RuntimeDnsError::UpstreamsExhausted { + last_error: "hosts snapshot unavailable".into(), + }); + } }; if !self.enabled { use crate::dialer::Resolver; diff --git a/src/inbound/http/server.rs b/src/inbound/http/server.rs index 7dc3cfb..2a02287 100644 --- a/src/inbound/http/server.rs +++ b/src/inbound/http/server.rs @@ -740,12 +740,12 @@ mod tests { assert!(!auth.verifies(&[("Proxy-Authorization".to_owned(), "Basic !!!".to_owned(),)])); assert_eq!(format!("{auth:?}"), "ProxyCredentials { .. }"); - assert!(HttpBasicAuth::new("", "password").is_err()); - assert!(HttpBasicAuth::new("user", "").is_err()); + assert!(HttpBasicAuth::new("", "password").is_ok()); + assert!(HttpBasicAuth::new("user", "").is_ok()); assert!(HttpBasicAuth::new("user:name", "password").is_err()); assert!(HttpBasicAuth::new("u".repeat(255), "p".repeat(255)).is_ok()); - assert!(HttpBasicAuth::new("u".repeat(256), "password").is_err()); - assert!(HttpBasicAuth::new("user", "p".repeat(256)).is_err()); + assert!(HttpBasicAuth::new("u".repeat(256), "password").is_ok()); + assert!(HttpBasicAuth::new("user", "p".repeat(256)).is_ok()); } #[tokio::test] diff --git a/src/runtime.rs b/src/runtime.rs index 32e7015..7d8b419 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -300,6 +300,7 @@ impl PreparedCore { } else { None }; + crate::dns::hosts::materialize_lan(&mut config.dns.hosts)?; let (rules, geodata_registration) = prepare_routing(&mut config, &geodata_manager)?; let _ = resolver; let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 7daf87a..950896f 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -291,6 +291,7 @@ impl ProviderRuntime { let dialer = self.dialer.clone(); let geo = self.geo.clone(); let previous_dns = generation.dns.clone(); + let hosts = generation.config.dns.hosts.clone(); let connections = generation.connections.clone(); let data_dir = self .manager @@ -303,6 +304,8 @@ impl ProviderRuntime { let mut config = template .materialize_with_cancel(&contents, Some(&expression_cancellation)) .map_err(io::Error::other)?; + // Provider refreshes do not refresh the session's startup LAN snapshot. + config.dns.hosts = hosts; let requirements = GeoRequirements::for_config(&config).map_err(io::Error::other)?; let rules = RuleSet::compile_config(&mut config).map_err(io::Error::other)?; diff --git a/tests/README.md b/tests/README.md index a426dbb..fdb7f03 100644 --- a/tests/README.md +++ b/tests/README.md @@ -12,6 +12,8 @@ cargo test --locked --all-features --all-targets --no-run cargo test --locked --no-default-features --features cli --bin vole cli::tests:: cargo test --locked --no-default-features --features cli --lib invoke::foreground::tests:: cargo test --locked --lib config::tests:: +cargo test --locked --lib config::credentials::tests:: +cargo test --locked --lib dns::hosts::tests:: cargo test --locked --lib config::name_regex::tests:: cargo test --locked --lib config::tls_policy_tests:: cargo test --locked --no-default-features --features cli --test grpc_config --test grpc_pool --test grpc_protocol_pool From 28d6269f5990b01ad6a09a9ded9ecce3bbec8359 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 21:17:29 +0800 Subject: [PATCH 048/445] feat: share client trust and support TUIC mutual TLS --- .github/workflows/test.yml | 1 + Cargo.lock | 51 ++++++++++++++ Cargo.toml | 1 + docs/config.yaml | 15 +++- docs/geodata.md | 2 +- docs/invoke-api.md | 4 +- docs/mihomo-compatibility.md | 4 ++ docs/outbounds.md | 2 +- docs/tls-client-fingerprint.md | 23 ++++++- docs/tls-dependencies.md | 2 +- docs/tun-icmp-dns.md | 2 +- src/config/identity_files.rs | 4 +- src/config/measure.rs | 27 +++++++- src/config/mod.rs | 6 ++ src/config/tls_policy_tests.rs | 4 ++ src/config/tls_trust.rs | 106 +++++++++++++++++++++++++++++ src/config/tuic.rs | 16 ++++- src/dns/runtime.rs | 67 +++++++++++++++++- src/geodata/service.rs | 6 ++ src/invoke/measure_delay.rs | 6 +- src/outbound/tuic/outbound.rs | 11 +++ src/outbound/wireguard/engine.rs | 3 + src/outbound/wireguard/mod.rs | 11 ++- src/outbound/wireguard/resolver.rs | 2 +- src/resource/cache.rs | 2 + src/resource/cache_tests.rs | 1 + src/resource/http.rs | 48 ++++++------- src/resource/probe.rs | 62 ++++++++--------- src/runtime.rs | 39 +++++++++-- src/runtime/health.rs | 14 +++- src/runtime/providers.rs | 5 ++ src/security/context.rs | 57 ++++++++++++++++ src/security/tls_tests.rs | 35 ++++++++++ tests/README.md | 2 + 34 files changed, 552 insertions(+), 89 deletions(-) create mode 100644 src/config/tls_trust.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 749cda0..b0d1b69 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -114,6 +114,7 @@ jobs: require(not any("watfaq" in (item.get("source") or "").lower() for item in packages), "retired dependency source is forbidden") rustls = package("rustls", "0.23.45") package("tokio-rustls", "0.26.5") + package("rustls-native-certs", "0.8.4") package("ring") wireguard = package("boringtun", "0.7.1") require(wireguard.get("license") == "BSD-3-Clause", "unapproved WireGuard license") diff --git a/Cargo.lock b/Cargo.lock index 6c966c1..270d7bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1812,6 +1812,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "oslog" version = "0.2.0" @@ -2211,6 +2217,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -2244,6 +2262,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2261,6 +2288,29 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -2990,6 +3040,7 @@ dependencies = [ "regex-syntax", "ring", "rustls", + "rustls-native-certs", "serde", "serde_json", "serde_yaml_ng", diff --git a/Cargo.toml b/Cargo.toml index a74274d..f3a7e63 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -128,6 +128,7 @@ psl = "=2.1.241" regex = "1.13" regex-syntax = { version = "0.8", default-features = false, features = ["std", "unicode-case"] } rustls = { version = "=0.23.45", default-features = false, features = ["ring", "std", "tls12"] } +rustls-native-certs = "=0.8.4" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } serde_yaml_ng = "0.10" diff --git a/docs/config.yaml b/docs/config.yaml index 329dfa1..7fb6b5f 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -51,6 +51,16 @@ geox-url: geo-auto-update: true geo-update-interval: 24 +# 客户端共享信任根:内置 WebPKI + 启动时可读系统根 + 此处证书内容。 +# 字段沿用 Mihomo 的真实拼写 custom-certifactes;内容为 PEM(或可表示的 DER),不是路径。 +# 不设置本项仍使用基础信任;pure validate 不读取系统根。平台读取限制见 tls-client-fingerprint.md。 +# tls: +# custom-certifactes: +# - |- +# -----BEGIN CERTIFICATE----- +# +# -----END CERTIFICATE----- + # 可选的 HTTP/SOCKS5 混合入口;mixed-port 省略或 0 时关闭,1–65535 时启用。 # TCP 在同端口接入 HTTP 转发 / CONNECT / Upgrade 和 SOCKS5 CONNECT / UDP ASSOCIATE。 # SOCKS5 UDP 随入口启用并绑定同端口,不提供独立入站 UDP 开关或 listeners 配置。 @@ -234,7 +244,7 @@ proxies: # dialer-proxy: upstream-select # 上游必须提供足够的双向 UDP 预算 # TUIC v5:固定 QUIC/TLS1.3;UUID 必填,password 缺省为空,原样使用(最多 65535 UTF-8 字节)。 - # 不接受 v4、token、0-RTT、client-fingerprint、mTLS、ECH 或其他 TLS 后端选项。 + # 支持成对 PEM/文件 certificate/private-key 客户端身份;不接受 v4、token、0-RTT、client-fingerprint、ECH。 - name: tuic-edge type: tuic server: tuic.example.com @@ -246,6 +256,7 @@ proxies: skip-cert-verify: false # fingerprint: "" # name-cert-verify: verified.example.com + # certificate/private-key: 成对内联 PEM 或文件路径;不改变服务端信任根 congestion-controller: cubic # cubic(默认)/new_reno/bbr udp: true # 缺省 false;只控制业务 UDP,QUIC 首跳始终需要数据报 udp-relay-mode: native # native(默认)/quic @@ -698,7 +709,7 @@ sub-rules: - DOMAIN-SUFFIX,example.com,PASS-RULE - NETWORK,UDP,anytls-edge -# measureDelay 使用独立的 node-only 配置:顶层只能包含 proxies,不能包含 +# measureDelay 使用独立的 node-only 配置:顶层只接受 proxies 和 tls.custom-certifactes,不能包含 # proxy-groups。节点字段和代理图校验与上文一致,但不能出现 mixed-port/authentication、 # tun、sniffer、Controller、DNS、rules 或 GeoData;此接口的 dialer-proxy 可指定 DIRECT 或具体 # 节点。Vole 从“未被任何 dialer-proxy 引用”的节点推导唯一链头,且该链必须覆盖 diff --git a/docs/geodata.md b/docs/geodata.md index 8597954..49be318 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -126,7 +126,7 @@ geo-update-interval: 24 接受以域名为主机、无 userinfo/fragment 的 HTTP fixture;仍通过同一个 dispatcher 和 流式下载器,不建立 TLS。HTTP 重定向可保持 HTTP 或升级到 HTTPS,HTTPS 不允许降级。 该入口没有环境变量或新的 YAML/FFI 开关;未编译该 feature 时仍强制 HTTPS,HTTPS 的 -公开根和完整证书验证不变。平台构建及 delivery 拒绝该测试 feature。HTTP 实验只验收 +会话共享信任根和完整证书验证不变。平台构建及 delivery 拒绝该测试 feature。HTTP 实验只验收 更新逻辑与资源占用,不构成生产 HTTPS 信任链验收,不应启用 `interop-test`。 ## GeoSite 匹配 diff --git a/docs/invoke-api.md b/docs/invoke-api.md index b2f4b9e..7feba60 100644 --- a/docs/invoke-api.md +++ b/docs/invoke-api.md @@ -373,10 +373,10 @@ Unix 等待 SIGINT/SIGTERM,Windows 等待 Ctrl+C/Ctrl+Break;标准输入先 - `configYamls` 接受 1–5 份非空节点配置,`timeout` 为 1–30 秒。 - 同一运行时一次只允许一个测速批次,最多并发五个私有工作器。 -- 节点配置顶层只允许 `proxies`,不接受 `proxy-groups`;`dialer-proxy` 可指定 DIRECT 或引用具体节点。Vole 推导唯一链头,且该链必须覆盖全部节点。 +- 节点配置顶层只允许 `proxies` 和客户端信任 `tls.custom-certifactes`,不接受服务端 TLS 字段或 `proxy-groups`;`dialer-proxy` 可指定 DIRECT 或引用具体节点。Vole 推导唯一链头,且该链必须覆盖全部节点。 - 工作器只准备出站图并执行 TCP、可选 TLS 和 HTTP/1.1 HEAD;不创建公共实例、监听器、TUN、DNS、规则、嗅探器或 GeoData。 - 宿主测量组成员时,按当前选择快照展开具体链后提交;上游选到 DIRECT 时移除相应 `dialer-proxy`,选到 REJECT 时不发起该项测量。该快照不改变运行中的组选择。 -- URL 必须是无 userinfo 和 fragment 的绝对 HTTP/HTTPS URL;HTTPS 使用发布信任根。 +- URL 必须是无 userinfo 和 fragment 的绝对 HTTP/HTTPS URL;HTTPS 与本次节点握手共享内置、可读系统及配置自定义信任根;各测量工作器的信任互相隔离。 - 任意合法 HTTP 状态都表示探测成功;不跟随重定向、不读取正文。 - 单项失败不取消其他项;方法返回前释放全部私有任务。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index e2bae66..83698e9 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -12,6 +12,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 | DNS | hosts(含启动 LAN 地址快照)、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | | mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | | 出站 | HTTP(S) CONNECT、SOCKS5、AnyTLS、Trojan、VMess AEAD、VLESS、Hysteria2、TUIC v5、Shadowsocks 2022、标准 WireGuard 单/多 peer;依赖启用的 Cargo feature | +| 客户端 TLS | tls.custom-certifactes 证书内容;启动时合并可读系统根、WebPKI 与显式根并跨组件共享;TUIC v5 与其它 TLS 节点复用 cert/private-key 身份加载 | | 嗅探 | TUN/mixed TCP 的 HTTP/TLS;TUN UDP 的 QUIC;域名/地址排除、强制条件和目标覆盖 | | Linux 自建设备 | auto-detect-interface、auto-route、systemd-resolved DNS 接管;失败和停止时恢复本次修改 | | 运行状态 | 连接快照、流量计数和关闭;组与资源状态;规则命中、有效配置、受限 DNS 诊断 | @@ -19,6 +20,9 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 ## 必须保留的边界 - 业务入口只有 mixed-port 与 TUN;不支持 port、socks-port、listeners、redir/tproxy 或服务端协议配置。 +- tls 仅接受客户端信任根;无效显式证书拒绝配置,Mihomo 会跳过无效项。iOS/tvOS、Android 不保证取得完整系统信任库; + 所有后端仍使用 WebPKI 校验,未复制 Go 的原生系统验证器。详细范围见 [TLS 契约](tls-client-fingerprint.md)。 + 独立测量 YAML 只接受 proxies 与同名客户端 tls 子集,不接受 TLS 服务端配置。 - Shadowsocks 只支持三个 2022 cipher;插件仅 strict ShadowTLS v3,UoT 仅 v2。 - DNS 不提供独立 listen、DoQ、DoH/H3 或 ECS。LRU/ARC 与显式 system/DHCP 上游已支持, 来源发现和物理绑定的可用平台及参考差异以 DNS 契约为准。 diff --git a/docs/outbounds.md b/docs/outbounds.md index 0dd1306..949074b 100644 --- a/docs/outbounds.md +++ b/docs/outbounds.md @@ -202,7 +202,7 @@ pacer;这是混淆不是认证,错误密钥由 QUIC 拒绝,短包有界丢 固定 QUIC/TLS 1.3,复用官方 Quinn、rustls/ring 与共享受控数据报适配器。 仅 v5;必填标准 UUID。password 为原样 UTF-8,省略/空串为空密码,最长 65,535 字节。 SNI 缺省 server,ALPN 缺省 h3;显式空列表拒绝,非空有序列表必须实际协商命中。 -使用共享 pin/skip/独立验证名;无命名 TCP 指纹、mTLS、ECH、0-RTT 或跨节点恢复。 +使用共享 pin/skip/独立验证名,支持配对内联 PEM 或文件路径的 certificate/private-key 客户端身份;无命名 TCP 指纹、ECH、0-RTT 或跨节点恢复。 拥塞使用官方 Quinn cubic(默认)、new_reno 或 bbr,不承诺与其他实现的吞吐相同。 `log-level: debug` 在物理会话建立后记录实际 controller 的固定算法标签; 标签不包含节点、凭据或业务目标,配置验证和复用同一会话不重复记录。 diff --git a/docs/tls-client-fingerprint.md b/docs/tls-client-fingerprint.md index 37c7d34..d28d7a0 100644 --- a/docs/tls-client-fingerprint.md +++ b/docs/tls-client-fingerprint.md @@ -46,7 +46,7 @@ client-fingerprint: chrome - 命名指纹的标准 TLS,以及所有 REALITY/JLS,使用自有 boring fork;开发及 PR/发布的来源规则见 [TLS 依赖](tls-dependencies.md)。 - 普通 TLS 两条路径共用同一个 WebPKI 证书验证器和发布信任根;叶 pin、非叶信任锚、 独立验证名、skip 优先级不变。TLS 握手签名仍由各自后端强制验证。 -- HTTP、SOCKS5、AnyTLS、Trojan、VMess、VLESS 与 Hysteria2 的 mTLS 使用配对内联 PEM 或文件路径, +- HTTP、SOCKS5、AnyTLS、Trojan、VMess、VLESS、Hysteria2 与 TUIC v5 的 mTLS 使用配对内联 PEM 或文件路径, 配置校验与资源生成时读取文件并检查密钥匹配;客户端证书不增加服务端信任根,profile 不修改身份。 - REALITY 仅允许自身临时证书认证,不能混用 pin/skip/mTLS 或降级到普通站点证书。 - [JLS](vless.md#jls) 使用共享凭据身份和完整原生 TLS1.3,不启用恢复或 PKI 回退;profile 不改变身份。 @@ -57,6 +57,27 @@ client-fingerprint: chrome 每个资源生成过程读取的身份文件合计最多 8 MiB,只接受普通 UTF-8 PEM 文件;错误不回显路径或内容。 载入后的身份不可变,不在 TLS 连接时读取或自动监测文件变化。 +## 客户端信任根 + +`tls.custom-certifactes` 使用 Mihomo 的真实字段拼写,接受证书内容字符串数组, +不读取路径,也不增加 `custom-certificates` 别名。内容在配置校验时解码,非法证书 +使 Vole 校验失败;与 Mihomo 对非法条目发警告后继续的行为不同。未知 TLS 字段 +继续拒绝,服务端证书与认证不属于此客户端信任对象。 + +启动时读取可用系统根,合并内置 WebPKI 和自定义根,构造一份会话共享的不可变根库。 +普通/指纹 TLS、QUIC、DNS 各角色、WireGuard 私有 DNS、Provider/GeoData 下载及 +健康探测都使用该根库。资源更新复用它,不重读系统根;新启动才能取得新的系统信任。 +独立 `measureDelay` 配置也接受该客户端对象,节点与最终 HTTPS 探测共享其工作器根库。 +纯校验只解析显式证书内容,不读取系统证书存储。客户端身份不增加服务端根。 + +系统读取使用官方 rustls-native-certs:macOS 读取可导出的信任设置,Windows 读取 +Current User ROOT,Linux 发现 CA 文件/目录。SSL_CERT_FILE/SSL_CERT_DIR 可按该库 +规则覆盖来源,读取失败保留基础根和有效自定义根,不关闭验证或转为明文。 +导出根后的证书验证仍为 WebPKI,不承诺 native verifier 的全部信任、撤销与策略行为。 +Android、iOS 和 tvOS 在此库的 Unix 发现分支中,不能完整读取平台系统/用户信任; +这些平台保留内置与自定义根,实际可读文件仅补充来源,不宣称完整系统信任等价。 +Windows UWP/AppContainer 的真实根读取仍需独立设备验证;交叉编译不构成签收。 + ## 四模板边界 模板由 fork 固定密码套件、组/share、签名算法、扩展排列及受限扩展编码。 diff --git a/docs/tls-dependencies.md b/docs/tls-dependencies.md index 15540f9..8789d72 100644 --- a/docs/tls-dependencies.md +++ b/docs/tls-dependencies.md @@ -6,7 +6,7 @@ | 用途 | 来源与约束 | | --- | --- | -| 普通 TLS、QUIC、WebPKI | 官方 rustls/tokio-rustls,ring provider | +| 普通 TLS、QUIC、WebPKI | 官方 rustls/tokio-rustls,ring provider;rustls-native-certs 只读取可用平台根,不替换验证器 | | 命名 ClientHello、REALITY、JLS、ShadowTLS v3 | YuanDevTeam/boring,release 分支及锁定 revision | | SS 2022 | 官方 shadowsocks,仅 `aead-cipher-2022` | | Encryption | 同一 boring 的 X25519、ML-KEM、AEAD、AES-CTR;官方 blake3 | diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 365f655..71da73d 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -109,7 +109,7 @@ dns: ### Endpoint - `dns.enable`、`dns.ipv6` 默认 false。主 nameserver 默认使用 DoH/DoT,可显式替换为非空列表。 -- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用共享 TLS 证书验证。DoH 采用 HTTP/1.1 POST。 +- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用会话的内置、可读系统与 `tls.custom-certifactes` 信任根;各解析角色及 WireGuard 私有 DNS 同样适用,平台边界见 [TLS 契约](tls-client-fingerprint.md)。DoH 采用 HTTP/1.1 POST。 - `rcode://success`、`rcode://format_error`、`rcode://server_failure`、`rcode://name_error`、`rcode://not_implemented`、`rcode://refused` 分别返回本地 RCODE 0–5;可用于 nameserver、policy、fallback、节点和直连 resolver,不能用于 default-nameserver。 - 同一个上游列表中存在本地 RCODE 时使用第一项本地响应,不建立网络连接,也不把 SERVFAIL/REFUSED 当作上游故障重试。响应保留原查询的 flags 和 additional,仅设置 QR 与 RCODE;不额外开启 RA,不写入结果缓存。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 diff --git a/src/config/identity_files.rs b/src/config/identity_files.rs index d211d17..3395a4e 100644 --- a/src/config/identity_files.rs +++ b/src/config/identity_files.rs @@ -26,7 +26,9 @@ impl<'a> IdentityFiles<'a> { }; if !matches!( map.get("type").and_then(Value::as_str), - Some("http" | "socks5" | "anytls" | "trojan" | "vmess" | "vless" | "hysteria2") + Some( + "http" | "socks5" | "anytls" | "trojan" | "vmess" | "vless" | "hysteria2" | "tuic" + ) ) { return Ok(()); } diff --git a/src/config/measure.rs b/src/config/measure.rs index 5f8a2f7..a965069 100644 --- a/src/config/measure.rs +++ b/src/config/measure.rs @@ -4,13 +4,14 @@ use crate::{Result, VoleError}; use super::{ProxyConfig, ProxyId, RawOutbound, RouteTargetId, normalize_proxy_graph}; -/// Strict node-only configuration accepted by the built-in latency runner. +/// Strict node graph and client trust accepted by the built-in latency runner. /// /// A measurement never owns an inbound, routing rules, DNS, sniffer state, or /// GeoData. Keeping a separate deserializer makes those fields unknown rather /// than silently ignoring runtime-only behavior. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct MeasureConfig { + pub(crate) tls: super::TlsTrustConfig, pub(crate) proxies: Vec, pub(crate) default_proxy: ProxyId, } @@ -18,6 +19,8 @@ pub(crate) struct MeasureConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawMeasureConfig { + #[serde(default)] + tls: super::TlsTrustConfig, proxies: Vec, } @@ -94,6 +97,7 @@ impl RawMeasureConfig { } Ok(MeasureConfig { + tls: self.tls, proxies, default_proxy, }) @@ -178,6 +182,27 @@ proxies: } } + #[test] + fn measurement_accepts_client_trust_without_accepting_tls_server_settings() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let certificate = rcgen::generate_simple_self_signed(vec!["measure.example".into()]) + .unwrap() + .cert; + let yaml = format!( + "tls:\n custom-certifactes:\n - |\n -----BEGIN CERTIFICATE-----\n {}\n -----END CERTIFICATE-----\n{NODE}", + STANDARD.encode(certificate.der()) + ); + let config = MeasureConfig::parse_yaml(yaml.as_bytes()).unwrap(); + assert_eq!(config.tls.certificates.len(), 1); + assert_eq!(config.proxies.len(), 1); + assert!( + MeasureConfig::parse_yaml( + format!("tls: {{ certificate: cert.pem }}\n{NODE}").as_bytes() + ) + .is_err() + ); + } + #[test] fn reuses_proxy_graph_validation() { let chain = NODE.replace( diff --git a/src/config/mod.rs b/src/config/mod.rs index af3996e..b811945 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -58,6 +58,8 @@ mod tuic; pub use tuic::{TuicCongestion, TuicOutboundConfig, TuicUdpMode}; mod jls; pub use jls::JlsConfig; +mod tls_trust; +pub use tls_trust::TlsTrustConfig; mod shadowsocks; pub use shadowsocks::{ShadowTlsConfig, ShadowsocksCipher, ShadowsocksOutboundConfig}; mod trojan; @@ -97,6 +99,7 @@ pub const GEO_UPDATE_INTERVAL_HOURS: u64 = 24; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Config { + pub tls: TlsTrustConfig, pub ipv6: bool, pub proxies: Vec, pub proxy_groups: Vec, @@ -1040,6 +1043,8 @@ impl std::fmt::Debug for HttpInboundConfig { #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct RawVoleConfig { + #[serde(default)] + tls: TlsTrustConfig, #[serde(rename = "bind-address", default)] bind_address: Option, #[serde(rename = "lan-allowed-ips", default)] @@ -2161,6 +2166,7 @@ impl RawVoleConfig { } Ok(Config { + tls: self.tls, ipv6: self.ipv6, proxies, proxy_groups, diff --git a/src/config/tls_policy_tests.rs b/src/config/tls_policy_tests.rs index f8d691a..c31b4e0 100644 --- a/src/config/tls_policy_tests.rs +++ b/src/config/tls_policy_tests.rs @@ -20,6 +20,7 @@ fn node(protocol: &str) -> serde_json::Value { node["tls"] = true.into(); } "socks5" | "http" => node["tls"] = true.into(), + "tuic" => node["uuid"] = "00000000-0000-4000-8000-000000000001".into(), _ => unreachable!(), } node @@ -51,6 +52,7 @@ fn client_identity(config: &Config) -> &TlsIdentityPem { ProxyProtocol::Socks5(config) => &config.tls.as_ref().unwrap().identity, ProxyProtocol::Http(config) => &config.tls.as_ref().unwrap().identity, ProxyProtocol::Hysteria2(config) => &config.tls.identity, + ProxyProtocol::Tuic(config) => &config.tls.identity, ProxyProtocol::Vless(config) => match &config.security { SecurityConfig::Tls(tls) => &tls.identity, _ => unreachable!(), @@ -80,6 +82,8 @@ fn common_tls_identity_files_use_the_configuration_directory_and_inline_validati "vless", #[cfg(feature = "outbound-hysteria2")] "hysteria2", + #[cfg(feature = "outbound-tuic")] + "tuic", ] { let mut node = node(protocol); node["certificate"] = "./client.crt".into(); diff --git a/src/config/tls_trust.rs b/src/config/tls_trust.rs new file mode 100644 index 0000000..510cba3 --- /dev/null +++ b/src/config/tls_trust.rs @@ -0,0 +1,106 @@ +use std::sync::Arc; + +use rustls::{ + RootCertStore, + pki_types::{CertificateDer, pem::PemObject}, +}; +use serde::Deserialize; + +use crate::{Result, VoleError}; + +/// Client trust only. Certificates are decoded once; resource generations share them. +#[derive(Clone, Default, PartialEq, Eq)] +pub struct TlsTrustConfig { + pub(crate) certificates: Arc<[CertificateDer<'static>]>, +} + +impl std::fmt::Debug for TlsTrustConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsTrustConfig") + .field("certificate_count", &self.certificates.len()) + .finish() + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawTrust { + #[serde(rename = "custom-certifactes", default)] + certificates: Vec, +} + +impl<'de> Deserialize<'de> for TlsTrustConfig { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + let raw = RawTrust::deserialize(deserializer) + .map_err(|_| serde::de::Error::custom("invalid TLS trust fields"))?; + Self::parse(raw.certificates) + .map_err(|_| serde::de::Error::custom("invalid TLS trust certificate")) + } +} + +impl TlsTrustConfig { + fn parse(values: Vec) -> Result { + let invalid = || VoleError::InvalidConfig("invalid TLS trust certificate".into()); + let mut certificates = Vec::new(); + for value in values { + if value.is_empty() { + return Err(invalid()); + } + let mut parsed = CertificateDer::pem_slice_iter(value.as_bytes()) + .collect::, _>>() + .map_err(|_| invalid())?; + if parsed.is_empty() { + parsed.push(CertificateDer::from(value.into_bytes())); + } + let mut validator = RootCertStore::empty(); + for certificate in &parsed { + validator.add(certificate.clone()).map_err(|_| invalid())?; + } + certificates.extend(parsed); + } + certificates.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref())); + certificates.dedup_by(|a, b| a.as_ref() == b.as_ref()); + Ok(Self { + certificates: certificates.into(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_roots_decode_once_and_remain_redacted() { + let certificate = rcgen::generate_simple_self_signed(vec!["trust.example".into()]) + .unwrap() + .cert; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let pem = format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + STANDARD.encode(certificate.der()) + ); + let config = TlsTrustConfig::parse(vec![pem.clone(), pem]).unwrap(); + assert_eq!(config.certificates.len(), 1); + assert!(Arc::ptr_eq( + &config.certificates, + &config.clone().certificates + )); + assert!(!format!("{config:?}").contains("CERTIFICATE")); + } + + #[test] + fn trust_is_strict_certificate_content_not_a_path_or_alias() { + for value in [ + "custom-certificates: []", + "custom-certifactes: [./ca.pem]", + "certificate: ca", + "custom-certifactes: [null]", + ] { + assert!(serde_yaml_ng::from_str::(value).is_err()); + } + assert!(serde_yaml_ng::from_str::("custom-certifactes: []").is_ok()); + } +} diff --git a/src/config/tuic.rs b/src/config/tuic.rs index 724b54e..5d2fffe 100644 --- a/src/config/tuic.rs +++ b/src/config/tuic.rs @@ -65,6 +65,10 @@ pub(super) struct RawTuic { fingerprint: Option, #[serde(default, deserialize_with = "super::deserialize_present_option")] name_cert_verify: Option, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + certificate: Option, + #[serde(default, deserialize_with = "super::deserialize_present_option")] + private_key: Option, #[serde(default)] congestion_controller: TuicCongestion, #[serde(default)] @@ -112,7 +116,7 @@ impl RawTuic { .collect(), tls13_only: true, required_alpn: None, - identity: None, + identity: super::normalize_client_identity(self.certificate, self.private_key)?, ech: None, client_fingerprint: None, certificate: TlsCertificatePolicy { @@ -153,12 +157,20 @@ impl TuicOutboundConfig { } if !self.tls.tls13_only || self.tls.ech.is_some() - || self.tls.identity.is_some() || self.tls.client_fingerprint.is_some() || self.tls.required_alpn.is_some() { return invalid("TUIC requires standard QUIC TLS 1.3"); } + if let Some(identity) = &self.tls.identity { + crate::security::TlsClientIdentity::from_pem( + &identity.certificate, + &identity.private_key, + ) + .map_err(|_| { + crate::VoleError::InvalidConfig("invalid TUIC TLS client identity".into()) + })?; + } Ok(()) } } diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 99f6b3a..f53f7af 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -224,14 +224,14 @@ struct TcpPoolSnapshot { } impl TcpConnectionPool { - fn new() -> Arc { + fn new(security: crate::security::SecurityContext) -> Arc { let max_idle = TCP_POOL_MAX_IDLE; let max_idle_per_key = TCP_POOL_MAX_IDLE_PER_KEY; let mut stats = TcpPoolStats::default(); stats.idle_limit = max_idle; stats.idle_per_key_limit = max_idle_per_key; Arc::new(Self { - security: crate::security::SecurityContext::new(), + security, state: Mutex::new(TcpPoolState::default()), max_idle, max_idle_per_key, @@ -970,6 +970,7 @@ impl RuntimeDns { config: &DnsConfig, dispatcher: Arc, bootstrap: super::resolution::ResolutionContext, + security: crate::security::SecurityContext, ) -> Self { let mut resolver = Self::assemble_leaf( config, @@ -978,6 +979,7 @@ impl RuntimeDns { config.cache_max_size, 0, Arc::new(RwLock::new(0)), + security, ); resolver.node_bootstrap = Some(bootstrap); // No idle background reaper belongs to this private node resolver. @@ -1006,6 +1008,7 @@ impl RuntimeDns { geo_matcher, config.cache_max_size, MAX_REDIR_HOST_ENTRIES, + crate::security::SecurityContext::new(), ) } @@ -1060,6 +1063,29 @@ impl RuntimeDns { geo_matcher: Arc, address_cache_entries: usize, redir_host_entries: usize, + ) -> Self { + Self::new_routed_proxies_with_cache_limits_and_security( + config, + proxies, + direct, + rules, + geo_matcher, + address_cache_entries, + redir_host_entries, + crate::security::SecurityContext::new(), + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_routed_proxies_with_cache_limits_and_security( + config: &DnsConfig, + proxies: ProxyDispatchers, + direct: Arc, + rules: RuleSet, + geo_matcher: Arc, + address_cache_entries: usize, + redir_host_entries: usize, + security: crate::security::SecurityContext, ) -> Self { debug_assert!(address_cache_entries != 0); Self::assemble( @@ -1072,6 +1098,7 @@ impl RuntimeDns { geo_matcher, address_cache_entries, redir_host_entries, + security, ) } @@ -1081,6 +1108,7 @@ impl RuntimeDns { geo: Arc, cache: usize, hints: usize, + security: crate::security::SecurityContext, ) -> Self { let sources = Arc::new(super::source::NetworkSources::default()); let cache_epoch = sources.epoch(); @@ -1096,6 +1124,7 @@ impl RuntimeDns { cache, 0, cache_epoch.clone(), + security.clone(), )); let mut root = Self::assemble_leaf( config, @@ -1104,6 +1133,7 @@ impl RuntimeDns { cache, hints, cache_epoch.clone(), + security.clone(), ); root.bootstrap = Some(bootstrap.clone()); if config.enable && config.fake_ip.enabled { @@ -1124,6 +1154,7 @@ impl RuntimeDns { cache, 0, cache_epoch.clone(), + security.clone(), ); resolver.bootstrap = Some(bootstrap.clone()); Arc::new(resolver) @@ -1154,6 +1185,7 @@ impl RuntimeDns { address_cache_entries: usize, redir_host_entries: usize, cache_epoch: Arc>, + security: crate::security::SecurityContext, ) -> Self { let resource_stats = RuntimeResourceStats::new("runtime_dns"); Self { @@ -1193,7 +1225,7 @@ impl RuntimeDns { ))) }), singleflight: SingleflightRegistry::new(resource_stats.clone()), - tcp_pool: TcpConnectionPool::new(), + tcp_pool: TcpConnectionPool::new(security), next_id: AtomicU16::new(1), resource_stats, } @@ -3199,6 +3231,31 @@ mod tests { } } + #[test] + fn all_dns_roles_share_the_session_trust_store() { + let mut config = config(false, vec![nameserver(1, DnsTransport::Tls)]); + config.default_nameservers = vec![nameserver(2, DnsTransport::Tls)]; + config.proxy_nameservers = vec![nameserver(3, DnsTransport::Https)]; + config.direct_nameservers = vec![nameserver(4, DnsTransport::Https)]; + let security = crate::security::SecurityContext::new(); + let resolver = RuntimeDns::assemble( + &config, + DnsEgress::Single(Arc::new(MockDispatcher::default())), + Arc::new(EmptyGeoMatcher), + config.cache_max_size, + 0, + security.clone(), + ); + for role in [ + &resolver, + resolver.bootstrap.as_deref().unwrap(), + resolver.proxy_resolver.as_deref().unwrap(), + resolver.direct_resolver.as_deref().unwrap(), + ] { + assert!(security.shares_roots_with(&role.tcp_pool.security)); + } + } + fn response_for_query(query: &[u8], spec: &ResponseSpec) -> Vec { classify_query(query).unwrap(); let mut response = query.to_vec(); @@ -4899,6 +4956,7 @@ dns: Arc::new(EmptyGeoMatcher), MAX_CACHE_ENTRIES, 0, + crate::security::SecurityContext::new(), ); assert!(resolver.hints.is_none()); @@ -5338,6 +5396,7 @@ dns: Arc::new(EmptyGeoMatcher), MAX_CACHE_ENTRIES, MAX_REDIR_HOST_ENTRIES, + crate::security::SecurityContext::new(), )); let mut tasks = Vec::with_capacity(CLIENTS); @@ -6332,6 +6391,7 @@ dns: Arc::new(EmptyGeoMatcher), MAX_CACHE_ENTRIES, MAX_REDIR_HOST_ENTRIES, + crate::security::SecurityContext::new(), )); let observed_at = TokioInstant::now(); let permit = resolver.begin_query(); @@ -6361,6 +6421,7 @@ dns: Arc::new(EmptyGeoMatcher), MAX_CACHE_ENTRIES, MAX_REDIR_HOST_ENTRIES, + crate::security::SecurityContext::new(), )); let query = build_query(92, "retained.example", QueryType::A).unwrap(); diff --git a/src/geodata/service.rs b/src/geodata/service.rs index 0e54cf7..15f1517 100644 --- a/src/geodata/service.rs +++ b/src/geodata/service.rs @@ -32,6 +32,7 @@ const RETRY_BACKOFF: [Duration; 4] = [ ]; pub(crate) struct GeoDataUpdateService { + security: crate::security::SecurityContext, manager: Arc, dispatcher: Arc, registration: GeoDataRegistrationLease, @@ -43,6 +44,7 @@ pub(crate) struct GeoDataUpdateService { impl GeoDataUpdateService { #[must_use] + #[allow(clippy::too_many_arguments)] pub(crate) fn new( manager: Arc, dispatcher: Arc, @@ -51,8 +53,10 @@ impl GeoDataUpdateService { user_agent: String, etag_support: bool, auto_update: bool, + security: crate::security::SecurityContext, ) -> Self { Self { + security, manager, dispatcher, registration, @@ -196,6 +200,7 @@ impl GeoDataUpdateService { .then(|| session.request_etag().map(ToOwned::to_owned)) .flatten(); let request = DownloadRequest { + security: self.security.clone(), dispatcher: self.dispatcher.clone(), url: source_url, etag: etag.clone(), @@ -432,6 +437,7 @@ mod tests { "fixture".into(), true, true, + crate::security::SecurityContext::new(), ); let run = tokio::spawn(service.run(cancellation.clone())); tokio::time::timeout(Duration::from_secs(1), async { diff --git a/src/invoke/measure_delay.rs b/src/invoke/measure_delay.rs index ca14adf..7675323 100644 --- a/src/invoke/measure_delay.rs +++ b/src/invoke/measure_delay.rs @@ -204,7 +204,7 @@ async fn measure_one( let runtime = prepared .into_runtime(Dialer::default()) .map_err(InvokeFailure::from)?; - let result = run_probe(target, runtime.dispatcher(), timeout).await; + let result = run_probe(target, runtime.dispatcher(), timeout, runtime.security()).await; runtime.shutdown().await; result } @@ -213,6 +213,7 @@ async fn run_probe( target: &Target, dispatcher: Arc, timeout: Duration, + security: &crate::security::SecurityContext, ) -> Result { crate::resource::probe::probe( target, @@ -221,6 +222,7 @@ async fn run_probe( false, &[], &tokio_util::sync::CancellationToken::new(), + security, ) .await .map_err(InvokeFailure::from) @@ -439,7 +441,7 @@ mod tests { probe_once( &Target::parse("http://example.test/path?q=1").unwrap(), dispatcher.clone(), - None, + &crate::security::SecurityContext::new(), ) .await .unwrap(); diff --git a/src/outbound/tuic/outbound.rs b/src/outbound/tuic/outbound.rs index 4d3a2f0..8ce5f2a 100644 --- a/src/outbound/tuic/outbound.rs +++ b/src/outbound/tuic/outbound.rs @@ -121,6 +121,17 @@ impl TuicOutbound { versions: TlsVersions::Tls13, alpn: config.tls.alpn.clone(), certificate: config.tls.certificate.clone(), + identity: config + .tls + .identity + .as_ref() + .map(|identity| { + crate::security::TlsClientIdentity::from_pem( + &identity.certificate, + &identity.private_key, + ) + }) + .transpose()?, ..Default::default() }, 0, diff --git a/src/outbound/wireguard/engine.rs b/src/outbound/wireguard/engine.rs index e59caba..73a8538 100644 --- a/src/outbound/wireguard/engine.rs +++ b/src/outbound/wireguard/engine.rs @@ -23,6 +23,7 @@ struct PeerSlot { current: Mutex>>, } pub(super) struct Engine { + pub security: crate::security::SecurityContext, pub cancel: CancellationToken, pub tcp: TcpConnector, pub registry: Arc, @@ -48,6 +49,7 @@ impl Engine { upstream: UpstreamPath, context: EstablishContext, cancel: CancellationToken, + security: crate::security::SecurityContext, ) -> Result, DispatchError> { let ClientNetStackParts { packet_sink, @@ -72,6 +74,7 @@ impl Engine { .map(|_| PeerSlot::default()) .collect(); let engine = Arc::new(Self { + security, cancel: cancel.clone(), tcp: tcp_connector, registry: Arc::new(Registry::default()), diff --git a/src/outbound/wireguard/mod.rs b/src/outbound/wireguard/mod.rs index d6d4120..6e25e93 100644 --- a/src/outbound/wireguard/mod.rs +++ b/src/outbound/wireguard/mod.rs @@ -28,6 +28,7 @@ use tokio::sync::Mutex as AsyncMutex; use tokio_util::{sync::CancellationToken, task::TaskTracker}; pub struct WireGuardOutbound { + security: crate::security::SecurityContext, config: Arc, upstream: UpstreamPath, current: AsyncMutex>>, @@ -44,6 +45,13 @@ impl WireGuardOutbound { pub fn new_with_path( config: &WireGuardOutboundConfig, upstream: UpstreamPath, + ) -> io::Result { + Self::with_shared_security(config, upstream, &crate::security::SecurityContext::new()) + } + pub(crate) fn with_shared_security( + config: &WireGuardOutboundConfig, + upstream: UpstreamPath, + security: &crate::security::SecurityContext, ) -> io::Result { config.validate().map_err(|_| { io::Error::new( @@ -52,6 +60,7 @@ impl WireGuardOutbound { ) })?; Ok(Self { + security: security.clone(), config: Arc::new(config.clone()), upstream, current: AsyncMutex::new(None), @@ -69,7 +78,7 @@ impl WireGuardOutbound { let _admission=self.admission.lock().unwrap(); if self.cancel.is_cancelled() {return Err(DispatchError::NotAllowed);} if let Some(old)=current.take() {self.retired.spawn(observation::task(async move {old.stop().await;}));} - let engine=Engine::start(self.config.clone(),self.upstream.clone(),context.clone(),self.cancel.child_token())?; + let engine=Engine::start(self.config.clone(),self.upstream.clone(),context.clone(),self.cancel.child_token(),self.security.clone())?; // Publish ownership before waiting for network setup or authentication. *current=Some(engine.clone()); Ok(engine) diff --git a/src/outbound/wireguard/resolver.rs b/src/outbound/wireguard/resolver.rs index 746fbb0..e8e7959 100644 --- a/src/outbound/wireguard/resolver.rs +++ b/src/outbound/wireguard/resolver.rs @@ -24,7 +24,7 @@ pub(super) async fn resolve( let dns=engine.remote.get_or_init(||{ let mut config=DnsConfig{enable:true,ipv6:engine.ipv6.is_some()&&resolution.ipv6_allowed(),nameservers:engine.config.dns.clone(),use_hosts:false,use_system_hosts:false,..Default::default()}; config.hosts.clear();config.default_nameservers.clear(); - RuntimeDns::new_for_node(&config,Arc::new(NodeDns{engine:Arc::downgrade(engine)}),engine.resolution.clone()) + RuntimeDns::new_for_node(&config,Arc::new(NodeDns{engine:Arc::downgrade(engine)}),engine.resolution.clone(),engine.security.clone()) }); resolution.resolve_node_local(target,deadline,dns,engine.ipv4.is_some(),engine.ipv6.is_some()).await }=>result} diff --git a/src/resource/cache.rs b/src/resource/cache.rs index e9c237c..7e2e790 100644 --- a/src/resource/cache.rs +++ b/src/resource/cache.rs @@ -200,6 +200,7 @@ impl ProviderCache { dispatcher: Arc, user_agent: String, etag_support: bool, + security: crate::security::SecurityContext, cancellation: CancellationToken, ) -> io::Result> { let entry = &mut self.entries[index]; @@ -236,6 +237,7 @@ impl ProviderCache { } let temporary = temporary_path(&path); let outcome = http::download(DownloadRequest { + security, dispatcher, url: entry.spec.url.clone(), etag: etag_support.then(|| entry.metadata.etag.clone()).flatten(), diff --git a/src/resource/cache_tests.rs b/src/resource/cache_tests.rs index e26e9ab..e557a33 100644 --- a/src/resource/cache_tests.rs +++ b/src/resource/cache_tests.rs @@ -74,6 +74,7 @@ async fn fetch(cache: &mut ProviderCache, response: Vec) -> io::Result, pub url: String, pub etag: Option, @@ -129,7 +128,9 @@ pub(crate) enum DownloadError { /// The successful `Downloaded` result leaves a fully flushed temporary file at /// `request.temporary_path`. All other outcomes remove the temporary file. pub(crate) async fn download(request: DownloadRequest) -> Result { - let connector = WebPkiHttpsConnector::new()?; + let connector = WebPkiHttpsConnector { + security: request.security.clone(), + }; download_with_connector(request, &connector).await } @@ -143,24 +144,7 @@ trait HttpsConnector: Send + Sync { } struct WebPkiHttpsConnector { - connector: TlsConnector, -} - -impl WebPkiHttpsConnector { - fn new() -> Result { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - let roots: RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); - let mut config = ClientConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(|error| DownloadError::TlsSetup(error.to_string()))? - .with_root_certificates(Arc::new(roots)) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - config.resumption = Resumption::disabled(); - Ok(Self { - connector: TlsConnector::from(Arc::new(config)), - }) - } + security: crate::security::SecurityContext, } #[async_trait] @@ -170,13 +154,20 @@ impl HttpsConnector for WebPkiHttpsConnector { server_name: &str, stream: BoxStream, ) -> Result { - let server_name = ServerName::try_from(server_name.to_owned()) - .map_err(|error| DownloadError::TlsSetup(error.to_string()))?; - self.connector - .connect(server_name, stream) - .await - .map(|stream| Box::new(stream) as BoxStream) - .map_err(|error| DownloadError::TlsHandshake(error.to_string())) + crate::security::StandardTlsClient::with_options( + &self.security, + server_name, + crate::security::TlsClientOptions { + alpn: vec![b"http/1.1".to_vec()], + ..Default::default() + }, + 0, + 64 * 1024, + ) + .map_err(|_| DownloadError::TlsSetup("invalid TLS policy".into()))? + .connect(stream) + .await + .map_err(|_| DownloadError::TlsHandshake("TLS handshake failed".into())) } } @@ -1203,6 +1194,7 @@ mod tests { temporary_path: PathBuf, ) -> DownloadRequest { DownloadRequest { + security: crate::security::SecurityContext::new(), dispatcher, url: url.to_owned(), etag: Some("\"old\"".to_owned()), diff --git a/src/resource/probe.rs b/src/resource/probe.rs index ea5085f..bcac514 100644 --- a/src/resource/probe.rs +++ b/src/resource/probe.rs @@ -1,11 +1,11 @@ //! Shared, bounded HTTP(S) health probes through the runtime dispatcher. +use crate::security::{SecurityContext, StandardTlsClient, TlsClientOptions}; use crate::{ dispatch::{BoxStream, DispatchError, Dispatcher}, inbound::DEFAULT_HEADER_LIMIT, outbound::{OutboundDiagnostic, capture_outbound_diagnostic}, session::{Destination, InboundKind, StreamSession}, }; -use rustls::{ClientConfig, RootCertStore, client::Resumption, pki_types::ServerName}; use std::{ io, net::{IpAddr, Ipv4Addr, SocketAddr}, @@ -13,7 +13,6 @@ use std::{ time::{Duration, Instant}, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio_rustls::TlsConnector; use tokio_util::sync::CancellationToken; use url::{Host, Url}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -103,23 +102,10 @@ fn authority(host: &TargetHost, port: u16) -> String { } } -fn build_tls_connector() -> io::Result { - let provider = Arc::new(rustls::crypto::ring::default_provider()); - let roots: RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); - let mut config = ClientConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions() - .map_err(io::Error::other)? - .with_root_certificates(Arc::new(roots)) - .with_no_client_auth(); - config.alpn_protocols = vec![b"http/1.1".to_vec()]; - config.resumption = Resumption::disabled(); - Ok(TlsConnector::from(Arc::new(config))) -} - async fn connect( target: &Target, dispatcher: Arc, - tls_connector: Option<&TlsConnector>, + security: &SecurityContext, ) -> io::Result { let session = StreamSession { metadata: Default::default(), @@ -131,9 +117,17 @@ async fn connect( let (stream, diagnostic) = capture_outbound_diagnostic(dispatcher.connect_tcp(session)).await; let stream = stream.map_err(|error| outbound_connect_error(error, diagnostic.as_ref()))?; let stream = if target.scheme == TargetScheme::Https { - let connector = tls_connector - .ok_or_else(|| io::Error::other("HTTPS probe is missing its TLS connector"))?; - connect_target_tls(connector, stream, &target.tls_server_name).await? + let client = StandardTlsClient::with_options( + security, + &target.tls_server_name, + TlsClientOptions { + alpn: vec![b"http/1.1".to_vec()], + ..Default::default() + }, + 0, + 64 * 1024, + )?; + connect_target_tls(&client, stream).await? } else { stream }; @@ -156,17 +150,13 @@ fn outbound_connect_error( } async fn connect_target_tls( - connector: &TlsConnector, + connector: &StandardTlsClient, stream: BoxStream, - server_name: &str, ) -> io::Result { - let server_name = ServerName::try_from(server_name.to_owned()) - .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; connector - .connect(server_name, stream) + .connect(stream) .await - .map(|stream| Box::new(stream) as BoxStream) - .map_err(io::Error::other) + .map_err(|_| io::Error::other("probe TLS handshake failed")) } async fn head(stream: &mut BoxStream, target: &Target, keep_alive: bool) -> io::Result { @@ -179,9 +169,9 @@ async fn head(stream: &mut BoxStream, target: &Target, keep_alive: bool) -> io:: pub(crate) async fn probe_once( target: &Target, dispatcher: Arc, - tls: Option<&TlsConnector>, + security: &SecurityContext, ) -> io::Result<()> { - let mut stream = connect(target, dispatcher, tls).await?; + let mut stream = connect(target, dispatcher, security).await?; head(&mut stream, target, false).await.map(drop) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -197,8 +187,12 @@ pub(crate) async fn probe( unified: bool, expected: &[(u16, u16)], cancel: &CancellationToken, + security: &SecurityContext, ) -> io::Result { - let observation = observe(target, dispatcher, timeout, unified, expected, cancel).await?; + let observation = observe( + target, dispatcher, timeout, unified, expected, cancel, security, + ) + .await?; if observation.expected_matches { Ok(observation.delay) } else { @@ -213,13 +207,11 @@ pub(crate) async fn observe( unified: bool, expected: &[(u16, u16)], cancel: &CancellationToken, + security: &SecurityContext, ) -> io::Result { let operation = async { - let tls = (target.scheme == TargetScheme::Https) - .then(build_tls_connector) - .transpose()?; let mut started = Instant::now(); - let mut stream = connect(target, dispatcher, tls.as_ref()).await?; + let mut stream = connect(target, dispatcher, security).await?; let mut status = head(&mut stream, target, unified).await?; if unified { let second = Instant::now(); @@ -353,6 +345,7 @@ mod tests { true, &[(204, 204)], &cancel, + &SecurityContext::new(), ) .await .unwrap(); @@ -365,7 +358,8 @@ mod tests { Duration::from_secs(1), false, &[], - &cancel + &cancel, + &SecurityContext::new(), ) .await .unwrap_err() diff --git a/src/runtime.rs b/src/runtime.rs index 7d8b419..20dbed6 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -81,6 +81,7 @@ use crate::outbound::trojan::TrojanOutbound; use crate::outbound::{AnyTlsOutbound, server_destination}; #[cfg(feature = "outbound-vless")] use crate::outbound::{VlessOutbound, VlessResourceLimits}; +use crate::security::SecurityContext; #[cfg(feature = "outbound-anytls")] use crate::security::StandardTlsClient; #[cfg(any( @@ -94,7 +95,7 @@ use crate::security::StandardTlsClient; feature = "outbound-tuic", feature = "shadow-tls-v3" ))] -use crate::security::{SecurityContext, TLS_RESUMPTION_SESSION_BUDGET}; +use crate::security::TLS_RESUMPTION_SESSION_BUDGET; mod health; mod providers; @@ -104,6 +105,7 @@ use providers::ProviderRuntime; #[derive(Debug)] pub(crate) struct PreparedCore { config: Config, + security: SecurityContext, provider_cache: std::sync::Mutex>, endpoints: Vec, limits: ResourceLimits, @@ -127,6 +129,7 @@ pub(crate) struct PreparedCore { #[cfg(feature = "invoke")] pub(crate) struct PreparedMeasurement { config: MeasureConfig, + security: SecurityContext, endpoints: Vec, limits: ResourceLimits, } @@ -142,12 +145,16 @@ struct PreparedProxyEndpoints { #[cfg(feature = "invoke")] pub(crate) struct MeasurementRuntime { + security: SecurityContext, dispatcher: Arc, proxy_graph: Arc, } #[cfg(feature = "invoke")] impl MeasurementRuntime { + pub(crate) fn security(&self) -> &SecurityContext { + &self.security + } #[must_use] pub(crate) fn dispatcher(&self) -> Arc { self.dispatcher.clone() @@ -308,8 +315,13 @@ impl PreparedCore { .tun .enable .then(|| Arc::new(TunTrafficStats::default())); + let trust = config.tls.clone(); + let security = tokio::task::spawn_blocking(move || SecurityContext::load(&trust)) + .await + .map_err(io::Error::other)?; Ok(Self { config, + security, provider_cache: std::sync::Mutex::new(provider_cache), endpoints, limits, @@ -389,11 +401,13 @@ impl PreparedCore { self.limits, dialer, ResolutionContext::runtime(self.config.ipv6), + &self.security, ) } fn build_dispatcher(&self, dialer: Dialer) -> io::Result { let mut parts = GraphBuilder { + security: self.security.clone(), connections: Arc::default(), config: self.config.clone(), endpoints: self.endpoints.clone(), @@ -432,6 +446,7 @@ impl PreparedCore { self.config.global_ua.clone(), self.config.etag_support, update.auto_update && update.interval_hours > 0, + self.security.clone(), )); } Ok(parts) @@ -633,6 +648,7 @@ impl PreparedCore { } struct GraphBuilder { + security: SecurityContext, connections: Arc, config: Config, endpoints: Vec, @@ -695,7 +711,7 @@ impl GraphBuilder { dns_config.ipv6 = self.config.ipv6; } let dns = Some(Arc::new( - RuntimeDns::new_routed_proxies_with_cache_limits( + RuntimeDns::new_routed_proxies_with_cache_limits_and_security( &dns_config, route_targets.clone(), direct.clone(), @@ -703,6 +719,7 @@ impl GraphBuilder { geo_matcher.clone(), dns_config.cache_max_size, redir_host_entries, + self.security.clone(), ) .with_network_sources( dialer.clone(), @@ -758,6 +775,7 @@ impl GraphBuilder { direct.clone(), activities, dns.clone(), + self.security.clone(), ); let diagnostics = self.config.external_controller.as_ref().map(|_| { Arc::new(diagnostics::Diagnostics::new( @@ -805,6 +823,7 @@ impl GraphBuilder { self.limits, dialer, ResolutionContext::runtime(self.config.ipv6), + &self.security, ) } @@ -838,8 +857,13 @@ impl PreparedMeasurement { .validate() .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?; let endpoints = prepare_proxy_endpoints(&config.proxies, &[], resolver, true).await?; + let trust = config.tls.clone(); + let security = tokio::task::spawn_blocking(move || SecurityContext::load(&trust)) + .await + .map_err(io::Error::other)?; Ok(Self { config, + security, endpoints, limits, }) @@ -862,6 +886,7 @@ impl PreparedMeasurement { self.limits, dialer, ResolutionContext::measurement(resolver, true), + &self.security, )?; let connector = proxy_graph .get(self.config.default_proxy.index()) @@ -879,6 +904,7 @@ impl PreparedMeasurement { let dispatcher = observe_handshakes_with_stats(dispatcher, handshake_stats); let session_stats = RuntimeResourceStats::new("measurement_session_observation"); Ok(MeasurementRuntime { + security: self.security, dispatcher: observe_sessions_with_stats(dispatcher, session_stats), proxy_graph: Arc::new(proxy_graph), }) @@ -892,6 +918,7 @@ fn build_proxy_graph( limits: ResourceLimits, dialer: Dialer, resolution: ResolutionContext, + security: &SecurityContext, ) -> io::Result { if endpoints.len() != proxies.len() { return Err(io::Error::new( @@ -912,7 +939,7 @@ fn build_proxy_graph( feature = "outbound-tuic", feature = "shadow-tls-v3" ))] - let (security_client_count, standard_tls_count) = security_counts(proxies); + let (_, standard_tls_count) = security_counts(proxies); #[cfg(any( feature = "outbound-http", feature = "outbound-socks5", @@ -924,7 +951,7 @@ fn build_proxy_graph( feature = "outbound-tuic", feature = "shadow-tls-v3" ))] - let security_context = (security_client_count != 0).then(SecurityContext::new); + let security_context = Some(security.clone()); #[cfg(any( feature = "outbound-http", feature = "outbound-socks5", @@ -1011,8 +1038,8 @@ fn build_proxy_graph( #[cfg(feature = "outbound-wireguard")] { Arc::new( - crate::outbound::wireguard::WireGuardOutbound::new_with_path( - config, upstream, + crate::outbound::wireguard::WireGuardOutbound::with_shared_security( + config, upstream, security, )?, ) } diff --git a/src/runtime/health.rs b/src/runtime/health.rs index f630abf..97b28c3 100644 --- a/src/runtime/health.rs +++ b/src/runtime/health.rs @@ -68,6 +68,7 @@ struct Job { provider: Option, } pub(super) struct HealthRuntime { + security: SecurityContext, jobs: Mutex>, samples: Mutex>, manual_samples: Mutex>, @@ -90,6 +91,7 @@ impl HealthRuntime { direct: Arc, activity: Vec>, dns: Option>, + security: SecurityContext, ) -> Arc { let mut jobs = Vec::new(); for (i, group) in config.proxy_groups.iter().enumerate() { @@ -144,6 +146,7 @@ impl HealthRuntime { .zip(activity) .collect(); Arc::new(Self { + security, jobs: Mutex::new(jobs), samples: Mutex::new(BTreeMap::new()), manual_samples: Mutex::new(BTreeMap::new()), @@ -264,6 +267,7 @@ impl HealthRuntime { self.unified, &request.expected, cancel, + &self.security, ) .await .map_err(|error| match error.kind() { @@ -556,7 +560,15 @@ mod tests { }); let groups = ProxyGroups::new(&config.proxy_groups, vec![], direct.clone()).unwrap(); let targets = RouteTargetDispatchers::with_proxy_groups(vec![], &groups).unwrap(); - let runtime = HealthRuntime::new(&config, groups, targets, direct.clone(), vec![], None); + let runtime = HealthRuntime::new( + &config, + groups, + targets, + direct.clone(), + vec![], + None, + SecurityContext::new(), + ); let cancel = CancellationToken::new(); for index in 0..8 { let request = DelayRequest { diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 950896f..143787e 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -64,6 +64,7 @@ struct Refresh { reply: oneshot::Sender>, } pub(super) struct ProviderRuntime { + security: SecurityContext, store: crate::resource::profile::SelectionStore, persistence_error: RwLock>, current: ArcSwap, @@ -101,6 +102,7 @@ impl ProviderRuntime { ); store.restore(&parts.proxy_groups); Arc::new(Self { + security: prepared.security.clone(), store, persistence_error: RwLock::new(None), current: ArcSwap::from_pointee(Generation::from_parts( @@ -277,6 +279,7 @@ impl ProviderRuntime { dispatcher, user_agent, etag, + self.security.clone(), self.cancellation.clone(), ) .await; @@ -298,6 +301,7 @@ impl ProviderRuntime { .store_dir() .parent() .map(std::path::Path::to_path_buf); + let security = self.security.clone(); let expression_cancellation = self.cancellation.clone(); let prepared = tokio::task::spawn_blocking( move || -> io::Result<(Config, BuiltRuntimeParts, GeoRequirements)> { @@ -311,6 +315,7 @@ impl ProviderRuntime { let rules = RuleSet::compile_config(&mut config).map_err(io::Error::other)?; let endpoints = vec![PreparedProxyEndpoints::default(); config.proxies.len()]; let parts = GraphBuilder { + security, connections, config: config.clone(), endpoints, diff --git a/src/security/context.rs b/src/security/context.rs index f119279..ec90f78 100644 --- a/src/security/context.rs +++ b/src/security/context.rs @@ -22,6 +22,40 @@ impl std::fmt::Debug for SecurityContext { } impl SecurityContext { + #[cfg(test)] + pub(crate) fn shares_roots_with(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.tls_roots, &other.tls_roots) + } + /// Read available platform roots once during preparation, retaining the + /// approved built-in roots when a platform store is unavailable. + #[cfg(any(test, feature = "invoke"))] + pub(crate) fn load(trust: &crate::config::TlsTrustConfig) -> Self { + let native = rustls_native_certs::load_native_certs(); + if !native.errors.is_empty() { + tracing::debug!("some platform TLS trust roots were unavailable"); + } + Self::with_certificates(trust, native.certs) + } + + #[cfg(any(test, feature = "invoke"))] + pub(crate) fn with_certificates( + trust: &crate::config::TlsTrustConfig, + mut native: Vec>, + ) -> Self { + native.extend(trust.certificates.iter().cloned()); + native.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref())); + native.dedup_by(|a, b| a.as_ref() == b.as_ref()); + let mut roots: RootCertStore = webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(); + roots.add_parsable_certificates(native); + // Native and embedded sources may contain the same trust anchor. + let mut unique = std::collections::HashSet::new(); + roots.roots.retain(|root| unique.insert(root.clone())); + Self { + provider: Arc::new(rustls::crypto::ring::default_provider()), + tls_roots: Arc::new(roots), + } + } + #[must_use] pub fn new() -> Self { Self { @@ -57,4 +91,27 @@ mod tests { assert!(Arc::ptr_eq(&context.tls_roots, &clone.tls_roots)); assert!(!context.tls_roots.is_empty()); } + + #[test] + fn configured_and_native_roots_merge_once_without_removing_embedded_roots() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let certificate = rcgen::generate_simple_self_signed(vec!["trust.example".into()]) + .unwrap() + .cert; + let pem = format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + STANDARD.encode(certificate.der()) + ); + let trust = serde_yaml_ng::from_value::( + serde_yaml_ng::to_value(serde_json::json!({"custom-certifactes": [pem]})).unwrap(), + ) + .unwrap(); + let context = SecurityContext::with_certificates(&trust, vec![certificate.der().clone()]); + assert_eq!( + context.tls_roots.len(), + SecurityContext::new().tls_roots.len() + 1 + ); + assert!(context.shares_roots_with(&context.clone())); + assert!(!context.shares_roots_with(&SecurityContext::new())); + } } diff --git a/src/security/tls_tests.rs b/src/security/tls_tests.rs index fa76d1e..c1d4bab 100644 --- a/src/security/tls_tests.rs +++ b/src/security/tls_tests.rs @@ -237,6 +237,41 @@ async fn named_fingerprint_uses_the_shared_webpki_policy_and_transport_alpn() { } } +#[tokio::test] +async fn configured_roots_authenticate_standard_and_named_tls_without_relaxing_names() { + use base64::{Engine as _, engine::general_purpose::STANDARD}; + let chain = chain(false); + let pem = format!( + "-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", + STANDARD.encode(&chain.certificates[2]) + ); + let config = crate::config::Config::parse_yaml( + &serde_json::to_vec(&serde_json::json!({ + "tls": {"custom-certifactes": [pem]}, + })) + .unwrap(), + ) + .unwrap(); + let context = SecurityContext::with_certificates(&config.tls, Vec::new()); + let server = server(&chain, &TLS13, false); + for profile in crate::security::test_profiles() { + for (name, expected) in [("fixture.invalid", true), ("different.invalid", false)] { + let client = StandardTlsClient::with_options( + &context, + name, + TlsClientOptions { + client_fingerprint: *profile, + ..Default::default() + }, + 0, + DEFAULT_TLS_BUFFER_LIMIT, + ) + .unwrap(); + assert_eq!(handshake(&client, server.clone()).await.0, expected); + } + } +} + #[cfg(feature = "tls-fingerprint")] #[tokio::test] async fn named_fingerprint_resumption_is_node_owned_and_disabled_at_zero_budget() { diff --git a/tests/README.md b/tests/README.md index fdb7f03..323f6b7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,8 @@ cargo test --locked --lib config::credentials::tests:: cargo test --locked --lib dns::hosts::tests:: cargo test --locked --lib config::name_regex::tests:: cargo test --locked --lib config::tls_policy_tests:: +cargo test --locked --lib config::tls_trust::tests:: +cargo test --locked --lib security::context::tests:: cargo test --locked --no-default-features --features cli --test grpc_config --test grpc_pool --test grpc_protocol_pool cargo test --locked --no-default-features --features cli --lib grpc_duplex_pool_keeps_response cargo test --locked --no-default-features --features cli --test sing_mux_config --test sing_mux From 845c8ff0f1d53a92adacf245f63398a1a5b57c46 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 21:41:12 +0800 Subject: [PATCH 049/445] feat: support bounded DNS upstream query options --- Cargo.lock | 92 ++++++++ Cargo.toml | 1 + docs/mihomo-compatibility.md | 3 +- docs/tun-icmp-dns.md | 31 ++- src/config/dns.rs | 173 ++++++++++++-- src/config/mod.rs | 39 +++- src/dns/mod.rs | 16 ++ src/dns/options.rs | 386 ++++++++++++++++++++++++++++++++ src/dns/runtime.rs | 232 +++++++++++++++++-- src/dns/tests_encrypted.rs | 67 +++++- src/outbound/wireguard/tests.rs | 1 + src/tun_runtime/tests.rs | 1 + tests/README.md | 6 + tests/resolution_foundations.rs | 2 + 14 files changed, 1000 insertions(+), 50 deletions(-) create mode 100644 src/dns/options.rs diff --git a/Cargo.lock b/Cargo.lock index 270d7bb..c20f392 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,6 +757,32 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "domain" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b68712c02bff2348323c1441ab2e8528e32af4f8f9edf29356356290328c8e6" +dependencies = [ + "bumpalo", + "bytes", + "domain-macros", + "hashbrown", + "jiff", + "octseq", + "serde", +] + +[[package]] +name = "domain-macros" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fba8fbc4580c745dfeab102232dcb729f030ac3e2c9ff5374a9dfd6dc9ddf8e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dunce" version = "1.0.5" @@ -885,6 +911,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -1149,6 +1181,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "heapless" @@ -1461,6 +1496,43 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -1780,6 +1852,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "octseq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "182eab3e1cd9cdc0ecf1ce3342d9844f3dc7d098f0694569bfdf327b612d69fd" +dependencies = [ + "bytes", + "serde", +] + [[package]] name = "oid-registry" version = "0.8.1" @@ -1932,6 +2014,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -3012,6 +3103,7 @@ dependencies = [ "boringtun", "bytes", "crc32fast", + "domain", "fancy-regex", "foreign-types", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index f3a7e63..57af9cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,6 +101,7 @@ boringtun = { version = "=0.7.1", default-features = false, optional = true } boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", features = ["client-fingerprint"], optional = true } bytes = "1" crc32fast = { version = "1.5.2", optional = true } +domain = { version = "=0.12.3", default-features = false, features = ["std"] } fancy-regex = { version = "=0.19.2", default-features = false, features = ["std", "unicode"] } futures-util = { version = "0.3", features = ["sink"] } getifaddrs = "=0.6.2" diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 83698e9..f61fcdf 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -24,7 +24,8 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 所有后端仍使用 WebPKI 校验,未复制 Go 的原生系统验证器。详细范围见 [TLS 契约](tls-client-fingerprint.md)。 独立测量 YAML 只接受 proxies 与同名客户端 tls 子集,不接受 TLS 服务端配置。 - Shadowsocks 只支持三个 2022 cipher;插件仅 strict ShadowTLS v3,UoT 仅 v2。 -- DNS 不提供独立 listen、DoQ、DoH/H3 或 ECS。LRU/ARC 与显式 system/DHCP 上游已支持, +- DNS 不提供独立 listen、DoQ 或 DoH/H3。ECS、上游类型过滤、TLS 验证参数、 + DoT 独立禁用复用、LRU/ARC 与显式 system/DHCP 上游已支持, 来源发现和物理绑定的可用平台及参考差异以 DNS 契约为准。 DoH 使用 H1 POST;详细解析角色、缓存与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 - Provider/group 名称筛选和改名支持 lookahead、定长 lookbehind、数字/命名 backreference; diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 71da73d..dc73d66 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -116,7 +116,9 @@ dns: - 上游域名由 `default-nameserver` 引导解析,后者允许 IP 上游和显式 system。未启用 DNS 时,内部解析使用系统 resolver;启用后不因查询失败回退系统解析。 - `system`、`system://`、`dhcp://system` 使用物理系统 DNS;`dhcp://<精确接口名>` 仅使用该接口的 DHCP DNS,不匹配其他接口。各 nameserver 角色、policy 和 fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终直连物理接口,fragment 与 respect-rules 不改其出口。 - 无 fragment 时默认 DIRECT;`respect-rules: true` 改为 RULES,要求非空 `proxy-server-nameserver`。 -- Fragment 只接受 `DIRECT`、`RULES`、实际代理节点名或支持的代理组名。 +- Fragment 使用 `#出口&参数=值`;出口接受 `DIRECT`、`RULES`、实际代理节点名或支持的代理组名。 + 仅提供参数时沿用默认出口;整个 fragment 先百分号解码,再按 `&` 和首个 `=` 拆分, + 同名参数和出口 token 以后项为准。未知出口、参数及物理网卡 token 不支持。 - `#RULES` 只按 nameserver endpoint 和传输执行业务规则,不把 DNS question 当作选路域名。 - 规则模式下,UDP 跳过不支持 UDP 的节点或组,继续匹配后续规则;显式出口和 global 模式不做此跳过。 - `RULES` 与业务 TCP/UDP 使用同一子规则求值器:`SUB-RULE`、`PASS`、`PASS-RULE` 的作用域一致;规则控制和组控制先于 UDP 能力检查。 @@ -124,6 +126,24 @@ dns: 代理组成员可以是具体节点、嵌套组、`DIRECT`、`REJECT`、`PASS` 或 `PASS-RULE`。后两者仅在 rule 模式的 `RULES` 中参与规则流程,global 模式、显式命名组或 dialer-proxy 选中控制目标时拒绝连接。DNS exchange 需要新建物理 transport 时解析当时的当前叶节点;选中成员失败不会使代理组自动改选,但 nameserver 列表仍按本节已有故障转移语义继续下一项。 +### 上游参数 + +`ecs` 接受 IPv4/IPv6 IP 或 CIDR,裸 IP 使用完整位数。已有 EDNS ECS 默认原样保留; +`ecs-override=true` 只替换首个 ECS,保留其 scope、OPT flags 和其他 option,编码时掩去前缀外的位。 +缺少 OPT 时添加 UDP size 为零的新 OPT。包装作用于所有解析角色和 system、DHCP、RCODE, +DHCP 接口名只取 URL endpoint,fragment 不参与接口查找。 + +`disable-ipv4=true`、`disable-ipv6=true` 和 `disable-qtype-<编号>=true` 抑制对应 question, +直接返回 NOERROR 空响应并设置 AA、RA,不读取来源或建链;编号限固定 Mihomo 参考已有 RR constructor。 +其他请求先验证上游响应,再过滤 Answer、Authority、Additional 中对应 RR。 +全报文通过官方 `domain` codec 重编码,保持所有存留名称引用有效;RFC 3597 未知 RDATA 保持原始字节。 +报文大小、记录数和名称压缩跳数仍受本节预算约束,不能通过过滤移除错误记录来绕过验证。 +类型参数和 `ecs-override` 仅在字面 `true` 时启用,与顶层及 DNS 的 IPv6 门禁独立。 + +DoT/HTTPS 的 `skip-cert-verify=true` 和 `name-cert-verify` 接共享 TLS 安全层,显式名称验证 +先于跳过验证且不改变 SNI。`disable-reuse=true` 仅使指定 DoT 上游每次新建并关闭连接, +不改变 HTTPS 或其他上游的复用。不同参数的上游保持池身份隔离。 + ### Policy `nameserver-policy` 是有序映射: @@ -197,6 +217,8 @@ bootstrap、单独配置的节点/直连 resolver,以及非地址 qtype 不使 `dns.cache-max-size` 控制每个 DNS 角色的结果总条目数,缺省或零值为 4,096。 A/AAAA 的类型化结果和其他 qtype 的原始响应共享这份容量与替换策略。 +客户端携带 EDNS 或额外语义 flags 的 A/AAAA 使用同一有界原始响应缓存,以完整查询摘要区分 +ECS、DO、CD 等上下文并保留响应 additional/flags;普通 A/AAAA 仍使用小型地址缓存。 `dns.cache-algorithm` 精确为 `arc` 时使用 ARC,缺省和其他字符串使用 LRU。 LRU 命中和替换将条目移到最近使用端;满容量时淘汰最久未使用的结果,命中不延长 TTL。 哈希索引和安全槽位链表按需分配,查询时只检查命中条目的寿命,过期结果重新查询。 @@ -218,7 +240,7 @@ ARC 分别保留近期与常用结果,命中提升到常用组,使用两组 - 正缓存寿命取可缓存记录的最小 TTL,并限制在 30–3,600 秒。 - NXDOMAIN 固定负缓存 30 秒;NOERROR 空 answer 只有在 authority 含 IN/SOA 时才缓存为 NODATA。 - Referral、TTL 0、无可缓存记录和扩展错误码不缓存。 -- 原始响应缓存不生成 TUN 域名提示。 +- 非地址 qtype 的原始响应不生成 TUN 域名提示;A/AAAA 仍按既有地址校验生成提示。 ### Singleflight @@ -265,7 +287,8 @@ wire/RCODE 语义,不使用这段地址合并等待。 TCP、DoT 和 DoH 使用解析器所属的连接池: -- Key 包含上游地址、认证名称、传输、HTTP 路径、endpoint 与最终 route target; +- Key 包含上游地址、TLS 验证策略、查询包装、传输、HTTP 路径、endpoint、最终 route target、 + 来源 generation 和物理接口绑定; - 一条连接同时只处理一个查询,不做 pipeline; - 活动连接不设固定业务数量上限;空闲连接总数最多 4、同 key 最多 2、超时 30 秒; - 完成帧和响应校验后才能归还连接; @@ -310,7 +333,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; - DoQ、DoH HTTP/2/3; - 运行中随接口变化自动刷新 hosts: lan; -- 用户可配置的缓存容量、查询总超时、重试或并发度; +- 用户可配置的查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 自动化和物理 TUN 覆盖范围见 [验收矩阵](acceptance.md)。 diff --git a/src/config/dns.rs b/src/config/dns.rs index d879e58..17daa81 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -126,11 +126,24 @@ pub(super) fn parse_dns_nameserver( .into(), ) }; - // Mihomo's special clients use physical DNS and ignore proxy fragments. - let special = input - .split_once('#') - .map_or(input, |(endpoint, _)| endpoint); - let source = match special { + let (endpoint, fragment) = input.split_once('#').unwrap_or((input, "")); + let (route_name, options) = + parse_nameserver_options(fragment, !endpoint.starts_with("https://"))?; + let route = if route_name.is_empty() { + default_route + } else { + match route_name.as_str() { + "DIRECT" => DnsRoute::Direct, + "RULES" => DnsRoute::Rules, + _ => targets + .get(&route_name) + .copied() + .map(DnsRoute::Route) + .ok_or_else(bad)?, + } + }; + // Special sources remain physical, but use the same query wrappers. + let source = match endpoint { "system" | "system://" | "dhcp://system" => Some(DnsSource::System), endpoint if endpoint.starts_with("dhcp://") => { let interface = &endpoint[7..]; @@ -146,24 +159,9 @@ pub(super) fn parse_dns_nameserver( port: 53, route: DnsRoute::Direct, path: None, + options, }); } - let (endpoint, route) = match input.split_once('#') { - Some((endpoint, fragment)) => { - let name = percent_decode(fragment)?; - let route = match name.as_str() { - "DIRECT" => DnsRoute::Direct, - "RULES" => DnsRoute::Rules, - _ => targets - .get(&name) - .copied() - .map(DnsRoute::Route) - .ok_or_else(bad)?, - }; - (endpoint, route) - } - None => (input, default_route), - }; if let Ok(address) = endpoint.parse::() { return Ok(DnsNameserver { address: address.into(), @@ -171,6 +169,7 @@ pub(super) fn parse_dns_nameserver( port: 53, route, path: None, + options, }); } let endpoint = if endpoint.contains("://") { @@ -201,6 +200,7 @@ pub(super) fn parse_dns_nameserver( port: 0, route, path: None, + options, }); } let (transport, default_port) = match url.scheme() { @@ -248,9 +248,140 @@ pub(super) fn parse_dns_nameserver( port, route, path, + options, }) } +fn parse_nameserver_options( + fragment: &str, + ignore_h3: bool, +) -> Result<(String, DnsNameserverOptions)> { + let fragment = percent_decode(fragment)?; + let mut route = String::new(); + let mut params = std::collections::BTreeMap::new(); + for part in fragment.split('&') { + if let Some((key, value)) = part.split_once('=') { + params.insert(key, value); + } else { + route = part.to_owned(); + } + } + let mut options = DnsNameserverOptions::default(); + let mut disabled = std::collections::BTreeSet::new(); + for (key, value) in params { + match key { + "ecs" if !value.is_empty() => { + let (address, prefix) = value + .split_once('/') + .map_or((value, None), |(a, p)| (a, Some(p))); + let address = address + .parse::() + .map_err(|_| VoleError::InvalidConfig("invalid DNS ECS address".into()))?; + let bits = if address.is_ipv4() { 32 } else { 128 }; + let prefix_len = prefix + .map_or(Ok(bits), |p| p.parse::()) + .map_err(|_| VoleError::InvalidConfig("invalid DNS ECS prefix".into()))?; + if prefix_len > bits { + return invalid("invalid DNS ECS prefix"); + } + options.ecs = Some(DnsEcs { + address, + prefix_len, + }); + } + "ecs" => {} + "ecs-override" => options.ecs_override = value == "true", + "disable-ipv4" if value == "true" => { + disabled.insert(1); + } + "disable-ipv6" if value == "true" => { + disabled.insert(28); + } + "disable-ipv4" | "disable-ipv6" => {} + key if key.starts_with("disable-qtype-") => { + if value == "true" + && let Ok(kind) = key[14..].parse::() + && valid_disabled_type(kind) + { + disabled.insert(kind); + } + } + "skip-cert-verify" => options.skip_cert_verify = value == "true", + "name-cert-verify" => { + options.verification_name = normalize_verification_name(Some(value.to_owned()))? + } + "disable-reuse" => options.disable_reuse = value == "true", + "h3" if value != "true" || ignore_h3 => {} + _ => return invalid("unsupported DNS upstream parameter"), + } + } + options.disabled_types = disabled.into_iter().collect(); + Ok((route, options)) +} + +fn valid_disabled_type(kind: u16) -> bool { + // Registered RR constructors in the fixed Mihomo miekg/dns v1.1.63 + // reference. Unknown 16-bit questions remain forwardable, not filters. + matches!(kind, 1..=10 | 12..=21 | 23..=33 | 35..=37 | 39 | 41..=53 | 55..=65 | 99..=102 | 104..=109 | 128 | 249 | 250 | 255..=258 | 260 | 32768 | 32769) +} + +#[cfg(test)] +mod upstream_options_tests { + use super::*; + + #[test] + fn upstream_options_decode_fragments_and_use_last_values() { + let server = parse_dns_nameserver( + "tls://192.0.2.1#DIRECT%26RULES%26ecs=2001:db8::7/57%26ecs-override=true%26disable-ipv4=true%26disable-ipv6=True%26disable-qtype-16=true%26disable-qtype-65280=true%26skip-cert-verify=false%26skip-cert-verify=true%26name-cert-verify=dns.example%26disable-reuse=true", + &RouteTargetsByName::new(), + DnsRoute::Direct, + ).unwrap(); + assert_eq!(server.route, DnsRoute::Rules); + assert_eq!(server.options.ecs.unwrap().prefix_len, 57); + assert!(server.options.ecs_override); + assert_eq!(&*server.options.disabled_types, &[1, 16]); + assert!(server.options.skip_cert_verify); + assert_eq!( + server.options.verification_name.as_deref(), + Some("dns.example") + ); + assert!(server.options.disable_reuse); + let parameter_only = parse_dns_nameserver( + "192.0.2.1#disable-ipv6=true", + &RouteTargetsByName::new(), + DnsRoute::Rules, + ) + .unwrap(); + assert_eq!(parameter_only.route, DnsRoute::Rules); + assert_eq!(&*parameter_only.options.disabled_types, &[28]); + } + + #[test] + fn upstream_options_survive_system_dhcp_and_rcode_parsing() { + for endpoint in [ + "system", + "system://", + "dhcp://system", + "dhcp://en0", + "rcode://refused", + ] { + let server = parse_dns_nameserver( + &format!("{endpoint}#ecs=192.0.2.9&disable-ipv6=true"), + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(server.options.ecs.unwrap().prefix_len, 32); + assert_eq!(&*server.options.disabled_types, &[28]); + if endpoint == "dhcp://en0" { + assert!( + matches!(server.address, DnsAddress::Source(DnsSource::Dhcp(ref interface)) if interface.as_ref() == "en0") + ); + } + } + } +} + fn percent_decode(input: &str) -> Result { let mut bytes = Vec::with_capacity(input.len()); let mut iter = input.bytes(); diff --git a/src/config/mod.rs b/src/config/mod.rs index b811945..1b7e747 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -390,6 +390,36 @@ pub struct DnsNameserver { pub port: u16, pub route: DnsRoute, pub path: Option, + pub options: DnsNameserverOptions, +} + +#[derive(Clone, Default, PartialEq, Eq, Hash)] +pub struct DnsNameserverOptions { + pub ecs: Option, + pub ecs_override: bool, + pub disabled_types: Box<[u16]>, + pub skip_cert_verify: bool, + pub verification_name: Option, + pub disable_reuse: bool, +} + +impl std::fmt::Debug for DnsNameserverOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DnsNameserverOptions") + .field("ecs", &self.ecs.is_some()) + .field("ecs_override", &self.ecs_override) + .field("disabled_type_count", &self.disabled_types.len()) + .field("skip_cert_verify", &self.skip_cert_verify) + .field("explicit_name", &self.verification_name.is_some()) + .field("disable_reuse", &self.disable_reuse) + .finish() + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct DnsEcs { + pub address: IpAddr, + pub prefix_len: u8, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -5366,6 +5396,7 @@ rules: [ DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Udp, address: "1.1.1.1".parse().unwrap(), port: 53, @@ -5373,6 +5404,7 @@ rules: }, DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Udp, address: "2001:db8::1".parse().unwrap(), port: 5353, @@ -5380,6 +5412,7 @@ rules: }, DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Tcp, address: "8.8.8.8".parse().unwrap(), port: 53, @@ -5387,6 +5420,7 @@ rules: }, DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Tcp, address: "::1".parse().unwrap(), port: 53, @@ -6523,6 +6557,7 @@ dns: dns.nameserver_policies[0].nameservers.as_ref(), [DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Tcp, address: "223.5.5.5".parse().unwrap(), port: 53, @@ -6685,11 +6720,9 @@ dns: #[test] fn current_config_strictly_rejects_invalid_dns_route_fragments() { for nameserver in [ - "udp://1.1.1.1:53#", "udp://1.1.1.1:53#direct", "udp://1.1.1.1:53#unknown", - "udp://1.1.1.1:53#DIRECT&RULES", - "udp://1.1.1.1:53#h3=true", + "https://1.1.1.1/dns-query#h3=true", "udp://1.1.1.1:53#DIRECT#RULES", ] { let yaml = current_yaml(&format!( diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 4d7a7f5..620ba1b 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod fake_ip; mod fallback; pub(crate) mod hosts; mod https; +mod options; pub(crate) mod policy; pub(crate) mod trace; @@ -168,6 +169,9 @@ pub struct ClassifiedDnsQuery { pub kind: DnsQueryKind, question_wire: Box<[u8]>, semantics_digest: [u8; 32], + // Plain address lookups use the small address cache. Client EDNS or + // semantic flags require retaining the validated wire response instead. + needs_wire_cache: bool, } impl ClassifiedDnsQuery { @@ -368,6 +372,7 @@ pub fn classify_query(message: &[u8]) -> Result { kind, question_wire, semantics_digest, + needs_wire_cache: parsed.additional_count != 0 || parsed.flags & !0x0100 != 0, }) } @@ -409,6 +414,13 @@ pub fn validate_opaque_response( if !query.is_opaque() { return Err(DnsError::ExpectedOpaqueQuestion); } + validate_wire_response(query, response) +} + +fn validate_wire_response( + query: &ClassifiedDnsQuery, + response: &[u8], +) -> Result { let parsed = parse_wire_question_header(response)?; if parsed.flags & 0x8000 == 0 { return Err(DnsError::NotAResponse); @@ -1091,6 +1103,10 @@ impl DnsCache { if !query.is_opaque() { return None; } + self.get_wire(query, now) + } + + pub(crate) fn get_wire(&mut self, query: &ClassifiedDnsQuery, now: Instant) -> Option> { let key = cache::Key::Opaque(query.question.clone(), query.semantics_digest); let ( cache::Value::Opaque { diff --git a/src/dns/options.rs b/src/dns/options.rs new file mode 100644 index 0000000..8857123 --- /dev/null +++ b/src/dns/options.rs @@ -0,0 +1,386 @@ +//! Per-upstream DNS query wrappers, independent of the physical source. + +use std::{borrow::Cow, net::IpAddr}; + +use domain::{ + base::{Message, MessageBuilder, ParsedName, StaticCompressor}, + rdata::AllRecordData, +}; + +use crate::config::DnsNameserverOptions; + +use super::{ + DnsError, MAX_MESSAGE_SIZE, MAX_RESPONSE_RECORDS, Result, classify_query, decode_name, + parse_wire_question_header, read_u16, scan_record, +}; + +pub(super) fn question_disabled(options: &DnsNameserverOptions, qtype: u16) -> bool { + options.disabled_types.binary_search(&qtype).is_ok() +} + +/// Preserves an existing ECS unless override was explicitly requested. Only +/// the first ECS is replaced, retaining its scope and all other EDNS options. +pub(super) fn with_ecs<'a>( + query: &'a [u8], + options: &DnsNameserverOptions, +) -> Result> { + let Some(ecs) = options.ecs else { + return Ok(Cow::Borrowed(query)); + }; + classify_query(query)?; + let header = parse_wire_question_header(query)?; + let mut data_start = query.len(); + let mut data_end = query.len(); + let mut length_offset = None; + if header.additional_count != 0 { + let mut cursor = header.records_offset; + let record = scan_record(query, &mut cursor)?; + data_start = usize::from(record.data_start); + data_end = usize::from(record.data_end); + length_offset = Some(data_start - 2); + } + let mut replace = None; + let mut scope = 0; + let mut cursor = data_start; + while cursor < data_end { + let code = read_u16(query, cursor)?; + let end = cursor + 4 + usize::from(read_u16(query, cursor + 2)?); + if code == 8 { + if !options.ecs_override { + return Ok(Cow::Borrowed(query)); + } + if end < cursor + 8 { + return Err(DnsError::InvalidRecord); + } + scope = query[cursor + 7]; + replace = Some(cursor..end); + break; + } + cursor = end; + } + let (family, bytes): (u16, Vec) = match ecs.address { + IpAddr::V4(address) => (1, address.octets().to_vec()), + IpAddr::V6(address) => (2, address.octets().to_vec()), + }; + if usize::from(ecs.prefix_len) > bytes.len() * 8 { + return Err(DnsError::InvalidRecord); + } + let address_len = usize::from(ecs.prefix_len).div_ceil(8); + let mut option = Vec::with_capacity(8 + address_len); + option.extend_from_slice(&8_u16.to_be_bytes()); + option.extend_from_slice(&(4 + address_len as u16).to_be_bytes()); + option.extend_from_slice(&family.to_be_bytes()); + option.extend_from_slice(&[ecs.prefix_len, scope]); + option.extend_from_slice(&bytes[..address_len]); + if ecs.prefix_len % 8 != 0 { + *option + .last_mut() + .expect("a partial prefix has an address byte") &= 0xff << (8 - ecs.prefix_len % 8); + } + let replaced_len = replace.as_ref().map_or(0, |range| range.len()); + let extra_record = usize::from(length_offset.is_none()) * 11; + let output_len = query.len() - replaced_len + option.len() + extra_record; + if output_len > MAX_MESSAGE_SIZE { + return Err(DnsError::MessageTooLarge); + } + let mut output = Vec::with_capacity(output_len); + if let Some(range) = replace { + output.extend_from_slice(&query[..range.start]); + output.extend_from_slice(&option); + output.extend_from_slice(&query[range.end..]); + } else { + output.extend_from_slice(query); + if length_offset.is_none() { + // Mihomo's new OPT has a zero advertised UDP size and no flags. + output.extend_from_slice(&[0, 0, 41, 0, 0, 0, 0, 0, 0, 0, 0]); + output[10..12].copy_from_slice(&1_u16.to_be_bytes()); + length_offset = Some(output.len() - 2); + } + output.extend_from_slice(&option); + } + let data_len = data_end - data_start - replaced_len + option.len(); + let offset = length_offset.expect("ECS always belongs to an OPT record"); + output[offset..offset + 2].copy_from_slice(&(data_len as u16).to_be_bytes()); + Ok(Cow::Owned(output)) +} + +/// Re-composes every surviving RR so names compressed against a removed RR +/// cannot retain stale offsets. The official codec understands all RFC 1035 +/// compressible RDATA; RFC 3597 unknown RDATA remains byte-for-byte opaque. +pub(super) fn filter_response(wire: &[u8], disabled: &[u16]) -> Result> { + preflight_names(wire)?; + let message = Message::from_octets(wire).map_err(|_| DnsError::InvalidRecord)?; + let mut builder = MessageBuilder::from_target(StaticCompressor::new(Vec::new())) + .map_err(|_| DnsError::MessageTooLarge)?; + builder.set_push_limit(MAX_MESSAGE_SIZE); + *builder.header_mut() = message.header(); + let mut builder = builder.question(); + for question in message.question() { + builder + .push(question.map_err(|_| DnsError::InvalidRecord)?) + .map_err(|_| DnsError::MessageTooLarge)?; + } + let mut builder = builder.answer(); + for record in message + .answer() + .map_err(|_| DnsError::InvalidRecord)? + .into_records::>>() + { + let record = record.map_err(|_| DnsError::InvalidRecord)?; + if disabled.binary_search(&record.rtype().to_int()).is_err() { + builder + .push(record) + .map_err(|_| DnsError::MessageTooLarge)?; + } + } + let mut builder = builder.authority(); + for record in message + .authority() + .map_err(|_| DnsError::InvalidRecord)? + .into_records::>>() + { + let record = record.map_err(|_| DnsError::InvalidRecord)?; + if disabled.binary_search(&record.rtype().to_int()).is_err() { + builder + .push(record) + .map_err(|_| DnsError::MessageTooLarge)?; + } + } + let mut builder = builder.additional(); + for record in message + .additional() + .map_err(|_| DnsError::InvalidRecord)? + .into_records::>>() + { + let record = record.map_err(|_| DnsError::InvalidRecord)?; + if disabled.binary_search(&record.rtype().to_int()).is_err() { + builder + .push(record) + .map_err(|_| DnsError::MessageTooLarge)?; + } + } + Ok(builder.finish().into_target()) +} + +/// Retains Core's smaller name/pointer budget before the borrowed codec +/// parses name-bearing RDATA. Binary RDATA is never searched for pointers. +fn preflight_names(wire: &[u8]) -> Result<()> { + let header = parse_wire_question_header(wire)?; + let count = usize::from(header.answer_count) + + usize::from(header.authority_count) + + usize::from(header.additional_count); + if count > MAX_RESPONSE_RECORDS { + return Err(DnsError::TooManyRecords); + } + let mut cursor = header.records_offset; + for _ in 0..count { + let record = scan_record(wire, &mut cursor)?; + let mut name_cursor = usize::from(record.data_start); + let end = usize::from(record.data_end); + let (prefix, names, exact) = match record.record_type { + 2..=5 | 7..=9 | 12 | 39 => (0, 1, true), + 6 => (0, 2, false), + 14 | 17 => (0, 2, true), + 15 => (2, 1, true), + 33 => (6, 1, true), + 46 => (18, 1, false), + 47 | 250 => (0, 1, false), + 64 | 65 => (2, 1, false), + 45 if wire.get(name_cursor + 1) == Some(&3) => (3, 1, false), + 35 => { + name_cursor += 4; + for _ in 0..3 { + let length = + usize::from(*wire.get(name_cursor).ok_or(DnsError::InvalidRecord)?); + name_cursor += length + 1; + if name_cursor > end { + return Err(DnsError::InvalidRecord); + } + } + (0, 1, true) + } + _ => continue, + }; + name_cursor += prefix; + for _ in 0..names { + if name_cursor >= end { + return Err(DnsError::InvalidRecord); + } + let (_, consumed) = decode_name(wire, name_cursor)?; + name_cursor += consumed; + if name_cursor > end { + return Err(DnsError::InvalidRecord); + } + } + if exact && name_cursor != end || record.record_type == 6 && name_cursor + 20 != end { + return Err(DnsError::InvalidRecord); + } + } + if cursor != wire.len() { + return Err(DnsError::TrailingData); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + config::DnsEcs, + dns::{QueryType, build_query, validate_opaque_response}, + }; + + fn options(address: &str, prefix_len: u8) -> DnsNameserverOptions { + DnsNameserverOptions { + ecs: Some(DnsEcs { + address: address.parse().unwrap(), + prefix_len, + }), + ..Default::default() + } + } + + #[test] + fn upstream_ecs_masks_prefix_and_preserves_other_options_and_scope() { + let query = build_query(7, "ecs.example", QueryType::A).unwrap(); + let mut ipv4 = with_ecs(&query, &options("192.0.2.255", 25)) + .unwrap() + .into_owned(); + assert_eq!( + &ipv4[ipv4.len() - 12..], + &[0, 8, 0, 8, 0, 1, 25, 0, 192, 0, 2, 128] + ); + let header = parse_wire_question_header(&ipv4).unwrap(); + let mut cursor = header.records_offset; + let record = scan_record(&ipv4, &mut cursor).unwrap(); + assert_eq!(record.record_class, 0); + assert_eq!(record.ttl, 0); + let start = usize::from(record.data_start); + ipv4[start + 7] = 17; + // Append a non-ECS EDNS option, keeping its exact bytes. + ipv4.extend_from_slice(&[0, 12, 0, 2, 0xc0, 0xff]); + ipv4[start - 2..start].copy_from_slice(&18_u16.to_be_bytes()); + let unchanged = with_ecs(&ipv4, &options("2001:db8::ffff", 57)).unwrap(); + assert!(matches!(unchanged, Cow::Borrowed(_))); + let mut replacement = options("2001:db8::ffff", 57); + replacement.ecs_override = true; + let output = with_ecs(&ipv4, &replacement).unwrap(); + classify_query(&output).unwrap(); + assert_eq!(&output[start..start + 8], &[0, 8, 0, 12, 0, 2, 57, 17]); + assert_eq!( + &output[start + 8..start + 16], + &[0x20, 1, 0x0d, 0xb8, 0, 0, 0, 0] + ); + assert_eq!(&output[output.len() - 6..], &[0, 12, 0, 2, 0xc0, 0xff]); + } + + fn rr(wire: &mut Vec, owner: &[u8], kind: u16, data: &[u8]) { + wire.extend_from_slice(owner); + wire.extend_from_slice(&kind.to_be_bytes()); + wire.extend_from_slice(&1_u16.to_be_bytes()); + wire.extend_from_slice(&60_u32.to_be_bytes()); + wire.extend_from_slice(&(data.len() as u16).to_be_bytes()); + wire.extend_from_slice(data); + } + + #[test] + fn upstream_type_filter_reencodes_removed_compression_targets_in_all_sections() { + let mut query = build_query(23, "mail.example", QueryType::A).unwrap(); + let offset = query.len() - 4; + query[offset..offset + 2].copy_from_slice(&15_u16.to_be_bytes()); + let classified = classify_query(&query).unwrap(); + let mut wire = query; + wire[2] |= 0x80; + wire[6..8].copy_from_slice(&2_u16.to_be_bytes()); + wire[8..10].copy_from_slice(&2_u16.to_be_bytes()); + wire[10..12].copy_from_slice(&2_u16.to_be_bytes()); + let target = wire.len() as u16 | 0xc000; + rr(&mut wire, b"\x05alias\x07example\0", 16, b"\x03txt"); + let mut mx = vec![0, 10]; + mx.extend_from_slice(&target.to_be_bytes()); + rr(&mut wire, &[0xc0, 12], 15, &mx); + let mut soa = Vec::new(); + soa.extend_from_slice(&target.to_be_bytes()); + soa.extend_from_slice(&target.to_be_bytes()); + soa.extend_from_slice(&[0; 20]); + rr(&mut wire, &[0xc0, 12], 6, &soa); + rr(&mut wire, &[0xc0, 12], 16, b"\x03txt"); + rr(&mut wire, &[0xc0, 12], 65280, &[0xc0, 0xff, 0, 42]); + rr(&mut wire, &[0xc0, 12], 16, b"\x03txt"); + let filtered = filter_response(&wire, &[16]).unwrap(); + let header = parse_wire_question_header(&filtered).unwrap(); + assert_eq!( + ( + header.answer_count, + header.authority_count, + header.additional_count + ), + (1, 1, 1) + ); + let mut cursor = header.records_offset; + let mx = scan_record(&filtered, &mut cursor).unwrap(); + assert_eq!( + decode_name(&filtered, usize::from(mx.data_start) + 2) + .unwrap() + .0, + "alias.example" + ); + let soa = scan_record(&filtered, &mut cursor).unwrap(); + assert_eq!( + decode_name(&filtered, usize::from(soa.data_start)) + .unwrap() + .0, + "alias.example" + ); + let unknown = scan_record(&filtered, &mut cursor).unwrap(); + assert_eq!( + &filtered[usize::from(unknown.data_start)..usize::from(unknown.data_end)], + &[0xc0, 0xff, 0, 42] + ); + assert_eq!( + validate_opaque_response(&classified, &filtered) + .unwrap() + .ttl_offsets + .len(), + 3 + ); + } + + #[test] + fn upstream_type_filter_retains_core_pointer_and_output_budgets() { + let query = build_query(3, "budget.example", QueryType::A).unwrap(); + let mut wire = query; + wire[2] |= 0x80; + wire[6..8].copy_from_slice(&1_u16.to_be_bytes()); + let mut chain = 12_u16; + // Null binary RDATA supplies a legal place for a deliberately excessive + // chain used by the later CNAME, without treating all binary bytes as names. + let mut data = vec![]; + let data_start = wire.len() + 12; + for index in 0..17 { + data.extend_from_slice(&(chain | 0xc000).to_be_bytes()); + chain = (data_start + index * 2) as u16; + } + rr(&mut wire, &[0xc0, 12], 10, &data); + wire[6..8].copy_from_slice(&2_u16.to_be_bytes()); + rr(&mut wire, &[0xc0, 12], 5, &(chain | 0xc000).to_be_bytes()); + assert_eq!( + filter_response(&wire, &[16]), + Err(DnsError::TooManyCompressionJumps) + ); + let name = ["a".repeat(63), "b".repeat(63), "c".repeat(63)].join("."); + let mut wire = build_query(4, &name, QueryType::A).unwrap(); + wire[2] |= 0x80; + wire[6..8].copy_from_slice(&64_u16.to_be_bytes()); + for _ in 0..64 { + // A legacy compressed DNAME is expanded by the codec as required + // for non-RFC1035 RDATA; expansion must still obey our output cap. + rr(&mut wire, &[0xc0, 12], 39, &[0xc0, 12]); + } + assert!(wire.len() < MAX_MESSAGE_SIZE); + assert_eq!( + filter_response(&wire, &[16]), + Err(DnsError::MessageTooLarge) + ); + } +} diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index f53f7af..f510c8e 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -67,6 +67,10 @@ const RCODE_SERVFAIL: u8 = 2; const RCODE_NXDOMAIN: u8 = 3; const RCODE_REFUSED: u8 = 5; +fn dot_reuse_disabled(nameserver: &DnsNameserver) -> bool { + nameserver.transport == DnsTransport::Tls && nameserver.options.disable_reuse +} + /// Errors returned by the runtime DNS service. #[derive(Debug, Clone, thiserror::Error)] pub enum RuntimeDnsError { @@ -269,7 +273,7 @@ impl TcpConnectionPool { .idle .iter() .enumerate() - .filter(|(_, idle)| idle.key == key) + .filter(|(_, idle)| !dot_reuse_disabled(&nameserver.config) && idle.key == key) .max_by_key(|(_, idle)| idle.idle_since) .map(|(index, _)| index); let connection = index.map(|index| state.idle.swap_remove(index).connection); @@ -318,6 +322,11 @@ impl TcpConnectionPool { DnsTransport::Tls | DnsTransport::Https ) { let options = crate::security::TlsClientOptions { + certificate: crate::config::TlsCertificatePolicy { + verification_name: nameserver.options.verification_name.clone(), + skip_cert_verify: nameserver.options.skip_cert_verify, + fingerprint: None, + }, alpn: if nameserver.transport == DnsTransport::Https { vec![b"http/1.1".to_vec()] } else { @@ -370,7 +379,7 @@ impl TcpConnectionPool { return; }; lease.busy.take(); - if self.max_idle == 0 { + if self.max_idle == 0 || dot_reuse_disabled(&lease.key.upstream) { drop(connection); return; } @@ -1749,7 +1758,13 @@ impl RuntimeDns { // entry, so a caller cannot observe a miss and then create a // duplicate flight after that leader has disappeared. let mut cache = self.cache.lock().await; - if let Some((cached, remaining_ttl)) = cache.get_with_ttl( + if classified.needs_wire_cache { + if let Some(wire) = cache.get_wire(classified, Instant::now()) { + super::trace::record("cache"); + self.resource_stats.dns_cache_hit(); + return Ok(wire); + } + } else if let Some((cached, remaining_ttl)) = cache.get_with_ttl( &parsed_query.question.name, parsed_query.question.query_type, Instant::now(), @@ -1844,7 +1859,8 @@ impl RuntimeDns { .await? }; if cacheable { - self.cache_response(&parsed_response, epoch).await?; + self.cache_response(&parsed_response, classified, &response, epoch) + .await?; } restore_response_id(&mut response, 0); Ok(response) @@ -2091,6 +2107,47 @@ impl RuntimeDns { validate: V, permits: &Semaphore, ) -> Result<(Vec, R), AttemptError> + where + M: Fn(&[u8]) -> Result, + V: Fn(&[u8]) -> Result, + { + if nameserver.options.disabled_types.is_empty() { + let wrapped = super::options::with_ecs(query, &nameserver.options)?; + return self + .exchange_raw_validated(nameserver, &wrapped, matches, validate, permits) + .await; + } + let classified = classify_query(query)?; + if super::options::question_disabled(&nameserver.options, classified.question.query_type) { + let mut response = super::synthesize_empty_response(&classified, RCODE_NOERROR)?; + response[2] |= 0x04; // AA, matching Mihomo's disable-type wrapper. + let parsed = validate(&response)?; + return Ok((response, parsed)); + } + let wrapped = super::options::with_ecs(query, &nameserver.options)?; + let disabled = nameserver.options.disabled_types.clone(); + let (wire, ()) = self + .exchange_raw_validated( + nameserver, + &wrapped, + matches, + |wire| validate(wire).map(|_| ()), + permits, + ) + .await?; + let wire = super::options::filter_response(&wire, &disabled)?; + let parsed = validate(&wire)?; + Ok((wire, parsed)) + } + + async fn exchange_raw_validated( + &self, + nameserver: DnsNameserver, + query: &[u8], + matches: M, + validate: V, + permits: &Semaphore, + ) -> Result<(Vec, R), AttemptError> where M: Fn(&[u8]) -> Result, V: Fn(&[u8]) -> Result, @@ -2299,13 +2356,14 @@ impl RuntimeDns { .tcp_pool .checkout_for(nameserver, |state| { self.egress_for(nameserver, Network::Tcp, |egress| { - state.idle.iter().any(|idle| { - idle.key.upstream == nameserver.config - && idle.key.endpoint == nameserver.endpoint() - && idle.key.egress == egress - && idle.key.source_generation == nameserver.source_generation - && idle.key.source_binding == nameserver.source_binding - }) + !dot_reuse_disabled(&nameserver.config) + && state.idle.iter().any(|idle| { + idle.key.upstream == nameserver.config + && idle.key.endpoint == nameserver.endpoint() + && idle.key.egress == egress + && idle.key.source_generation == nameserver.source_generation + && idle.key.source_binding == nameserver.source_binding + }) }) }) .await?; @@ -2355,6 +2413,8 @@ impl RuntimeDns { async fn cache_response( &self, response: &DnsMessage, + query: &ClassifiedDnsQuery, + wire: &[u8], epoch: u64, ) -> Result<(), RuntimeDnsError> { let current_epoch = self.cache_epoch.read().await; @@ -2363,9 +2423,16 @@ impl RuntimeDns { } let mut cache = self.cache.lock().await; let now = Instant::now(); + if query.needs_wire_cache { + let mut upstream_query = query.clone(); + upstream_query.id = response.id; + let validated = super::validate_wire_response(&upstream_query, wire) + .map_err(RuntimeDnsError::InvalidResponse)?; + cache.insert_opaque(&validated, now); + } let name = &response.question.name; let query_type = response.question.query_type; - if response.rcode == RCODE_NXDOMAIN { + if response.rcode == RCODE_NXDOMAIN && !query.needs_wire_cache { cache .insert_negative(name, query_type, RCODE_NXDOMAIN, now) .map_err(RuntimeDnsError::Cache)?; @@ -2377,16 +2444,20 @@ impl RuntimeDns { let answers = matching_answer_set(response).map_err(RuntimeDnsError::InvalidResponse)?; if answers.addresses.is_empty() { - cache - .insert_negative(name, query_type, RCODE_NOERROR, now) - .map_err(RuntimeDnsError::Cache)?; + if !query.needs_wire_cache { + cache + .insert_negative(name, query_type, RCODE_NOERROR, now) + .map_err(RuntimeDnsError::Cache)?; + } return Ok(()); } let ttl = answers.ttl.unwrap_or(MIN_TTL_SECS); - cache - .insert_positive(name, query_type, &answers.addresses, ttl, now) - .map_err(RuntimeDnsError::Cache)?; + if !query.needs_wire_cache { + cache + .insert_positive(name, query_type, &answers.addresses, ttl, now) + .map_err(RuntimeDnsError::Cache)?; + } if let Some(hints) = &self.hints { let mut hints = hints .lock() @@ -2919,6 +2990,7 @@ mod tests { udp_queries: Arc>>, tcp_queries: Arc>>, udp_ids: Arc>>, + udp_payloads: Arc>>>, tcp_ids: Arc>>, udp_destinations: Arc>>, tcp_destinations: Arc>>, @@ -3078,6 +3150,7 @@ mod tests { remote: None, queries: self.udp_queries.clone(), ids: self.udp_ids.clone(), + payloads: self.udp_payloads.clone(), destinations: self.udp_destinations.clone(), send_count: 0, })) @@ -3090,6 +3163,7 @@ mod tests { remote: Option, queries: Arc>>, ids: Arc>>, + payloads: Arc>>>, destinations: Arc>>, send_count: usize, } @@ -3098,6 +3172,10 @@ mod tests { impl DatagramTransport for MockDatagrams { async fn send(&mut self, datagram: Datagram) -> Result<(), DispatchError> { self.send_count += 1; + self.payloads + .lock() + .unwrap() + .push(datagram.payload.to_vec()); let parsed = classify_query(&datagram.payload) .map_err(|error| DispatchError::Other(error.to_string()))?; self.queries @@ -3184,6 +3262,7 @@ mod tests { fn nameserver(index: u8, transport: DnsTransport) -> DnsNameserver { DnsNameserver { path: None, + options: Default::default(), transport, address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, index)).into(), port: 53, @@ -3231,6 +3310,115 @@ mod tests { } } + #[tokio::test] + async fn upstream_wrappers_cover_system_dhcp_and_rcode_before_source_io() { + for endpoint in ["system", "dhcp://physical1", "rcode://refused"] { + let configuration = crate::config::Config::parse_yaml(format!("mixed-port: 1080\ndns:\n enable: true\n ipv6: true\n use-system-hosts: false\n nameserver: ['{endpoint}#ecs=192.0.2.255/25&disable-ipv6=true']\n").as_bytes()).unwrap(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + vec![MockReply::Response(ResponseSpec::answer( + "203.0.113.10".parse().unwrap(), + ))], + vec![], + )); + let resolver = Arc::new(RuntimeDns::new(&configuration.dns, dispatcher.clone())); + resolver.sources.register(&resolver); + resolver + .sources + .replace(dns_source_snapshot(1, "203.0.113.53")) + .await + .unwrap(); + let blocked = build_query(79, "blocked.example", QueryType::Aaaa).unwrap(); + let response = resolver.exchange(&blocked).await.unwrap(); + assert_eq!(response[3] & 0x0f, RCODE_NOERROR); + assert_eq!(response[2] & 0x04, 0x04); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); + let query = build_query(80, "allowed.example", QueryType::A).unwrap(); + let response = resolver.exchange(&query).await.unwrap(); + if endpoint.starts_with("rcode") { + assert_eq!(response[3] & 0x0f, RCODE_REFUSED); + assert_eq!(response[10..12], [0, 1]); + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 0); + } else { + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 1); + let payloads = dispatcher.udp_payloads.lock().unwrap(); + assert_eq!( + &payloads[0][payloads[0].len() - 12..], + &[0, 8, 0, 8, 0, 1, 25, 0, 192, 0, 2, 128] + ); + let expected = if endpoint == "system" { + "203.0.113.53:53" + } else { + "192.0.2.53:53" + }; + assert_eq!( + *dispatcher.udp_destinations.lock().unwrap(), + vec![Destination::Ip(expected.parse().unwrap())] + ); + } + } + } + + #[tokio::test] + async fn upstream_typed_cache_separates_client_ecs_and_preserves_edns_wire() { + let first: IpAddr = "203.0.113.1".parse().unwrap(); + let second: IpAddr = "203.0.113.2".parse().unwrap(); + let plain: IpAddr = "203.0.113.3".parse().unwrap(); + let dispatcher = Arc::new(MockDispatcher::with_replies( + [first, second, plain] + .into_iter() + .map(|address| MockReply::Response(ResponseSpec::answer(address))) + .collect(), + vec![], + )); + let resolver = RuntimeDns::new( + &config(false, vec![nameserver(1, DnsTransport::Udp)]), + dispatcher.clone(), + ); + let plain_query = build_query(91, "cache.example", QueryType::A).unwrap(); + for (index, (address, expected)) in [ + ("192.0.2.1", first), + ("192.0.2.2", second), + ("192.0.2.1", first), + ] + .into_iter() + .enumerate() + { + let options = crate::config::DnsNameserverOptions { + ecs: Some(crate::config::DnsEcs { + address: address.parse().unwrap(), + prefix_len: 32, + }), + ..Default::default() + }; + let mut query = super::super::options::with_ecs(&plain_query, &options) + .unwrap() + .into_owned(); + let id = 92 + index as u16; + query[..2].copy_from_slice(&id.to_be_bytes()); + let response = resolver.exchange(&query).await.unwrap(); + assert_eq!(parse_response(&response).unwrap().id, id); + assert_eq!( + matching_answer_set(&parse_response(&response).unwrap()) + .unwrap() + .addresses, + vec![expected] + ); + assert_eq!(response[10..12], [0, 1]); + assert_eq!(&response[response.len() - 12..], &query[query.len() - 12..]); + } + for _ in 0..2 { + let response = resolver.exchange(&plain_query).await.unwrap(); + assert_eq!( + matching_answer_set(&parse_response(&response).unwrap()) + .unwrap() + .addresses, + vec![plain] + ); + } + assert_eq!(dispatcher.udp_calls.load(Ordering::Acquire), 3); + assert_eq!(resolver.cache.lock().await.len(), 3); + } + #[test] fn all_dns_roles_share_the_session_trust_store() { let mut config = config(false, vec![nameserver(1, DnsTransport::Tls)]); @@ -3258,7 +3446,8 @@ mod tests { fn response_for_query(query: &[u8], spec: &ResponseSpec) -> Vec { classify_query(query).unwrap(); - let mut response = query.to_vec(); + let header = super::super::parse_wire_question_header(query).unwrap(); + let mut response = query[..header.records_offset].to_vec(); let mut flags = DNS_FLAG_RESPONSE | DNS_FLAG_RECURSION_DESIRED | DNS_FLAG_RECURSION_AVAILABLE @@ -3288,6 +3477,7 @@ mod tests { } } } + response.extend_from_slice(&query[header.records_offset..]); response } @@ -4284,6 +4474,7 @@ dns: )); let main = DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Tcp, address: "1.1.1.1".parse().unwrap(), port: 53, @@ -4293,6 +4484,7 @@ dns: }; let policy_nameserver = DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Tcp, address: "223.5.5.5".parse().unwrap(), port: 53, @@ -4575,6 +4767,7 @@ dns: let direct: Arc = Arc::new(DirectOutbound::new(Dialer::default())); let udp_nameserver = DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Udp, address: udp_address.ip().into(), port: udp_address.port(), @@ -4618,6 +4811,7 @@ dns: }); let tcp_nameserver = DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Tcp, address: tcp_address.ip().into(), port: tcp_address.port(), diff --git a/src/dns/tests_encrypted.rs b/src/dns/tests_encrypted.rs index 17ffe59..73beb53 100644 --- a/src/dns/tests_encrypted.rs +++ b/src/dns/tests_encrypted.rs @@ -21,13 +21,14 @@ impl Dispatcher for Peer { let length = if https { let mut head = Vec::new(); while !head.ends_with(b"\r\n\r\n") { - head.push(stream.read_u8().await.unwrap()); + let Ok(byte) = stream.read_u8().await else { return; }; + head.push(byte); assert!(head.len() < 4096); } let head = String::from_utf8(head).unwrap(); assert!(head.starts_with("POST /dns-query HTTP/1.1\r\n")); head.lines().find_map(|line| line.strip_prefix("Content-Length: ")).unwrap().parse::().unwrap() - } else { stream.read_u16().await.unwrap() as usize }; + } else { let Ok(length) = stream.read_u16().await else { return; }; length as usize }; let mut query = vec![0; length]; stream.read_exact(&mut query).await.unwrap(); let question = classify_query(&query).unwrap(); @@ -100,3 +101,65 @@ async fn encrypted_dns_authenticates_and_reuses_tls_and_https_streams() { peer.join().await; } } + +#[tokio::test] +async fn upstream_tls_options_verify_names_skip_roots_and_disable_only_dot_reuse() { + let certificate = rcgen::generate_simple_self_signed(vec!["dns.example".into()]).unwrap(); + let server = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![certificate.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into()), + ) + .unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(certificate.cert.der().clone()).unwrap(); + for (upstream, trusted, succeeds, opens) in [ + ( + "tls://192.0.2.1#name-cert-verify=dns.example&disable-reuse=true", + true, + true, + 2, + ), + ( + "https://192.0.2.1/dns-query#name-cert-verify=dns.example&disable-reuse=true", + true, + true, + 1, + ), + ("tls://192.0.2.1#skip-cert-verify=true", false, true, 1), + ( + "tls://192.0.2.1#name-cert-verify=wrong.example&skip-cert-verify=true", + true, + false, + 2, + ), + ] { + let config = crate::config::Config::parse_yaml(format!("mixed-port: 1080\ndns:\n enable: true\n use-system-hosts: false\n nameserver: ['{upstream}']\n").as_bytes()).unwrap(); + let peer = Arc::new(Peer { + tls: Arc::new(server.clone()), + https: upstream.starts_with("https"), + opens: AtomicUsize::new(0), + tasks: StdMutex::new(Vec::new()), + }); + let mut dns = RuntimeDns::new(&config.dns, peer.clone()); + if trusted { + Arc::get_mut(&mut dns.tcp_pool).unwrap().security = + crate::security::SecurityContext::with_tls_roots(roots.clone()); + } + for name in ["first.example", "second.example"] { + assert_eq!(dns.resolve(name).await.is_ok(), succeeds, "{upstream}"); + } + assert_eq!(peer.opens.load(Ordering::Relaxed), opens, "{upstream}"); + assert_eq!( + dns.tcp_pool.snapshot().current_idle, + usize::from(succeeds && opens == 1), + "{upstream}" + ); + peer.join().await; + } +} diff --git a/src/outbound/wireguard/tests.rs b/src/outbound/wireguard/tests.rs index afe7d34..9d3d390 100644 --- a/src/outbound/wireguard/tests.rs +++ b/src/outbound/wireguard/tests.rs @@ -522,6 +522,7 @@ async fn wireguard_peers_longest_prefix_reserved_and_remote_dns_have_owned_io() transport: crate::config::DnsTransport::Udp, address: crate::config::DnsAddress::Domain("dns.example".into()), port: 53, route: crate::config::DnsRoute::Direct, path: None, + options: Default::default(), }]; let records = Arc::>>::default(); let closes = Arc::::default(); diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index 6adbc1e..a3a8be6 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -2250,6 +2250,7 @@ fn test_runtime_dns(dispatcher: Arc) -> Arc { ipv6: true, nameservers: vec![DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Udp, address: Ipv4Addr::new(198, 51, 100, 53).into(), port: 53, diff --git a/tests/README.md b/tests/README.md index 323f6b7..c05a145 100644 --- a/tests/README.md +++ b/tests/README.md @@ -58,6 +58,10 @@ cargo test --locked --lib dns::runtime::tests::dns_flush_ cargo test --locked --features cli --lib dns::source::tests:: cargo test --locked --features cli --lib dns::runtime::tests::dns_source_ cargo test --locked --features cli --lib config::tests::dns_source_ +cargo test --locked --features cli --lib config::dns::upstream_options_tests +cargo test --locked --features cli --lib dns::options::tests:: +cargo test --locked --features cli --lib dns::runtime::tests::upstream_ +cargo test --locked --features cli --lib dns::runtime::encrypted_tests::upstream_tls_options_ cargo test --locked --features cli --lib invoke::tests::update_dns_network_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests @@ -65,6 +69,8 @@ uv run --project scripts --locked ruff check scripts uv run --project scripts --locked ruff format --check scripts ``` +DNS URL 参数过滤器只使用内存报文、显式来源快照和 duplex TLS 对端,不读取宿主 DNS 或启动监听服务。 + Windows UWP bridge 的名称准入与匹配使用独立过滤器;下列测试仅处理 JSON、匹配策略 和未注册的内存 `VpnPlugInProfile` 对象,不创建系统 VPN profile 或启动连接: diff --git a/tests/resolution_foundations.rs b/tests/resolution_foundations.rs index f21c59f..35fa7b4 100644 --- a/tests/resolution_foundations.rs +++ b/tests/resolution_foundations.rs @@ -182,6 +182,7 @@ async fn runtime_uses_configured_dns_and_weak_binding_releases_owner() { ipv6: false, nameservers: vec![DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Udp, address: address.ip().into(), port: address.port(), @@ -267,6 +268,7 @@ async fn recursive_dns_dependency_fails_before_connecting_or_joining_its_own_fli ipv6: false, nameservers: vec![DnsNameserver { path: None, + options: Default::default(), transport: DnsTransport::Udp, address: "192.0.2.1".parse().unwrap(), port: 53, From 9c036f9da614d7ecc16a52a9882263c267e0e21b Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:09:18 +0800 Subject: [PATCH 050/445] feat: support multiplexed DoH GET and basic authentication --- docs/config.yaml | 18 +- docs/mihomo-compatibility.md | 2 +- docs/tun-icmp-dns.md | 29 +- src/config/dns.rs | 130 +++++++- src/config/mod.rs | 15 + src/dns/http2.rs | 528 +++++++++++++++++++++++++++++++ src/dns/https.rs | 127 +++++++- src/dns/mod.rs | 2 + src/dns/resolution.rs | 17 + src/dns/runtime.rs | 210 +++++++++++- src/dns/tests_encrypted.rs | 139 +++++++- src/outbound/wireguard/engine.rs | 6 + src/outbound/wireguard/tests.rs | 1 + src/runtime.rs | 7 + src/security/boring.rs | 17 +- src/security/tls.rs | 16 +- src/tun_runtime/tests.rs | 1 + tests/README.md | 4 +- tests/resolution_foundations.rs | 2 + 19 files changed, 1208 insertions(+), 63 deletions(-) create mode 100644 src/dns/http2.rs diff --git a/docs/config.yaml b/docs/config.yaml index 7fb6b5f..417ad7d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -606,16 +606,26 @@ hosts: # enable 关闭时不接管 TUN DNS,内部解析使用系统 resolver;配置中的 hosts 仍可用于内部解析。 # enable 开启时使用配置的 resolver,不因上游失败回退到系统 DNS。 # nameserver 默认 [https://doh.pub/dns-query, tls://223.5.5.5:853],不能显式留空。 -# 支持裸 IP、UDP/TCP、tls://、http(s)://,允许上游域名;DoH 使用 HTTP/1.1 POST, -# DoT 与 HTTPS 校验证书与名称。default-nameserver 接受 IP 上游或显式 system,用于 bootstrap。 +# 支持裸 IP、UDP/TCP、tls://、http(s)://,允许上游域名;DoH GET 使用 ID 0 与无 padding +# Base64URL dns=;清除原 query/fragment,URL userinfo 转 Basic,不进入 Host/path 或日志。 +# HTTPS 实际 ALPN 协商 H2/H1,无 ALPN 回退 H1;明文 HTTP 仅 H1,无 h2c。 +# DoT 与 HTTPS 使用共享 TLS 信任根,校验证书与名称。 +# default-nameserver 接受 IP 上游或显式 system,用于 bootstrap。 # system、system://、dhcp://system 使用物理系统 DNS;dhcp://<精确接口名> 使用该接口的 DHCP DNS。 # 各角色、policy、fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终物理直连, -# 不应用 fragment/代理规则。只读现有网络来源;缺失、加密要求无法满足或物理绑定失败时不可用。 +# fragment/代理规则不改变物理出口,但 ECS/类型过滤包装仍适用。 +# 只读现有网络来源;缺失、加密要求无法满足或物理绑定失败时不可用。 # rcode://success/format_error/server_failure/name_error/not_implemented/refused 返回本地 RCODE 0–5, # 可用于各 nameserver 列表与 policy(default-nameserver 除外);不建立网络连接或额外设置 RA。 # 无 fragment 时默认 DIRECT;respect-rules: true 时默认 RULES,且必须配置 # proxy-server-nameserver。显式 fragment 可选 DIRECT、RULES 或已配置的节点/组名; -# 名称支持百分号编码。proxy-server-nameserver 专门解析节点,direct-nameserver +# fragment 使用 #出口&参数=值,整个 fragment 先百分号解码,再拆分;重复项以后者为准。 +# ecs=IP/CIDR 与 ecs-override=true 控制 ECS,保留其他 EDNS 参数。 +# disable-ipv4=true、disable-ipv6=true、disable-qtype-N=true 抑制相应 question 并过滤全部响应 sections; +# ECS/过滤适用于 system、DHCP、RCODE 及各 resolver 角色,DHCP 接口名不含 fragment。 +# skip-cert-verify=true、name-cert-verify 用于 DoT/HTTPS;显式名称验证优先且不改变 SNI。 +# disable-reuse=true 仅禁用指定 DoT 复用;HTTPS 仍可复用,未知参数/出口拒绝。 +# proxy-server-nameserver 专门解析节点,direct-nameserver # 专门解析 DIRECT 业务目标;未配置时复用 nameserver。两者不隐式开启 respect-rules。 # direct-nameserver-follow-policy 默认 false;true 让直连解析遵循 nameserver-policy。 # 新建 DNS 连接采用当前组选择,cache 和已有连接不随选择改变而清空。 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index f61fcdf..981b3c7 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -27,7 +27,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 - DNS 不提供独立 listen、DoQ 或 DoH/H3。ECS、上游类型过滤、TLS 验证参数、 DoT 独立禁用复用、LRU/ARC 与显式 system/DHCP 上游已支持, 来源发现和物理绑定的可用平台及参考差异以 DNS 契约为准。 - DoH 使用 H1 POST;详细解析角色、缓存与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 + DoH 使用 GET、URL Basic 与实际 ALPN H2/H1;详细解析角色、缓存、预算与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 - Provider/group 名称筛选和改名支持 lookahead、定长 lookbehind、数字/命名 backreference; 普通表达式保留 Rust regex 快速路径。复杂表达式和改名有内部预算,超限使候选失败。 不支持递归/条件式或完整 .NET 方言;DOMAIN-REGEX 与 GeoSite regex 不扩展。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index dc73d66..8c41b0e 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -109,7 +109,7 @@ dns: ### Endpoint - `dns.enable`、`dns.ipv6` 默认 false。主 nameserver 默认使用 DoH/DoT,可显式替换为非空列表。 -- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用会话的内置、可读系统与 `tls.custom-certifactes` 信任根;各解析角色及 WireGuard 私有 DNS 同样适用,平台边界见 [TLS 契约](tls-client-fingerprint.md)。DoH 采用 HTTP/1.1 POST。 +- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用会话的内置、可读系统与 `tls.custom-certifactes` 信任根;各解析角色及 WireGuard 私有 DNS 同样适用,平台边界见 [TLS 契约](tls-client-fingerprint.md)。HTTPS 根据实际 ALPN 使用 HTTP/2 或 HTTP/1.1;没有 ALPN 时使用 HTTP/1.1。明文 HTTP 仅支持 HTTP/1.1,不启用 h2c。 - `rcode://success`、`rcode://format_error`、`rcode://server_failure`、`rcode://name_error`、`rcode://not_implemented`、`rcode://refused` 分别返回本地 RCODE 0–5;可用于 nameserver、policy、fallback、节点和直连 resolver,不能用于 default-nameserver。 - 同一个上游列表中存在本地 RCODE 时使用第一项本地响应,不建立网络连接,也不把 SERVFAIL/REFUSED 当作上游故障重试。响应保留原查询的 flags 和 additional,仅设置 QR 与 RCODE;不额外开启 RA,不写入结果缓存。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 @@ -144,6 +144,18 @@ DoT/HTTPS 的 `skip-cert-verify=true` 和 `name-cert-verify` 接共享 TLS 安 先于跳过验证且不改变 SNI。`disable-reuse=true` 仅使指定 DoT 上游每次新建并关闭连接, 不改变 HTTPS 或其他上游的复用。不同参数的上游保持池身份隔离。 +### DoH 请求 + +URL 只保留协议、带默认端口的主机、路径和 userinfo;丢弃原 query/fragment。 +路径按解码后的值重新编码,userinfo 百分号解码后生成 `Basic base64(用户名:密码)`, +省略密码视为空串。认证值不出现在 Host、请求路径或 Debug/日志中,也不会跨池身份复用。 +请求使用 GET、`Accept: application/dns-message`,没有 body 和默认 User-Agent; +`dns=` 为完整 DNS wire 的无 padding Base64URL。传输前 ID 改为 0,响应 ID 必须为 0, +之后恢复原 ID 并执行原有 question、完整报文及缓存校验。 + +H1/H2 的请求及响应头各限 16 KiB,DNS body 仍限 4,096 字节。 +仅接受 HTTP 200 与 `application/dns-message`,响应长度声明、分帧及完整 DNS 校验必须通过。 + ### Policy `nameserver-policy` 是有序映射: @@ -287,14 +299,17 @@ wire/RCODE 语义,不使用这段地址合并等待。 TCP、DoT 和 DoH 使用解析器所属的连接池: -- Key 包含上游地址、TLS 验证策略、查询包装、传输、HTTP 路径、endpoint、最终 route target、 +- Key 包含上游地址、TLS 验证策略、查询包装、传输、HTTP 路径与 Basic 身份、endpoint、最终 route target、 来源 generation 和物理接口绑定; -- 一条连接同时只处理一个查询,不做 pipeline; -- 活动连接不设固定业务数量上限;空闲连接总数最多 4、同 key 最多 2、超时 30 秒; -- 完成帧和响应校验后才能归还连接; -- 复用连接遇到 EOF、I/O、reset 或响应不匹配时,在原尝试期限内最多新建一次连接; +- TCP、DoT、HTTP/1.1 一条连接只处理一个查询,不做 pipeline;这部分空闲连接总数最多 4、同 key 最多 2、超时 30 秒; +- HTTP/2 同 key 首次建连串行发布,之后共享 sender;每连接最多 32 个并发 stream,超出时在查询原期限内等待。流接收窗口 4 KiB、连接接收窗口 128 KiB,禁用 server push;H2 空闲连接另限 4、超时 30 秒; +- 活动物理连接不设固定业务数量上限;完整响应校验后才能归还 H1 连接或继续复用 H2; +- TCP/DoT/H1 复用连接遇到 EOF、I/O、reset 或响应不匹配时,在原尝试期限内最多新建一次连接; - 非法、截断或超限响应不重试、不归还连接。 +HTTP/2 单流取消发送 RST,其他流可继续使用原连接;响应错误使该连接不再接受新查询。 +解析器拥有连接 driver 与有界空闲回收任务,Session Stop 和资源代际退役关闭全部解析角色并等待 driver 结束;WireGuard 私有 DNS 同样由节点 Stop 关闭且不保留空闲连接。 + Controller 切换代理组后,只在后续需要创建新 DNS transport 时使用新选择。类型化/原始响应 cache、singleflight 状态和已经位于 TCP pool 中的 transport 不清空、不迁移;因此命中 cache 或复用既有 TCP transport 时,不会为切换单独建链。停止 session 才统一释放这些状态。 ## 队列与生命周期 @@ -331,7 +346,7 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only ## 不支持 - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; -- DoQ、DoH HTTP/2/3; +- DoQ、DoH HTTP/3; - 运行中随接口变化自动刷新 hosts: lan; - 用户可配置的查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 17daa81..4cd1d50 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -159,6 +159,7 @@ pub(super) fn parse_dns_nameserver( port: 53, route: DnsRoute::Direct, path: None, + http_auth: None, options, }); } @@ -169,6 +170,7 @@ pub(super) fn parse_dns_nameserver( port: 53, route, path: None, + http_auth: None, options, }); } @@ -200,6 +202,7 @@ pub(super) fn parse_dns_nameserver( port: 0, route, path: None, + http_auth: None, options, }); } @@ -211,9 +214,34 @@ pub(super) fn parse_dns_nameserver( "http" => (DnsTransport::Http, 80), _ => return Err(bad()), }; - if !url.username().is_empty() || url.password().is_some() || url.fragment().is_some() { + let http = matches!(transport, DnsTransport::Http | DnsTransport::Https); + let has_userinfo = endpoint.split_once("://").is_some_and(|(_, rest)| { + rest.split(['/', '?']) + .next() + .is_some_and(|authority| authority.contains('@')) + }); + if (!http && has_userinfo) || url.fragment().is_some() { return Err(bad()); } + let http_auth = if http && has_userinfo { + use base64::Engine as _; + let mut value = percent_decode_bytes(url.username())?; + value.push(b':'); + value.extend_from_slice(&percent_decode_bytes(url.password().unwrap_or_default())?); + let encoded_len = value.len().div_ceil(3).saturating_mul(4); + if encoded_len + 6 > crate::dns::MAX_HTTP_HEADER_BYTES { + return invalid("DNS HTTP authentication exceeds the header budget"); + } + Some(DnsHttpAuth( + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(value) + ) + .into(), + )) + } else { + None + }; let address = match url.host().ok_or_else(bad)? { Host::Ipv4(ip) => DnsAddress::Ip(ip.into()), Host::Ipv6(ip) => DnsAddress::Ip(ip.into()), @@ -222,18 +250,19 @@ pub(super) fn parse_dns_nameserver( Err(_) => DnsAddress::Domain(crate::dns::canonicalize_name(host).map_err(|_| bad())?), }, }; - let path = if matches!(transport, DnsTransport::Http | DnsTransport::Https) { - Some(format!( - "{}{}", - if url.path().is_empty() { - "/" + let path = if http { + // URL's browser-style parser resolves dot segments; Mihomo's URL.Path + // keeps them. Decode the original path before rebuilding its escapes. + let rest = endpoint.split_once("://").expect("DNS URL has a scheme").1; + let path = rest.find(['/', '?']).map_or("/", |index| { + let tail = &rest[index..]; + if tail.starts_with('/') { + tail.split('?').next().unwrap_or("/") } else { - url.path() - }, - url.query() - .map(|value| format!("?{value}")) - .unwrap_or_default() - )) + "/" + } + }); + Some(normalize_http_path(path)?) } else { if !matches!(url.path(), "" | "/") || url.query().is_some() { return Err(bad()); @@ -248,6 +277,7 @@ pub(super) fn parse_dns_nameserver( port, route, path, + http_auth, options, }) } @@ -329,6 +359,53 @@ fn valid_disabled_type(kind: u16) -> bool { mod upstream_options_tests { use super::*; + #[test] + fn doh_url_clears_query_and_preserves_only_decoded_basic_identity() { + use base64::Engine as _; + let server = parse_dns_nameserver( + "https://a%3Alice:p%40ss@dns.example/a%2Fb%20c?discard=secret#DIRECT&ecs=192.0.2.1", + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(server.path.as_deref(), Some("/a/b%20c")); + assert_eq!(server.port, 443); + let auth = server.http_auth.as_ref().unwrap(); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(auth.0.strip_prefix("Basic ").unwrap()) + .unwrap(), + b"a:lice:p@ss" + ); + assert_eq!(format!("{auth:?}"), "DnsHttpAuth([redacted])"); + let dot_path = parse_dns_nameserver( + "https://dns.example/a/%2e%2e/b", + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(dot_path.path.as_deref(), Some("/a/../b")); + for (userinfo, decoded) in [ + ("alice", &b"alice:"[..]), + ("", &b":"[..]), + ("%FF:%00", &b"\xff:\0"[..]), + ] { + let server = parse_dns_nameserver( + &format!("http://{userinfo}@192.0.2.1?discard"), + &RouteTargetsByName::new(), + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(server.path.as_deref(), Some("/")); + assert_eq!( + base64::engine::general_purpose::STANDARD + .decode(server.http_auth.unwrap().0.strip_prefix("Basic ").unwrap()) + .unwrap(), + decoded + ); + } + } + #[test] fn upstream_options_decode_fragments_and_use_last_values() { let server = parse_dns_nameserver( @@ -383,6 +460,11 @@ mod upstream_options_tests { } fn percent_decode(input: &str) -> Result { + String::from_utf8(percent_decode_bytes(input)?) + .map_err(|_| VoleError::InvalidConfig("invalid DNS route encoding".into())) +} + +fn percent_decode_bytes(input: &str) -> Result> { let mut bytes = Vec::with_capacity(input.len()); let mut iter = input.bytes(); while let Some(byte) = iter.next() { @@ -397,8 +479,28 @@ fn percent_decode(input: &str) -> Result { bytes.push(byte); } } - String::from_utf8(bytes) - .map_err(|_| VoleError::InvalidConfig("invalid DNS route encoding".into())) + Ok(bytes) +} + +fn normalize_http_path(path: &str) -> Result { + let mut result = String::new(); + for byte in percent_decode_bytes(path)? { + // Mihomo rebuilds URL.Path without RawPath. Go encodePath retains + // whole-path reserved separators except '?', with uppercase escapes. + if byte.is_ascii_alphanumeric() || b"-_.~$&+,/:;=@".contains(&byte) { + result.push(char::from(byte)); + } else { + use std::fmt::Write as _; + write!(result, "%{byte:02X}").expect("writing a String cannot fail"); + } + if result.len() > crate::dns::MAX_HTTP_HEADER_BYTES { + return invalid("DNS HTTP path exceeds the header budget"); + } + } + if result.is_empty() { + result.push('/'); + } + Ok(result) } pub(super) fn normalize_hosts( diff --git a/src/config/mod.rs b/src/config/mod.rs index 1b7e747..3a5c2c4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -390,9 +390,19 @@ pub struct DnsNameserver { pub port: u16, pub route: DnsRoute, pub path: Option, + pub http_auth: Option, pub options: DnsNameserverOptions, } +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct DnsHttpAuth(pub(crate) Box); + +impl std::fmt::Debug for DnsHttpAuth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("DnsHttpAuth([redacted])") + } +} + #[derive(Clone, Default, PartialEq, Eq, Hash)] pub struct DnsNameserverOptions { pub ecs: Option, @@ -5396,6 +5406,7 @@ rules: [ DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Udp, address: "1.1.1.1".parse().unwrap(), @@ -5404,6 +5415,7 @@ rules: }, DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Udp, address: "2001:db8::1".parse().unwrap(), @@ -5412,6 +5424,7 @@ rules: }, DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Tcp, address: "8.8.8.8".parse().unwrap(), @@ -5420,6 +5433,7 @@ rules: }, DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Tcp, address: "::1".parse().unwrap(), @@ -6557,6 +6571,7 @@ dns: dns.nameserver_policies[0].nameservers.as_ref(), [DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Tcp, address: "223.5.5.5".parse().unwrap(), diff --git a/src/dns/http2.rs b/src/dns/http2.rs new file mode 100644 index 0000000..6451be8 --- /dev/null +++ b/src/dns/http2.rs @@ -0,0 +1,528 @@ +//! Multiplexed DoH over an authenticated, protected caller-owned stream. +//! Drivers and the idle reaper belong to one resolver's shutdown barrier. + +use super::{MAX_HTTP_HEADER_BYTES, MAX_MESSAGE_SIZE}; +use crate::{config::DnsNameserver, dispatch::BoxStream}; +use bytes::Bytes; +use std::{ + collections::HashMap, + hash::Hash, + io, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{ + sync::{Mutex as AsyncMutex, Semaphore}, + task::JoinHandle, + time::Instant, +}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +const MAX_STREAMS: usize = 32; +const MAX_IDLE: usize = 4; +const IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +pub(super) struct Pool { + state: Mutex>, + admission: Mutex<()>, + cancel: CancellationToken, + tasks: TaskTracker, + reaper_running: AtomicBool, + max_idle: AtomicUsize, +} + +struct State { + connections: Vec<(K, Arc)>, + initialization: HashMap>>, +} + +struct Connection { + sender: h2::client::SendRequest, + meta: Arc, + streams: Arc, + driver: JoinHandle<()>, +} + +struct Metadata { + reusable: AtomicBool, + active: AtomicUsize, + last_used: Mutex, +} + +pub(super) struct Lease { + connection: Arc, + owner: Weak>, +} + +impl Pool { + pub(super) fn new() -> Arc { + Arc::new(Self { + state: Mutex::new(State { + connections: Vec::new(), + initialization: HashMap::new(), + }), + admission: Mutex::new(()), + cancel: CancellationToken::new(), + tasks: TaskTracker::new(), + reaper_running: AtomicBool::new(false), + max_idle: AtomicUsize::new(MAX_IDLE), + }) + } + + pub(super) fn disable_idle(&self) { + self.max_idle.store(0, Ordering::Release); + } + + pub(super) fn is_closed(&self) -> bool { + self.cancel.is_cancelled() + } + + pub(super) fn has(&self, key: &K) -> bool { + if self.is_closed() { + return false; + } + self.state + .lock() + .unwrap() + .connections + .iter() + .any(|(candidate, connection)| { + candidate == key + && connection.meta.reusable.load(Ordering::Acquire) + && (connection.meta.active.load(Ordering::Acquire) != 0 + || Instant::now() + .duration_since(*connection.meta.last_used.lock().unwrap()) + < IDLE_TIMEOUT) + }) + } + + pub(super) fn get(self: &Arc, key: &K) -> Option> { + if self.is_closed() { + return None; + } + let mut state = self.state.lock().unwrap(); + self.maintain_locked(&mut state); + let connection = state + .connections + .iter() + .find(|(candidate, connection)| { + candidate == key && connection.meta.reusable.load(Ordering::Acquire) + })? + .1 + .clone(); + connection.meta.active.fetch_add(1, Ordering::AcqRel); + Some(Lease { + connection, + owner: Arc::downgrade(self), + }) + } + + /// Cold builders remain caller-owned. A canceled builder releases its + /// per-key gate, so the next waiter can build without orphaned work. + pub(super) fn initialization(&self, key: K) -> Arc> { + let mut state = self.state.lock().unwrap(); + state + .initialization + .retain(|_, gate| gate.strong_count() != 0); + if let Some(gate) = state.initialization.get(&key).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(AsyncMutex::new(())); + state.initialization.insert(key, Arc::downgrade(&gate)); + gate + } + + pub(super) async fn connect( + self: &Arc, + key: K, + stream: BoxStream, + physical: impl Send + 'static, + ) -> io::Result> { + let (sender, connection) = h2::client::Builder::new() + .enable_push(false) + .initial_window_size(MAX_MESSAGE_SIZE as u32) + .initial_connection_window_size((MAX_MESSAGE_SIZE * MAX_STREAMS) as u32) + .max_header_list_size(MAX_HTTP_HEADER_BYTES as u32) + .max_concurrent_reset_streams(MAX_STREAMS * 2) + .max_send_buffer_size(MAX_MESSAGE_SIZE) + .handshake(stream) + .await + .map_err(error)?; + let meta = Arc::new(Metadata { + reusable: AtomicBool::new(true), + active: AtomicUsize::new(1), + last_used: Mutex::new(Instant::now()), + }); + let finished = meta.clone(); + let driver = self.spawn(async move { + let _physical = physical; + let _ = connection.await; + finished.reusable.store(false, Ordering::Release); + })?; + let connection = Arc::new(Connection { + sender, + meta, + streams: Arc::new(Semaphore::new(MAX_STREAMS)), + driver, + }); + { + // Serialize publication with shutdown, after the handshake. + let _admission = self.admission.lock().unwrap(); + if self.is_closed() { + return Err(closed()); + } + self.state + .lock() + .unwrap() + .connections + .push((key, connection.clone())); + } + self.ensure_reaper()?; + Ok(Lease { + connection, + owner: Arc::downgrade(self), + }) + } + + fn spawn( + &self, + future: impl std::future::Future + Send + 'static, + ) -> io::Result> { + let _admission = self.admission.lock().unwrap(); + if self.is_closed() { + return Err(closed()); + } + let cancel = self.cancel.clone(); + let observation = + crate::resources::observation::track(crate::resources::observation::ResourceKind::Task); + Ok(self + .tasks + .spawn(crate::resources::observation::bind(async move { + let _observation = observation; + tokio::select! { biased; ()=cancel.cancelled()=>{}, ()=future=>{} } + }))) + } + + fn maintain_locked(&self, state: &mut State) { + let now = Instant::now(); + state.connections.retain(|(_, connection)| { + connection.meta.active.load(Ordering::Acquire) != 0 + || connection.meta.reusable.load(Ordering::Acquire) + && now.duration_since(*connection.meta.last_used.lock().unwrap()) < IDLE_TIMEOUT + }); + while state + .connections + .iter() + .filter(|(_, connection)| connection.meta.active.load(Ordering::Acquire) == 0) + .count() + > self.max_idle.load(Ordering::Acquire) + { + let Some(index) = state + .connections + .iter() + .enumerate() + .filter(|(_, (_, connection))| connection.meta.active.load(Ordering::Acquire) == 0) + .min_by_key(|(_, (_, connection))| *connection.meta.last_used.lock().unwrap()) + .map(|(index, _)| index) + else { + break; + }; + state.connections.swap_remove(index); + } + } + + fn ensure_reaper(self: &Arc) -> io::Result<()> { + if self.reaper_running.swap(true, Ordering::AcqRel) { + return Ok(()); + } + let pool = Arc::downgrade(self); + self.spawn(async move { + loop { + tokio::time::sleep(IDLE_TIMEOUT).await; + let Some(pool) = pool.upgrade() else { + return; + }; + let mut state = pool.state.lock().unwrap(); + pool.maintain_locked(&mut state); + if state.connections.is_empty() { + pool.reaper_running.store(false, Ordering::Release); + return; + } + } + })?; + Ok(()) + } + + pub(super) fn close(&self) { + let _admission = self.admission.lock().unwrap(); + self.cancel.cancel(); + self.tasks.close(); + let mut state = self.state.lock().unwrap(); + state.initialization.clear(); + let connections = std::mem::take(&mut state.connections); + drop(state); + drop(connections); + } + + pub(super) async fn shutdown(&self) { + self.close(); + self.tasks.wait().await; + } + + #[cfg(test)] + pub(super) fn driver_count(&self) -> usize { + self.tasks.len() + } +} + +impl Drop for Pool { + fn drop(&mut self) { + self.cancel.cancel(); + self.tasks.close(); + } +} + +impl Drop for Connection { + fn drop(&mut self) { + self.driver.abort(); + } +} + +struct ResetOnDrop(h2::SendStream, bool); +impl Drop for ResetOnDrop { + fn drop(&mut self) { + if !self.1 { + self.0.send_reset(h2::Reason::CANCEL); + } + } +} + +impl Lease { + pub(super) async fn exchange( + &self, + server: &DnsNameserver, + query: &[u8], + validate: impl Fn(&[u8]) -> io::Result, + ) -> io::Result<(Vec, R)> { + let result = self.exchange_inner(server, query, validate).await; + if result.is_err() { + self.connection + .meta + .reusable + .store(false, Ordering::Release); + } + result + } + + async fn exchange_inner( + &self, + server: &DnsNameserver, + query: &[u8], + validate: impl Fn(&[u8]) -> io::Result, + ) -> io::Result<(Vec, R)> { + let _permit = self + .connection + .streams + .clone() + .acquire_owned() + .await + .map_err(|_| closed())?; + let request = super::https::request(server, query)?; + let mut sender = self + .connection + .sender + .clone() + .ready() + .await + .map_err(error)?; + let (response, stream) = sender.send_request(request, true).map_err(error)?; + let mut reset = ResetOnDrop(stream, false); + let response = response.await.map_err(error)?; + if response.status() != http::StatusCode::OK + || !response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value.split(';').next().is_some_and(|value| { + value.trim().eq_ignore_ascii_case("application/dns-message") + }) + }) + { + return Err(io::ErrorKind::InvalidData.into()); + } + let expected = response + .headers() + .get(http::header::CONTENT_LENGTH) + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|length| *length <= MAX_MESSAGE_SIZE) + .ok_or(io::ErrorKind::InvalidData) + }) + .transpose()?; + let mut receive = response.into_body(); + let mut body = Vec::new(); + while let Some(data) = receive.data().await { + let data = data.map_err(error)?; + if body.len() + data.len() > MAX_MESSAGE_SIZE { + return Err(io::ErrorKind::InvalidData.into()); + } + body.extend_from_slice(&data); + receive + .flow_control() + .release_capacity(data.len()) + .map_err(error)?; + } + if expected.is_some_and(|length| body.len() != length) { + return Err(io::ErrorKind::InvalidData.into()); + } + if let Some(trailers) = receive.trailers().await.map_err(error)? + && trailers + .iter() + .map(|(name, value)| name.as_str().len() + value.as_bytes().len() + 32) + .sum::() + > MAX_HTTP_HEADER_BYTES + { + return Err(io::ErrorKind::InvalidData.into()); + } + super::https::restore_id(query, &mut body)?; + let validated = validate(&body)?; + reset.1 = true; + Ok((body, validated)) + } +} + +impl Drop for Lease { + fn drop(&mut self) { + // Use the same lock order as get/maintenance: pool, then timestamp. + if let Some(owner) = self.owner.upgrade() { + let mut state = owner.state.lock().unwrap(); + *self.connection.meta.last_used.lock().unwrap() = Instant::now(); + self.connection.meta.active.fetch_sub(1, Ordering::AcqRel); + owner.maintain_locked(&mut state); + } else { + self.connection.meta.active.fetch_sub(1, Ordering::AcqRel); + } + } +} + +fn error(error: h2::Error) -> io::Error { + io::Error::new(io::ErrorKind::ConnectionAborted, error) +} +fn closed() -> io::Error { + io::ErrorKind::BrokenPipe.into() +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::oneshot; + + struct Physical(Arc); + impl Drop for Physical { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } + } + + #[tokio::test] + async fn doh_h2_cancel_resets_one_stream_and_stop_joins_inflight_driver() { + let config = crate::config::Config::parse_yaml(b"mixed-port: 1080\ndns:\n enable: true\n nameserver: ['https://192.0.2.1/dns-query']\n").unwrap(); + let server = config.dns.nameservers[0].clone(); + let (client, peer) = tokio::io::duplex(64 * 1024); + let (arrived, arrival) = oneshot::channel(); + let (reset_sent, reset_seen) = oneshot::channel(); + let (third_sent, third_seen) = oneshot::channel(); + let peer = tokio::spawn(async move { + let mut connection = h2::server::handshake(peer).await.unwrap(); + let (_, mut first) = connection.accept().await.unwrap().unwrap(); + arrived.send(()).unwrap(); + let reason = tokio::select! { + reason = futures_util::future::poll_fn(|cx| first.poll_reset(cx)) => reason.unwrap(), + request = connection.accept() => panic!("unexpected request before reset: {}", request.is_some()), + }; + assert_eq!(reason, h2::Reason::CANCEL); + reset_sent.send(()).unwrap(); + let (request, mut respond) = connection.accept().await.unwrap().unwrap(); + use base64::Engine as _; + let query = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(request.uri().query().unwrap().strip_prefix("dns=").unwrap()) + .unwrap(); + let query = crate::dns::classify_query(&query).unwrap(); + let wire = crate::dns::synthesize_empty_response(&query, 0).unwrap(); + let response = http::Response::builder() + .status(200) + .header("content-type", "application/dns-message") + .body(()) + .unwrap(); + respond + .send_response(response, false) + .unwrap() + .send_data(Bytes::from(wire), true) + .unwrap(); + let (_, _held) = connection.accept().await.unwrap().unwrap(); + third_sent.send(()).unwrap(); + // The client stops while this response remains pending. + while let Some(result) = connection.accept().await { + if result.is_err() { + break; + } + } + }); + let pool = Pool::new(); + let physical = Arc::new(AtomicUsize::new(1)); + let first = pool + .connect((), Box::new(client), Physical(physical.clone())) + .await + .unwrap(); + let query = + crate::dns::build_query(0x2345, "first.example", crate::dns::QueryType::A).unwrap(); + let request_server = server.clone(); + let first = + tokio::spawn(async move { first.exchange(&request_server, &query, |_| Ok(())).await }); + tokio::time::timeout(Duration::from_secs(3), arrival) + .await + .unwrap() + .unwrap(); + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(3), reset_seen) + .await + .unwrap() + .unwrap(); + let query = + crate::dns::build_query(0x4567, "second.example", crate::dns::QueryType::A).unwrap(); + let (wire, ()) = pool + .get(&()) + .unwrap() + .exchange(&server, &query, |wire| { + let classified = crate::dns::classify_query(&query).unwrap(); + crate::dns::validate_wire_response(&classified, wire) + .map(|_| ()) + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData)) + }) + .await + .unwrap(); + assert_eq!(&wire[..2], &query[..2]); + let held = pool.get(&()).unwrap(); + let third = tokio::spawn(async move { held.exchange(&server, &query, |_| Ok(())).await }); + tokio::time::timeout(Duration::from_secs(3), third_seen) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(3), pool.shutdown()) + .await + .unwrap(); + assert_eq!(pool.driver_count(), 0); + assert_eq!(physical.load(Ordering::Acquire), 0); + assert!(pool.get(&()).is_none()); + assert!(third.await.unwrap().is_err()); + peer.await.unwrap(); + } +} diff --git a/src/dns/https.rs b/src/dns/https.rs index 397fc66..420cd17 100644 --- a/src/dns/https.rs +++ b/src/dns/https.rs @@ -1,11 +1,103 @@ -//! DNS-over-HTTP/1.1 on a caller-owned authenticated stream. No socket factory -//! or detached connection driver; cancellation discards the current lease. -use super::MAX_MESSAGE_SIZE; +//! Shared DoH GET construction and HTTP/1.1 framing over caller-owned IO. +use super::{MAX_HTTP_HEADER_BYTES, MAX_MESSAGE_SIZE}; use crate::{config::DnsNameserver, dispatch::BoxStream}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; use std::io; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -const HEADER_LIMIT: usize = 16 * 1024; +pub(super) fn request(server: &DnsNameserver, query: &[u8]) -> io::Result> { + if query.len() < 12 || query.len() > MAX_MESSAGE_SIZE { + return Err(io::ErrorKind::InvalidInput.into()); + } + let mut wire = query.to_vec(); + wire[..2].fill(0); + let host = match &server.address { + crate::config::DnsAddress::Ip(std::net::IpAddr::V6(ip)) => format!("[{ip}]"), + other => other.to_string(), + }; + let authority = format!("{host}:{}", server.port); + let target = format!( + "{}?dns={}", + server.path.as_deref().unwrap_or("/dns-query"), + URL_SAFE_NO_PAD.encode(wire) + ); + let size = authority.len() + + target.len() + + server.http_auth.as_ref().map_or(0, |auth| auth.0.len()) + // Covers HTTP/1 framing and the HTTP/2 pseudo/header names with + // RFC 7540's 32-byte accounting per field. + + 512; + if size > MAX_HTTP_HEADER_BYTES { + return Err(io::ErrorKind::InvalidInput.into()); + } + let uri = http::Uri::builder() + .scheme(if server.transport == crate::config::DnsTransport::Http { + "http" + } else { + "https" + }) + .authority(authority) + .path_and_query(target) + .build() + .map_err(|_| io::ErrorKind::InvalidInput)?; + let mut builder = http::Request::builder() + .method("GET") + .uri(uri) + .header("accept", "application/dns-message"); + if let Some(auth) = &server.http_auth { + let mut value = + http::HeaderValue::from_str(&auth.0).map_err(|_| io::ErrorKind::InvalidInput)?; + value.set_sensitive(true); + builder = builder.header("authorization", value); + } + builder + .body(()) + .map_err(|_| io::ErrorKind::InvalidInput.into()) +} + +pub(super) fn restore_id(query: &[u8], response: &mut [u8]) -> io::Result<()> { + if response.len() < 12 || response[..2] != [0, 0] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "DNS HTTP response ID is not zero", + )); + } + response[..2].copy_from_slice(&query[..2]); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn doh_get_uses_id_zero_unpadded_dns_and_sensitive_basic() { + let config = crate::config::Config::parse_yaml(b"mixed-port: 1080\ndns:\n enable: true\n nameserver: ['http://alice:password@[2001:db8::1]:8080/dns%2Fquery?old=value#ecs=192.0.2.1']\n").unwrap(); + let query = + crate::dns::build_query(0x1234, "example.com", crate::dns::QueryType::A).unwrap(); + let request = request(&config.dns.nameservers[0], &query).unwrap(); + assert_eq!(request.method(), http::Method::GET); + assert_eq!(request.uri().authority().unwrap(), "[2001:db8::1]:8080"); + assert_eq!(request.uri().path(), "/dns/query"); + let encoded = request.uri().query().unwrap().strip_prefix("dns=").unwrap(); + assert!(!encoded.contains('=')); + let mut decoded = URL_SAFE_NO_PAD.decode(encoded).unwrap(); + assert_eq!(&decoded[..2], &[0, 0]); + assert_eq!(&decoded[2..], &query[2..]); + assert_eq!( + request.headers()["authorization"], + "Basic YWxpY2U6cGFzc3dvcmQ=" + ); + assert!(request.headers()["authorization"].is_sensitive()); + assert_eq!(request.headers()["accept"], "application/dns-message"); + for name in ["content-type", "content-length", "user-agent"] { + assert!(!request.headers().contains_key(name)); + } + restore_id(&query, &mut decoded).unwrap(); + assert_eq!(decoded, query); + assert!(restore_id(&query, &mut decoded).is_err()); + } +} async fn line(stream: &mut BoxStream, remaining: &mut usize) -> io::Result { let mut bytes = Vec::new(); @@ -26,20 +118,22 @@ pub(super) async fn exchange( server: &DnsNameserver, query: &[u8], ) -> io::Result<(Vec, bool)> { - let host = match &server.address { - crate::config::DnsAddress::Ip(std::net::IpAddr::V6(ip)) => format!("[{ip}]"), - other => other.to_string(), - }; - let request = format!( - "POST {} HTTP/1.1\r\nHost: {host}:{}\r\nAccept: application/dns-message\r\nContent-Type: application/dns-message\r\nContent-Length: {}\r\n\r\n", - server.path.as_deref().unwrap_or("/dns-query"), - server.port, - query.len() + let request = request(server, query)?; + let mut head = format!( + "GET {} HTTP/1.1\r\nHost: {}\r\n", + request.uri().path_and_query().expect("DoH has a path"), + request.uri().authority().expect("DoH has an authority") ); - stream.write_all(request.as_bytes()).await?; - stream.write_all(query).await?; + for (name, value) in request.headers() { + head.push_str(name.as_str()); + head.push_str(": "); + head.push_str(value.to_str().map_err(|_| io::ErrorKind::InvalidInput)?); + head.push_str("\r\n"); + } + head.push_str("\r\n"); + stream.write_all(head.as_bytes()).await?; stream.flush().await?; - let mut budget = HEADER_LIMIT; + let mut budget = MAX_HTTP_HEADER_BYTES; let status = line(stream, &mut budget).await?; let mut parts = status.split_ascii_whitespace(); let version = parts.next().unwrap_or_default(); @@ -130,5 +224,6 @@ pub(super) async fn exchange( return Err(io::ErrorKind::InvalidData.into()); } } + restore_id(query, &mut body)?; Ok((body, reusable)) } diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 620ba1b..d5629a5 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -10,6 +10,7 @@ mod cache; pub(crate) mod fake_ip; mod fallback; pub(crate) mod hosts; +mod http2; mod https; mod options; pub(crate) mod policy; @@ -28,6 +29,7 @@ use std::{ use sha2::{Digest as _, Sha256}; pub const MAX_MESSAGE_SIZE: usize = 4_096; +pub(crate) const MAX_HTTP_HEADER_BYTES: usize = 16 * 1024; /// Maximum number of resource records scanned across all response sections. pub const MAX_RESPONSE_RECORDS: usize = 64; /// Maximum number of unique addresses retained in the typed cache and hints. diff --git a/src/dns/resolution.rs b/src/dns/resolution.rs index e346aa4..b1459e7 100644 --- a/src/dns/resolution.rs +++ b/src/dns/resolution.rs @@ -100,6 +100,23 @@ impl ResolutionContext { if let Some(inner) = &self.inner { inner.cancel.cancel(); } + if let Some(dns) = self.runtime_dns() { + dns.begin_shutdown(); + } + } + + pub(crate) fn runtime_dns(&self) -> Option> { + match self.inner.as_ref()?.source.get()? { + Source::Runtime(Some(dns)) => dns.upgrade(), + _ => None, + } + } + + pub(crate) async fn shutdown(&self) { + self.close(); + if let Some(dns) = self.runtime_dns() { + dns.shutdown().await; + } } /// Resolve only when the consuming protocol requires an IP. This does not diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index f510c8e..34d535e 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -143,6 +143,7 @@ struct TcpPoolKey { struct TcpConnectionPool { security: crate::security::SecurityContext, + http2: Arc>, state: Mutex, max_idle: usize, max_idle_per_key: usize, @@ -164,6 +165,7 @@ struct IdleTcpConnection { struct PooledTcpConnection { stream: BoxStream, + negotiated_alpn: Option>, _slot: TcpPhysicalSlot, _observation: crate::resources::observation::Guard, } @@ -236,6 +238,7 @@ impl TcpConnectionPool { stats.idle_per_key_limit = max_idle_per_key; Arc::new(Self { security, + http2: super::http2::Pool::new(), state: Mutex::new(TcpPoolState::default()), max_idle, max_idle_per_key, @@ -250,6 +253,9 @@ impl TcpConnectionPool { nameserver: &ResolvedNameserver, resolve: impl FnOnce(&TcpPoolState) -> Result<(DnsEgressKey, Arc), AttemptError>, ) -> Result { + if self.http2.is_closed() { + return Err(io::Error::from(io::ErrorKind::BrokenPipe).into()); + } let (key, egress, connection, expired) = { let mut state = self.state.lock().await; let expired = self.take_expired_locked(&mut state, TokioInstant::now()); @@ -306,6 +312,9 @@ impl TcpConnectionPool { nameserver: &ResolvedNameserver, egress: &Arc, ) -> Result { + if self.http2.is_closed() { + return Err(io::Error::from(io::ErrorKind::BrokenPipe).into()); + } let slot = TcpPhysicalSlot::new(Arc::clone(&self.stats)); let connecting = TcpConnectingGuard::new(Arc::clone(&self.stats)); let stream = egress @@ -317,7 +326,7 @@ impl TcpConnectionPool { sniffed_domain: None, }) .await?; - let stream = if matches!( + let (stream, negotiated_alpn) = if matches!( nameserver.transport, DnsTransport::Tls | DnsTransport::Https ) { @@ -328,7 +337,7 @@ impl TcpConnectionPool { fingerprint: None, }, alpn: if nameserver.transport == DnsTransport::Https { - vec![b"http/1.1".to_vec()] + vec![b"h2".to_vec(), b"http/1.1".to_vec()] } else { Vec::new() }, @@ -341,15 +350,16 @@ impl TcpConnectionPool { 0, 64 * 1024, )? - .connect(stream) + .connect_negotiated(stream) .await? } else { - stream + (stream, None) }; drop(connecting); self.stats.physical_opens.fetch_add(1, Ordering::Relaxed); Ok(PooledTcpConnection { stream, + negotiated_alpn, _slot: slot, _observation: crate::resources::observation::track( crate::resources::observation::ResourceKind::Pool, @@ -379,7 +389,7 @@ impl TcpConnectionPool { return; }; lease.busy.take(); - if self.max_idle == 0 || dot_reuse_disabled(&lease.key.upstream) { + if self.http2.is_closed() || self.max_idle == 0 || dot_reuse_disabled(&lease.key.upstream) { drop(connection); return; } @@ -519,6 +529,7 @@ impl TcpConnectionPool { impl Drop for TcpConnectionPool { fn drop(&mut self) { + self.http2.close(); self.reaper_cancel.cancel(); let idle = std::mem::take(&mut self.state.get_mut().idle); self.stats.current_idle.store(0, Ordering::Release); @@ -933,6 +944,56 @@ impl RuntimeDns { } } + /// Retires multiplexed transports before outbound shutdown begins. + pub(crate) fn begin_shutdown(&self) { + let mut pending = vec![self]; + let mut seen = HashSet::new(); + while let Some(resolver) = pending.pop() { + if !seen.insert(std::ptr::from_ref(resolver) as usize) { + continue; + } + resolver.tcp_pool.http2.close(); + resolver.tcp_pool.reaper_cancel.cancel(); + pending.extend( + [ + resolver.bootstrap.as_deref(), + resolver.proxy_resolver.as_deref(), + resolver.direct_resolver.as_deref(), + ] + .into_iter() + .flatten(), + ); + } + } + + pub(crate) async fn shutdown(&self) { + self.begin_shutdown(); + let mut pending = vec![self]; + let mut seen = HashSet::new(); + while let Some(resolver) = pending.pop() { + if !seen.insert(std::ptr::from_ref(resolver) as usize) { + continue; + } + resolver.tcp_pool.http2.shutdown().await; + let idle = { + let mut state = resolver.tcp_pool.state.lock().await; + let idle = std::mem::take(&mut state.idle); + resolver.tcp_pool.record_idle_locked(&state); + idle + }; + drop(idle); + pending.extend( + [ + resolver.bootstrap.as_deref(), + resolver.proxy_resolver.as_deref(), + resolver.direct_resolver.as_deref(), + ] + .into_iter() + .flatten(), + ); + } + } + pub(crate) fn with_fake_ip_store(mut self, root: Option<&std::path::Path>) -> io::Result { if self.enabled && self.fake_config.enabled @@ -996,6 +1057,7 @@ impl RuntimeDns { let pool = Arc::get_mut(&mut resolver.tcp_pool).expect("fresh node DNS pool"); pool.max_idle = 0; pool.max_idle_per_key = 0; + pool.http2.disable_idle(); let stats = Arc::get_mut(&mut pool.stats).expect("fresh node DNS counters"); stats.idle_limit = 0; stats.idle_per_key_limit = 0; @@ -2352,6 +2414,9 @@ impl RuntimeDns { where F: Fn(&[u8]) -> Result, { + if nameserver.transport == DnsTransport::Https { + return self.exchange_https(nameserver, query, validate).await; + } let mut lease = self .tcp_pool .checkout_for(nameserver, |state| { @@ -2410,6 +2475,136 @@ impl RuntimeDns { Ok((response, validated)) } + async fn exchange_https( + &self, + nameserver: &ResolvedNameserver, + query: &[u8], + validate: F, + ) -> Result<(Vec, R), AttemptError> + where + F: Fn(&[u8]) -> Result, + { + let resolve = |state: &TcpPoolState| { + self.egress_for(nameserver, Network::Tcp, |egress| { + let key = TcpPoolKey { + upstream: nameserver.config.clone(), + endpoint: nameserver.endpoint(), + egress, + source_generation: nameserver.source_generation, + source_binding: nameserver.source_binding.clone(), + }; + self.tcp_pool.http2.has(&key) || state.idle.iter().any(|idle| idle.key == key) + }) + }; + // Only a stale reused HTTP/1 connection gets one retry. Each fresh + // handshake branches on actual ALPN, including a changed server choice. + for attempt in 0..2 { + let key = { + let state = self.tcp_pool.state.lock().await; + let (egress, _) = resolve(&state)?; + TcpPoolKey { + upstream: nameserver.config.clone(), + endpoint: nameserver.endpoint(), + egress, + source_generation: nameserver.source_generation, + source_binding: nameserver.source_binding.clone(), + } + }; + if let Some(lease) = self.tcp_pool.http2.get(&key) { + self.tcp_pool + .stats + .reuse_hits + .fetch_add(1, Ordering::Relaxed); + return lease + .exchange(&nameserver.config, query, |wire| { + validate(wire) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .await + .map_err(AttemptError::Io); + } + let initialization = self.tcp_pool.http2.initialization(key.clone()); + let gate = initialization.lock().await; + if let Some(lease) = self.tcp_pool.http2.get(&key) { + drop(gate); + self.tcp_pool + .stats + .reuse_hits + .fetch_add(1, Ordering::Relaxed); + return lease + .exchange(&nameserver.config, query, |wire| { + validate(wire) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .await + .map_err(AttemptError::Io); + } + let mut lease = self.tcp_pool.checkout_for(nameserver, &resolve).await?; + let alpn = lease + .connection + .as_ref() + .expect("TCP lease owns a connection") + .negotiated_alpn + .as_deref(); + if alpn == Some(b"h2") { + let connection = lease + .connection + .take() + .expect("TCP lease owns a connection"); + lease.busy.take(); + let PooledTcpConnection { + stream, + _slot, + _observation, + .. + } = connection; + let h2 = self + .tcp_pool + .http2 + .connect(lease.key.clone(), stream, (_slot, _observation)) + .await?; + drop(gate); + return h2 + .exchange(&nameserver.config, query, |wire| { + validate(wire) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .await + .map_err(AttemptError::Io); + } + if alpn.is_some_and(|protocol| protocol != b"http/1.1") { + return Err(io::Error::from(io::ErrorKind::InvalidData).into()); + } + drop(gate); + let result = exchange_stream_validated( + &mut lease + .connection + .as_mut() + .expect("TCP lease owns a connection") + .stream, + nameserver, + query, + &validate, + ) + .await; + if attempt == 0 && lease.reused && result.as_ref().is_err_and(stale_reuse_is_retryable) + { + self.tcp_pool + .stats + .stale_retries + .fetch_add(1, Ordering::Relaxed); + drop(lease); + continue; + } + let (response, validated, reusable) = result?; + if reusable { + self.tcp_pool.check_in(lease).await; + } + return Ok((response, validated)); + } + unreachable!("stale HTTP/1 reuse retries once") + } + async fn cache_response( &self, response: &DnsMessage, @@ -3262,6 +3457,7 @@ mod tests { fn nameserver(index: u8, transport: DnsTransport) -> DnsNameserver { DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport, address: IpAddr::V4(Ipv4Addr::new(192, 0, 2, index)).into(), @@ -4474,6 +4670,7 @@ dns: )); let main = DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Tcp, address: "1.1.1.1".parse().unwrap(), @@ -4484,6 +4681,7 @@ dns: }; let policy_nameserver = DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Tcp, address: "223.5.5.5".parse().unwrap(), @@ -4767,6 +4965,7 @@ dns: let direct: Arc = Arc::new(DirectOutbound::new(Dialer::default())); let udp_nameserver = DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Udp, address: udp_address.ip().into(), @@ -4811,6 +5010,7 @@ dns: }); let tcp_nameserver = DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Tcp, address: tcp_address.ip().into(), diff --git a/src/dns/tests_encrypted.rs b/src/dns/tests_encrypted.rs index 73beb53..fb65f3e 100644 --- a/src/dns/tests_encrypted.rs +++ b/src/dns/tests_encrypted.rs @@ -18,19 +18,25 @@ impl Dispatcher for Peer { self.tasks.lock().unwrap().push(tokio::spawn(async move { let Ok(mut stream) = tokio_rustls::TlsAcceptor::from(tls).accept(peer).await else { return; }; for _ in 0..2 { - let length = if https { + let query = if https { let mut head = Vec::new(); while !head.ends_with(b"\r\n\r\n") { let Ok(byte) = stream.read_u8().await else { return; }; head.push(byte); - assert!(head.len() < 4096); + assert!(head.len() < 16 * 1024); } let head = String::from_utf8(head).unwrap(); - assert!(head.starts_with("POST /dns-query HTTP/1.1\r\n")); - head.lines().find_map(|line| line.strip_prefix("Content-Length: ")).unwrap().parse::().unwrap() - } else { let Ok(length) = stream.read_u16().await else { return; }; length as usize }; - let mut query = vec![0; length]; - stream.read_exact(&mut query).await.unwrap(); + let target = head.lines().next().unwrap().strip_prefix("GET /dns-query?dns=").unwrap().strip_suffix(" HTTP/1.1").unwrap(); + use base64::Engine as _; + let query = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(target).unwrap(); + assert_eq!(&query[..2], &[0, 0]); + query + } else { + let Ok(length) = stream.read_u16().await else { return; }; + let mut query = vec![0; length as usize]; + stream.read_exact(&mut query).await.unwrap(); + query + }; let question = classify_query(&query).unwrap(); let response = super::super::synthesize_empty_response(&question, 0).unwrap(); if https { @@ -79,10 +85,16 @@ async fn encrypted_dns_authenticates_and_reuses_tls_and_https_streams() { .unwrap(); let mut roots = rustls::RootCertStore::empty(); roots.add(certificate.cert.der().clone()).unwrap(); - for upstream in ["tls://dns.example", "https://dns.example/dns-query"] { + for (upstream, alpn) in [ + ("tls://dns.example", None), + ("https://dns.example/dns-query", None), + ("https://dns.example/dns-query", Some(b"http/1.1".to_vec())), + ] { + let mut server = server.clone(); + server.alpn_protocols = alpn.into_iter().collect(); let config = crate::config::Config::parse_yaml(format!("mixed-port: 1080\nhosts: {{dns.example: 192.0.2.1}}\ndns:\n enable: true\n use-system-hosts: false\n nameserver: ['{upstream}']\n").as_bytes()).unwrap(); let peer = Arc::new(Peer { - tls: Arc::new(server.clone()), + tls: Arc::new(server), https: upstream.starts_with("https"), opens: AtomicUsize::new(0), tasks: StdMutex::new(Vec::new()), @@ -102,6 +114,115 @@ async fn encrypted_dns_authenticates_and_reuses_tls_and_https_streams() { } } +struct H2Peer { + tls: Arc, + opens: AtomicUsize, + tasks: StdMutex>>, +} + +#[async_trait] +impl Dispatcher for H2Peer { + async fn connect_tcp(&self, _: StreamSession) -> Result { + self.opens.fetch_add(1, Ordering::Relaxed); + let (client, peer) = tokio::io::duplex(64 * 1024); + let tls = self.tls.clone(); + self.tasks.lock().unwrap().push(tokio::spawn(async move { + let stream = tokio_rustls::TlsAcceptor::from(tls) + .accept(peer) + .await + .unwrap(); + assert_eq!(stream.get_ref().1.alpn_protocol(), Some(&b"h2"[..])); + let mut connection = h2::server::handshake(stream).await.unwrap(); + let mut pending = Vec::new(); + // Neither response is sent until both streams arrive on this + // connection. Serial H1 or per-query H2 connections cannot pass. + for _ in 0..2 { + let (request, respond) = connection.accept().await.unwrap().unwrap(); + assert_eq!(request.method(), http::Method::GET); + assert!(request.body().is_end_stream()); + assert_eq!(request.uri().path(), "/dns/query"); + assert_eq!(request.headers()["authorization"], "Basic YWxpY2U6cDp3b3Jk"); + use base64::Engine as _; + let query = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(request.uri().query().unwrap().strip_prefix("dns=").unwrap()) + .unwrap(); + assert_eq!(&query[..2], &[0, 0]); + let question = classify_query(&query).unwrap(); + pending.push(( + super::super::synthesize_empty_response(&question, 0).unwrap(), + respond, + )); + } + for (wire, mut respond) in pending { + let response = http::Response::builder() + .status(200) + .header("content-type", "application/dns-message") + .header("content-length", wire.len()) + .body(()) + .unwrap(); + respond + .send_response(response, false) + .unwrap() + .send_data(Bytes::from(wire), true) + .unwrap(); + } + while connection.accept().await.transpose().unwrap().is_some() {} + })); + Ok(Box::new(client)) + } + + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + Err(DispatchError::NotAllowed) + } +} + +#[tokio::test] +async fn doh_h2_negotiates_multiplexes_and_joins_resolver_drivers() { + let certificate = rcgen::generate_simple_self_signed(vec!["dns.example".into()]).unwrap(); + let mut server = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![certificate.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into()), + ) + .unwrap(); + server.alpn_protocols = vec![b"h2".to_vec()]; + let mut roots = rustls::RootCertStore::empty(); + roots.add(certificate.cert.der().clone()).unwrap(); + let peer = Arc::new(H2Peer { + tls: Arc::new(server), + opens: AtomicUsize::new(0), + tasks: StdMutex::new(Vec::new()), + }); + let config = crate::config::Config::parse_yaml(b"mixed-port: 1080\nhosts: {dns.example: 192.0.2.1}\ndns:\n enable: true\n use-system-hosts: false\n nameserver: ['https://alice:p%3Aword@dns.example/dns%2Fquery?discard=value']\n").unwrap(); + let mut dns = RuntimeDns::new(&config.dns, peer.clone()); + Arc::get_mut(&mut dns.tcp_pool).unwrap().security = + crate::security::SecurityContext::with_tls_roots(roots); + let (first, second) = tokio::time::timeout(Duration::from_secs(3), async { + tokio::join!(dns.resolve("first.example"), dns.resolve("second.example")) + }) + .await + .unwrap(); + assert!(first.unwrap().is_empty()); + assert!(second.unwrap().is_empty()); + assert_eq!(peer.opens.load(Ordering::Relaxed), 1); + assert_eq!(dns.tcp_pool.snapshot().reuse_hits, 1); + assert_eq!(dns.tcp_pool.snapshot().current_physical, 1); + dns.shutdown().await; + assert_eq!(dns.tcp_pool.snapshot().current_physical, 0); + assert_eq!(dns.tcp_pool.http2.driver_count(), 0); + for task in std::mem::take(&mut *peer.tasks.lock().unwrap()) { + task.await.unwrap(); + } +} + #[tokio::test] async fn upstream_tls_options_verify_names_skip_roots_and_disable_only_dot_reuse() { let certificate = rcgen::generate_simple_self_signed(vec!["dns.example".into()]).unwrap(); diff --git a/src/outbound/wireguard/engine.rs b/src/outbound/wireguard/engine.rs index 73a8538..55bb20e 100644 --- a/src/outbound/wireguard/engine.rs +++ b/src/outbound/wireguard/engine.rs @@ -168,6 +168,9 @@ impl Engine { self.cancel.cancel(); self.retired.close(); } + if let Some(remote) = self.remote.get() { + remote.shutdown().await; + } let channels = self .slots .iter() @@ -186,6 +189,9 @@ impl Engine { } impl Drop for Engine { fn drop(&mut self) { + if let Some(remote) = self.remote.get() { + remote.begin_shutdown(); + } self.cancel.cancel(); self.control.cancel(); if let Some(router) = self.router.get_mut().unwrap().take() { diff --git a/src/outbound/wireguard/tests.rs b/src/outbound/wireguard/tests.rs index 9d3d390..d8e67df 100644 --- a/src/outbound/wireguard/tests.rs +++ b/src/outbound/wireguard/tests.rs @@ -522,6 +522,7 @@ async fn wireguard_peers_longest_prefix_reserved_and_remote_dns_have_owned_io() transport: crate::config::DnsTransport::Udp, address: crate::config::DnsAddress::Domain("dns.example".into()), port: 53, route: crate::config::DnsRoute::Direct, path: None, + http_auth: None, options: Default::default(), }]; let records = Arc::>>::default(); diff --git a/src/runtime.rs b/src/runtime.rs index 20dbed6..bd24998 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -231,6 +231,7 @@ impl BuiltProxyGraph { } async fn shutdown(&self) { + self.resolution.shutdown().await; for target in self.lifecycle_order.iter().rev() { if let BuiltRouteTarget::Proxy(connector) = target { connector.shutdown().await; @@ -1688,6 +1689,9 @@ impl RunningCore { result = &mut shutdown => (None, result.err()), completed = wait_first_task(&mut self.tasks), if !self.tasks.is_empty() => (completed, None), }; + // Keep weakly bound DNS alive through listener teardown and its driver + // join barrier. Resource generations retain their own resolver owners. + let dns = self.proxy_graph.resolution.runtime_dns(); self.proxy_graph.begin_shutdown(); if let Some(resources) = &self.resources { resources.begin_shutdown(); @@ -1702,6 +1706,9 @@ impl RunningCore { if let Some(resources) = &self.resources { resources.shutdown().await; } + if let Some(dns) = dns { + dns.shutdown().await; + } #[cfg(all(feature = "tun", target_os = "linux"))] let result = if let Some(mut network) = self.system_network.take() { result.and(network.restore()) diff --git a/src/security/boring.rs b/src/security/boring.rs index 7af3028..309dac7 100644 --- a/src/security/boring.rs +++ b/src/security/boring.rs @@ -261,10 +261,19 @@ impl BoringTlsClient { stream: BoxStream, buffer_limit: usize, ) -> io::Result { - Ok(Box::new(BoringStream::new( - self.handshake(stream).await?, - buffer_limit, - ))) + self.connect_negotiated(stream, buffer_limit) + .await + .map(|(stream, _)| stream) + } + + pub(super) async fn connect_negotiated( + &self, + stream: BoxStream, + buffer_limit: usize, + ) -> io::Result<(BoxStream, Option>)> { + let tls = self.handshake(stream).await?; + let alpn = tls.ssl().selected_alpn_protocol().map(<[u8]>::to_vec); + Ok((Box::new(BoringStream::new(tls, buffer_limit)), alpn)) } #[cfg(feature = "outbound-vless")] diff --git a/src/security/tls.rs b/src/security/tls.rs index 2a485fc..7e087e3 100644 --- a/src/security/tls.rs +++ b/src/security/tls.rs @@ -369,11 +369,22 @@ impl StandardTlsClient { } pub async fn connect(&self, stream: BoxStream) -> io::Result { + self.connect_negotiated(stream) + .await + .map(|(stream, _)| stream) + } + + /// Returns the authenticated stream and the server's actual ALPN choice. + /// Existing required-ALPN/ECH checks apply before either becomes visible. + pub async fn connect_negotiated( + &self, + stream: BoxStream, + ) -> io::Result<(BoxStream, Option>)> { let connector = match &self.connector { StandardConnector::Rustls(connector) => connector, #[cfg(feature = "tls-fingerprint")] StandardConnector::Boring(client) => { - return client.connect(stream, self.buffer_limit).await; + return client.connect_negotiated(stream, self.buffer_limit).await; } }; let server_name = ServerName::try_from(self.server_name.clone()) @@ -398,7 +409,8 @@ impl StandardTlsClient { )); } - Ok(Box::new(super::stream::TlsStream::new(tls))) + let alpn = tls.get_ref().1.alpn_protocol().map(<[u8]>::to_vec); + Ok((Box::new(super::stream::TlsStream::new(tls)), alpn)) } #[cfg(feature = "outbound-vless")] diff --git a/src/tun_runtime/tests.rs b/src/tun_runtime/tests.rs index a3a8be6..de41493 100644 --- a/src/tun_runtime/tests.rs +++ b/src/tun_runtime/tests.rs @@ -2250,6 +2250,7 @@ fn test_runtime_dns(dispatcher: Arc) -> Arc { ipv6: true, nameservers: vec![DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Udp, address: Ipv4Addr::new(198, 51, 100, 53).into(), diff --git a/tests/README.md b/tests/README.md index c05a145..b3943d4 100644 --- a/tests/README.md +++ b/tests/README.md @@ -62,6 +62,8 @@ cargo test --locked --features cli --lib config::dns::upstream_options_tests cargo test --locked --features cli --lib dns::options::tests:: cargo test --locked --features cli --lib dns::runtime::tests::upstream_ cargo test --locked --features cli --lib dns::runtime::encrypted_tests::upstream_tls_options_ +cargo test --locked --features cli --lib doh_ +cargo test --locked --features cli --lib dns::runtime::encrypted_tests::encrypted_dns_ cargo test --locked --features cli --lib invoke::tests::update_dns_network_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets uv run --project scripts --locked python -m unittest discover -s scripts/tests @@ -69,7 +71,7 @@ uv run --project scripts --locked ruff check scripts uv run --project scripts --locked ruff format --check scripts ``` -DNS URL 参数过滤器只使用内存报文、显式来源快照和 duplex TLS 对端,不读取宿主 DNS 或启动监听服务。 +DNS URL 参数、DoH GET/Basic、H2 并发与取消、ALPN/H1 回退只使用内存报文、显式来源快照和 duplex TLS 对端,不读取宿主 DNS 或启动监听服务。 Windows UWP bridge 的名称准入与匹配使用独立过滤器;下列测试仅处理 JSON、匹配策略 和未注册的内存 `VpnPlugInProfile` 对象,不创建系统 VPN profile 或启动连接: diff --git a/tests/resolution_foundations.rs b/tests/resolution_foundations.rs index 35fa7b4..ee7e630 100644 --- a/tests/resolution_foundations.rs +++ b/tests/resolution_foundations.rs @@ -182,6 +182,7 @@ async fn runtime_uses_configured_dns_and_weak_binding_releases_owner() { ipv6: false, nameservers: vec![DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Udp, address: address.ip().into(), @@ -268,6 +269,7 @@ async fn recursive_dns_dependency_fails_before_connecting_or_joining_its_own_fli ipv6: false, nameservers: vec![DnsNameserver { path: None, + http_auth: None, options: Default::default(), transport: DnsTransport::Udp, address: "192.0.2.1".parse().unwrap(), From e7f58a8ba0e4b36ce15b7f2f69bff39e61fbf99f Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:40:32 +0800 Subject: [PATCH 051/445] feat: support DoQ and HTTP/3 DNS transports --- Cargo.toml | 4 +- docs/config.yaml | 10 +- docs/mihomo-compatibility.md | 4 +- docs/tun-icmp-dns.md | 26 +- src/config/dns.rs | 41 ++- src/config/mod.rs | 7 +- src/config/wireguard.rs | 1 + src/dns/https.rs | 32 +- src/dns/mod.rs | 2 + src/dns/quic.rs | 575 +++++++++++++++++++++++++++++++++++ src/dns/runtime.rs | 316 ++++++++++++++++++- src/dns/tests_encrypted.rs | 12 +- src/dns/tests_quic.rs | 441 +++++++++++++++++++++++++++ src/security/tls.rs | 6 +- tests/README.md | 3 +- 15 files changed, 1452 insertions(+), 28 deletions(-) create mode 100644 src/dns/quic.rs create mode 100644 src/dns/tests_quic.rs diff --git a/Cargo.toml b/Cargo.toml index 57af9cf..e08281c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ required-features = ["ffi", "windows-uwp"] [features] default = [ + "dns-quic", "inbound-http", "inbound-socks5", "outbound-http", @@ -62,12 +63,13 @@ sing-mux = ["stream-transport", "dep:yamux", "tokio-util/compat"] outbound-wireguard = ["dep:boringtun", "dep:vole-netstack"] stream-transport = ["dep:tokio-tungstenite", "dep:httparse", "dep:log"] quic-transport = ["dep:quinn"] +dns-quic = ["quic-transport", "dep:h3", "dep:h3-quinn"] # Enables local interoperability-only trust-root injection. Release build # scripts must not enable this feature. interop-test = ["outbound-anytls", "outbound-vless"] # Allows plaintext GeoData fixtures for isolated benchmarks, never production. benchmark-geodata-http = [] -invoke = ["tun", "tokio/rt-multi-thread", "tokio/signal"] +invoke = ["tun", "dns-quic", "tokio/rt-multi-thread", "tokio/signal"] ffi = ["invoke"] cli = ["invoke"] windows-wintun = ["tun", "dep:tun-rs"] diff --git a/docs/config.yaml b/docs/config.yaml index 417ad7d..28002c0 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -606,10 +606,13 @@ hosts: # enable 关闭时不接管 TUN DNS,内部解析使用系统 resolver;配置中的 hosts 仍可用于内部解析。 # enable 开启时使用配置的 resolver,不因上游失败回退到系统 DNS。 # nameserver 默认 [https://doh.pub/dns-query, tls://223.5.5.5:853],不能显式留空。 -# 支持裸 IP、UDP/TCP、tls://、http(s)://,允许上游域名;DoH GET 使用 ID 0 与无 padding +# 支持裸 IP、UDP/TCP、tls://、http(s)://、quic://(DoQ 默认 853),允许上游域名。 +# DoH GET 使用 ID 0 与无 padding # Base64URL dns=;清除原 query/fragment,URL userinfo 转 Basic,不进入 Host/path 或日志。 # HTTPS 实际 ALPN 协商 H2/H1,无 ALPN 回退 H1;明文 HTTP 仅 H1,无 h2c。 -# DoT 与 HTTPS 使用共享 TLS 信任根,校验证书与名称。 +# prefer-h3: true 让 HTTPS 首次建连竞争已认证 H3 与 TCP TLS,第一条成功胜出;失败允许另一条继续。 +# HTTPS #h3=true 强制实际 H3,不降级 H2/H1;明文 HTTP 的 h3/prefer-h3 不改变 H1 行为。 +# DoT、DoH、DoQ 使用共享 TLS 信任根,校验证书与名称;QUIC 使用受控 UDP,不绕过 DNS 出口/平台绑定。 # default-nameserver 接受 IP 上游或显式 system,用于 bootstrap。 # system、system://、dhcp://system 使用物理系统 DNS;dhcp://<精确接口名> 使用该接口的 DHCP DNS。 # 各角色、policy、fallback 均支持;DHCP 接口不能用于 default-nameserver。来源始终物理直连, @@ -623,7 +626,7 @@ hosts: # ecs=IP/CIDR 与 ecs-override=true 控制 ECS,保留其他 EDNS 参数。 # disable-ipv4=true、disable-ipv6=true、disable-qtype-N=true 抑制相应 question 并过滤全部响应 sections; # ECS/过滤适用于 system、DHCP、RCODE 及各 resolver 角色,DHCP 接口名不含 fragment。 -# skip-cert-verify=true、name-cert-verify 用于 DoT/HTTPS;显式名称验证优先且不改变 SNI。 +# skip-cert-verify=true、name-cert-verify 用于 DoT/DoH/DoQ;显式名称验证优先且不改变 SNI。 # disable-reuse=true 仅禁用指定 DoT 复用;HTTPS 仍可复用,未知参数/出口拒绝。 # proxy-server-nameserver 专门解析节点,direct-nameserver # 专门解析 DIRECT 业务目标;未配置时复用 nameserver。两者不隐式开启 respect-rules。 @@ -639,6 +642,7 @@ hosts: dns: enable: true ipv6: true + prefer-h3: false ipv6-timeout: 100 # 毫秒;内部 A/AAAA 并发,A 完成后最多等待 AAAA;0 同缺省 100 cache-max-size: 4096 # 每个 DNS 角色的结果总条目数;0 同缺省 4096,A/AAAA 和 wire 共用 cache-algorithm: lru # 精确 arc 使用 ARC;缺省与其他字符串使用 LRU,与 Mihomo 一致 diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 981b3c7..704483c 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -24,10 +24,10 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 所有后端仍使用 WebPKI 校验,未复制 Go 的原生系统验证器。详细范围见 [TLS 契约](tls-client-fingerprint.md)。 独立测量 YAML 只接受 proxies 与同名客户端 tls 子集,不接受 TLS 服务端配置。 - Shadowsocks 只支持三个 2022 cipher;插件仅 strict ShadowTLS v3,UoT 仅 v2。 -- DNS 不提供独立 listen、DoQ 或 DoH/H3。ECS、上游类型过滤、TLS 验证参数、 +- DNS 不提供独立 listen。DoQ、DoH/H3、prefer-h3、ECS、上游类型过滤、TLS 验证参数、 DoT 独立禁用复用、LRU/ARC 与显式 system/DHCP 上游已支持, 来源发现和物理绑定的可用平台及参考差异以 DNS 契约为准。 - DoH 使用 GET、URL Basic 与实际 ALPN H2/H1;详细解析角色、缓存、预算与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 + DoH 使用 GET、URL Basic 与实际 H3/H2/H1;详细解析角色、缓存、预算与过滤行为见 [DNS 契约](tun-icmp-dns.md)。 - Provider/group 名称筛选和改名支持 lookahead、定长 lookbehind、数字/命名 backreference; 普通表达式保留 Rust regex 快速路径。复杂表达式和改名有内部预算,超限使候选失败。 不支持递归/条件式或完整 .NET 方言;DOMAIN-REGEX 与 GeoSite regex 不扩展。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 8c41b0e..2aed4f7 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -109,7 +109,7 @@ dns: ### Endpoint - `dns.enable`、`dns.ipv6` 默认 false。主 nameserver 默认使用 DoH/DoT,可显式替换为非空列表。 -- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://` 和 `http://`。加密连接复用会话的内置、可读系统与 `tls.custom-certifactes` 信任根;各解析角色及 WireGuard 私有 DNS 同样适用,平台边界见 [TLS 契约](tls-client-fingerprint.md)。HTTPS 根据实际 ALPN 使用 HTTP/2 或 HTTP/1.1;没有 ALPN 时使用 HTTP/1.1。明文 HTTP 仅支持 HTTP/1.1,不启用 h2c。 +- 接受裸 IPv4/IPv6、`udp://`、`tcp://`、`tls://`、`https://`、`http://` 和 `quic://`。加密连接复用会话的内置、可读系统与 `tls.custom-certifactes` 信任根;各解析角色及 WireGuard 私有 DNS 同样适用,平台边界见 [TLS 契约](tls-client-fingerprint.md)。HTTPS 根据实际 ALPN 使用 HTTP/2 或 HTTP/1.1;没有 ALPN 时使用 HTTP/1.1。明文 HTTP 仅支持 HTTP/1.1,不启用 h2c。 - `rcode://success`、`rcode://format_error`、`rcode://server_failure`、`rcode://name_error`、`rcode://not_implemented`、`rcode://refused` 分别返回本地 RCODE 0–5;可用于 nameserver、policy、fallback、节点和直连 resolver,不能用于 default-nameserver。 - 同一个上游列表中存在本地 RCODE 时使用第一项本地响应,不建立网络连接,也不把 SERVFAIL/REFUSED 当作上游故障重试。响应保留原查询的 flags 和 additional,仅设置 QR 与 RCODE;不额外开启 RA,不写入结果缓存。 - `dns.ipv6: false` 不限制 IPv6 nameserver;顶层 `ipv6: false` 会阻止通过 DIRECT(含 `RULES` 选中 DIRECT)的 IPv6 nameserver 在本机物理建链,并继续当前组的故障转移。 @@ -140,7 +140,7 @@ DHCP 接口名只取 URL endpoint,fragment 不参与接口查找。 报文大小、记录数和名称压缩跳数仍受本节预算约束,不能通过过滤移除错误记录来绕过验证。 类型参数和 `ecs-override` 仅在字面 `true` 时启用,与顶层及 DNS 的 IPv6 门禁独立。 -DoT/HTTPS 的 `skip-cert-verify=true` 和 `name-cert-verify` 接共享 TLS 安全层,显式名称验证 +DoT/DoH/DoQ 的 `skip-cert-verify=true` 和 `name-cert-verify` 接共享 TLS 安全层,显式名称验证 先于跳过验证且不改变 SNI。`disable-reuse=true` 仅使指定 DoT 上游每次新建并关闭连接, 不改变 HTTPS 或其他上游的复用。不同参数的上游保持池身份隔离。 @@ -153,9 +153,26 @@ URL 只保留协议、带默认端口的主机、路径和 userinfo;丢弃原 `dns=` 为完整 DNS wire 的无 padding Base64URL。传输前 ID 改为 0,响应 ID 必须为 0, 之后恢复原 ID 并执行原有 question、完整报文及缓存校验。 -H1/H2 的请求及响应头各限 16 KiB,DNS body 仍限 4,096 字节。 +H1/H2/H3 的请求及响应头各限 16 KiB,DNS body 仍限 4,096 字节。 仅接受 HTTP 200 与 `application/dns-message`,响应长度声明、分帧及完整 DNS 校验必须通过。 +`dns.prefer-h3` 默认 false。true 时 HTTPS 的冷初始化同时建立已认证的 TCP TLS 与实际 H3, +第一条成功连接用于查询;某条先失败时继续等待另一条,取消输家并等待其资源清理。 +复用已选连接,H1/H2 的版本选择最多保留 4 项、空闲 30 秒,失败后重新选择;不为每次查询重新探测。 +`https://…#h3=true` 仅建立 H3,失败不能降级;明文 HTTP 的 h3/prefer-h3 不改变 H1 行为。 +bootstrap、主、节点和直连 DNS 角色均应用全局 prefer-h3;WireGuard 私有 DNS 与固定参考一致, +不继承全局偏好,但支持 URL `h3=true`。H3 共用 GET/Basic/ID 与包装,不启用 0-RTT。 + +### DoQ + +`quic://host[:port]` 默认 853,使用受控 UDP、TLS 1.3 与实际 `doq` ALPN。 +每条查询使用独立双向 stream:两字节网络序长度、完整 DNS wire,ID 设为 0,并结束写方向。 +响应必须是一份 12..=4,096 字节的完整 DNS message,ID 为 0,读至 FIN 后恢复原 ID 并校验; +空长度、超限、截断或额外消息均拒绝。并发查询共享连接,取消只停止当前 stream。 +DoQ/H3 都沿用原 DNS 路由、bootstrap、证书政策与查询包装,不新增 socket 工厂或系统解析回退。 +独立 `dns-quic` feature 提供两种能力,默认构建和 Invoke 生产入口启用;显式关闭该 feature 的 +精简 Core 构建拒绝 `quic://`、`prefer-h3: true` 和 HTTPS `h3=true`,不会接受后忽略。 + ### Policy `nameserver-policy` 是有序映射: @@ -303,12 +320,14 @@ TCP、DoT 和 DoH 使用解析器所属的连接池: 来源 generation 和物理接口绑定; - TCP、DoT、HTTP/1.1 一条连接只处理一个查询,不做 pipeline;这部分空闲连接总数最多 4、同 key 最多 2、超时 30 秒; - HTTP/2 同 key 首次建连串行发布,之后共享 sender;每连接最多 32 个并发 stream,超出时在查询原期限内等待。流接收窗口 4 KiB、连接接收窗口 128 KiB,禁用 server push;H2 空闲连接另限 4、超时 30 秒; +- DoQ/H3 同 key 串行初始化后复用,每连接最多 32 条查询;QUIC stream 接收窗口 4,098 字节、connection 接收/发送窗口 128 KiB,不接收服务器双向 stream 或 QUIC DATAGRAM。H3 最多接收 4 条单向控制 stream,DoQ 为 0;空闲 QUIC 连接另限 4、超时 30 秒; - 活动物理连接不设固定业务数量上限;完整响应校验后才能归还 H1 连接或继续复用 H2; - TCP/DoT/H1 复用连接遇到 EOF、I/O、reset 或响应不匹配时,在原尝试期限内最多新建一次连接; - 非法、截断或超限响应不重试、不归还连接。 HTTP/2 单流取消发送 RST,其他流可继续使用原连接;响应错误使该连接不再接受新查询。 解析器拥有连接 driver 与有界空闲回收任务,Session Stop 和资源代际退役关闭全部解析角色并等待 driver 结束;WireGuard 私有 DNS 同样由节点 Stop 关闭且不保留空闲连接。 +DoQ/H3 的取消和退役先关闭 endpoint,最多等待一秒 QUIC close exchange,随后停止并等待 Quinn runtime 与受控 datagram driver;初始化探测同样属于此 Stop 所有权。 Controller 切换代理组后,只在后续需要创建新 DNS transport 时使用新选择。类型化/原始响应 cache、singleflight 状态和已经位于 TCP pool 中的 transport 不清空、不迁移;因此命中 cache 或复用既有 TCP transport 时,不会为切换单独建链。停止 session 才统一释放这些状态。 @@ -346,7 +365,6 @@ Running Session上下文仅弱引用本 session 的 RuntimeDns;业务 IP-only ## 不支持 - 真实 ICMP 转发、ICMP error、Traceroute、ICMP 选路规则或 ICMP 测速; -- DoQ、DoH HTTP/3; - 运行中随接口变化自动刷新 hosts: lan; - 用户可配置的查询总超时、重试或并发度; - 使用 DNS question 执行业务规则,或用 DNS 出口改写后续业务动作。 diff --git a/src/config/dns.rs b/src/config/dns.rs index 4cd1d50..979e9e5 100644 --- a/src/config/dns.rs +++ b/src/config/dns.rs @@ -33,6 +33,9 @@ impl DnsConfig { impl RawDnsConfig { pub(super) fn normalize(self, targets: &RouteTargetsByName) -> Result { + if self.prefer_h3 && !cfg!(feature = "dns-quic") { + return invalid("DNS HTTP/3 support is disabled at build time"); + } let fake_ip = self.fake_ip()?; if self.enable && self.nameserver.is_empty() { return invalid("enabled DNS requires nameserver"); @@ -75,6 +78,7 @@ impl RawDnsConfig { fake_ip, enable: self.enable, ipv6: self.ipv6, + prefer_h3: self.prefer_h3, ipv6_timeout: Duration::from_millis(if self.ipv6_timeout == 0 { default_ipv6_timeout() } else { @@ -122,7 +126,7 @@ pub(super) fn parse_dns_nameserver( ) -> Result { let bad = || { VoleError::InvalidConfig( - "invalid DNS upstream; supported transports are UDP, TCP, TLS, HTTP(S), RCODE, system and DHCP" + "invalid DNS upstream; supported transports are UDP, TCP, TLS, HTTP(S), QUIC, RCODE, system and DHCP" .into(), ) }; @@ -212,6 +216,8 @@ pub(super) fn parse_dns_nameserver( "tls" => (DnsTransport::Tls, 853), "https" => (DnsTransport::Https, 443), "http" => (DnsTransport::Http, 80), + "quic" if cfg!(feature = "dns-quic") => (DnsTransport::Quic, 853), + "quic" => return invalid("DNS QUIC support is disabled at build time"), _ => return Err(bad()), }; let http = matches!(transport, DnsTransport::Http | DnsTransport::Https); @@ -342,6 +348,7 @@ fn parse_nameserver_options( } "disable-reuse" => options.disable_reuse = value == "true", "h3" if value != "true" || ignore_h3 => {} + "h3" if cfg!(feature = "dns-quic") => options.force_h3 = true, _ => return invalid("unsupported DNS upstream parameter"), } } @@ -359,6 +366,38 @@ fn valid_disabled_type(kind: u16) -> bool { mod upstream_options_tests { use super::*; + #[test] + fn dns_quic_feature_gates_and_plain_http_fragment() { + let targets = RouteTargetsByName::new(); + let quic = parse_dns_nameserver("quic://dns.example", &targets, DnsRoute::Direct); + let h3 = parse_dns_nameserver( + "https://dns.example/dns-query#h3=true", + &targets, + DnsRoute::Direct, + ); + let raw: RawDnsConfig = serde_yaml_ng::from_str("prefer-h3: true").unwrap(); + let preferred = raw.normalize(&targets); + if cfg!(feature = "dns-quic") { + let quic = quic.unwrap(); + assert_eq!(quic.port, 853); + assert_eq!(quic.transport, DnsTransport::Quic); + assert!(h3.unwrap().options.force_h3); + assert!(preferred.unwrap().prefer_h3); + } else { + assert!(quic.is_err()); + assert!(h3.is_err()); + assert!(preferred.is_err()); + } + let http = parse_dns_nameserver( + "http://dns.example/dns-query#h3=true", + &targets, + DnsRoute::Direct, + ) + .unwrap(); + assert_eq!(http.transport, DnsTransport::Http); + assert!(!http.options.force_h3); + } + #[test] fn doh_url_clears_query_and_preserves_only_decoded_basic_identity() { use base64::Engine as _; diff --git a/src/config/mod.rs b/src/config/mod.rs index 3a5c2c4..caaa5a9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -296,6 +296,7 @@ pub struct DnsConfig { pub fallback: DnsFallbackConfig, pub enable: bool, pub ipv6: bool, + pub prefer_h3: bool, pub ipv6_timeout: Duration, pub cache_max_size: usize, pub cache_algorithm: DnsCacheAlgorithm, @@ -411,6 +412,7 @@ pub struct DnsNameserverOptions { pub skip_cert_verify: bool, pub verification_name: Option, pub disable_reuse: bool, + pub force_h3: bool, } impl std::fmt::Debug for DnsNameserverOptions { @@ -422,6 +424,7 @@ impl std::fmt::Debug for DnsNameserverOptions { .field("skip_cert_verify", &self.skip_cert_verify) .field("explicit_name", &self.verification_name.is_some()) .field("disable_reuse", &self.disable_reuse) + .field("force_h3", &self.force_h3) .finish() } } @@ -466,6 +469,7 @@ pub enum DnsTransport { Tls, Https, Http, + Quic, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -1411,6 +1415,8 @@ struct RawDnsConfig { enable: bool, #[serde(default)] ipv6: bool, + #[serde(rename = "prefer-h3", default)] + prefer_h3: bool, #[serde(rename = "ipv6-timeout", default = "dns::default_ipv6_timeout")] ipv6_timeout: u64, #[serde(rename = "cache-max-size", default)] @@ -6737,7 +6743,6 @@ dns: for nameserver in [ "udp://1.1.1.1:53#direct", "udp://1.1.1.1:53#unknown", - "https://1.1.1.1/dns-query#h3=true", "udp://1.1.1.1:53#DIRECT#RULES", ] { let yaml = current_yaml(&format!( diff --git a/src/config/wireguard.rs b/src/config/wireguard.rs index 71a9f11..91a388f 100644 --- a/src/config/wireguard.rs +++ b/src/config/wireguard.rs @@ -216,6 +216,7 @@ fn supported_node_dns(nameserver: &DnsNameserver) -> bool { | super::DnsTransport::Tls | super::DnsTransport::Http | super::DnsTransport::Https + | super::DnsTransport::Quic | super::DnsTransport::Rcode(_) ) } diff --git a/src/dns/https.rs b/src/dns/https.rs index 420cd17..3740b03 100644 --- a/src/dns/https.rs +++ b/src/dns/https.rs @@ -59,13 +59,43 @@ pub(super) fn restore_id(query: &[u8], response: &mut [u8]) -> io::Result<()> { if response.len() < 12 || response[..2] != [0, 0] { return Err(io::Error::new( io::ErrorKind::InvalidData, - "DNS HTTP response ID is not zero", + "DNS response ID is not zero", )); } response[..2].copy_from_slice(&query[..2]); Ok(()) } +#[cfg(feature = "dns-quic")] +pub(super) fn validate_headers( + status: http::StatusCode, + headers: &http::HeaderMap, +) -> io::Result> { + if status != http::StatusCode::OK + || !headers + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value.split(';').next().is_some_and(|value| { + value.trim().eq_ignore_ascii_case("application/dns-message") + }) + }) + { + return Err(io::ErrorKind::InvalidData.into()); + } + headers + .get(http::header::CONTENT_LENGTH) + .map(|value| { + value + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|length| *length <= MAX_MESSAGE_SIZE) + .ok_or_else(|| io::ErrorKind::InvalidData.into()) + }) + .transpose() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/dns/mod.rs b/src/dns/mod.rs index d5629a5..03de445 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -14,6 +14,8 @@ mod http2; mod https; mod options; pub(crate) mod policy; +#[cfg(feature = "dns-quic")] +mod quic; pub(crate) mod trace; pub mod resolution; diff --git a/src/dns/quic.rs b/src/dns/quic.rs new file mode 100644 index 0000000..b4bcd1b --- /dev/null +++ b/src/dns/quic.rs @@ -0,0 +1,575 @@ +//! DNS-only QUIC ownership, budgets and per-query streams over protected IO. +use super::{MAX_HTTP_HEADER_BYTES, MAX_MESSAGE_SIZE}; +use crate::{ + config::DnsNameserver, + dispatch::{DatagramBudget, DatagramTransport}, + transport::quic::{self, OwnedRuntime}, +}; +use bytes::{Buf, Bytes}; +use std::{ + collections::HashMap, + future::Future, + hash::Hash, + io, + net::SocketAddr, + sync::{ + Arc, Mutex, Weak, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + time::Duration, +}; +use tokio::{ + io::AsyncReadExt, + sync::{Mutex as AsyncMutex, Semaphore, oneshot}, + time::Instant, +}; +use tokio_util::{sync::CancellationToken, task::TaskTracker}; + +const MAX_STREAMS: usize = 32; +const MAX_IDLE: usize = 4; +const IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Copy)] +pub(super) enum Kind { + Doq, + H3, +} + +pub(super) struct Raw { + pub transport: Box, + pub peer: SocketAddr, + pub budget: DatagramBudget, + pub tls: Arc, + pub server_name: String, +} + +enum Protocol { + Doq(quinn::Connection), + H3(h3::client::SendRequest), +} + +struct Connection { + protocol: Protocol, + metadata: Arc, + cancel: CancellationToken, + streams: Arc, +} +struct Metadata { + healthy: AtomicBool, + active: AtomicUsize, + used: Mutex, +} +struct State { + connections: Vec<(K, Arc)>, + initializing: HashMap>>, +} + +pub(super) struct Pool { + state: Mutex>, + admission: Mutex<()>, + cancel: CancellationToken, + tasks: TaskTracker, + reaper: AtomicBool, + max_idle: AtomicUsize, +} +pub(super) struct Lease { + connection: Arc, + owner: Weak>, +} + +/// A probe is already owned by its resolver, including its close exchange. +/// Dropping a canceled query cancels this child; Stop joins its cleanup task. +pub(super) struct Pending { + owner: Arc>, + key: Option, + ready: oneshot::Receiver>, + done: Option>, + protocol: Option, + metadata: Arc, + cancel: CancellationToken, + published: bool, +} + +impl Pool { + pub(super) fn new() -> Arc { + Arc::new(Self { + state: Mutex::new(State { + connections: Vec::new(), + initializing: HashMap::new(), + }), + admission: Mutex::new(()), + cancel: CancellationToken::new(), + tasks: TaskTracker::new(), + reaper: AtomicBool::new(false), + max_idle: AtomicUsize::new(MAX_IDLE), + }) + } + pub(super) fn disable_idle(&self) { + self.max_idle.store(0, Ordering::Release); + } + pub(super) fn has(&self, key: &K) -> bool { + !self.cancel.is_cancelled() + && self + .state + .lock() + .unwrap() + .connections + .iter() + .any(|(candidate, connection)| candidate == key && live(connection)) + } + pub(super) fn get(self: &Arc, key: &K) -> Option> { + if self.cancel.is_cancelled() { + return None; + } + let mut state = self.state.lock().unwrap(); + self.maintain(&mut state); + let connection = state + .connections + .iter() + .find(|(candidate, connection)| candidate == key && live(connection))? + .1 + .clone(); + connection.metadata.active.fetch_add(1, Ordering::AcqRel); + Some(Lease { + connection, + owner: Arc::downgrade(self), + }) + } + pub(super) fn initialization(&self, key: K) -> Arc> { + let mut state = self.state.lock().unwrap(); + state + .initializing + .retain(|_, gate| gate.strong_count() != 0); + if let Some(gate) = state.initializing.get(&key).and_then(Weak::upgrade) { + return gate; + } + let gate = Arc::new(AsyncMutex::new(())); + state.initializing.insert(key, Arc::downgrade(&gate)); + gate + } + pub(super) fn start( + self: &Arc, + key: K, + kind: Kind, + open: impl Future> + Send + 'static, + ) -> io::Result> { + let _admission = self.admission.lock().unwrap(); + if self.cancel.is_cancelled() { + return Err(closed()); + } + let cancel = self.cancel.child_token(); + let metadata = Arc::new(Metadata { + healthy: AtomicBool::new(true), + active: AtomicUsize::new(0), + used: Mutex::new(Instant::now()), + }); + let (ready, response) = oneshot::channel(); + let (done, completed) = oneshot::channel(); + let observation = + crate::resources::observation::track(crate::resources::observation::ResourceKind::Task); + let stop = cancel.clone(); + let meta = metadata.clone(); + self.tasks + .spawn(crate::resources::observation::bind(async move { + let _observation = observation; + drive(open, kind, stop, meta, ready).await; + let _ = done.send(()); + })); + Ok(Pending { + owner: self.clone(), + key: Some(key), + ready: response, + done: Some(completed), + protocol: None, + metadata, + cancel, + published: false, + }) + } + fn maintain(&self, state: &mut State) { + state.connections.retain(|(_, connection)| { + connection.metadata.active.load(Ordering::Acquire) != 0 || live(connection) + }); + while state + .connections + .iter() + .filter(|(_, connection)| connection.metadata.active.load(Ordering::Acquire) == 0) + .count() + > self.max_idle.load(Ordering::Acquire) + { + let Some(index) = state + .connections + .iter() + .enumerate() + .filter(|(_, (_, connection))| { + connection.metadata.active.load(Ordering::Acquire) == 0 + }) + .min_by_key(|(_, (_, connection))| *connection.metadata.used.lock().unwrap()) + .map(|(index, _)| index) + else { + break; + }; + state.connections.swap_remove(index); + } + } + fn start_reaper(self: &Arc) { + let _admission = self.admission.lock().unwrap(); + if self.cancel.is_cancelled() || self.reaper.swap(true, Ordering::AcqRel) { + return; + } + let owner = Arc::downgrade(self); + let cancel = self.cancel.clone(); + self.tasks.spawn(crate::resources::observation::task(async move { + loop { + tokio::select! { biased; ()=cancel.cancelled()=>return, ()=tokio::time::sleep(IDLE_TIMEOUT)=>{} } + let Some(owner) = owner.upgrade() else { return; }; + let mut state = owner.state.lock().unwrap(); + owner.maintain(&mut state); + if state.connections.is_empty() { owner.reaper.store(false, Ordering::Release); return; } + } + })); + } + pub(super) fn close(&self) { + let _admission = self.admission.lock().unwrap(); + self.cancel.cancel(); + self.tasks.close(); + let mut state = self.state.lock().unwrap(); + state.initializing.clear(); + let connections = std::mem::take(&mut state.connections); + drop(state); + drop(connections); + } + pub(super) async fn shutdown(&self) { + self.close(); + self.tasks.wait().await; + } + #[cfg(test)] + pub(super) fn task_count(&self) -> usize { + self.tasks.len() + } +} + +fn live(connection: &Connection) -> bool { + connection.metadata.healthy.load(Ordering::Acquire) + && !connection.cancel.is_cancelled() + && (connection.metadata.active.load(Ordering::Acquire) != 0 + || Instant::now().duration_since(*connection.metadata.used.lock().unwrap()) + < IDLE_TIMEOUT) +} +impl Drop for Pool { + fn drop(&mut self) { + self.cancel.cancel(); + self.tasks.close(); + } +} +impl Drop for Connection { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +impl Pending { + pub(super) async fn wait_ready(&mut self) -> io::Result<()> { + self.protocol = Some((&mut self.ready).await.map_err(|_| closed())??); + Ok(()) + } + pub(super) fn publish(mut self) -> io::Result> { + let connection = Arc::new(Connection { + protocol: self.protocol.take().ok_or_else(closed)?, + metadata: self.metadata.clone(), + cancel: self.cancel.clone(), + streams: Arc::new(Semaphore::new(MAX_STREAMS)), + }); + { + let _admission = self.owner.admission.lock().unwrap(); + if self.owner.cancel.is_cancelled() + || !connection.metadata.healthy.load(Ordering::Acquire) + { + return Err(closed()); + } + connection.metadata.active.store(1, Ordering::Release); + self.owner + .state + .lock() + .unwrap() + .connections + .push((self.key.take().unwrap(), connection.clone())); + } + self.published = true; + self.owner.start_reaper(); + Ok(Lease { + connection, + owner: Arc::downgrade(&self.owner), + }) + } + pub(super) async fn finish(mut self) -> io::Result> { + self.wait_ready().await?; + self.publish() + } + pub(super) async fn cancel(mut self) { + self.cancel.cancel(); + if let Some(done) = self.done.take() { + let _ = done.await; + } + } +} +impl Drop for Pending { + fn drop(&mut self) { + if !self.published { + self.cancel.cancel(); + } + } +} + +async fn drive( + open: impl Future>, + kind: Kind, + stop: CancellationToken, + metadata: Arc, + ready: oneshot::Sender>, +) { + let runtime = Arc::new(OwnedRuntime::new(CancellationToken::new())); + let mut endpoint = None; + let mut datagram = None; + let mut ready = Some(ready); + let connected = async { + let raw = open.await?; + let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(raw.tls).map_err(failure)?; + let (socket, driver) = quic::attach(raw.transport, raw.peer, raw.budget)?; + datagram = Some(driver); + let mtu = socket.budget().quic_payload_limit()?; + let mut limits = quinn::TransportConfig::default(); + limits + .initial_mtu(mtu) + .min_mtu(mtu) + .mtu_discovery_config(None) + .max_concurrent_bidi_streams(0_u8.into()) + .max_concurrent_uni_streams((if matches!(kind, Kind::H3) { 4_u8 } else { 0 }).into()) + .stream_receive_window(((MAX_MESSAGE_SIZE + 2) as u32).into()) + .receive_window(((MAX_MESSAGE_SIZE * MAX_STREAMS) as u32).into()) + .send_window((MAX_MESSAGE_SIZE * MAX_STREAMS) as u64) + .datagram_receive_buffer_size(None) + .datagram_send_buffer_size(0) + .max_idle_timeout(Some(IDLE_TIMEOUT.try_into().map_err(failure)?)); + let mut client = quinn::ClientConfig::new(Arc::new(crypto)); + client.transport_config(Arc::new(limits)); + let mut config = quinn::EndpointConfig::default(); + config.max_udp_payload_size(mtu).map_err(failure)?; + let mut peer = + quinn::Endpoint::new_with_abstract_socket(config, None, socket, runtime.clone())?; + peer.set_default_client_config(client); + endpoint = Some(peer); + let connection = endpoint + .as_ref() + .unwrap() + .connect(raw.peer, &raw.server_name) + .map_err(failure)? + .await + .map_err(failure)?; + let handshake = connection + .handshake_data() + .ok_or_else(closed)? + .downcast::() + .map_err(|_| closed())?; + let alpn: &[u8] = if matches!(kind, Kind::Doq) { + b"doq" + } else { + b"h3" + }; + if handshake.protocol.as_deref() != Some(alpn) { + return Err(io::ErrorKind::InvalidData.into()); + } + match kind { + Kind::Doq => { + ready + .take() + .unwrap() + .send(Ok(Protocol::Doq(connection.clone()))) + .map_err(|_| closed())?; + connection.closed().await; + } + Kind::H3 => { + let (mut control, requests) = h3::client::builder() + .max_field_section_size(MAX_HTTP_HEADER_BYTES as u64) + .build(h3_quinn::Connection::new(connection)) + .await + .map_err(failure)?; + let keep_requests = requests.clone(); + ready + .take() + .unwrap() + .send(Ok(Protocol::H3(requests))) + .map_err(|_| closed())?; + let _ = control.wait_idle().await; + drop(keep_requests); + } + } + Ok::<_, io::Error>(()) + }; + let result = + tokio::select! { biased; ()=stop.cancelled()=>Err(closed()), result=connected=>result }; + metadata.healthy.store(false, Ordering::Release); + if let Some(ready) = ready { + let _ = ready.send(Err(result.err().unwrap_or_else(closed))); + } + if let Some(endpoint) = endpoint { + endpoint.close(0_u8.into(), b""); + let _ = tokio::time::timeout(quic::CLOSE_TIMEOUT, endpoint.wait_idle()).await; + } + runtime.stop().await; + if let Some(driver) = datagram { + let _ = driver.stop().await; + } +} + +struct DoqStream { + send: quinn::SendStream, + receive: quinn::RecvStream, + completed: bool, +} +impl Drop for DoqStream { + fn drop(&mut self) { + if !self.completed { + let _ = self.send.reset(3_u8.into()); + let _ = self.receive.stop(3_u8.into()); + } + } +} +struct H3Stream( + h3::client::RequestStream, Bytes>, + bool, +); +impl Drop for H3Stream { + fn drop(&mut self) { + if !self.1 { + self.0.stop_sending(h3::error::Code::H3_REQUEST_CANCELLED); + self.0.stop_stream(h3::error::Code::H3_REQUEST_CANCELLED); + } + } +} + +impl Lease { + pub(super) async fn exchange( + &self, + server: &DnsNameserver, + query: &[u8], + validate: impl Fn(&[u8]) -> io::Result, + ) -> io::Result<(Vec, R)> { + let result = self.exchange_inner(server, query, validate).await; + if result.is_err() { + self.connection + .metadata + .healthy + .store(false, Ordering::Release); + } + result + } + async fn exchange_inner( + &self, + server: &DnsNameserver, + query: &[u8], + validate: impl Fn(&[u8]) -> io::Result, + ) -> io::Result<(Vec, R)> { + if query.len() < 12 || query.len() > MAX_MESSAGE_SIZE { + return Err(io::ErrorKind::InvalidInput.into()); + } + let _permit = self + .connection + .streams + .clone() + .acquire_owned() + .await + .map_err(|_| closed())?; + match &self.connection.protocol { + Protocol::Doq(connection) => { + let (send, receive) = connection.open_bi().await.map_err(failure)?; + let mut stream = DoqStream { + send, + receive, + completed: false, + }; + let mut wire = Vec::with_capacity(query.len() + 2); + wire.extend_from_slice(&(query.len() as u16).to_be_bytes()); + wire.extend_from_slice(query); + wire[2..4].fill(0); + stream.send.write_all(&wire).await.map_err(failure)?; + stream.send.finish().map_err(failure)?; + let length = stream.receive.read_u16().await? as usize; + if !(12..=MAX_MESSAGE_SIZE).contains(&length) { + return Err(io::ErrorKind::InvalidData.into()); + } + let mut response = vec![0; length]; + stream + .receive + .read_exact(&mut response) + .await + .map_err(failure)?; + if stream + .receive + .read(&mut [0; 1]) + .await + .map_err(failure)? + .is_some() + { + return Err(io::ErrorKind::InvalidData.into()); + } + super::https::restore_id(query, &mut response)?; + let validated = validate(&response)?; + stream.completed = true; + Ok((response, validated)) + } + Protocol::H3(sender) => { + let mut sender = sender.clone(); + let mut stream = H3Stream( + sender + .send_request(super::https::request(server, query)?) + .await + .map_err(failure)?, + false, + ); + stream.0.finish().await.map_err(failure)?; + let headers = stream.0.recv_response().await.map_err(failure)?; + let length = super::https::validate_headers(headers.status(), headers.headers())?; + let mut response = Vec::new(); + while let Some(mut bytes) = stream.0.recv_data().await.map_err(failure)? { + if response.len() + bytes.remaining() > MAX_MESSAGE_SIZE { + return Err(io::ErrorKind::InvalidData.into()); + } + response.extend_from_slice(&bytes.copy_to_bytes(bytes.remaining())); + } + if length.is_some_and(|length| length != response.len()) { + return Err(io::ErrorKind::InvalidData.into()); + } + let _ = stream.0.recv_trailers().await.map_err(failure)?; + super::https::restore_id(query, &mut response)?; + let validated = validate(&response)?; + stream.1 = true; + Ok((response, validated)) + } + } + } +} +impl Drop for Lease { + fn drop(&mut self) { + if let Some(owner) = self.owner.upgrade() { + let mut state = owner.state.lock().unwrap(); + *self.connection.metadata.used.lock().unwrap() = Instant::now(); + self.connection + .metadata + .active + .fetch_sub(1, Ordering::AcqRel); + owner.maintain(&mut state); + } else { + self.connection + .metadata + .active + .fetch_sub(1, Ordering::AcqRel); + } + } +} +fn failure(_: impl std::fmt::Display) -> io::Error { + io::Error::other("DNS QUIC exchange failed") +} +fn closed() -> io::Error { + io::ErrorKind::ConnectionAborted.into() +} diff --git a/src/dns/runtime.rs b/src/dns/runtime.rs index 34d535e..eb0759d 100644 --- a/src/dns/runtime.rs +++ b/src/dns/runtime.rs @@ -6,6 +6,9 @@ #[cfg(test)] #[path = "tests_encrypted.rs"] mod encrypted_tests; +#[cfg(all(test, feature = "dns-quic"))] +#[path = "tests_quic.rs"] +mod quic_tests; use std::{ collections::{HashMap, HashSet}, @@ -95,6 +98,7 @@ pub enum RuntimeDnsError { pub struct RuntimeDns { enabled: bool, ipv6: bool, + prefer_h3: bool, ipv6_timeout: Duration, nameservers: Box<[DnsNameserver]>, hosts: super::hosts::Hosts, @@ -144,6 +148,10 @@ struct TcpPoolKey { struct TcpConnectionPool { security: crate::security::SecurityContext, http2: Arc>, + #[cfg(feature = "dns-quic")] + quic: Arc>, + #[cfg(feature = "dns-quic")] + http_tcp_choices: StdMutex>, state: Mutex, max_idle: usize, max_idle_per_key: usize, @@ -239,6 +247,10 @@ impl TcpConnectionPool { Arc::new(Self { security, http2: super::http2::Pool::new(), + #[cfg(feature = "dns-quic")] + quic: super::quic::Pool::new(), + #[cfg(feature = "dns-quic")] + http_tcp_choices: StdMutex::new(Vec::new()), state: Mutex::new(TcpPoolState::default()), max_idle, max_idle_per_key, @@ -307,6 +319,37 @@ impl TcpConnectionPool { }) } + #[cfg(feature = "dns-quic")] + fn tcp_selected(&self, key: &TcpPoolKey) -> bool { + let mut choices = self.http_tcp_choices.lock().unwrap(); + let now = TokioInstant::now(); + choices.retain(|(_, used)| now.duration_since(*used) < TCP_POOL_IDLE_TIMEOUT); + if let Some((_, used)) = choices.iter_mut().find(|(candidate, _)| candidate == key) { + *used = now; + true + } else { + false + } + } + + #[cfg(feature = "dns-quic")] + fn select_tcp(&self, key: TcpPoolKey) { + let mut choices = self.http_tcp_choices.lock().unwrap(); + choices.retain(|(candidate, _)| *candidate != key); + if choices.len() == TCP_POOL_MAX_IDLE { + choices.remove(0); + } + choices.push((key, TokioInstant::now())); + } + + #[cfg(feature = "dns-quic")] + fn forget_tcp(&self, key: &TcpPoolKey) { + self.http_tcp_choices + .lock() + .unwrap() + .retain(|(candidate, _)| candidate != key); + } + async fn open_connection( &self, nameserver: &ResolvedNameserver, @@ -530,6 +573,8 @@ impl TcpConnectionPool { impl Drop for TcpConnectionPool { fn drop(&mut self) { self.http2.close(); + #[cfg(feature = "dns-quic")] + self.quic.close(); self.reaper_cancel.cancel(); let idle = std::mem::take(&mut self.state.get_mut().idle); self.stats.current_idle.store(0, Ordering::Release); @@ -953,6 +998,10 @@ impl RuntimeDns { continue; } resolver.tcp_pool.http2.close(); + #[cfg(feature = "dns-quic")] + resolver.tcp_pool.quic.close(); + #[cfg(feature = "dns-quic")] + resolver.tcp_pool.http_tcp_choices.lock().unwrap().clear(); resolver.tcp_pool.reaper_cancel.cancel(); pending.extend( [ @@ -975,6 +1024,8 @@ impl RuntimeDns { continue; } resolver.tcp_pool.http2.shutdown().await; + #[cfg(feature = "dns-quic")] + resolver.tcp_pool.quic.shutdown().await; let idle = { let mut state = resolver.tcp_pool.state.lock().await; let idle = std::mem::take(&mut state.idle); @@ -1058,6 +1109,8 @@ impl RuntimeDns { pool.max_idle = 0; pool.max_idle_per_key = 0; pool.http2.disable_idle(); + #[cfg(feature = "dns-quic")] + pool.quic.disable_idle(); let stats = Arc::get_mut(&mut pool.stats).expect("fresh node DNS counters"); stats.idle_limit = 0; stats.idle_per_key_limit = 0; @@ -1262,6 +1315,7 @@ impl RuntimeDns { Self { enabled: config.enable, ipv6: config.ipv6, + prefer_h3: config.prefer_h3, ipv6_timeout: config.ipv6_timeout, hosts: super::hosts::Hosts::new( &config.hosts, @@ -2292,6 +2346,7 @@ impl RuntimeDns { DnsTransport::Tls => "tls", DnsTransport::Https => "https", DnsTransport::Http => "http", + DnsTransport::Quic => "quic", }); let server = self.resolve_nameserver(&nameserver).await?; let _attempt = permits @@ -2313,6 +2368,14 @@ impl RuntimeDns { M: Fn(&[u8]) -> Result, V: Fn(&[u8]) -> Result, { + if server.transport == DnsTransport::Quic { + #[cfg(feature = "dns-quic")] + return self + .exchange_quic(&server, query, validate, super::quic::Kind::Doq) + .await; + #[cfg(not(feature = "dns-quic"))] + return Err(io::Error::from(io::ErrorKind::Unsupported).into()); + } if server.transport == DnsTransport::Udp { let (_, egress) = self.egress_for(&server, Network::Udp, |_| false)?; let response = self.exchange_udp(&server, &egress, query, matches).await?; @@ -2484,6 +2547,16 @@ impl RuntimeDns { where F: Fn(&[u8]) -> Result, { + #[cfg(feature = "dns-quic")] + if nameserver.options.force_h3 { + return self + .exchange_quic(nameserver, query, validate, super::quic::Kind::H3) + .await; + } + #[cfg(not(feature = "dns-quic"))] + if nameserver.options.force_h3 || self.prefer_h3 { + return Err(io::Error::from(io::ErrorKind::Unsupported).into()); + } let resolve = |state: &TcpPoolState| { self.egress_for(nameserver, Network::Tcp, |egress| { let key = TcpPoolKey { @@ -2499,6 +2572,19 @@ impl RuntimeDns { // Only a stale reused HTTP/1 connection gets one retry. Each fresh // handshake branches on actual ALPN, including a changed server choice. for attempt in 0..2 { + #[cfg(feature = "dns-quic")] + if self.prefer_h3 + && let Some(lease) = self.cached_quic(nameserver)? + { + let result = lease + .exchange(&nameserver.config, query, |wire| { + validate(wire) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .await + .map_err(AttemptError::Io); + return result; + } let key = { let state = self.tcp_pool.state.lock().await; let (egress, _) = resolve(&state)?; @@ -2515,30 +2601,110 @@ impl RuntimeDns { .stats .reuse_hits .fetch_add(1, Ordering::Relaxed); - return lease + let result = lease .exchange(&nameserver.config, query, |wire| { validate(wire) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) }) .await .map_err(AttemptError::Io); + #[cfg(feature = "dns-quic")] + if result.is_err() { + self.tcp_pool.forget_tcp(&key); + } + return result; } let initialization = self.tcp_pool.http2.initialization(key.clone()); let gate = initialization.lock().await; + #[cfg(feature = "dns-quic")] + if self.prefer_h3 + && let Some(lease) = self.cached_quic(nameserver)? + { + drop(gate); + return lease + .exchange(&nameserver.config, query, |wire| { + validate(wire) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .await + .map_err(AttemptError::Io); + } if let Some(lease) = self.tcp_pool.http2.get(&key) { drop(gate); self.tcp_pool .stats .reuse_hits .fetch_add(1, Ordering::Relaxed); - return lease + let result = lease .exchange(&nameserver.config, query, |wire| { validate(wire) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) }) .await .map_err(AttemptError::Io); + #[cfg(feature = "dns-quic")] + if result.is_err() { + self.tcp_pool.forget_tcp(&key); + } + return result; } + #[cfg(feature = "dns-quic")] + let mut lease = if self.prefer_h3 && !self.tcp_pool.tcp_selected(&key) { + let mut quic = self.start_quic(nameserver, super::quic::Kind::H3)?; + let mut tcp = Box::pin(self.tcp_pool.checkout_for(nameserver, &resolve)); + tokio::select! { + result = &mut tcp => match result { + Ok(lease) => { + drop(tcp); + quic.cancel().await; + self.tcp_pool.select_tcp(lease.key.clone()); + lease + } + Err(_) => { + drop(tcp); + if let Err(error) = quic.wait_ready().await { + quic.cancel().await; + return Err(error.into()); + } + let lease = quic.publish()?; + drop(gate); + return lease + .exchange(&nameserver.config, query, |wire| { + validate(wire).map_err(|error| { + io::Error::new(io::ErrorKind::InvalidData, error) + }) + }) + .await + .map_err(AttemptError::Io); + } + }, + result = quic.wait_ready() => match result { + Ok(()) => { + drop(tcp); + let lease = quic.publish()?; + drop(gate); + return lease + .exchange(&nameserver.config, query, |wire| { + validate(wire).map_err(|error| { + io::Error::new(io::ErrorKind::InvalidData, error) + }) + }) + .await + .map_err(AttemptError::Io); + } + Err(_) => { + let tcp = tcp.await; + quic.cancel().await; + let lease = tcp?; + self.tcp_pool.select_tcp(lease.key.clone()); + lease + } + } + } + } else { + self.tcp_pool.checkout_for(nameserver, &resolve).await? + }; + #[cfg(not(feature = "dns-quic"))] let mut lease = self.tcp_pool.checkout_for(nameserver, &resolve).await?; let alpn = lease .connection @@ -2564,13 +2730,18 @@ impl RuntimeDns { .connect(lease.key.clone(), stream, (_slot, _observation)) .await?; drop(gate); - return h2 + let result = h2 .exchange(&nameserver.config, query, |wire| { validate(wire) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) }) .await .map_err(AttemptError::Io); + #[cfg(feature = "dns-quic")] + if result.is_err() { + self.tcp_pool.forget_tcp(&key); + } + return result; } if alpn.is_some_and(|protocol| protocol != b"http/1.1") { return Err(io::Error::from(io::ErrorKind::InvalidData).into()); @@ -2589,6 +2760,8 @@ impl RuntimeDns { .await; if attempt == 0 && lease.reused && result.as_ref().is_err_and(stale_reuse_is_retryable) { + #[cfg(feature = "dns-quic")] + self.tcp_pool.forget_tcp(&key); self.tcp_pool .stats .stale_retries @@ -2596,6 +2769,10 @@ impl RuntimeDns { drop(lease); continue; } + #[cfg(feature = "dns-quic")] + if result.is_err() { + self.tcp_pool.forget_tcp(&key); + } let (response, validated, reusable) = result?; if reusable { self.tcp_pool.check_in(lease).await; @@ -2605,6 +2782,138 @@ impl RuntimeDns { unreachable!("stale HTTP/1 reuse retries once") } + #[cfg(feature = "dns-quic")] + fn quic_route( + &self, + nameserver: &ResolvedNameserver, + ) -> Result<(TcpPoolKey, Arc), AttemptError> { + let (egress, dispatcher) = self.egress_for(nameserver, Network::Udp, |egress| { + self.tcp_pool.quic.has(&TcpPoolKey { + upstream: nameserver.config.clone(), + endpoint: nameserver.endpoint(), + egress, + source_generation: nameserver.source_generation, + source_binding: nameserver.source_binding.clone(), + }) + })?; + Ok(( + TcpPoolKey { + upstream: nameserver.config.clone(), + endpoint: nameserver.endpoint(), + egress, + source_generation: nameserver.source_generation, + source_binding: nameserver.source_binding.clone(), + }, + dispatcher, + )) + } + + #[cfg(feature = "dns-quic")] + fn cached_quic( + &self, + nameserver: &ResolvedNameserver, + ) -> Result>, AttemptError> { + // UDP-disabled rules must not prevent an otherwise valid H1/H2 path. + let Ok((key, _)) = self.quic_route(nameserver) else { + return Ok(None); + }; + Ok(self.tcp_pool.quic.get(&key)) + } + + #[cfg(feature = "dns-quic")] + fn start_quic( + &self, + nameserver: &ResolvedNameserver, + kind: super::quic::Kind, + ) -> Result, AttemptError> { + let route = self.quic_route(nameserver); + let key = route.as_ref().map_or_else( + |_| TcpPoolKey { + upstream: nameserver.config.clone(), + endpoint: nameserver.endpoint(), + egress: DnsEgressKey::Single, + source_generation: nameserver.source_generation, + source_binding: nameserver.source_binding.clone(), + }, + |(key, _)| key.clone(), + ); + let nameserver = nameserver.clone(); + let security = self.tcp_pool.security.clone(); + let open = async move { + let (_, dispatcher) = + route.map_err(|error| io::Error::new(io::ErrorKind::ConnectionAborted, error))?; + let alpn = match kind { + super::quic::Kind::Doq => b"doq".to_vec(), + super::quic::Kind::H3 => b"h3".to_vec(), + }; + let (tls, server_name) = crate::security::StandardTlsClient::with_options( + &security, + nameserver.address.to_string(), + crate::security::TlsClientOptions { + certificate: crate::config::TlsCertificatePolicy { + verification_name: nameserver.options.verification_name.clone(), + skip_cert_verify: nameserver.options.skip_cert_verify, + fingerprint: None, + }, + alpn: vec![alpn.clone()], + required_alpn: Some(alpn), + versions: crate::security::TlsVersions::Tls13, + ..Default::default() + }, + 0, + 64 * 1024, + )? + .quic_config()?; + let transport = dispatcher + .open_datagram(DatagramSession::new( + InboundKind::InternalDns, + internal_source(nameserver.endpoint.ip()), + )) + .await + .map_err(|error| io::Error::new(io::ErrorKind::ConnectionAborted, error))?; + let budget = transport.payload_budget(&Destination::Ip(nameserver.endpoint())); + Ok(super::quic::Raw { + transport, + peer: nameserver.endpoint(), + budget, + tls, + server_name, + }) + }; + self.tcp_pool + .quic + .start(key, kind, open) + .map_err(AttemptError::Io) + } + + #[cfg(feature = "dns-quic")] + async fn exchange_quic( + &self, + nameserver: &ResolvedNameserver, + query: &[u8], + validate: impl Fn(&[u8]) -> Result, + kind: super::quic::Kind, + ) -> Result<(Vec, R), AttemptError> { + let (key, _) = self.quic_route(nameserver)?; + let lease = if let Some(lease) = self.tcp_pool.quic.get(&key) { + lease + } else { + let initialization = self.tcp_pool.quic.initialization(key.clone()); + let _gate = initialization.lock().await; + if let Some(lease) = self.tcp_pool.quic.get(&key) { + lease + } else { + self.start_quic(nameserver, kind)?.finish().await? + } + }; + lease + .exchange(&nameserver.config, query, |wire| { + validate(wire).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) + }) + .await + .map_err(AttemptError::Io) + } + async fn cache_response( &self, response: &DnsMessage, @@ -2794,6 +3103,7 @@ where Err(RuntimeDnsError::UpstreamsExhausted { last_error }) } +#[derive(Clone)] struct ResolvedNameserver { config: DnsNameserver, endpoint: SocketAddr, diff --git a/src/dns/tests_encrypted.rs b/src/dns/tests_encrypted.rs index fb65f3e..647ae5b 100644 --- a/src/dns/tests_encrypted.rs +++ b/src/dns/tests_encrypted.rs @@ -2,11 +2,11 @@ use super::*; use crate::dispatch::DatagramTransport; use async_trait::async_trait; -struct Peer { - tls: Arc, - https: bool, - opens: AtomicUsize, - tasks: StdMutex>>, +pub(super) struct Peer { + pub(super) tls: Arc, + pub(super) https: bool, + pub(super) opens: AtomicUsize, + pub(super) tasks: StdMutex>>, } #[async_trait] impl Dispatcher for Peer { @@ -61,7 +61,7 @@ impl Dispatcher for Peer { } } impl Peer { - async fn join(&self) { + pub(super) async fn join(&self) { let tasks = std::mem::take(&mut *self.tasks.lock().unwrap()); for task in tasks { task.await.unwrap(); diff --git a/src/dns/tests_quic.rs b/src/dns/tests_quic.rs new file mode 100644 index 0000000..fcd0236 --- /dev/null +++ b/src/dns/tests_quic.rs @@ -0,0 +1,441 @@ +//! Actual DoQ/H3 over bounded memory datagrams; no OS sockets or DNS sources. +use super::*; +use crate::{ + dispatch::{DatagramBudget, DatagramTransport}, + transport::quic::OwnedRuntime, +}; +use async_trait::async_trait; +use std::{ + io::IoSliceMut, + pin::Pin, + task::{Context, Poll, ready}, +}; +use tokio::sync::mpsc; + +struct Packets { + sender: mpsc::Sender, + receiver: mpsc::Receiver, + peer: SocketAddr, + silent: bool, + live: Arc, + closed: Arc, +} +#[async_trait] +impl DatagramTransport for Packets { + fn payload_budget(&self, _: &Destination) -> DatagramBudget { + DatagramBudget::new(1400, 1400) + } + async fn send(&mut self, packet: Datagram) -> Result<(), DispatchError> { + assert_eq!(packet.remote, Destination::Ip(self.peer)); + self.sender + .send(packet.payload) + .await + .map_err(|_| DispatchError::NotAllowed) + } + async fn receive(&mut self) -> Result { + if self.silent { + std::future::pending::<()>().await; + } + Ok(Datagram { + remote: self.peer.into(), + payload: self + .receiver + .recv() + .await + .ok_or(DispatchError::NotAllowed)?, + sniffed_domain: None, + }) + } + async fn close(&mut self) -> Result<(), DispatchError> { + self.closed.fetch_add(1, Ordering::AcqRel); + self.receiver.close(); + Ok(()) + } +} +impl Drop for Packets { + fn drop(&mut self) { + self.live.fetch_sub(1, Ordering::AcqRel); + } +} + +#[derive(Debug)] +struct PeerSocket { + sender: mpsc::Sender, + receiver: StdMutex>, + local: SocketAddr, + peer: SocketAddr, +} +#[derive(Debug)] +struct Writable(StdMutex>); +impl quinn::UdpPoller for Writable { + fn poll_writable(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut sender = self.0.lock().unwrap(); + if ready!(sender.poll_reserve(cx)).is_ok() { + sender.abort_send(); + } + Poll::Ready(Ok(())) + } +} +impl quinn::AsyncUdpSocket for PeerSocket { + fn create_io_poller(self: Arc) -> Pin> { + Box::pin(Writable(StdMutex::new(tokio_util::sync::PollSender::new( + self.sender.clone(), + )))) + } + fn try_send(&self, packet: &quinn::udp::Transmit<'_>) -> io::Result<()> { + assert_eq!(packet.destination, self.peer); + match self + .sender + .try_send(Bytes::copy_from_slice(packet.contents)) + { + Ok(()) | Err(mpsc::error::TrySendError::Closed(_)) => Ok(()), + Err(mpsc::error::TrySendError::Full(_)) => Err(io::ErrorKind::WouldBlock.into()), + } + } + fn poll_recv( + &self, + cx: &mut Context<'_>, + buffers: &mut [IoSliceMut<'_>], + metadata: &mut [quinn::udp::RecvMeta], + ) -> Poll> { + let Some(packet) = ready!(self.receiver.lock().unwrap().poll_recv(cx)) else { + return Poll::Pending; + }; + buffers[0][..packet.len()].copy_from_slice(&packet); + metadata[0] = quinn::udp::RecvMeta { + addr: self.peer, + len: packet.len(), + stride: packet.len(), + ecn: None, + dst_ip: Some(self.local.ip()), + }; + Poll::Ready(Ok(1)) + } + fn local_addr(&self) -> io::Result { + Ok(self.local) + } +} + +#[derive(Clone, Copy)] +enum Tcp { + Http, + Pending, +} +#[derive(Clone, Copy)] +enum Udp { + Ready, + Silent, + Unavailable, +} +struct Peer { + h1: encrypted_tests::Peer, + tls: rustls::ServerConfig, + protocol: super::super::quic::Kind, + tcp: Tcp, + udp: Udp, + tcp_calls: AtomicUsize, + tls_hello: Arc, + udp_calls: AtomicUsize, + live: Arc, + closed: Arc, + requests: Arc, + cancel: CancellationToken, + tasks: StdMutex>>, +} +#[async_trait] +impl Dispatcher for Peer { + async fn connect_tcp(&self, session: StreamSession) -> Result { + self.tcp_calls.fetch_add(1, Ordering::AcqRel); + if matches!(self.tcp, Tcp::Http) { + return self.h1.connect_tcp(session).await; + } + let (client, mut peer) = tokio::io::duplex(64 * 1024); + let hello = self.tls_hello.clone(); + let cancel = self.cancel.clone(); + self.tasks.lock().unwrap().push(tokio::spawn(async move { + if peer.read_u8().await.is_ok() { hello.fetch_add(1, Ordering::AcqRel); } + let mut sink = tokio::io::sink(); + let _ = tokio::select! { ()=cancel.cancelled()=>Ok(0), result=tokio::io::copy(&mut peer, &mut sink)=>result }; + })); + Ok(Box::new(client)) + } + async fn open_datagram( + &self, + _: DatagramSession, + ) -> Result, DispatchError> { + self.udp_calls.fetch_add(1, Ordering::AcqRel); + if matches!(self.udp, Udp::Unavailable) { + return Err(DispatchError::NotAllowed); + } + let peer_address: SocketAddr = "192.0.2.1:853".parse().unwrap(); + let client_address: SocketAddr = "192.0.2.2:1234".parse().unwrap(); + let (to_peer, from_client) = mpsc::channel(32); + let (to_client, from_peer) = mpsc::channel(32); + let socket = Arc::new(PeerSocket { + sender: to_client, + receiver: StdMutex::new(from_client), + local: peer_address, + peer: client_address, + }); + let mut tls = self.tls.clone(); + tls.alpn_protocols = vec![if matches!(self.protocol, super::super::quic::Kind::Doq) { + b"doq".to_vec() + } else { + b"h3".to_vec() + }]; + let crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls).unwrap(); + let mut config = quinn::ServerConfig::with_crypto(Arc::new(crypto)); + Arc::get_mut(&mut config.transport) + .unwrap() + .mtu_discovery_config(None); + let runtime = Arc::new(OwnedRuntime::new(CancellationToken::new())); + let endpoint = quinn::Endpoint::new_with_abstract_socket( + quinn::EndpointConfig::default(), + Some(config), + socket, + runtime.clone(), + ) + .unwrap(); + let cancel = self.cancel.clone(); + let protocol = self.protocol; + let requests = self.requests.clone(); + self.tasks.lock().unwrap().push(tokio::spawn(async move { + let serve = async { + let Ok(connection) = endpoint.accept().await.unwrap().await else { + return; + }; + if matches!(protocol, super::super::quic::Kind::Doq) { + while let Ok((mut send, mut receive)) = connection.accept_bi().await { + let length = receive.read_u16().await.unwrap() as usize; + assert!(length <= MAX_MESSAGE_SIZE); + let mut query = vec![0; length]; + receive.read_exact(&mut query).await.unwrap(); + assert!(receive.read(&mut [0; 1]).await.unwrap().is_none()); + let response = response(&query); + requests.fetch_add(1, Ordering::AcqRel); + send.write_all(&(response.len() as u16).to_be_bytes()) + .await + .unwrap(); + send.write_all(&response).await.unwrap(); + send.finish().unwrap(); + } + } else { + let mut h3 = h3::server::builder() + .build::<_, Bytes>(h3_quinn::Connection::new(connection)) + .await + .unwrap(); + while let Ok(Some(request)) = h3.accept().await { + let (request, mut stream) = request.resolve_request().await.unwrap(); + assert_eq!(request.method(), http::Method::GET); + assert_eq!(request.uri().path(), "/dns-query"); + assert_eq!(request.headers()["authorization"], "Basic YWxpY2U6cHc="); + assert!(stream.recv_data().await.unwrap().is_none()); + use base64::Engine as _; + let query = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(request.uri().query().unwrap().strip_prefix("dns=").unwrap()) + .unwrap(); + let wire = response(&query); + requests.fetch_add(1, Ordering::AcqRel); + stream + .send_response( + http::Response::builder() + .status(200) + .header("content-type", "application/dns-message") + .body(()) + .unwrap(), + ) + .await + .unwrap(); + stream.send_data(Bytes::from(wire)).await.unwrap(); + stream.finish().await.unwrap(); + } + } + }; + tokio::select! { biased; ()=cancel.cancelled()=>{}, ()=serve=>{} } + endpoint.close(0_u8.into(), b""); + runtime.stop().await; + })); + self.live.fetch_add(1, Ordering::AcqRel); + Ok(Box::new(Packets { + sender: to_peer, + receiver: from_peer, + peer: peer_address, + silent: matches!(self.udp, Udp::Silent), + live: self.live.clone(), + closed: self.closed.clone(), + })) + } +} +fn response(query: &[u8]) -> Vec { + assert_eq!(&query[..2], &[0, 0]); + assert_eq!( + u16::from_be_bytes([query[10], query[11]]), + 1, + "ECS wrapper survives ID normalization" + ); + super::super::synthesize_empty_response(&classify_query(query).unwrap(), 0).unwrap() +} +impl Peer { + fn new( + protocol: super::super::quic::Kind, + tcp: Tcp, + udp: Udp, + ) -> (Arc, rustls::RootCertStore) { + let certificate = rcgen::generate_simple_self_signed(vec!["dns.example".into()]).unwrap(); + let tls = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(&[&rustls::version::TLS13]) + .unwrap() + .with_no_client_auth() + .with_single_cert( + vec![certificate.cert.der().clone()], + rustls::pki_types::PrivateKeyDer::Pkcs8(certificate.signing_key.serialize_der().into()), + ) + .unwrap(); + let mut roots = rustls::RootCertStore::empty(); + roots.add(certificate.cert.der().clone()).unwrap(); + ( + Arc::new(Self { + h1: encrypted_tests::Peer { + tls: Arc::new(tls.clone()), + https: true, + opens: AtomicUsize::new(0), + tasks: StdMutex::new(Vec::new()), + }, + tls, + protocol, + tcp, + udp, + tcp_calls: AtomicUsize::new(0), + tls_hello: Arc::new(AtomicUsize::new(0)), + udp_calls: AtomicUsize::new(0), + live: Arc::new(AtomicUsize::new(0)), + closed: Arc::new(AtomicUsize::new(0)), + requests: Arc::new(AtomicUsize::new(0)), + cancel: CancellationToken::new(), + tasks: StdMutex::new(Vec::new()), + }), + roots, + ) + } + async fn stop(&self) { + self.cancel.cancel(); + for task in std::mem::take(&mut *self.tasks.lock().unwrap()) { + task.await.unwrap(); + } + self.h1.join().await; + } +} +fn resolver( + peer: Arc, + roots: rustls::RootCertStore, + upstream: &str, + prefer: bool, +) -> Arc { + let config = crate::config::Config::parse_yaml(format!("mixed-port: 1080\ndns:\n enable: true\n prefer-h3: {prefer}\n use-system-hosts: false\n nameserver: ['{upstream}']\n").as_bytes()).unwrap(); + let mut dns = RuntimeDns::new(&config.dns, peer); + Arc::get_mut(&mut dns.tcp_pool).unwrap().security = + crate::security::SecurityContext::with_tls_roots(roots); + Arc::new(dns) +} +async fn two_queries(dns: &RuntimeDns) { + for (id, name) in [(0x1234, "first.example"), (0x2345, "second.example")] { + let query = build_query(id, name, QueryType::A).unwrap(); + let wire = dns.exchange(&query).await.unwrap(); + assert_eq!(&wire[..2], &query[..2]); + validate_response_identity(&parse_query(&query).unwrap(), &wire).unwrap(); + } +} + +#[tokio::test] +async fn dns_quic_doq_and_forced_h3_exchange_real_frames_reuse_and_stop() { + for (kind, endpoint) in [ + ( + super::super::quic::Kind::Doq, + "quic://192.0.2.1:853#name-cert-verify=dns.example&ecs=192.0.2.1", + ), + ( + super::super::quic::Kind::H3, + "https://alice:pw@192.0.2.1:853/dns-query?discard=value#h3=true&name-cert-verify=dns.example&ecs=192.0.2.1", + ), + ] { + let (peer, roots) = Peer::new(kind, Tcp::Pending, Udp::Ready); + let dns = resolver(peer.clone(), roots, endpoint, false); + two_queries(&dns).await; + assert_eq!(peer.requests.load(Ordering::Acquire), 2); + assert_eq!(peer.udp_calls.load(Ordering::Acquire), 1); + assert_eq!(peer.tcp_calls.load(Ordering::Acquire), 0); + dns.shutdown().await; + assert_eq!(dns.tcp_pool.quic.task_count(), 0); + assert_eq!(peer.live.load(Ordering::Acquire), 0); + assert_eq!(peer.closed.load(Ordering::Acquire), 1); + peer.stop().await; + } +} + +#[tokio::test] +async fn dns_quic_prefer_h3_races_success_and_cleans_losers_without_reprobing() { + for (tcp, udp, expects_h3) in [ + (Tcp::Pending, Udp::Ready, true), + (Tcp::Http, Udp::Silent, false), + (Tcp::Http, Udp::Unavailable, false), + ] { + let (peer, roots) = Peer::new(super::super::quic::Kind::H3, tcp, udp); + let dns = resolver( + peer.clone(), + roots, + "https://alice:pw@192.0.2.1:853/dns-query#name-cert-verify=dns.example&ecs=192.0.2.1", + true, + ); + two_queries(&dns).await; + assert_eq!(peer.tcp_calls.load(Ordering::Acquire), 1); + assert_eq!(peer.udp_calls.load(Ordering::Acquire), 1); + assert_eq!( + peer.requests.load(Ordering::Acquire), + if expects_h3 { 2 } else { 0 } + ); + if expects_h3 { + assert_eq!(peer.tls_hello.load(Ordering::Acquire), 1); + } + dns.shutdown().await; + assert_eq!(peer.live.load(Ordering::Acquire), 0); + assert_eq!(dns.tcp_pool.quic.task_count(), 0); + peer.stop().await; + } +} + +#[tokio::test] +async fn dns_quic_forced_failure_does_not_fallback_and_stop_joins_canceled_handshake() { + let (peer, roots) = Peer::new(super::super::quic::Kind::H3, Tcp::Http, Udp::Unavailable); + let dns = resolver( + peer.clone(), + roots, + "https://alice:pw@192.0.2.1:853/dns-query#h3=true&name-cert-verify=dns.example", + true, + ); + assert!(dns.resolve("first.example").await.is_err()); + assert_eq!(peer.tcp_calls.load(Ordering::Acquire), 0); + dns.shutdown().await; + peer.stop().await; + let (peer, roots) = Peer::new(super::super::quic::Kind::Doq, Tcp::Pending, Udp::Silent); + let dns = resolver( + peer.clone(), + roots, + "quic://192.0.2.1:853#name-cert-verify=dns.example", + false, + ); + let request_dns = dns.clone(); + let request = tokio::spawn(async move { request_dns.resolve("first.example").await }); + while peer.udp_calls.load(Ordering::Acquire) == 0 { + tokio::task::yield_now().await; + } + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(3), dns.shutdown()) + .await + .unwrap(); + assert_eq!(peer.live.load(Ordering::Acquire), 0); + assert_eq!(dns.tcp_pool.quic.task_count(), 0); + peer.stop().await; +} diff --git a/src/security/tls.rs b/src/security/tls.rs index 7e087e3..ea6b542 100644 --- a/src/security/tls.rs +++ b/src/security/tls.rs @@ -168,11 +168,7 @@ impl std::fmt::Debug for StandardTlsClient { } impl StandardTlsClient { - #[cfg(any( - feature = "outbound-vless", - feature = "outbound-hysteria2", - feature = "outbound-tuic" - ))] + #[cfg(feature = "quic-transport")] pub(crate) fn quic_config(&self) -> io::Result<(Arc, String)> { match &self.connector { StandardConnector::Rustls(connector) => { diff --git a/tests/README.md b/tests/README.md index b3943d4..c68ef33 100644 --- a/tests/README.md +++ b/tests/README.md @@ -63,6 +63,7 @@ cargo test --locked --features cli --lib dns::options::tests:: cargo test --locked --features cli --lib dns::runtime::tests::upstream_ cargo test --locked --features cli --lib dns::runtime::encrypted_tests::upstream_tls_options_ cargo test --locked --features cli --lib doh_ +cargo test --locked --features cli --lib dns_quic_ cargo test --locked --features cli --lib dns::runtime::encrypted_tests::encrypted_dns_ cargo test --locked --features cli --lib invoke::tests::update_dns_network_ cargo test --manifest-path crates/vole-netstack/Cargo.toml --all-targets @@ -71,7 +72,7 @@ uv run --project scripts --locked ruff check scripts uv run --project scripts --locked ruff format --check scripts ``` -DNS URL 参数、DoH GET/Basic、H2 并发与取消、ALPN/H1 回退只使用内存报文、显式来源快照和 duplex TLS 对端,不读取宿主 DNS 或启动监听服务。 +DNS URL 参数、DoH GET/Basic、H2 并发与取消、ALPN/H1 回退只使用内存报文、显式来源快照和 duplex TLS 对端。DoQ/H3 与版本竞争使用有界 memory DatagramTransport 和抽象 Quinn socket;不读取宿主 DNS 或启动监听服务。 Windows UWP bridge 的名称准入与匹配使用独立过滤器;下列测试仅处理 JSON、匹配策略 和未注册的内存 `VpnPlugInProfile` 对象,不创建系统 VPN profile 或启动连接: From 568f733aba7a6d5670ca90dfdd3a7ba1c8163e9b Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:46:42 +0800 Subject: [PATCH 052/445] feat: support GeoIP MMDB databases --- README.md | 2 +- docs/config.yaml | 8 +- docs/geodata.md | 43 ++- docs/runtime-resource-policy.md | 4 +- src/config/mod.rs | 29 +- src/dns/mod.rs | 2 +- src/geodata/country.rs | 206 ++++++++++++ src/geodata/country_tests.rs | 229 ++++++++++++++ src/geodata/manager.rs | 383 ++++++++++++++++++----- src/geodata/mod.rs | 58 +++- src/geodata/service.rs | 32 +- src/geodata/tests.rs | 6 +- src/runtime.rs | 7 +- tests/fixtures/geodata/Country-Test.mmdb | Bin 0 -> 277 bytes tests/fixtures/geodata/Meta-Test.mmdb | Bin 0 -> 246 bytes tests/fixtures/geodata/README.md | 9 +- tests/fixtures/geodata/Sing-Test.mmdb | Bin 0 -> 235 bytes 17 files changed, 905 insertions(+), 113 deletions(-) create mode 100644 src/geodata/country.rs create mode 100644 src/geodata/country_tests.rs create mode 100644 tests/fixtures/geodata/Country-Test.mmdb create mode 100644 tests/fixtures/geodata/Meta-Test.mmdb create mode 100644 tests/fixtures/geodata/Sing-Test.mmdb diff --git a/README.md b/README.md index d551e76..b6c6402 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ rules: - MATCH,Proxy ``` -Replace the example endpoint and credentials. GeoSite/GeoIP rules use assets under `/geodata`; missing required assets are downloaded through the configured route, and affected rules remain unavailable until loading succeeds. See the [complete configuration reference](docs/config.yaml) and [GeoData behavior](docs/geodata.md). +Replace the example endpoint and credentials. GeoSite/DAT/ASN assets use `/geodata`; the default GeoIP mode uses a country MMDB in `` (`geodata-mode: true` selects DAT); missing required assets are downloaded through the configured route, and affected rules remain unavailable until loading succeeds. See the [complete configuration reference](docs/config.yaml) and [GeoData behavior](docs/geodata.md). `mixed-port` shares one TCP port between HTTP and SOCKS5 and enables SOCKS5 UDP on the same port. `allow-lan` controls binding independently of `authentication`: omitting `authentication` or using `[]` allows unauthenticated access on either loopback or wildcard addresses; configured credentials are checked by both HTTP and SOCKS5. Top-level `port`, `socks-port`, `udp` and `listeners` are rejected; proxy-node `port` and `udp` keep their outbound meanings. diff --git a/docs/config.yaml b/docs/config.yaml index 28002c0..2dc1569 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -6,7 +6,8 @@ # # 库宿主先通过 Invoke API 调用 initialize({dataDir}),再通过 configYaml 内联 # 传入配置;CLI 使用 -f 指定文件,-d 指定数据目录,省略时读取数据目录下的 config.yaml。 -# GeoData 固定由 Vole 管理在 dataDir/geodata 中;CLI 的 -t 只校验,不创建目录或联网。 +# GeoSite/DAT/ASN 由 Vole 管理在 dataDir/geodata 中;国家 MMDB 位于 dataDir 根目录。 +# CLI 的 -t 只校验,不创建目录或联网。 # certificate/private-key 可为成对 PEM 或文件路径;相对路径从 dataDir 解析,校验时读取并验证。 # 所有平台均不设 GeoData 条数上限,不按数量截断,也不设总内存预算; # 仅加载当前业务规则和 DNS policy 引用的分类/属性交集,详见 docs/geodata.md。 @@ -39,13 +40,16 @@ profile: store-fake-ip: false # true:dataDir/fake-ip.redb 持久化双向映射与分配位置 # 资源下载使用 global-ua(缺省 Vole/ 加 Core 版本);etag-support 缺省 true。 -# GeoData 字段独立可选;URL 缺省 MetaCubeX meta-rules-dat latest DAT / ASN MMDB。 +# geodata-mode 默认 false,使用国家 MMDB;true 使用 GeoIP DAT。此示例显式选择 DAT。 +# URL 缺省 MetaCubeX meta-rules-dat latest DAT / 国家 MMDB / ASN MMDB。 # URL 以域名为主机、无 userinfo/fragment,生产构建使用 HTTPS。 # 自动更新默认 false;启动后仍获取缺失的必需资产。周期单位小时,默认 24,0 关闭周期检查。 global-ua: "Vole/0.1.0" etag-support: true +geodata-mode: true geox-url: geoip: https://assets.example.com/geoip.dat + mmdb: https://assets.example.com/geoip.metadb geosite: https://assets.example.com/geosite.dat asn: https://assets.example.com/ASN.mmdb geo-auto-update: true diff --git a/docs/geodata.md b/docs/geodata.md index 49be318..fd78959 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -1,6 +1,8 @@ # GeoData 规则与资产 -Vole 管理 `dataDir/geodata` 下的 `geosite.dat`、`geoip.dat` 和 `ASN.mmdb`,只为当前配置实际引用的 +Vole 管理 `dataDir/geodata` 下的 `geosite.dat`、DAT 模式的 `geoip.dat` 和独立 `ASN.mmdb`, +国家 MMDB 位于 `dataDir` 根目录。`geodata-mode` 缺省为 false(MMDB),true 使用 DAT。 +只为当前配置实际引用的 分类与属性交集构建匹配器。主规则和所有 `sub-rules` 定义中的谓词、逻辑子项及 classical provider 内容共同收集引用;每个定义只遍历一次,不沿调用边展开。所有平台均不设 GeoData 条数上限,不按数量截断记录, 也不设总内存预算。资产缺失或加载失败时,对应种类不可用,跳过相应 Geo 规则并继续 @@ -61,16 +63,32 @@ Unicode 版本的 Mihomo 在所有新字符上完全等价。 | --- | --- | --- | --- | | `GEOSITE` | `geosite.dat` | `GeoSiteList` | code 与 Domain 记录 | | `GEOIP` | `geoip.dat` | `GeoIPList` | code 与 IPv4/IPv6 CIDR | +| `GEOIP`(默认模式) | 根目录国家 MMDB | MaxMind DB | 直接 IPv4/IPv6 索引查询 | | `IP-ASN` / `SRC-IP-ASN` | `ASN.mmdb` | MaxMind DB | IPv4/IPv6 ASN 索引 | -- 文件路径固定,YAML 不能指定本地路径或环境变量。 -- 文件必须是普通文件;校验外层帧、code、分类引用、实际选中的 CIDR / Domain 记录。 -- 第一遍扫描建立 code 到文件范围的索引,容量按需增长;第二遍只解析被配置引用的分类。 +- YAML 不能指定本地路径或环境变量。MMDB 在 `dataDir` 的直接子文件中按文件名字典序, + 不区分大小写寻找 `Country.mmdb`、`geoip.db`、`geoip.metadb`,选择第一个并保留实际名称; + 不因它损坏而改选另一个数据库。缺省下载目标为 `geoip.metadb`。 +- 所有资产必须是普通文件;DAT 校验外层帧、code、分类引用、实际选中的 CIDR / Domain 记录。 +- DAT 第一遍扫描建立 code 到文件范围的索引,容量按需增长;第二遍只解析被配置引用的分类。 - 只引用属性交集时,先流式检查属性;未命中任何交集的记录按 wire/length 边界跳过, 不校验 value 或编译 Regex。引用无属性 selector 时则包含该分类的全部记录。 -- 目标 code 缺失、重复或损坏时,整份对应种类的快照不可用,不能当作空分类。 +- DAT 目标 code 缺失、重复或损坏时,整份对应种类的快照不可用,不能当作空分类。 - GeoSite、GeoIP 和 ASN 分别校验和报告可用性,一类不可用不影响其他种类或基础规则。 +## 国家 MMDB + +国家库支持 MaxMind `country.iso_code`、`sing-geoip` 根字符串和 `Meta-geoip0` 根字符串/字符串数组。 +MaxMind 国家代码按 ASCII 大小写折叠,sing/Meta 保留原字符串大小写。国家库与 DAT 二选一, +GEOIP、来源规则、classical/地址选择器及 DNS fallback 共用一个 owned Reader;查询借用字符串, +Meta 多 code 的临时索引只保存引用。没有全量 CIDR 展开或逐地址缓存。缺库、坏索引和无效记录 +不通过反选成为全匹配;有效库的无记录地址按空 code 集合处理。 +保留 maxminddb 的默认单次解码与索引验证保护;解码错误不当作合法的空记录。 + +国家 MMDB 更新与 ASN 同样先借用候选字节完整校验,旧读者排空后移动该缓冲到 owned Reader; +无效候选、取消或提交失败保留/恢复旧代。模式和实际文件名共同绑定调度来源与 ETag/hash, +下载暂存及原子替换始终使用被选中的实际路径,GeoSite/ASN 的存储与状态目录不变。 + ## ASN MMDB 仅在规则或 classical 内容引用 ASN 时读取 `ASN.mmdb`。支持数据库类型 @@ -98,12 +116,15 @@ ASN 提交保留旧磁盘文件的 hard link(不可用时复制);提交失 HTTP 传输由共享资源模块维护,所有跳转经过当前 Core dispatcher。下载、管理阻塞任务和解析均在停止屏障内;取消及下载 future 被丢弃时清理未发布的暂存文件。 -三个字段独立可选。缺省使用 MetaCubeX meta-rules-dat 的 latest GeoIP/GeoSite DAT 与 +四个 URL 字段独立可选。缺省使用 MetaCubeX meta-rules-dat 的 latest GeoIP/GeoSite DAT、 +国家 `geoip.metadb` 与 `GeoLite2-ASN.mmdb` URL;自动更新默认关闭,周期默认 24 小时: ```yaml +geodata-mode: true # DAT;省略或 false 使用国家 MMDB geox-url: geoip: https://assets.example.com/geoip.dat + mmdb: https://assets.example.com/geoip.metadb geosite: https://assets.example.com/geosite.dat asn: https://assets.example.com/ASN.mmdb geo-auto-update: true @@ -112,7 +133,7 @@ geo-update-interval: 24 约束: -- `geox-url` 只能包含 `geoip`、`geosite` 和 `asn`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 +- `geox-url` 只能包含 `geoip`、`mmdb`、`geosite` 和 `asn`;URL 必须是以域名为主机、无 userinfo/fragment 的绝对 HTTPS URL。 - `geo-update-interval` 单位为小时,支持非负整数;0 停用周期更新。 - `validateConfig` 和准备阶段不下载。运行后仅为当前引用的资产启动资源任务;缺失或不可用时获取默认或配置的 URL,自动更新控制可用资产的周期检查。 - 下载按当前 mode 与业务规则选路,未命中使用 DIRECT;可选目标包含内置动作、节点和代理组。选中代理组时,每次新建下载物理 transport 时沿嵌套组解析到当时选中的叶节点;失败不自动换成员,也不隐式回退 DIRECT 或系统代理。 @@ -204,7 +225,7 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 - 更新在下载和 staging 检查期间继续使用当前快照。构建新匹配器前,原子发布空匹配器、 释放 Manager 的旧快照引用,并等待最后一位旧选路读者完成、旧匹配器存储析构结束。 此后只构建一次候选;磁盘与调度状态提交成功后直接原子发布,不从最终文件重复加载。 - ASN 使用前文的完整预校验与失败恢复流程。 + 国家 MMDB 与 ASN 使用前文的完整预校验与失败恢复流程。 - 注册、重载与更新提交通过 Manager 管理锁串行,避免旧重载覆盖新发布;业务和 DNS 的 ArcSwap 读取不获取该锁,也不等待排空。旧读者继续完成本次求值;排空、加载期间 新请求复用首次资产缺失的语义,仅跳过不可用的 Geo 规则,继续普通规则与既有 DNS @@ -260,8 +281,8 @@ fallible Vec 扩容、合法 protobuf 帧/长度、code、选中匹配值的 UTF 容量账本仅作诊断:统计 Vole 自有 matcher/index/value/临时 Vec 的可见容量,扩容时 记录新旧缓冲可能重叠的容量;没有最大值、不控制准入。`regex::bytes::Regex` 不提供 -完整内部状态的准确 `memory_usage`;ASN 文件缓冲容量计入快照容量,借用候选 -验证期的新旧字节重叠与 MMDB metadata/图校验状态未计入。因此账本**不包含正则内部编译状态、搜索缓存、 +完整内部状态的准确 `memory_usage`;国家/ASN 文件缓冲容量计入快照容量,借用候选 +验证期的新旧字节重叠、Meta 多 code 查询的临时引用索引与 MMDB metadata/图校验状态未计入。因此账本**不包含正则内部编译状态、搜索缓存、 动态 scratch、配置需求集合、allocator 开销或整个进程 footprint**。这些未计入的 内存并不是零;accounted peak 不能作为加载或运行期峰值上界。 50,000,000 bytes 是限定输入与联合负载下的进程实测验收目标,不是加载器保证。 @@ -282,7 +303,7 @@ Vole 自有脚本仅负责编译。压力与横向比较固定只引用完整 `g wire 结构或 staging 检查失败发生在卸载之前,当前快照继续可用。 - 首次加载、外部资产重载或卸载后的所选记录加载失败,使对应种类不可用;健康种类 独立发布。排空超时或卸载后取消也保持已卸载状态,不能通过反选绕过不可用语义。 - DAT 失败更新保留旧磁盘资产,不恢复旧内存快照;ASN 更新按前文恢复旧代。 + DAT 失败更新保留旧磁盘资产,不恢复旧内存快照;国家 MMDB 与 ASN 更新按前文恢复旧代。 - 不可用的 GeoIP 或 ASN 规则不得触发惰性 DNS。 - 调度状态损坏时在更新锁内重置;异常退出留下的更新状态在锁释放后恢复。 diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index 939a48b..fd9eebc 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -185,7 +185,8 @@ DFA 缓存各 512 KiB;不接受递归、条件式和控制动词。每次复 - 所有平台完整加载被引用分类的所选记录,不设记录条数硬上限或截断;GeoIP 与 GeoSite 没有优先额度分配。 - 同种基础 code 按 ASCII 大小写归一,业务规则与 DNS policy 共用分类和值存储。属性取交集;正反选择器共享匹配器,多种属性筛选共享值及已编译正则,不重复解码同一分类。未引用分类不构建匹配器。 - 各平台均不设独立 GeoData 分类、引用、文件大小、累计源码/值字节或总加载内存预算;DNS GeoSite policy 也不保留独立项数/引用数上限。 -- Loader 只解析被引用的分类,其他分类按 wire 长度跳过。 +- DAT Loader 只解析被引用的分类,其他分类按 wire 长度跳过。默认国家 MMDB 与 DAT 二选一; + 国家库只保存一个拥有字节的 Reader,按索引借用查询,不展开 CIDR 或建立逐地址缓存。 - Domain/Full 匹配器使用紧凑连续存储,运行期查找不分配。Regex 使用共享的 `regex::bytes::Regex` 并关闭 Unicode 匹配模式;库管理编译状态和搜索缓存,搜索可能 分配 scratch、增长缓存或竞争共享缓存,不能将 Domain/Full 的无分配性质扩展到 Regex。 @@ -208,6 +209,7 @@ DFA 缓存各 512 KiB;不接受递归、条件式和控制动词。每次复 排空响应间隔不等于整体 Stop 时限,释放存储也不保证 allocator 立即归还 RSS。 - 卸载后的加载失败只使对应种类不可用,健康另一类仍发布;不恢复旧整体快照。 卸载前的下载/结构失败保留当前匹配器,失败更新均保留旧磁盘资产并按现有调度重试。 + 国家 MMDB 与 ASN 先借用候选校验,再排空旧 reader、移动候选字节,失败保留/恢复旧代。 304 在 matcher 已可用时只调度,不可用时重载本地资产。既有 TCP 选路与 TUN UDP 固定 action 不回溯;没有新增公开 API、业务数量配额或 GeoData 内存预算。 diff --git a/src/config/mod.rs b/src/config/mod.rs index caaa5a9..6cf1c99 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -187,6 +187,7 @@ impl std::fmt::Debug for ExternalControllerConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct GeoDataUpdateConfig { + pub geodata_mode: bool, pub urls: GeoDataUrls, pub auto_update: bool, pub interval_hours: u64, @@ -195,6 +196,7 @@ pub struct GeoDataUpdateConfig { #[derive(Debug, Clone, PartialEq, Eq)] pub struct GeoDataUrls { pub geoip: String, + pub mmdb: String, pub geosite: String, pub asn: String, } @@ -1129,6 +1131,8 @@ struct RawVoleConfig { deserialize_with = "deserialize_present_option" )] geox_url: Option, + #[serde(rename = "geodata-mode", default)] + geodata_mode: bool, #[serde( rename = "geo-auto-update", default, @@ -1224,6 +1228,8 @@ struct RawProxyGroup { struct RawGeoDataUrls { #[serde(default = "default_geoip_url")] geoip: String, + #[serde(default = "default_mmdb_url")] + mmdb: String, #[serde(default = "default_geosite_url")] geosite: String, #[serde(default = "default_asn_url")] @@ -1235,6 +1241,9 @@ fn default_global_ua() -> String { fn default_geoip_url() -> String { "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat".into() } +fn default_mmdb_url() -> String { + "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.metadb".into() +} fn default_geosite_url() -> String { "https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat".into() } @@ -1245,6 +1254,7 @@ impl Default for RawGeoDataUrls { fn default() -> Self { Self { geoip: default_geoip_url(), + mmdb: default_mmdb_url(), geosite: default_geosite_url(), asn: default_asn_url(), } @@ -2094,6 +2104,7 @@ impl RawVoleConfig { return invalid("global-ua must be a bounded HTTP header value"); } let geodata_update = normalize_geodata_update( + self.geodata_mode, self.geox_url, self.geo_auto_update, self.geo_update_interval, @@ -2280,6 +2291,7 @@ fn normalize_external_controller( } fn normalize_geodata_update( + geodata_mode: bool, urls: Option, auto_update: Option, interval_hours: Option, @@ -2290,8 +2302,10 @@ fn normalize_geodata_update( return invalid("geo-update-interval is too large"); } Ok(Some(GeoDataUpdateConfig { + geodata_mode, urls: GeoDataUrls { geoip: normalize_geox_url(urls.geoip, "geox-url.geoip")?, + mmdb: normalize_geox_url(urls.mmdb, "geox-url.mmdb")?, geosite: normalize_geox_url(urls.geosite, "geox-url.geosite")?, asn: normalize_geox_url(urls.asn, "geox-url.asn")?, }, @@ -3933,7 +3947,7 @@ proxies: fn with_geodata(fields: &str) -> String { current_yaml(&format!( - "{fields}\nmixed-port: 1080\nauthentication:\n - measure:secret" + "geodata-mode: true\n{fields}\nmixed-port: 1080\nauthentication:\n - measure:secret" )) } @@ -4093,6 +4107,19 @@ geo-update-interval: 24"#, assert!(defaults.etag_support); } + #[test] + fn geodata_mode_defaults_to_mmdb_and_urls_remain_independent() { + let default = Config::parse_yaml(b"rules: ['GEOIP,CN,DIRECT']").unwrap(); + let update = default.geodata_update.unwrap(); + assert!(!update.geodata_mode); + assert_eq!(update.urls.mmdb, default_mmdb_url()); + let explicit = Config::parse_yaml(b"geodata-mode: true\ngeox-url: {mmdb: https://assets.example.test/country}\nrules: ['GEOIP,CN,DIRECT']").unwrap(); + let update = explicit.geodata_update.unwrap(); + assert!(update.geodata_mode); + assert_eq!(update.urls.mmdb, "https://assets.example.test/country"); + assert_eq!(update.urls.geoip, default_geoip_url()); + } + #[test] fn geodata_fields_have_independent_defaults_and_configurable_intervals() { let config = Config::parse_yaml(with_geodata("geo-auto-update: true\ngeo-update-interval: 6\netag-support: false\nglobal-ua: custom-agent\ngeox-url:\n geosite: https://rules.example.test/site.dat").as_bytes()).unwrap(); diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 03de445..1c86436 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -8,7 +8,7 @@ mod cache; pub(crate) mod fake_ip; -mod fallback; +pub(crate) mod fallback; pub(crate) mod hosts; mod http2; mod https; diff --git a/src/geodata/country.rs b/src/geodata/country.rs new file mode 100644 index 0000000..9d87bc2 --- /dev/null +++ b/src/geodata/country.rs @@ -0,0 +1,206 @@ +//! One country MMDB, queried in place without expanding its network tree. + +use std::{fmt, io::Read, net::IpAddr, path::Path}; + +use maxminddb::Reader; +use serde::{ + Deserialize, + de::{self, SeqAccess, Visitor}, +}; + +use super::{GeoDataError, open_asset_path, selector_code}; + +#[derive(Debug, Clone, Copy)] +enum Format { + MaxMind, + Sing, + Meta, +} + +impl Format { + fn parse(value: &str) -> Self { + match value { + "sing-geoip" => Self::Sing, + "Meta-geoip0" => Self::Meta, + _ => Self::MaxMind, + } + } +} + +pub(super) struct CountryDb { + reader: Reader>, + format: Format, + selectors: Vec, + capacity: usize, +} + +impl CountryDb { + pub(super) fn load(path: &Path, selectors: &[String]) -> Result { + CountryCandidate::load(path)?.into_database(selectors) + } + + pub(super) fn capacity(&self) -> usize { + self.capacity + } + + pub(super) fn available(&self, selector: &str) -> bool { + self.selectors + .binary_search_by(|value| { + value + .bytes() + .cmp(selector.bytes().map(|byte| byte.to_ascii_lowercase())) + }) + .is_ok() + } + + pub(super) fn matches(&self, selector: &str, address: IpAddr) -> bool { + if !self.available(selector) { + return false; + } + let Ok(record) = self.reader.lookup(address) else { + return false; + }; + let code = selector_code(selector); + let matched = match self.format { + Format::MaxMind => { + match record.decode_path::<&str>(&maxminddb::path!["country", "iso_code"]) { + Ok(value) => value.is_some_and(|value| value.eq_ignore_ascii_case(code)), + Err(_) => return false, + } + } + Format::Sing => match record.decode::<&str>() { + Ok(value) => value.is_some_and(|value| canonical_code_matches(value, code)), + Err(_) => return false, + }, + Format::Meta => match record.decode::>() { + Ok(Some(BorrowedCodes::One(value))) => canonical_code_matches(value, code), + Ok(Some(BorrowedCodes::Many(values))) => values + .iter() + .any(|value| canonical_code_matches(value, code)), + Ok(None) => false, + Err(_) => return false, + }, + }; + matched != selector.starts_with('!') + } +} + +// Only string references are materialized for Meta's multi-code records. No +// country strings, full MaxMind maps or per-address cache survive the lookup. +enum BorrowedCodes<'a> { + One(&'a str), + Many(Vec<&'a str>), +} + +impl<'de> Deserialize<'de> for BorrowedCodes<'de> { + fn deserialize>(deserializer: D) -> Result { + struct CodesVisitor; + impl<'de> Visitor<'de> for CodesVisitor { + type Value = BorrowedCodes<'de>; + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a country code or an array of country codes") + } + fn visit_borrowed_str(self, value: &'de str) -> Result { + Ok(BorrowedCodes::One(value)) + } + fn visit_seq>( + self, + mut sequence: A, + ) -> Result { + let mut values = Vec::new(); + while let Some(value) = sequence.next_element::<&'de str>()? { + values + .try_reserve(1) + .map_err(|_| de::Error::custom("country code allocation failed"))?; + values.push(value); + } + Ok(BorrowedCodes::Many(values)) + } + } + deserializer.deserialize_any(CodesVisitor) + } +} + +/// Full index/data verification borrows the candidate bytes while the old +/// reader remains usable. Ownership transfers after old readers are drained. +pub(super) struct CountryCandidate { + bytes: Vec, + format: Format, +} + +impl CountryCandidate { + pub(super) fn load(path: &Path) -> Result { + let (mut file, len) = open_asset_path(path)?; + let len = usize::try_from(len) + .map_err(|_| GeoDataError::AllocationFailed { bytes: usize::MAX })?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(len) + .map_err(|_| GeoDataError::AllocationFailed { bytes: len })?; + bytes.resize(len, 0); + file.read_exact(&mut bytes) + .map_err(|source| GeoDataError::Io { + path: path.into(), + source, + })?; + let format = { + let reader = Reader::from_source(bytes.as_slice()) + .map_err(|_| invalid("invalid country MMDB"))?; + reader + .verify() + .map_err(|_| invalid("invalid country MMDB index or data"))?; + Format::parse(&reader.metadata().database_type) + }; + Ok(Self { bytes, format }) + } + + pub(super) fn into_database(self, selectors: &[String]) -> Result { + let mut stored = Vec::new(); + stored + .try_reserve_exact(selectors.len()) + .map_err(|_| GeoDataError::AllocationFailed { + bytes: selectors + .len() + .saturating_mul(std::mem::size_of::()), + })?; + for selector in selectors { + let mut value = String::new(); + value.try_reserve_exact(selector.len()).map_err(|_| { + GeoDataError::AllocationFailed { + bytes: selector.len(), + } + })?; + value.push_str(selector); + stored.push(value); + } + stored.sort_unstable(); + let capacity = stored + .iter() + .fold( + stored + .capacity() + .checked_mul(std::mem::size_of::()) + .and_then(|count| self.bytes.capacity().checked_add(count)), + |total, value| total.and_then(|total| total.checked_add(value.capacity())), + ) + .ok_or(GeoDataError::AllocationFailed { bytes: usize::MAX })?; + let reader = + Reader::from_source(self.bytes).map_err(|_| invalid("invalid country MMDB"))?; + Ok(CountryDb { + reader, + format: self.format, + selectors: stored, + capacity, + }) + } +} + +fn invalid(detail: &str) -> GeoDataError { + GeoDataError::InvalidCountry(detail.into()) +} + +fn canonical_code_matches(value: &str, selector: &str) -> bool { + value + .bytes() + .eq(selector.bytes().map(|byte| byte.to_ascii_lowercase())) +} diff --git a/src/geodata/country_tests.rs b/src/geodata/country_tests.rs new file mode 100644 index 0000000..5f21184 --- /dev/null +++ b/src/geodata/country_tests.rs @@ -0,0 +1,229 @@ +use std::{fs, sync::Arc, time::Duration}; + +use sha2::{Digest, Sha256}; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::{ + config::{Config, Network, RuleCollection}, + dns::fallback::Filter, + routing::{RoutingContext, matches_rule}, + session::Destination, +}; + +const COUNTRY: &[u8] = include_bytes!("../../tests/fixtures/geodata/Country-Test.mmdb"); +const SING: &[u8] = include_bytes!("../../tests/fixtures/geodata/Sing-Test.mmdb"); +const META: &[u8] = include_bytes!("../../tests/fixtures/geodata/Meta-Test.mmdb"); + +fn config() -> Config { + Config::parse_yaml( + br#" +dns: + enable: true + fallback: [1.1.1.1] + fallback-filter: {geoip: true, geoip-code: CN} +rule-providers: + geo: {type: inline, behavior: classical, payload: ["GEOIP,CN"]} +rules: + - GEOIP,CN,REJECT,no-resolve + - SRC-GEOIP,CN,DIRECT + - GEOIP,!CN,REJECT,no-resolve + - GEOIP,US,DIRECT,no-resolve + - RULE-SET,geo,DIRECT + - MATCH,DIRECT +"#, + ) + .unwrap() +} + +fn stage(session: &GeoUpdateSession, bytes: &[u8]) -> ([u8; 32], u64) { + fs::write(session.temporary_path(), bytes).unwrap(); + (Sha256::digest(bytes).into(), bytes.len() as u64) +} + +#[test] +fn country_mmdb_schemas_share_route_source_collection_and_fallback() { + for fixture in [COUNTRY, SING, META] { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("cOuNtRy.MmDb"), fixture).unwrap(); + let manager = + GeoDataManager::open(dir.path().join("geodata"), Duration::from_secs(3600)).unwrap(); + let config = config(); + assert!(!config.geodata_update.as_ref().unwrap().geodata_mode); + let registration = manager + .register(GeoRequirements::for_config(&config).unwrap()) + .unwrap(); + assert!(registration.initial_report().geoip.available); + let snapshot = registration.matcher().snapshot(); + assert!(snapshot.country.is_some() && snapshot.ips.is_empty()); + for address in ["1.2.3.4", "2001:db8::1"] { + let destination = Destination::Ip( + format!( + "{}:443", + if address.contains(':') { + format!("[{address}]") + } else { + address.into() + } + ) + .parse() + .unwrap(), + ); + let mut context = RoutingContext::new(Network::Tcp, &destination).unwrap(); + context.set_source("[2001:db8::2]:12345".parse().unwrap()); + assert_eq!( + matches_rule(&config.rules[0], &context, snapshot.as_ref()), + Ok(true) + ); + assert_eq!( + matches_rule(&config.rules[1], &context, snapshot.as_ref()), + Ok(true) + ); + assert_eq!( + matches_rule(&config.rules[2], &context, snapshot.as_ref()), + Ok(false) + ); + let ip = address.parse().unwrap(); + assert!(!Filter::new(&config.dns.fallback).address(ip, snapshot.as_ref())); + let collection = RuleCollection::from_payload( + vec!["GEOIP,CN".into()], + crate::config::providers::RuleBehavior::Classical, + ) + .unwrap(); + assert!(collection.matches_ip(ip, snapshot.as_ref())); + } + let other = "8000::1".parse().unwrap(); + assert!(snapshot.matches_geoip("us", other)); + assert!(snapshot.matches_geoip("!cn", other)); + assert!(Filter::new(&config.dns.fallback).address(other, snapshot.as_ref())); + } +} + +#[test] +fn country_mmdb_discovery_uses_first_name_and_preserves_missing_semantics() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("geoip.metadb"), COUNTRY).unwrap(); + fs::write(dir.path().join("COUNTRY.MMDB"), b"broken").unwrap(); + let manager = + GeoDataManager::open(dir.path().join("geodata"), Duration::from_secs(3600)).unwrap(); + let registration = manager + .register(GeoRequirements::for_config(&config()).unwrap()) + .unwrap(); + assert!(!registration.initial_report().geoip.available); + assert!( + !registration + .matcher() + .matches_geoip("!cn", "8000::1".parse().unwrap()) + ); + let session = manager.begin_update(GeoDataKind::GeoIp).unwrap(); + assert_eq!( + session.temporary_path().file_name().unwrap(), + "COUNTRY.MMDB" + ); + drop(session); + drop(registration); + fs::remove_file(dir.path().join("COUNTRY.MMDB")).unwrap(); + fs::remove_file(dir.path().join("geoip.metadb")).unwrap(); + let registration = manager + .register(GeoRequirements::for_config(&config()).unwrap()) + .unwrap(); + assert!(!registration.initial_report().geoip.available); + let update = manager.begin_update(GeoDataKind::GeoIp).unwrap(); + assert_eq!(update.temporary_path().file_name().unwrap(), "geoip.metadb"); + let (hash, size) = stage(&update, META); + assert!(update.commit(None, hash, size).unwrap().geoip_available); + assert_eq!(fs::read(dir.path().join("geoip.metadb")).unwrap(), META); + assert!(!manager.store_dir().join("geoip.metadb").exists()); +} + +#[test] +fn country_mmdb_update_rejects_bad_candidate_and_drains_before_publish() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("GeoIP.DB"); + fs::write(&path, COUNTRY).unwrap(); + let manager = + GeoDataManager::open(dir.path().join("geodata"), Duration::from_secs(3600)).unwrap(); + let registration = manager + .register(GeoRequirements::for_config(&config()).unwrap()) + .unwrap(); + let matcher = registration.matcher(); + let old = matcher.snapshot(); + let update = manager.begin_update(GeoDataKind::GeoIp).unwrap(); + let (hash, size) = stage(&update, b"broken"); + assert!(update.commit(None, hash, size).is_err()); + assert!(Arc::ptr_eq(&old, &matcher.snapshot())); + assert_eq!(fs::read(&path).unwrap(), COUNTRY); + + let update = manager.begin_update(GeoDataKind::GeoIp).unwrap(); + let (hash, size) = stage(&update, META); + let cancellation = CancellationToken::new(); + let token = cancellation.clone(); + let worker = + std::thread::spawn(move || update.commit_with_cancellation(None, hash, size, &token)); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + while matcher.geoip_available("cn") && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!(!matcher.geoip_available("cn")); + cancellation.cancel(); + assert!(matches!( + worker.join().unwrap(), + Err(GeoDataManagerError::Cancelled) + )); + assert!(Arc::ptr_eq(&old, &matcher.snapshot())); + assert_eq!(fs::read(&path).unwrap(), COUNTRY); + drop(old); + let update = manager.begin_update(GeoDataKind::GeoIp).unwrap(); + let (hash, size) = stage(&update, META); + assert!( + update + .commit(Some("new".into()), hash, size) + .unwrap() + .geoip_available + ); + assert_eq!(fs::read(&path).unwrap(), META); + assert!(matcher.matches_geoip("cn", "1.2.3.4".parse().unwrap())); +} + +#[test] +fn country_mmdb_mode_changes_do_not_reuse_dat_etag_for_the_same_url() { + let dir = tempfile::tempdir().unwrap(); + let manager = + GeoDataManager::open(dir.path().join("geodata"), Duration::from_secs(3600)).unwrap(); + let registration = manager + .register(GeoRequirements::for_config(&config()).unwrap()) + .unwrap(); + let url = "https://assets.example.test/shared"; + let lease = registration.updater_lease(); + let update = manager + .begin_update_for_active_registration( + &lease, + GeoDataKind::GeoIp, + url, + std::time::SystemTime::now(), + ) + .unwrap() + .unwrap(); + let (hash, size) = stage(&update, COUNTRY); + update.commit(Some("country".into()), hash, size).unwrap(); + drop(registration); + let mut config = config(); + config.geodata_update.as_mut().unwrap().geodata_mode = true; + let registration = manager + .register(GeoRequirements::for_config(&config).unwrap()) + .unwrap(); + let update = manager + .begin_update_for_active_registration( + ®istration.updater_lease(), + GeoDataKind::GeoIp, + url, + std::time::SystemTime::now(), + ) + .unwrap() + .unwrap(); + assert!(update.request_etag().is_none()); + assert_eq!( + update.temporary_path().file_name().unwrap(), + GEOIP_FILE_NAME + ); +} diff --git a/src/geodata/manager.rs b/src/geodata/manager.rs index 85680a2..95509cd 100644 --- a/src/geodata/manager.rs +++ b/src/geodata/manager.rs @@ -215,6 +215,7 @@ pub struct GeoResourceState { pub etag: Option, pub hash: Option, pub(crate) source_url: Option, + pub(crate) asset_identity: Option, } /// Current resource-manager view. @@ -283,6 +284,8 @@ struct PersistentResourceState { hash: Option, #[serde(default)] source_url: Option, + #[serde(default)] + asset_identity: Option, } impl PersistentResourceState { @@ -303,6 +306,7 @@ impl PersistentResourceState { etag: self.etag.clone(), hash: self.hash.clone(), source_url: self.source_url.clone(), + asset_identity: self.asset_identity.clone(), } } } @@ -338,6 +342,7 @@ const fn state_version() -> u8 { struct RegistrationEntry { lease: Arc<()>, requirements: GeoRequirements, + assets: AssetPaths, matcher: Arc, report: GeoDataLoadReport, retired: Option>, @@ -361,9 +366,9 @@ impl RegistrationEntry { }; } - /// ASN candidates are fully verified while old data is still usable. + /// MMDB candidates are fully verified while old data is still usable. /// Retain one rollback reference until all other snapshot readers finish. - fn unload_verified_asn( + fn unload_verified_database( &mut self, cancellation: &CancellationToken, ) -> Result<(), GeoDataManagerError> { @@ -413,6 +418,108 @@ impl RegistrationEntry { } } +/// Fixed paths for one registration. MMDB discovery never changes the store +/// used by GeoSite/ASN, state, locks, or provider data-directory derivation. +#[derive(Debug, Clone)] +struct AssetPaths { + store_dir: PathBuf, + geoip: PathBuf, + mmdb: bool, + discovery_error: Option, +} + +impl AssetPaths { + fn dat(store_dir: &Path) -> Self { + Self { + store_dir: store_dir.into(), + geoip: store_dir.join(super::GEOIP_FILE_NAME), + mmdb: false, + discovery_error: None, + } + } + + fn for_registration(store_dir: &Path, geodata_mode: bool) -> Self { + Self::discover(store_dir, geodata_mode).unwrap_or_else(|error| Self { + store_dir: store_dir.into(), + geoip: store_dir.parent().unwrap_or(store_dir).join("geoip.metadb"), + mmdb: true, + discovery_error: Some(bounded_error(error.to_string())), + }) + } + + fn discover(store_dir: &Path, geodata_mode: bool) -> Result { + if geodata_mode { + return Ok(Self::dat(store_dir)); + } + let root = store_dir.parent().unwrap_or(store_dir); + let mut selected = None; + for entry in fs::read_dir(root).map_err(|source| io_at(root, source))? { + let entry = entry.map_err(|source| io_at(root, source))?; + let name = entry.file_name(); + let Some(text) = name.to_str() else { continue }; + if !["Country.mmdb", "geoip.db", "geoip.metadb"] + .iter() + .any(|expected| text.eq_ignore_ascii_case(expected)) + || entry + .file_type() + .map_err(|source| io_at(&entry.path(), source))? + .is_dir() + { + continue; + } + if selected.as_ref().is_none_or(|old| &name < old) { + selected = Some(name); + } + } + Ok(Self { + store_dir: store_dir.into(), + geoip: root.join(selected.unwrap_or_else(|| "geoip.metadb".into())), + mmdb: true, + discovery_error: None, + }) + } + + fn path(&self, kind: GeoDataKind) -> PathBuf { + if kind == GeoDataKind::GeoIp { + self.geoip.clone() + } else { + self.store_dir.join(kind.file_name()) + } + } + + fn identity(&self, kind: GeoDataKind) -> String { + if kind == GeoDataKind::GeoIp { + format!( + "{}:{}", + if self.mmdb { "mmdb" } else { "dat" }, + self.geoip.file_name().unwrap().to_string_lossy() + ) + } else { + kind.file_name().into() + } + } + + fn staged(&self, directory: &Path) -> Self { + Self { + store_dir: directory.into(), + geoip: directory.join(self.geoip.file_name().unwrap()), + mmdb: self.mmdb, + discovery_error: self.discovery_error.clone(), + } + } +} + +enum VerifiedCandidate { + Asn(super::asn::AsnCandidate), + Country(super::country::CountryCandidate), +} + +#[derive(Default)] +struct UpdateSource { + url: Option, + etag: Option, +} + fn dormant_report(requirements: &GeoRequirements, kind: GeoDataKind) -> GeoDataResourceReport { if requirements.requires(kind) { GeoDataResourceReport::degraded("GeoData matcher is unavailable while reloading") @@ -497,12 +604,14 @@ impl GeoDataManager { } self.update_interval .store(interval.as_secs(), Ordering::Relaxed); - let (snapshot, report) = load_snapshot(&self.store_dir, &requirements); + let assets = AssetPaths::for_registration(&self.store_dir, requirements.geodata_mode); + let (snapshot, report) = load_snapshot(&assets, &requirements); let matcher = Arc::new(DynamicGeoData::new(snapshot)); let lease = Arc::new(()); *registration = Some(RegistrationEntry { lease: lease.clone(), requirements, + assets, matcher: matcher.clone(), report: report.clone(), retired: None, @@ -539,9 +648,13 @@ impl GeoDataManager { // even when cancellation prevents unloading the current matcher. entry.reload_pending = true; check_cancellation(cancellation)?; + if entry.assets.discovery_error.is_some() { + entry.assets = + AssetPaths::for_registration(&self.store_dir, entry.requirements.geodata_mode); + } entry.unload(); entry.drain(cancellation)?; - let (snapshot, report) = load_snapshot(&self.store_dir, &entry.requirements); + let (snapshot, report) = load_snapshot(&entry.assets, &entry.requirements); check_cancellation(cancellation)?; entry.publish(snapshot, report); } @@ -565,8 +678,11 @@ impl GeoDataManager { check_cancellation(cancellation)?; entry.unload(); entry.drain(cancellation)?; + if entry.requirements.geodata_mode != requirements.geodata_mode { + entry.assets = AssetPaths::for_registration(&self.store_dir, requirements.geodata_mode); + } entry.requirements = requirements; - let (snapshot, report) = load_snapshot(&self.store_dir, &entry.requirements); + let (snapshot, report) = load_snapshot(&entry.assets, &entry.requirements); check_cancellation(cancellation)?; entry.publish(snapshot, report); Ok(()) @@ -673,12 +789,17 @@ impl GeoDataManager { let Some(status) = self.status_for_active_registration(lease, cancellation)? else { return Ok(Vec::new()); }; + let registration = lock(&self.registration); + let Some(entry) = registration.as_ref().filter(|entry| lease.matches(entry)) else { + return Ok(Vec::new()); + }; Ok(sources .into_iter() .filter_map(|(kind, source_url)| { resource_due( status.resource(kind), source_url, + &entry.assets.identity(kind), now, self.update_interval.load(Ordering::Relaxed), ) @@ -697,7 +818,18 @@ impl GeoDataManager { ) -> Result { let lock_file = self.acquire_update_lock()?; let state = read_state(&self.store_dir)?; - self.start_update_session(kind, None, None, None, lock_file, state) + let assets = lock(&self.registration).as_ref().map_or_else( + || AssetPaths::dat(&self.store_dir), + |entry| entry.assets.clone(), + ); + self.start_update_session( + kind, + UpdateSource::default(), + None, + lock_file, + state, + assets, + ) } /// Begins a source-bound update only if the active registration is still @@ -736,19 +868,20 @@ impl GeoDataManager { let lock_file = self.acquire_update_lock()?; let state = read_state(&self.store_dir)?; self.observe_durable_state(&state, cancellation)?; - let status = { + let selected = { let registration = lock(&self.registration); registration .as_ref() .filter(|entry| lease.matches(entry)) - .map(|entry| scoped_status(&state, &entry.report)) + .map(|entry| (scoped_status(&state, &entry.report), entry.assets.clone())) }; - let Some(status) = status else { + let Some((status, assets)) = selected else { return Ok(None); }; if !resource_due( status.resource(kind), source_url, + &assets.identity(kind), unix_seconds(now), self.update_interval.load(Ordering::Relaxed), ) { @@ -756,19 +889,24 @@ impl GeoDataManager { } let resource = status.resource(kind); let request_etag = (resource.source_url.as_deref() == Some(source_url) + && resource.asset_identity.as_deref() == Some(assets.identity(kind).as_str()) && resource.available - && resource.hash.as_deref().is_some_and(|expected| { - asset_matches_hash(&self.store_dir.join(kind.file_name()), expected) - })) + && resource + .hash + .as_deref() + .is_some_and(|expected| asset_matches_hash(&assets.path(kind), expected))) .then(|| resource.etag.clone()) .flatten(); self.start_update_session( kind, - Some(source_url.to_owned()), - request_etag, + UpdateSource { + url: Some(source_url.to_owned()), + etag: request_etag, + }, Some(lease.clone()), lock_file, state, + assets, ) .map(Some) } @@ -786,19 +924,31 @@ impl GeoDataManager { fn start_update_session( self: &Arc, kind: GeoDataKind, - source_url: Option, - request_etag: Option, + source: UpdateSource, validation_lease: Option, lock_file: File, mut state: PersistentState, + mut assets: AssetPaths, ) -> Result { - let staging_dir = self.store_dir.join(format!( + if kind == GeoDataKind::GeoIp && assets.discovery_error.is_some() { + assets = AssetPaths::discover(&self.store_dir, !assets.mmdb)?; + if let Some(entry) = lock(&self.registration).as_mut() + && entry.assets.discovery_error.is_some() + && entry.assets.mmdb == assets.mmdb + && validation_lease + .as_ref() + .is_none_or(|lease| lease.matches(entry)) + { + entry.assets = assets.clone(); + } + } + let staging_dir = assets.path(kind).parent().unwrap().join(format!( ".update-{}-{}", std::process::id(), next_nonzero_id(&NEXT_TEMP_ID) )); create_private_directory_new(&staging_dir)?; - let staging_path = staging_dir.join(kind.file_name()); + let staging_path = assets.staged(&staging_dir).path(kind); state.resource_mut(kind).updating = true; persist_state(&self.store_dir, &state)?; @@ -807,11 +957,12 @@ impl GeoDataManager { Ok(GeoUpdateSession { manager: self.clone(), kind, + assets, lock_file, staging_dir, staging_path, - source_url, - request_etag, + source_url: source.url, + request_etag: source.etag, validation_lease, completed: false, }) @@ -851,30 +1002,36 @@ impl GeoDataManager { &self, kind: GeoDataKind, candidate_dir: &Path, - ) -> Result, GeoDataManagerError> { - let asn = if kind == GeoDataKind::Asn { - Some( + assets: &AssetPaths, + ) -> Result, GeoDataManagerError> { + let verified = if kind == GeoDataKind::Asn { + Some(VerifiedCandidate::Asn( super::asn::AsnCandidate::load(candidate_dir) .map_err(|error| GeoDataManagerError::Validation(error.to_string()))?, - ) + )) + } else if kind == GeoDataKind::GeoIp && assets.mmdb { + Some(VerifiedCandidate::Country( + super::country::CountryCandidate::load(&assets.staged(candidate_dir).geoip) + .map_err(|error| GeoDataManagerError::Validation(error.to_string()))?, + )) } else { None }; - if kind != GeoDataKind::Asn { + if verified.is_none() { validate_asset_structure(candidate_dir, kind) .map_err(|error| GeoDataManagerError::Validation(error.to_string()))?; } for other in GeoDataKind::ALL.into_iter().filter(|other| *other != kind) { - let other_source = self.store_dir.join(other.file_name()); - let other_candidate = candidate_dir.join(other.file_name()); + let other_source = assets.path(other); + let other_candidate = assets.staged(candidate_dir).path(other); if other_source.is_file() && fs::hard_link(&other_source, &other_candidate).is_err() { fs::copy(&other_source, &other_candidate) .map_err(|source| io_at(&other_candidate, source))?; } } - Ok(asn) + Ok(verified) } fn finish_success( @@ -883,6 +1040,7 @@ impl GeoDataManager { source_url: Option, etag: Option, hash: String, + asset_identity: String, ) -> Result<(), GeoDataManagerError> { let now = unix_seconds(SystemTime::now()); let interval = self.update_interval.load(Ordering::Relaxed); @@ -896,6 +1054,7 @@ impl GeoDataManager { resource.etag = etag; resource.hash = Some(hash); resource.source_url = source_url; + resource.asset_identity = Some(asset_identity); persist_state(&self.store_dir, &state)?; *lock(&self.state) = state; Ok(()) @@ -906,6 +1065,7 @@ impl GeoDataManager { kind: GeoDataKind, source_url: Option, etag: Option, + asset_identity: String, ) -> Result<(), GeoDataManagerError> { let now = unix_seconds(SystemTime::now()); let mut state = read_state(&self.store_dir)?; @@ -919,6 +1079,7 @@ impl GeoDataManager { resource.etag = etag; } resource.source_url = source_url; + resource.asset_identity = Some(asset_identity); persist_state(&self.store_dir, &state)?; *lock(&self.state) = state; Ok(()) @@ -1018,6 +1179,7 @@ impl Drop for GeoDataRegistration { pub struct GeoUpdateSession { manager: Arc, kind: GeoDataKind, + assets: AssetPaths, lock_file: File, staging_dir: PathBuf, staging_path: PathBuf, @@ -1118,13 +1280,17 @@ impl GeoUpdateSession { }) { entry.unload(); entry.drain(cancellation)?; - Some(load_snapshot(&self.manager.store_dir, &entry.requirements)) + Some(load_snapshot(&entry.assets, &entry.requirements)) } else { None }; check_cancellation(cancellation)?; - self.manager - .finish_not_modified(self.kind, self.source_url.clone(), etag)?; + self.manager.finish_not_modified( + self.kind, + self.source_url.clone(), + etag, + self.assets.identity(self.kind), + )?; check_cancellation(cancellation)?; if let Some(entry) = registration.as_mut() && let Some((snapshot, report)) = candidate @@ -1173,11 +1339,13 @@ impl GeoUpdateSession { // Framing checks and disk staging do not allocate matchers. Keep the // old rules until this boundary, then release every old reader before // constructing the new generation. - let asn_candidate = self.manager.stage_candidate(self.kind, &self.staging_dir)?; - if let Some(asn_candidate) = asn_candidate { - return self.commit_asn_candidate( + let candidate = self + .manager + .stage_candidate(self.kind, &self.staging_dir, &self.assets)?; + if let Some(candidate) = candidate { + return self.commit_verified_candidate( &mut registration, - asn_candidate, + candidate, etag, sha256, cancellation, @@ -1188,7 +1356,8 @@ impl GeoUpdateSession { { entry.unload(); entry.drain(cancellation)?; - let (snapshot, report) = load_snapshot(&self.staging_dir, &entry.requirements); + let (snapshot, report) = + load_snapshot(&self.assets.staged(&self.staging_dir), &entry.requirements); check_cancellation(cancellation)?; let target = report.resource(self.kind); if !target.available { @@ -1206,16 +1375,17 @@ impl GeoUpdateSession { None }; check_cancellation(cancellation)?; - let destination = self.manager.store_dir.join(self.kind.file_name()); + let destination = self.assets.path(self.kind); fs::rename(&self.staging_path, &destination) .map_err(|source| io_at(&destination, source))?; - sync_directory(&self.manager.store_dir)?; + sync_directory(destination.parent().unwrap())?; self.manager.finish_success( self.kind, self.source_url.clone(), etag, hex_digest(&sha256), + self.assets.identity(self.kind), )?; check_cancellation(cancellation)?; if let Some(entry) = registration.as_mut() @@ -1226,19 +1396,19 @@ impl GeoUpdateSession { Ok(reload_report(registration.as_ref())) } - /// ASN validation, persistence and recovery are separate from DAT's + /// MMDB validation, persistence and recovery are separate from DAT's /// existing degraded-reload contract. A failed candidate never becomes - /// the replacement for a usable ASN generation. - fn commit_asn_candidate( + /// the replacement for a usable MMDB generation. + fn commit_verified_candidate( &self, registration: &mut Option, - candidate: super::asn::AsnCandidate, + candidate: VerifiedCandidate, etag: Option, sha256: [u8; 32], cancellation: &CancellationToken, ) -> Result { - let destination = self.manager.store_dir.join(GeoDataKind::Asn.file_name()); - let previous_path = self.staging_dir.join("previous-ASN.mmdb"); + let destination = self.assets.path(self.kind); + let previous_path = self.staging_dir.join("previous.mmdb"); let previous = read_state(&self.manager.store_dir)?; let had_file = destination.is_file(); if had_file && fs::hard_link(&destination, &previous_path).is_err() { @@ -1250,18 +1420,22 @@ impl GeoUpdateSession { let result = (|| { check_cancellation(cancellation)?; let loaded = if let Some(entry) = registration.as_mut() - && entry.requirements.requires(GeoDataKind::Asn) + && entry.requirements.requires(self.kind) { - entry.unload_verified_asn(cancellation)?; + entry.unload_verified_database(cancellation)?; retired = true; - let (snapshot, report) = - load_snapshot_with_asn(&self.staging_dir, &entry.requirements, Some(candidate)); - if !report.asn.available { + let (snapshot, report) = load_snapshot_with_candidate( + &self.assets.staged(&self.staging_dir), + &entry.requirements, + Some(candidate), + ); + if !report.resource(self.kind).available { return Err(GeoDataManagerError::Validation( report - .asn + .resource(self.kind) .error - .unwrap_or_else(|| "ASN candidate unavailable".into()), + .clone() + .unwrap_or_else(|| "MMDB candidate unavailable".into()), )); } Some((snapshot, report)) @@ -1272,12 +1446,13 @@ impl GeoUpdateSession { fs::rename(&self.staging_path, &destination) .map_err(|source| io_at(&destination, source))?; file_published = true; - sync_directory(&self.manager.store_dir)?; + sync_directory(destination.parent().unwrap())?; self.manager.finish_success( - GeoDataKind::Asn, + self.kind, self.source_url.clone(), etag, hex_digest(&sha256), + self.assets.identity(self.kind), )?; check_cancellation(cancellation)?; if let Some(entry) = registration.as_mut() @@ -1301,7 +1476,7 @@ impl GeoUpdateSession { .map_err(|source| io_at(&destination, source))?; } disk_restored = true; - sync_directory(&self.manager.store_dir)?; + sync_directory(destination.parent().unwrap())?; persist_state(&self.manager.store_dir, &previous)?; *lock(&self.manager.state) = previous; Ok::<(), GeoDataManagerError>(()) @@ -1311,24 +1486,31 @@ impl GeoUpdateSession { }; if retired && let Some(entry) = registration.as_mut() { if disk_restored { - let (snapshot, report) = - load_snapshot(&self.manager.store_dir, &entry.requirements); + let (snapshot, report) = load_snapshot(&entry.assets, &entry.requirements); entry.publish(snapshot, report); } else { // An actual disk/state rollback error is exceptional. Do // not present uncertain on-disk ASN contents as the old generation. let mut requirements = entry.requirements.clone(); - requirements.asn = false; - let (snapshot, mut report) = - load_snapshot(&self.manager.store_dir, &requirements); - report.asn = - GeoDataResourceReport::degraded("ASN disk state could not be restored"); + match self.kind { + GeoDataKind::Asn => requirements.asn = false, + GeoDataKind::GeoIp => requirements.ips = super::CodeSet::EMPTY, + GeoDataKind::GeoSite => unreachable!(), + } + let (snapshot, mut report) = load_snapshot(&entry.assets, &requirements); + let unavailable = + GeoDataResourceReport::degraded("MMDB disk state could not be restored"); + match self.kind { + GeoDataKind::Asn => report.asn = unavailable, + GeoDataKind::GeoIp => report.geoip = unavailable, + GeoDataKind::GeoSite => unreachable!(), + } entry.publish(snapshot, report); } } if let Err(rollback) = rollback { return Err(GeoDataManagerError::Validation(bounded_error(format!( - "ASN update failed: {original}; previous disk state could not be restored: {rollback}" + "MMDB update failed: {original}; previous disk state could not be restored: {rollback}" )))); } return Err(original); @@ -1340,6 +1522,13 @@ impl GeoUpdateSession { &self, registration: Option<&RegistrationEntry>, ) -> Result<(), GeoDataManagerError> { + if registration.is_some_and(|entry| { + entry.assets.mmdb != self.assets.mmdb || entry.assets.geoip != self.assets.geoip + }) { + return Err(GeoDataManagerError::Validation( + "GeoData update asset selection is no longer active".into(), + )); + } if let Some(lease) = &self.validation_lease { let entry = registration .filter(|entry| lease.matches(entry)) @@ -1390,29 +1579,57 @@ impl Drop for GeoUpdateSession { } fn load_snapshot( - asset_dir: &Path, + assets: &AssetPaths, requirements: &GeoRequirements, ) -> (Arc, GeoDataLoadReport) { - load_snapshot_with_asn(asset_dir, requirements, None) + load_snapshot_with_candidate(assets, requirements, None) } -fn load_snapshot_with_asn( - asset_dir: &Path, +fn load_snapshot_with_candidate( + assets: &AssetPaths, requirements: &GeoRequirements, - candidate: Option, + candidate: Option, ) -> (Arc, GeoDataLoadReport) { let mut sites = Vec::new(); let mut ips = Vec::new(); - let mut used = 0; - let mut peak = 0; + let mut used: usize = 0; + let mut peak: usize = 0; let mut site_report = GeoDataResourceReport::not_required(); let mut ip_report = GeoDataResourceReport::not_required(); let mut asn_report = GeoDataResourceReport::not_required(); let mut asn = None; + let mut country = None; + let (country_candidate, asn_candidate) = match candidate { + Some(VerifiedCandidate::Country(value)) => (Some(value), None), + Some(VerifiedCandidate::Asn(value)) => (None, Some(value)), + None => (None, None), + }; - if requirements.requires(GeoDataKind::GeoIp) { + if requirements.requires(GeoDataKind::GeoIp) + && let Some(error) = &assets.discovery_error + { + ip_report = GeoDataResourceReport::degraded(error); + } else if requirements.requires(GeoDataKind::GeoIp) && assets.mmdb { + match country_candidate.map_or_else( + || super::country::CountryDb::load(&assets.geoip, &requirements.ips.selectors), + |candidate| candidate.into_database(&requirements.ips.selectors), + ) { + Ok(database) => { + if let Some(total) = used.checked_add(database.capacity()) { + used = total; + peak = peak.max(used); + country = Some(database); + ip_report = GeoDataResourceReport::available(); + } else { + ip_report = + GeoDataResourceReport::degraded("country allocation capacity overflow"); + } + } + Err(error) => ip_report = GeoDataResourceReport::degraded(error), + } + } else if requirements.requires(GeoDataKind::GeoIp) { match load_ips_for_requirements( - asset_dir, + &assets.store_dir, requirements.code_set(GeoDataKind::GeoIp), used, peak, @@ -1429,7 +1646,7 @@ fn load_snapshot_with_asn( } if requirements.requires(GeoDataKind::GeoSite) { match load_sites_for_requirements( - asset_dir, + &assets.store_dir, requirements.code_set(GeoDataKind::GeoSite), used, peak, @@ -1445,8 +1662,8 @@ fn load_snapshot_with_asn( } if requirements.requires(GeoDataKind::Asn) { - match candidate.map_or_else( - || super::asn::AsnDb::load(asset_dir), + match asn_candidate.map_or_else( + || super::asn::AsnDb::load(&assets.store_dir), super::asn::AsnCandidate::into_database, ) { Ok(database) => { @@ -1463,7 +1680,7 @@ fn load_snapshot_with_asn( Err(error) => asn_report = GeoDataResourceReport::degraded(error), } } - let snapshot = Arc::new(GeoData::from_loaded(sites, ips, asn, used, peak)); + let snapshot = Arc::new(GeoData::from_loaded(sites, ips, country, asn, used, peak)); ( snapshot, GeoDataLoadReport { @@ -1525,11 +1742,18 @@ fn report_error(report: &GeoDataResourceReport) -> Option<&str> { .flatten() } -fn resource_due(resource: &GeoResourceState, source_url: &str, now: u64, interval: u64) -> bool { +fn resource_due( + resource: &GeoResourceState, + source_url: &str, + asset_identity: &str, + now: u64, + interval: u64, +) -> bool { resource.required && !resource.updating && (!resource.available || resource.source_url.as_deref() != Some(source_url) + || resource.asset_identity.as_deref() != Some(asset_identity) || resource .last_success .is_none_or(|last| last.saturating_add(interval) <= now)) @@ -1724,6 +1948,7 @@ fn unix_seconds(time: SystemTime) -> u64 { fn asset_generation_changed(previous: &PersistentState, current: &PersistentState) -> bool { previous.geosite.hash != current.geosite.hash || previous.geosite.last_success != current.geosite.last_success + || previous.geoip.asset_identity != current.geoip.asset_identity || previous.geoip.hash != current.geoip.hash || previous.geoip.last_success != current.geoip.last_success || previous.asn.hash != current.asn.hash @@ -1970,7 +2195,7 @@ mod tests { registration .as_mut() .unwrap() - .unload_verified_asn(&worker_token) + .unload_verified_database(&worker_token) }); let deadline = Instant::now() + Duration::from_secs(2); while matcher.asn_available() && Instant::now() < deadline { @@ -1988,7 +2213,9 @@ mod tests { } fn requirements(rules: &[RuleSpec]) -> GeoRequirements { - GeoRequirements::collect(rules, &[]).unwrap() + GeoRequirements::collect(rules, &[]) + .unwrap() + .with_geodata_mode(true) } fn stage_candidate(session: &GeoUpdateSession, contents: &[u8]) -> ([u8; 32], u64) { diff --git a/src/geodata/mod.rs b/src/geodata/mod.rs index d9a0133..bb35549 100644 --- a/src/geodata/mod.rs +++ b/src/geodata/mod.rs @@ -1,4 +1,4 @@ -//! Selective Xray GeoData loading with compact, shared category matchers. +//! Selective DAT matchers and shared country/ASN MMDB readers. //! //! The loader deliberately scans protobuf wire framing itself. It indexes only //! category ranges during the first pass and decodes records only for codes @@ -26,6 +26,9 @@ use crate::{ mod asn; #[cfg(test)] mod asn_tests; +mod country; +#[cfg(test)] +mod country_tests; mod manager; mod selectors; pub(crate) mod service; @@ -45,7 +48,7 @@ pub const ASN_FILE_NAME: &str = "ASN.mmdb"; const MAX_CODE_BYTES: usize = 64; -/// Supported DAT category assets and the independent ASN MMDB. +/// Logical assets; GeoIP selects a DAT or country MMDB per registration. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeoDataKind { GeoSite, @@ -101,6 +104,8 @@ pub enum GeoDataError { InvalidCidr { code: String, detail: String }, #[error("invalid ASN MMDB: {0}")] InvalidAsn(String), + #[error("invalid country MMDB: {0}")] + InvalidCountry(String), #[error("GeoData resource `{resource}` is {actual}; limit is {maximum}")] ResourceLimit { resource: &'static str, @@ -152,6 +157,7 @@ impl AllocationLedger { pub struct GeoData { sites: Vec, ips: Vec, + country: Option, asn: Option, allocation_capacity: usize, peak_allocation_capacity: usize, @@ -166,6 +172,7 @@ impl std::fmt::Debug for GeoData { .debug_struct("GeoData") .field("site_categories", &self.sites.len()) .field("ip_categories", &self.ips.len()) + .field("country_available", &self.country.is_some()) .field("asn_available", &self.asn.is_some()) .field("allocation_capacity", &self.allocation_capacity) .field("peak_allocation_capacity", &self.peak_allocation_capacity) @@ -183,6 +190,7 @@ impl GeoData { pub const EMPTY: Self = Self { sites: Vec::new(), ips: Vec::new(), + country: None, asn: None, allocation_capacity: 0, peak_allocation_capacity: 0, @@ -227,12 +235,20 @@ impl GeoData { } else { None }; - Ok(Self::from_loaded(sites, ips, asn, ledger.used, ledger.peak)) + Ok(Self::from_loaded( + sites, + ips, + None, + asn, + ledger.used, + ledger.peak, + )) } fn from_loaded( mut sites: Vec, mut ips: Vec, + country: Option, asn: Option, allocation_capacity: usize, peak_allocation_capacity: usize, @@ -241,11 +257,12 @@ impl GeoData { // manager reloads share this allocation-free indexing step. sites.sort_unstable_by_key(|category| category.code); ips.sort_unstable_by_key(|category| category.code); - let release = (!sites.is_empty() || !ips.is_empty() || asn.is_some()) + let release = (!sites.is_empty() || !ips.is_empty() || country.is_some() || asn.is_some()) .then(manager::SnapshotRelease::new); Self { sites, ips, + country, asn, allocation_capacity, peak_allocation_capacity, @@ -269,7 +286,7 @@ impl GeoData { #[must_use] pub fn is_empty(&self) -> bool { - self.sites.is_empty() && self.ips.is_empty() && self.asn.is_none() + self.sites.is_empty() && self.ips.is_empty() && self.country.is_none() && self.asn.is_none() } #[must_use] @@ -279,7 +296,9 @@ impl GeoData { #[must_use] pub fn geoip_available(&self, code: &str) -> bool { - lan_selector(code) || self.ip_category(code).is_some() + lan_selector(code) + || self.country.as_ref().is_some_and(|db| db.available(code)) + || self.ip_category(code).is_some() } #[must_use] @@ -355,8 +374,13 @@ impl GeoMatcher for GeoData { fn matches_geoip(&self, code: &str, address: IpAddr) -> bool { matches_lan(code, address).unwrap_or_else(|| { - self.ip_category(code) - .is_some_and(|category| category.matches(address) != code.starts_with('!')) + self.country.as_ref().map_or_else( + || { + self.ip_category(code) + .is_some_and(|category| category.matches(address) != code.starts_with('!')) + }, + |db| db.matches(code, address), + ) }) } } @@ -530,6 +554,7 @@ pub struct GeoRequirements { sites: CodeSet, ips: CodeSet, asn: bool, + geodata_mode: bool, } impl GeoRequirements { @@ -592,6 +617,10 @@ impl GeoRequirements { sites: requested.sites, ips: requested.ips, asn: requested.asn, + geodata_mode: config + .geodata_update + .as_ref() + .is_some_and(|update| update.geodata_mode), }; if config.sniffer.enable { result.extend(&Self::collect(&config.sniffer.skip_src_address, &[])?)?; @@ -632,9 +661,17 @@ impl GeoRequirements { sites: requested.sites, ips: requested.ips, asn: requested.asn, + geodata_mode: false, }) } + /// Selects DAT (true) or country MMDB (false, the YAML default). + #[must_use] + pub fn with_geodata_mode(mut self, enabled: bool) -> Self { + self.geodata_mode = enabled; + self + } + #[must_use] pub const fn is_empty(&self) -> bool { self.sites.values.is_empty() && self.ips.values.is_empty() && !self.asn @@ -809,6 +846,11 @@ fn load_selected_file( fn open_asset(config_dir: &Path, kind: GeoDataKind) -> Result<(File, u64), GeoDataError> { let path = config_dir.join(kind.file_name()); + open_asset_path(&path) +} + +fn open_asset_path(path: &Path) -> Result<(File, u64), GeoDataError> { + let path = path.to_path_buf(); let symlink_metadata = fs::symlink_metadata(&path).map_err(|source| GeoDataError::Io { path: path.clone(), source, diff --git a/src/geodata/service.rs b/src/geodata/service.rs index 15f1517..5b23f4e 100644 --- a/src/geodata/service.rs +++ b/src/geodata/service.rs @@ -37,6 +37,7 @@ pub(crate) struct GeoDataUpdateService { dispatcher: Arc, registration: GeoDataRegistrationLease, urls: GeoDataUrls, + geodata_mode: bool, options: HttpOptions, etag_support: bool, auto_update: bool, @@ -50,6 +51,7 @@ impl GeoDataUpdateService { dispatcher: Arc, registration: GeoDataRegistrationLease, urls: GeoDataUrls, + geodata_mode: bool, user_agent: String, etag_support: bool, auto_update: bool, @@ -61,6 +63,7 @@ impl GeoDataUpdateService { dispatcher, registration, urls, + geodata_mode, options: HttpOptions { user_agent, ..HttpOptions::default() @@ -141,12 +144,16 @@ impl GeoDataUpdateService { let registration = self.registration.clone(); let urls = self.urls.clone(); let auto_update = self.auto_update; + let geodata_mode = self.geodata_mode; blocking_operation(move || { let due = manager.due_resources_for_active_registration_with_cancellation( ®istration, [ (GeoDataKind::GeoSite, urls.geosite.as_str()), - (GeoDataKind::GeoIp, urls.geoip.as_str()), + ( + GeoDataKind::GeoIp, + resource_url(&urls, GeoDataKind::GeoIp, geodata_mode), + ), (GeoDataKind::Asn, urls.asn.as_str()), ], SystemTime::now(), @@ -174,7 +181,7 @@ impl GeoDataUpdateService { kind: GeoDataKind, cancellation: CancellationToken, ) -> Result { - let source_url = resource_url(&self.urls, kind).to_owned(); + let source_url = resource_url(&self.urls, kind, self.geodata_mode).to_owned(); let manager = self.manager.clone(); let registration = self.registration.clone(); let begin_source = source_url.clone(); @@ -344,10 +351,16 @@ impl RetryState { } } -fn resource_url(urls: &GeoDataUrls, kind: GeoDataKind) -> &str { +fn resource_url(urls: &GeoDataUrls, kind: GeoDataKind, geodata_mode: bool) -> &str { match kind { GeoDataKind::GeoSite => &urls.geosite, - GeoDataKind::GeoIp => &urls.geoip, + GeoDataKind::GeoIp => { + if geodata_mode { + &urls.geoip + } else { + &urls.mmdb + } + } GeoDataKind::Asn => &urls.asn, } } @@ -430,10 +443,12 @@ mod tests { Arc::new(NoNetworkDispatcher), registration.updater_lease(), GeoDataUrls { + mmdb: "https://assets.example.test/geoip.metadb".into(), geoip: "https://rules.example.test/geoip.dat".to_owned(), geosite: "https://rules.example.test/geosite.dat".to_owned(), asn: "https://rules.example.test/ASN.mmdb".to_owned(), }, + true, "fixture".into(), true, true, @@ -486,18 +501,23 @@ mod tests { #[test] fn resources_use_configured_urls() { let urls = GeoDataUrls { + mmdb: "https://assets.example.test/geoip.metadb".into(), geoip: "https://rules.example.test/custom-geoip".to_owned(), geosite: "https://rules.example.test/custom-geosite".to_owned(), asn: "https://rules.example.test/custom-asn".to_owned(), }; assert_eq!( - resource_url(&urls, GeoDataKind::GeoSite), + resource_url(&urls, GeoDataKind::GeoSite, true), "https://rules.example.test/custom-geosite" ); assert_eq!( - resource_url(&urls, GeoDataKind::GeoIp), + resource_url(&urls, GeoDataKind::GeoIp, true), "https://rules.example.test/custom-geoip" ); + assert_eq!( + resource_url(&urls, GeoDataKind::GeoIp, false), + "https://assets.example.test/geoip.metadb" + ); } #[test] diff --git a/src/geodata/tests.rs b/src/geodata/tests.rs index a5de45f..b50b398 100644 --- a/src/geodata/tests.rs +++ b/src/geodata/tests.rs @@ -462,7 +462,11 @@ fn category_lookup_preserves_independent_codes_through_load_and_reload() { let data = GeoData::load(dir.path(), &rules).unwrap(); let manager = GeoDataManager::open(dir.path(), std::time::Duration::from_secs(60)).unwrap(); let registration = manager - .register(GeoRequirements::collect(&rules, &[]).unwrap()) + .register( + GeoRequirements::collect(&rules, &[]) + .unwrap() + .with_geodata_mode(true), + ) .unwrap(); assert!(registration.initial_report().geosite.available); assert!(registration.initial_report().geoip.available); diff --git a/src/runtime.rs b/src/runtime.rs index bd24998..26b0312 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -444,6 +444,7 @@ impl PreparedCore { parts.dispatcher.clone(), self.geodata_registration.updater_lease(), update.urls.clone(), + update.geodata_mode, self.config.global_ua.clone(), self.config.etag_support, update.auto_update && update.interval_hours > 0, @@ -2441,7 +2442,8 @@ dns: async fn geodata_updater_fetches_missing_assets_and_requires_actual_demand() { fn configured(auto_update: bool, geodata_rule: bool) -> String { let fields = format!( - r#"geox-url: + r#"geodata-mode: true +geox-url: geoip: https://downloads.example.test/custom-geoip.dat geosite: https://downloads.example.test/custom-geosite.dat geo-auto-update: {auto_update} @@ -2498,7 +2500,8 @@ geo-update-interval: 24 async fn a_manager_rejects_a_second_prepared_configuration() { fn configured(auto_update: bool, rule: &str, source: &str) -> String { let fields = format!( - r#"geox-url: + r#"geodata-mode: true +geox-url: geoip: https://{source}/geoip.dat geosite: https://{source}/geosite.dat geo-auto-update: {auto_update} diff --git a/tests/fixtures/geodata/Country-Test.mmdb b/tests/fixtures/geodata/Country-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..d4b7699c22be7faab66156d9a7f1277ad241ba42 GIT binary patch literal 277 zcmZvXF>b;@5JlGrrB9cVE)^*_0u%%+TahAYC|2X$2~1?qSi9>e<}^@q3J$;_T&Btu zvJxmrh$;Wq|MQb;@5JeXYrKd}UxIm(u!3_$QTbdNB@$O&-*)yxzF^Y2AeGP2WT-*rdMUf64Cz`zbBPq5RE?57apJg0PmGqiU0rr literal 0 HcmV?d00001 diff --git a/tests/fixtures/geodata/README.md b/tests/fixtures/geodata/README.md index 76459cd..d61382f 100644 --- a/tests/fixtures/geodata/README.md +++ b/tests/fixtures/geodata/README.md @@ -1,4 +1,4 @@ -# ASN fixture +# GeoData fixtures `GeoLite2-ASN-Test.mmdb` is the small official MaxMind test database from [MaxMind-DB](https://github.com/maxmind/MaxMind-DB/tree/6a690a705bc6962c0025a32e94a8f0dc8f86418c), @@ -8,3 +8,10 @@ commit `6a690a705bc6962c0025a32e94a8f0dc8f86418c`, path Copyright © 2013–2026 MaxMind, Inc. Used under the upstream MIT option; the permission notice is preserved in [LICENSE-MIT](LICENSE-MIT). This source fixture is not part of Vole runtime or release packages. + +`Country-Test.mmdb`, `Sing-Test.mmdb`, and `Meta-Test.mmdb` are independently +encoded Vole fixtures using the [MaxMind DB 2.0 format](https://maxmind.github.io/MaxMind-DB/). +One 24-bit node maps the lower IPv6 half (including IPv4 lookups) to CN and the +upper half to US. Records respectively use `country.iso_code`, a root string, +and a root array (`cn`, `private`) or string (`us`). They contain synthetic +data and no third-party database records. diff --git a/tests/fixtures/geodata/Sing-Test.mmdb b/tests/fixtures/geodata/Sing-Test.mmdb new file mode 100644 index 0000000000000000000000000000000000000000..fa4392fea3bc8ba4505e517e3daf03b4d0813677 GIT binary patch literal 235 zcmZ9Fu?@m75Jeq?=#eNWSpbGeN20W8D6ETpghR=lWjly44b)7*01QD%#R^0eAo1Jx z-~UQYm71x)q=wTjT<@;}js>w@8EK!JiWq#ax8hrfx`n-XeLx>b_%iyZ#6Nn@tBxA0 z(TUlqQDGrM1p#&J9cF>JUe+k&a*M%ua&cIEq{w9{4K6~@afgx2!br!__ZhkG?_}Ko c*B#UtlJTHqEEe^rcuR7RM!eO5PI#HW0p{dZF#rGn literal 0 HcmV?d00001 From 114f2f51e2885bbbab715d4bc9052f6f3b9d70f3 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:50:06 +0800 Subject: [PATCH 053/445] feat: decrypt AGE proxy providers --- .github/workflows/test.yml | 9 +- Cargo.lock | 672 +++++++++++++++++++++- Cargo.toml | 9 +- docs/config.yaml | 6 + docs/tls-dependencies.md | 5 + src/config/providers.rs | 90 ++- src/resource/age_provider.rs | 395 +++++++++++++ src/resource/cache.rs | 99 +++- src/resource/cache_tests.rs | 63 ++ src/resource/mod.rs | 1 + src/runtime/providers.rs | 25 +- tests/README.md | 3 + tests/fixtures/provider-age/README.md | 9 + tests/fixtures/provider-age/hybrid.age | 39 ++ tests/fixtures/provider-age/hybrid.key | 1 + tests/fixtures/provider-age/provider.yaml | 6 + tests/fixtures/provider-age/x25519.age | 9 + tests/fixtures/provider-age/x25519.key | 1 + 18 files changed, 1385 insertions(+), 57 deletions(-) create mode 100644 src/resource/age_provider.rs create mode 100644 tests/fixtures/provider-age/README.md create mode 100644 tests/fixtures/provider-age/hybrid.age create mode 100644 tests/fixtures/provider-age/hybrid.key create mode 100644 tests/fixtures/provider-age/provider.yaml create mode 100644 tests/fixtures/provider-age/x25519.age create mode 100644 tests/fixtures/provider-age/x25519.key diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b0d1b69..72e8ef1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -121,7 +121,12 @@ jobs: require(not features(wireguard) & {"device", "ffi-bindings", "jni-bindings"}, "WireGuard must use only the userspace protocol") require("ring" in features(rustls) and not features(rustls) & {"reality", "aws_lc_rs", "fips"}, "rustls must use only the ring TLS provider") hpke = package("hpke", "0.14.1") - require(features(hpke) == {"alloc", "aes", "chacha", "x25519", "hkdfsha2"}, "unapproved ECH algorithms") + require(features(hpke) == {"alloc", "aes", "chacha", "x25519", "hkdfsha2", "mlkem", "shake"}, "unapproved HPKE algorithms") + age = package("age", "0.11.5") + require(features(age) == {"armor"}, "AGE must use only native armored-file decoding") + package("age-core", "0.11.0") + package("x-wing", "0.1.1") + package("ml-kem", "0.3.2") native = {} for name, required in (("boring", {"reality", "client-fingerprint", "shadow-tls-v3", "jls"}), ("boring-sys", {"reality", "shadow-tls-v3", "jls"}), ("tokio-boring", set())): item = package(name, "5.2.0", {boring_source}) @@ -144,7 +149,7 @@ jobs: for name, required in (("shadowsocks", {"aead-cipher-2022"}), ("shadowsocks-crypto", {"v2", "aws-lc"})): require(required <= features(chain[name]), f"{name} must enable SS 2022") require(not any(features(item) & {"fips", "aead-cipher-2022-extra", "v2-extra"} for item in chain.values()), "unapproved SS 2022 or AWS-LC mode") - print("Locked TLS sources, providers, ECH algorithms and SS 2022 dependency chain passed") + print("Locked TLS sources, HPKE/AGE algorithms and SS 2022 dependency chain passed") PY - name: Production and netstack clippy if: matrix.profile == 'features' diff --git a/Cargo.lock b/Cargo.lock index c20f392..795d6ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -54,6 +54,49 @@ dependencies = [ "ghash", ] +[[package]] +name = "age" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "047a482d1843edf1ce76ada63183698144030fe1191bd5ddba6e41e164e0bc43" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32 0.9.1", + "chacha20poly1305 0.10.1", + "cookie-factory", + "hmac 0.12.1", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom", + "pin-project", + "rand 0.8.8", + "rust-embed", + "scrypt", + "sha2 0.10.9", + "subtle", + "x25519-dalek 2.0.1", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305 0.10.1", + "cookie-factory", + "hkdf 0.12.4", + "io_tee", + "nom", + "rand 0.8.8", + "secrecy", + "sha2 0.10.9", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -105,7 +148,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -179,6 +222,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -191,6 +240,27 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "bindgen" version = "0.72.1" @@ -204,7 +274,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 2.1.3", "shlex 1.3.0", "syn 2.0.119", ] @@ -292,6 +362,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -530,6 +601,15 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -700,7 +780,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -744,6 +824,7 @@ dependencies = [ "const-oid", "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -877,6 +958,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -893,6 +983,50 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "flume" version = "0.12.0" @@ -1201,6 +1335,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -1237,11 +1380,16 @@ dependencies = [ "aead 0.6.1", "aes-gcm", "chacha20poly1305 0.11.0", - "hkdf", + "hkdf 0.13.0", "hybrid-array", + "ml-kem", "rand_core 0.10.1", - "sha2", + "sha2 0.11.0", + "sha3 0.12.0", + "shake", "subtle", + "turboshake", + "x-wing", "x25519-dalek 3.0.0", "zeroize", ] @@ -1278,7 +1426,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", "typenum", + "zeroize", ] [[package]] @@ -1315,6 +1465,72 @@ dependencies = [ "tokio", ] +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -1447,6 +1663,31 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "ip_network" version = "0.4.1" @@ -1564,6 +1805,16 @@ dependencies = [ "cpufeatures 0.3.1", ] +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1650,7 +1901,7 @@ dependencies = [ "ipnetwork", "memchr", "serde", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1678,6 +1929,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1705,6 +1972,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-kem" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e15f3e5b957493873e396a66914e83e616b6afe335cdef7efe5c6e1216aba66" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3 0.11.0", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + [[package]] name = "multiversion_no_op" version = "1.0.0" @@ -1727,7 +2020,7 @@ dependencies = [ "nix 0.30.1", "scopeguard", "system-configuration-sys", - "thiserror", + "thiserror 2.0.20", "widestring", "windows 0.61.3", ] @@ -1938,6 +2231,16 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2038,6 +2341,37 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2074,10 +2408,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -2092,14 +2426,14 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.3", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -2134,6 +2468,17 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.10.3" @@ -2145,6 +2490,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2257,6 +2612,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2353,6 +2749,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2368,6 +2782,17 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + [[package]] name = "sealed" version = "0.7.0" @@ -2379,6 +2804,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -2402,6 +2836,21 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.3.0", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -2497,6 +2946,17 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -2508,6 +2968,27 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + [[package]] name = "shadowsocks" version = "1.25.0" @@ -2527,7 +3008,7 @@ dependencies = [ "lru_time_cache", "percent-encoding", "pin-project", - "rand", + "rand 0.10.3", "sealed", "sendfd", "serde", @@ -2536,7 +3017,7 @@ dependencies = [ "shadowsocks-crypto", "socket2", "spin 0.12.3", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-tfo", "trait-variant", @@ -2557,7 +3038,7 @@ dependencies = [ "bytes", "cfg-if", "chacha20poly1305 0.11.0", - "rand", + "rand 0.10.3", ] [[package]] @@ -2664,6 +3145,9 @@ name = "sponge-cursor" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +dependencies = [ + "zeroize", +] [[package]] name = "stable_deref_trait" @@ -2677,6 +3161,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -2750,13 +3240,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -2807,6 +3317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -2909,6 +3420,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "tracing" version = "0.1.44" @@ -2992,9 +3512,29 @@ dependencies = [ "http", "httparse", "log", - "rand", + "rand 0.10.3", "sha1", - "thiserror", + "thiserror 2.0.20", +] + +[[package]] +name = "turboshake" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f892c6904b0bd5a9241eac848347abcbbbd2b9f3892bad23ac3e6efab8d6f06a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", ] [[package]] @@ -3003,6 +3543,31 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -3093,9 +3658,12 @@ name = "vole" version = "0.1.0" dependencies = [ "aes", + "age", + "age-core", "arc-swap", "async-trait", "base64 0.23.1", + "bech32 0.12.0", "blake2 0.11.0", "blake3", "boring", @@ -3125,7 +3693,7 @@ dependencies = [ "psl", "quinn", "quinn-proto", - "rand", + "rand 0.10.3", "rcgen", "redb", "regex", @@ -3136,12 +3704,12 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "sha2", + "sha2 0.11.0", "shadowsocks", "shake", "socket2", "tempfile", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tokio-boring", @@ -3180,11 +3748,21 @@ dependencies = [ "bytes", "futures-util", "smoltcp", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3286,6 +3864,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3663,6 +4250,20 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "x-wing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01576e56599d01d75c5efcd5b30fa5de29e776f5a225e43fbf8c92c7302d4c8b" +dependencies = [ + "kem", + "ml-kem", + "sha3 0.12.0", + "shake", + "x25519-dalek 3.0.0", + "zeroize", +] + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -3700,7 +4301,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -3752,6 +4353,26 @@ dependencies = [ "synstructure 0.14.0", ] +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -3775,9 +4396,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -3810,6 +4431,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index e08281c..e985539 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ outbound-anytls = ["dep:md-5", "tls-fingerprint"] outbound-socks5 = [] outbound-http = [] outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] -outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:hpke", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn"] +outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn"] outbound-trojan = ["stream-transport", "tls-fingerprint"] outbound-vmess = ["stream-transport", "tls-fingerprint", "dep:aes", "dep:crc32fast", "dep:md-5", "dep:ring", "dep:shake"] tls-fingerprint = ["dep:boring", "dep:tokio-boring"] @@ -93,10 +93,13 @@ tun = [ ] [dependencies] +age = { version = "=0.11.5", default-features = false, features = ["armor"] } +age-core = "=0.11.0" aes = { version = "0.9.3", optional = true, features = ["zeroize"] } arc-swap = "1" async-trait = "0.1" base64 = "0.23" +bech32 = "=0.12.0" blake2 = { version = "=0.11.0", optional = true } blake3 = { version = "=1.8.7", optional = true } boringtun = { version = "=0.7.1", default-features = false, optional = true } @@ -110,7 +113,7 @@ getifaddrs = "=0.6.2" h2 = "0.4.19" h3 = { version = "=0.0.8", default-features = false, optional = true } h3-quinn = { version = "=0.0.10", default-features = false, optional = true } -hpke = { version = "=0.14.1", default-features = false, features = ["alloc", "aes", "chacha", "x25519"], optional = true } +hpke = { version = "=0.14.1", default-features = false, features = ["alloc", "aes", "chacha", "x25519", "mlkem"] } http = "1" hyper = { version = "=1.11.1", default-features = false, features = ["client", "http1"], optional = true } hyper-util = { version = "=0.1.20", default-features = false, features = ["tokio"], optional = true } @@ -154,7 +157,7 @@ vole-netstack = { path = "crates/vole-netstack", optional = true } vole-blake3-raw = { path = "crates/vole-blake3-raw", optional = true } webpki-roots = "1" yamux = { version = "=0.14.1", optional = true } -zeroize = { version = "=1.9.0" } +zeroize = { version = "=1.9.1" } zstd = { version = "0.14.0", default-features = false } [target.'cfg(any(target_os = "ios", target_os = "tvos", target_os = "macos"))'.dependencies] diff --git a/docs/config.yaml b/docs/config.yaml index 2dc1569..ea0a00a 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -573,6 +573,12 @@ proxy-groups: # 更新先解析并校验整个依赖图,成功才替换;失败保留旧配置与 HTTP 缓存。 # HTTP proxy provider 的订阅用量来自 Subscription-Userinfo 响应头,成功更新和 304 后 # 随缓存保存;缺少该头保留旧值,失败候选不更新。Controller 不用本地流量填充订阅用量。 +# proxy providers 的 age-secret-key 可含多行 X25519/Hybrid AGE 私钥,空行和 # 注释跳过。 +# HTTP/file 来源写法:age-secret-key: |,下方缩进填写私钥;不用于 rule providers。 +# 配置期验证私钥;仅精确 AGE armor 前缀触发原生解密,普通 YAML 仍可使用同一来源。 +# file、初始缓存和 HTTP 更新共用解密;缓存/hash/ETag 保留原密文,解密失败不发布候选。 +# 私钥最多 16 KiB/128 个,AGE 头最多 64 KiB/1024 次身份尝试;解密输出限 8 MiB, +# 沿用 YAML 展开预算。读取/解密共享 90 秒期限,并在读块和密钥操作之间检查取消。 # 新连接使用新资源,旧 TCP/UDP 会话保留原资源;停止等待下载、解析和资源释放。 # rule providers 支持 domain/ipcidr/classical,format 为 yaml/text/mrs;MRS 仅 domain/ipcidr。 # YAML 规则文件为 {payload: [...]},text 每行一条,支持空行和 # 注释。 diff --git a/docs/tls-dependencies.md b/docs/tls-dependencies.md index 8789d72..dc8d6b0 100644 --- a/docs/tls-dependencies.md +++ b/docs/tls-dependencies.md @@ -11,11 +11,16 @@ | SS 2022 | 官方 shadowsocks,仅 `aead-cipher-2022` | | Encryption | 同一 boring 的 X25519、ML-KEM、AEAD、AES-CTR;官方 blake3 | | 静态 ECH | 官方 hpke + rustls HPKE trait,命名模板使用 boring ECH 接口 | +| Provider AGE | 官方 age/age-core 的 armor、MAC 和 STREAM;Hybrid 身份用官方 hpke XWing,关闭插件与 CLI | AWS-LC 只用于 `shadowsocks → shadowsocks-crypto → aws-lc-rs → aws-lc-sys`, 不用于 TLS/REALITY。禁止 FIPS、2022-extra 和第二套 REALITY 后端。 BoringSSL 子模块保持原样,fork 补丁通过 feature 控制。 +AGE 使用获准的官方兼容稳定版本 `age 0.11.5` 与 `age-core 0.11.0`。 +其 0.12 系列强制依赖的旧 ML-KEM 与当前 `hpke 0.14.1` 的正式 KEM 依赖不兼容; +此版本例外仅限上述两项,Hybrid 保持最新稳定 HPKE/RustCrypto 路径。 + ## 开发与升级 1. 本地开发可将 boring、tokio-boring、测试用 boring-sys 一起切为同一自有 fork 的 diff --git a/src/config/providers.rs b/src/config/providers.rs index 02219c9..3000349 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -34,6 +34,8 @@ pub enum RuleFormat { #[derive(Clone, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct ProviderSpec { + #[serde(skip)] + pub(crate) age_identities: Option, #[serde(rename = "type")] pub kind: ProviderType, #[serde(default)] @@ -193,12 +195,12 @@ impl ProviderTemplate { let Some(value) = map.remove(key) else { return Ok(Vec::new()); }; - let entries = value - .as_mapping() - .ok_or_else(|| VoleError::InvalidConfig("providers must be a mapping".into()))?; + let Value::Mapping(entries) = value else { + return invalid("providers must be a mapping"); + }; entries - .iter() - .map(|(name, spec)| { + .into_iter() + .map(|(name, mut spec)| { let name = name .as_str() .filter(|name| !name.is_empty()) @@ -208,8 +210,22 @@ impl ProviderTemplate { ) })? .to_owned(); - let spec: ProviderSpec = serde_yaml_ng::from_value(spec.clone()) + let age = spec + .as_mapping_mut() + .and_then(|map| map.remove("age-secret-key")); + if is_rule && age.is_some() { + if let Some(Value::String(secret)) = age { + let _ = zeroize::Zeroizing::new(secret); + } + return invalid("rule providers do not accept age-secret-key"); + } + let identities = age + .map(crate::resource::age_provider::Identities::from_value) + .transpose()? + .flatten(); + let mut spec: ProviderSpec = serde_yaml_ng::from_value(spec) .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + spec.age_identities = identities; spec.validate(is_rule)?; Ok((name, spec)) }) @@ -227,11 +243,28 @@ impl ProviderTemplate { pub(crate) fn key(is_rule: bool, name: &str) -> String { format!("{}/{name}", if is_rule { "rules" } else { "proxies" }) } + #[cfg(test)] pub(crate) fn parse_content( &self, spec: &ProviderSpec, bytes: &[u8], ) -> Result { + self.parse_content_with_control( + spec, + bytes, + &crate::resource::age_provider::DecodeControl::new( + None, + std::time::Instant::now() + crate::resource::http::DEFAULT_DOWNLOAD_TIMEOUT, + ), + ) + } + pub(crate) fn parse_content_with_control( + &self, + spec: &ProviderSpec, + bytes: &[u8], + control: &crate::resource::age_provider::DecodeControl<'_>, + ) -> Result { + control.check()?; if let Some(behavior) = spec.behavior { return RuleCollection::parse(bytes, behavior, spec.format) .map(|data| ProviderContent::Rules(Arc::new(data))); @@ -240,9 +273,17 @@ impl ProviderTemplate { struct Payload { proxies: Vec, } - let raw = yaml::parse(bytes)?; + let decoded = crate::resource::age_provider::decode( + bytes, + spec.age_identities.as_ref(), + control, + MAX_CONFIG_BYTES, + )?; + let raw = yaml::parse(decoded.as_ref()) + .map_err(|_| VoleError::InvalidConfig("invalid proxy provider YAML".into()))?; let parsed: Payload = serde_yaml_ng::from_value(raw) - .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; + .map_err(|_| VoleError::InvalidConfig("invalid proxy provider YAML".into()))?; + control.check()?; Ok(ProviderContent::Proxies(parsed.proxies)) } fn bundled(&self, spec: &ProviderSpec) -> Result { @@ -739,6 +780,39 @@ fn apply_overrides( #[cfg(test)] mod tests { use super::*; + + #[test] + fn age_provider_identities_are_validated_before_content_read() { + let key = include_str!("../../tests/fixtures/provider-age/x25519.key").trim_end(); + let yaml = + format!("proxy-providers:\n nodes: {{type: inline, age-secret-key: '{key}'}}\n"); + let template = ProviderTemplate::extract(serde_yaml_ng::from_str(&yaml).unwrap()).unwrap(); + let spec = &template.proxies[0].1; + assert!(spec.age_identities.is_some()); + for bytes in [ + include_bytes!("../../tests/fixtures/provider-age/x25519.age").as_slice(), + include_bytes!("../../tests/fixtures/provider-age/provider.yaml").as_slice(), + ] { + let ProviderContent::Proxies(nodes) = template.parse_content(spec, bytes).unwrap() + else { + panic!("proxy provider content"); + }; + assert_eq!(nodes[0]["name"].as_str(), Some("age-node")); + } + for value in ["null", "''"] { + let yaml = + format!("proxy-providers:\n nodes: {{type: inline, age-secret-key: {value}}}\n"); + ProviderTemplate::extract(serde_yaml_ng::from_str(&yaml).unwrap()).unwrap(); + } + for yaml in [ + "proxy-providers:\n nodes: {type: inline, age-secret-key: invalid-private-fixture}\n", + "rule-providers:\n nodes: {type: inline, behavior: domain, age-secret-key: null}\n", + ] { + let error = + ProviderTemplate::extract(serde_yaml_ng::from_str(yaml).unwrap()).unwrap_err(); + assert!(!error.to_string().contains("invalid-private-fixture")); + } + } #[test] fn static_members_filtered_to_empty_use_the_declared_fallback() { for (fallback, expected) in [ diff --git a/src/resource/age_provider.rs b/src/resource/age_provider.rs new file mode 100644 index 0000000..d207d4c --- /dev/null +++ b/src/resource/age_provider.rs @@ -0,0 +1,395 @@ +//! Native subscription decryption; standard AGE authentication/STREAM belongs to age. +use crate::{Result, VoleError}; +use age_core::format::{FileKey, Stanza}; +use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; +use hpke::{Deserializable, aead::ChaCha20Poly1305, kdf::HkdfSha256, kem::XWing}; +use serde_yaml_ng::Value; +use sha2::{Digest, Sha256}; +use std::{ + cell::Cell, + fmt, + io::{self, Read}, + sync::Arc, + time::Instant, +}; +use tokio_util::sync::CancellationToken; +use zeroize::Zeroizing; + +const ARMOR_HEADER: &[u8] = b"-----BEGIN AGE ENCRYPTED FILE-----"; +const SECRET_BYTES: usize = 16 * 1024; +const IDENTITIES: usize = 128; +const HEADER_BYTES: usize = 64 * 1024; +const UNWRAP_ATTEMPTS: usize = 1024; +const HYBRID_TAG: &str = "mlkem768x25519"; +const HYBRID_INFO: &[u8] = b"age-encryption.org/mlkem768x25519"; + +fn invalid(message: &'static str) -> VoleError { + VoleError::InvalidConfig(message.into()) +} + +/// Immutable parsed identities, shared by template/spec clones. No source key text is kept. +#[derive(Clone)] +pub(crate) struct Identities { + entries: Arc<[Identity]>, + fingerprint: [u8; 32], +} +enum Identity { + X25519(age::x25519::Identity), + Hybrid(Box<::PrivateKey>), +} +impl fmt::Debug for Identities { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AgeIdentities { .. }") + } +} +impl PartialEq for Identities { + fn eq(&self, other: &Self) -> bool { + self.fingerprint == other.fingerprint + } +} +impl Eq for Identities {} +impl Identities { + pub(crate) fn from_value(value: Value) -> Result> { + let secret = match value { + Value::Null => return Ok(None), + Value::String(secret) => Zeroizing::new(secret), + _ => return Err(invalid("age-secret-key requires a string")), + }; + Self::parse(&secret).map(Some) + } + fn parse(secret: &str) -> Result { + if secret.len() > SECRET_BYTES { + return Err(invalid("AGE identity budget exceeded")); + } + let mut entries = Vec::new(); + for line in secret.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + if entries.len() >= IDENTITIES { + return Err(invalid("AGE identity budget exceeded")); + } + if line.starts_with("AGE-SECRET-KEY-PQ-1") { + let parsed = + bech32::primitives::decode::CheckedHrpstring::new::(line) + .map_err(|_| invalid("invalid AGE provider identity"))?; + if !parsed + .hrp() + .to_string() + .eq_ignore_ascii_case("AGE-SECRET-KEY-PQ-") + { + return Err(invalid("invalid AGE provider identity")); + } + let seed = Zeroizing::new(parsed.byte_iter().collect::>()); + let key = ::PrivateKey::from_bytes(&seed) + .map_err(|_| invalid("invalid AGE provider identity"))?; + entries.push(Identity::Hybrid(Box::new(key))); + } else { + let key = line + .parse::() + .map_err(|_| invalid("invalid AGE provider identity"))?; + entries.push(Identity::X25519(key)); + } + } + Ok(Self { + entries: entries.into(), + fingerprint: Sha256::digest(secret.as_bytes()).into(), + }) + } +} + +/// Processing scope is shared by header reads, all identities and plaintext reads. +pub(crate) struct DecodeControl<'a> { + cancellation: Option<&'a CancellationToken>, + deadline: Instant, + header: Cell, + attempts: Cell, +} +impl<'a> DecodeControl<'a> { + pub(crate) fn new(cancellation: Option<&'a CancellationToken>, deadline: Instant) -> Self { + Self { + cancellation, + deadline, + header: Cell::new(true), + attempts: Cell::new(0), + } + } + pub(crate) fn check(&self) -> Result<()> { + if self + .cancellation + .is_some_and(CancellationToken::is_cancelled) + { + return Err(invalid("provider decoding cancelled")); + } + if Instant::now() >= self.deadline { + return Err(invalid("provider decoding deadline exceeded")); + } + Ok(()) + } + fn unwrap(&self) -> std::result::Result<(), age::DecryptError> { + if self.check().is_err() || self.attempts.get() >= UNWRAP_ATTEMPTS { + return Err(age::DecryptError::DecryptionFailed); + } + self.attempts.set(self.attempts.get() + 1); + Ok(()) + } +} +struct CheckedIdentity<'a> { + identity: &'a Identity, + control: &'a DecodeControl<'a>, +} +impl age::Identity for CheckedIdentity<'_> { + fn unwrap_stanza( + &self, + stanza: &Stanza, + ) -> Option> { + // Unknown AGE extension stanzas remain skippable without private-key operations. + let expected = match self.identity { + Identity::X25519(_) => "X25519", + Identity::Hybrid(_) => HYBRID_TAG, + }; + if stanza.tag != expected { + return None; + } + if let Err(error) = self.control.unwrap() { + return Some(Err(error)); + } + match self.identity { + Identity::X25519(identity) => identity.unwrap_stanza(stanza), + Identity::Hybrid(secret) => { + if stanza.args.len() != 1 || stanza.body.len() != 32 || stanza.args[0].len() != 1494 + { + return Some(Err(age::DecryptError::InvalidHeader)); + } + let encapsulated = match STANDARD_NO_PAD.decode(&stanza.args[0]) { + Ok(bytes) if bytes.len() == 1120 => bytes, + _ => return Some(Err(age::DecryptError::InvalidHeader)), + }; + let encapped = match ::EncappedKey::from_bytes(&encapsulated) { + Ok(value) => value, + Err(_) => return Some(Err(age::DecryptError::InvalidHeader)), + }; + let mut receiver = match hpke::setup_receiver::( + &hpke::OpModeR::Base, + secret, + &encapped, + HYBRID_INFO, + ) { + Ok(receiver) => receiver, + Err(_) => return None, + }; + let file_key = match receiver.open(&stanza.body, b"") { + Ok(value) => Zeroizing::new(value), + Err(_) => return None, + }; + if file_key.len() != 16 { + return Some(Err(age::DecryptError::InvalidHeader)); + } + Some(Ok(FileKey::init_with_mut(|key| { + key.copy_from_slice(&file_key) + }))) + } + } + } +} +struct CheckedReader<'a, R> { + inner: R, + control: &'a DecodeControl<'a>, + header_read: usize, +} +impl Read for CheckedReader<'_, R> { + fn read(&mut self, output: &mut [u8]) -> io::Result { + self.control + .check() + .map_err(|_| io::Error::other("provider decoding stopped"))?; + let limit = if self.control.header.get() { + HEADER_BYTES + .saturating_sub(self.header_read) + .saturating_add(1) + .min(output.len()) + } else { + output.len() + }; + let count = self.inner.read(&mut output[..limit])?; + if self.control.header.get() { + self.header_read = self.header_read.saturating_add(count); + if self.header_read > HEADER_BYTES { + return Err(io::Error::other("AGE header budget exceeded")); + } + } + Ok(count) + } +} +pub(crate) enum Decoded<'a> { + Plain(&'a [u8]), + Decrypted(Zeroizing>), +} +impl AsRef<[u8]> for Decoded<'_> { + fn as_ref(&self) -> &[u8] { + match self { + Self::Plain(bytes) => bytes, + Self::Decrypted(bytes) => bytes, + } + } +} + +pub(crate) fn decode<'a>( + bytes: &'a [u8], + identities: Option<&Identities>, + control: &DecodeControl<'_>, + output_limit: usize, +) -> Result> { + control.check()?; + // Exactly the reference armor prefix, with no BOM/whitespace/binary sniffing. + if !bytes.starts_with(ARMOR_HEADER) { + return Ok(Decoded::Plain(bytes)); + } + let identities = + identities.ok_or_else(|| invalid("AGE provider requires a matching identity"))?; + control.header.set(true); + let input = CheckedReader { + inner: age::armor::ArmoredReader::new(bytes), + control, + header_read: 0, + }; + let decryptor = + age::Decryptor::new(input).map_err(|_| invalid("invalid AGE provider content"))?; + control.header.set(false); + let checked: Vec<_> = identities + .entries + .iter() + .map(|identity| CheckedIdentity { identity, control }) + .collect(); + let mut plaintext = decryptor + .decrypt( + checked + .iter() + .map(|identity| identity as &dyn age::Identity), + ) + .map_err(|_| invalid("AGE provider decryption failed"))?; + control.check()?; + let mut output = Zeroizing::new(Vec::new()); + let mut buffer = Zeroizing::new([0_u8; 16 * 1024]); + loop { + control.check()?; + let available = output_limit + .saturating_sub(output.len()) + .saturating_add(1) + .min(buffer.len()); + let count = plaintext + .read(&mut buffer[..available]) + .map_err(|_| invalid("AGE provider decryption failed"))?; + if count == 0 { + break; + } + if output + .len() + .checked_add(count) + .is_none_or(|length| length > output_limit) + { + return Err(invalid("decrypted provider exceeds YAML byte limit")); + } + let required = output.len() + count; + if required > output.capacity() { + // Grow geometrically without exceeding the plaintext byte budget. + let capacity = output + .capacity() + .saturating_mul(2) + .max(required) + .min(output_limit); + let additional = capacity - output.len(); + output + .try_reserve_exact(additional) + .map_err(|_| invalid("AGE provider output allocation failed"))?; + } + output.extend_from_slice(&buffer[..count]); + } + control.check()?; + Ok(Decoded::Decrypted(output)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{io::Write, time::Duration}; + + const X_KEY: &str = include_str!("../../tests/fixtures/provider-age/x25519.key"); + const PQ_KEY: &str = include_str!("../../tests/fixtures/provider-age/hybrid.key"); + const X_CONTENT: &[u8] = include_bytes!("../../tests/fixtures/provider-age/x25519.age"); + const PQ_CONTENT: &[u8] = include_bytes!("../../tests/fixtures/provider-age/hybrid.age"); + const PLAIN: &[u8] = include_bytes!("../../tests/fixtures/provider-age/provider.yaml"); + + fn control() -> DecodeControl<'static> { + DecodeControl::new(None, Instant::now() + Duration::from_secs(30)) + } + + #[test] + fn age_official_fixtures_multiline_and_plaintext() { + let identities = Identities::parse(&format!( + "# public fixture identities\n\n{}\n{}\n", + X_KEY.trim_end(), + PQ_KEY.trim_end() + )) + .unwrap(); + for bytes in [X_CONTENT, PQ_CONTENT, PLAIN] { + let decoded = decode(bytes, Some(&identities), &control(), PLAIN.len()).unwrap(); + assert_eq!(decoded.as_ref(), PLAIN); + } + assert!(matches!( + decode(PLAIN, None, &control(), 0).unwrap(), + Decoded::Plain(_) + )); + let prefixed = [b" ".as_slice(), X_CONTENT].concat(); + assert!(matches!( + decode(&prefixed, None, &control(), 0).unwrap(), + Decoded::Plain(_) + )); + assert!(!format!("{identities:?}").contains(X_KEY.trim_end())); + } + + #[test] + fn age_authentication_and_processing_budget_fail_closed() { + let identities = Identities::parse(X_KEY.trim_end()).unwrap(); + assert!(decode(PQ_CONTENT, Some(&identities), &control(), PLAIN.len()).is_err()); + assert!(decode(X_CONTENT, Some(&identities), &control(), PLAIN.len() - 1).is_err()); + let mut binary = Vec::new(); + age::armor::ArmoredReader::new(X_CONTENT) + .read_to_end(&mut binary) + .unwrap(); + *binary.last_mut().unwrap() ^= 1; + let mut damaged = Vec::new(); + let mut armor = + age::armor::ArmoredWriter::wrap_output(&mut damaged, age::armor::Format::AsciiArmor) + .unwrap(); + armor.write_all(&binary).unwrap(); + armor.finish().unwrap(); + assert!(decode(&damaged, Some(&identities), &control(), PLAIN.len()).is_err()); + let cancellation = CancellationToken::new(); + cancellation.cancel(); + assert!( + decode( + X_CONTENT, + Some(&identities), + &DecodeControl::new( + Some(&cancellation), + Instant::now() + Duration::from_secs(30) + ), + PLAIN.len() + ) + .is_err() + ); + assert!( + decode( + X_CONTENT, + Some(&identities), + &DecodeControl::new(None, Instant::now()), + PLAIN.len() + ) + .is_err() + ); + let error = Identities::parse(&format!("{}\ninvalid-private-fixture", X_KEY.trim_end())) + .unwrap_err(); + assert!(!error.to_string().contains("invalid-private-fixture")); + assert!(!error.to_string().contains(X_KEY.trim_end())); + } +} diff --git a/src/resource/cache.rs b/src/resource/cache.rs index 7e2e790..ca3a867 100644 --- a/src/resource/cache.rs +++ b/src/resource/cache.rs @@ -76,6 +76,13 @@ impl Drop for Candidate { } impl ProviderCache { pub fn open(root: &Path, template: Arc) -> io::Result { + Self::open_with_cancel(root, template, None) + } + pub fn open_with_cancel( + root: &Path, + template: Arc, + cancellation: Option<&CancellationToken>, + ) -> io::Result { let mut entries = Vec::new(); let mut contents = ProviderContents::new(); for (rule, (name, spec)) in template @@ -84,6 +91,9 @@ impl ProviderCache { .map(|item| (false, item)) .chain(template.rules.iter().map(|item| (true, item))) { + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err(io::ErrorKind::Interrupted.into()); + } let key = ProviderTemplate::key(rule, name); let mut entry = ResourceEntry { key: key.clone(), @@ -129,8 +139,12 @@ impl ProviderCache { .ok() .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) .unwrap_or_default(); - if let Ok(bytes) = read_bounded(&path, spec.size_limit) { - match template.parse_content(spec, &bytes) { + let control = super::age_provider::DecodeControl::new( + cancellation, + std::time::Instant::now() + http::DEFAULT_DOWNLOAD_TIMEOUT, + ); + if let Ok(bytes) = read_bounded(&path, spec.size_limit, &control) { + match template.parse_content_with_control(spec, &bytes, &control) { Ok(content) => { entry.state.available = true; entry.state.downloaded = spec.kind == ProviderType::Http; @@ -170,6 +184,9 @@ impl ProviderCache { } entries.push(entry); } + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err(io::ErrorKind::Interrupted.into()); + } Ok(Self { template, entries, @@ -203,6 +220,7 @@ impl ProviderCache { security: crate::security::SecurityContext, cancellation: CancellationToken, ) -> io::Result> { + let deadline = std::time::Instant::now() + http::DEFAULT_DOWNLOAD_TIMEOUT; let entry = &mut self.entries[index]; if cancellation.is_cancelled() { return Err(io::ErrorKind::Interrupted.into()); @@ -221,11 +239,18 @@ impl ProviderCache { let spec = entry.spec.clone(); let template = self.template.clone(); let path = path.clone(); + let parse_cancellation = cancellation.clone(); let content = tokio::task::spawn_blocking(move || { - let bytes = read_bounded(&path, spec.size_limit)?; - template.parse_content(&spec, &bytes).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "invalid provider content") - }) + let control = super::age_provider::DecodeControl::new( + Some(&parse_cancellation), + deadline, + ); + let bytes = read_bounded(&path, spec.size_limit, &control)?; + template + .parse_content_with_control(&spec, &bytes, &control) + .map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "invalid provider content") + }) }) .await .map_err(io::Error::other)??; @@ -242,7 +267,7 @@ impl ProviderCache { url: entry.spec.url.clone(), etag: etag_support.then(|| entry.metadata.etag.clone()).flatten(), temporary_path: temporary.clone(), - timeout: http::DEFAULT_DOWNLOAD_TIMEOUT, + timeout: deadline.saturating_duration_since(std::time::Instant::now()), cancellation: cancellation.clone(), options: HttpOptions { user_agent, @@ -282,11 +307,21 @@ impl ProviderCache { let spec = entry.spec.clone(); let template = self.template.clone(); let input = temporary.clone(); + let parse_cancellation = cancellation.clone(); let parsed = tokio::task::spawn_blocking(move || { - let bytes = read_bounded(&input, spec.size_limit)?; - template.parse_content(&spec, &bytes).map_err(|_| { - io::Error::new(io::ErrorKind::InvalidData, "invalid provider content") - }) + let control = super::age_provider::DecodeControl::new( + Some(&parse_cancellation), + deadline, + ); + let bytes = read_bounded(&input, spec.size_limit, &control)?; + template + .parse_content_with_control(&spec, &bytes, &control) + .map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid provider content", + ) + }) }) .await .map_err(io::Error::other)?; @@ -409,21 +444,53 @@ fn unix_time(time: SystemTime) -> u64 { .unwrap_or_default() .as_secs() } -fn read_bounded(path: &Path, limit: u64) -> io::Result> { +fn read_bounded( + path: &Path, + limit: u64, + control: &super::age_provider::DecodeControl<'_>, +) -> io::Result> { let limit = if limit == 0 { MAX_PARSE_BYTES } else { limit.min(MAX_PARSE_BYTES) }; - let file = fs::File::open(path)?; + control + .check() + .map_err(|_| io::Error::other("provider read stopped"))?; + let mut file = fs::File::open(path)?; if !file.metadata()?.is_file() { return Err(io::Error::other("provider path must be a regular file")); } let mut bytes = Vec::new(); - file.take(limit + 1).read_to_end(&mut bytes)?; - if bytes.len() as u64 > limit { - return Err(io::Error::other("provider exceeds parser byte limit")); + let mut buffer = [0_u8; 16 * 1024]; + loop { + control + .check() + .map_err(|_| io::Error::other("provider read stopped"))?; + let available = (limit + 1 - bytes.len() as u64).min(buffer.len() as u64) as usize; + let count = file.read(&mut buffer[..available])?; + if count == 0 { + break; + } + if bytes.len() as u64 + count as u64 > limit { + return Err(io::Error::other("provider exceeds parser byte limit")); + } + let required = bytes.len() + count; + if required > bytes.capacity() { + let capacity = bytes + .capacity() + .saturating_mul(2) + .max(required) + .min(limit as usize); + bytes + .try_reserve_exact(capacity - bytes.len()) + .map_err(|_| io::Error::other("provider input allocation failed"))?; + } + bytes.extend_from_slice(&buffer[..count]); } + control + .check() + .map_err(|_| io::Error::other("provider read stopped"))?; Ok(bytes) } fn metadata_path(path: &Path) -> PathBuf { diff --git a/src/resource/cache_tests.rs b/src/resource/cache_tests.rs index e557a33..8fb829c 100644 --- a/src/resource/cache_tests.rs +++ b/src/resource/cache_tests.rs @@ -371,3 +371,66 @@ async fn failed_304_sidecar_write_does_not_publish_subscription_metadata() { body ); } + +#[tokio::test] +async fn age_provider_cache_and_file_refresh_preserve_candidate_contract() { + let root = tempfile::tempdir().unwrap(); + let mut template = template(); + Arc::make_mut(&mut template).proxies[0].1.age_identities = + super::super::age_provider::Identities::from_value(serde_yaml_ng::Value::String( + include_str!("../../tests/fixtures/provider-age/x25519.key").into(), + )) + .unwrap(); + let ciphertext = include_str!("../../tests/fixtures/provider-age/x25519.age"); + let mut cache = ProviderCache::open(root.path(), template.clone()).unwrap(); + let candidate = fetch(&mut cache, response(200, "", ciphertext)) + .await + .unwrap() + .unwrap(); + assert_eq!( + candidate.metadata.hash, + hex(&Sha256::digest(ciphertext.as_bytes())) + ); + commit_valid(&mut cache, candidate); + let old = cache.contents.clone(); + let cache_path = cache.entries[0].path.as_ref().unwrap().clone(); + assert_eq!(fs::read(&cache_path).unwrap(), ciphertext.as_bytes()); + let recovered = ProviderCache::open(root.path(), template).unwrap(); + assert_eq!(recovered.contents, old); + assert_eq!( + recovered.entries[0].metadata.etag.as_deref(), + Some("\"fixture\"") + ); + let wrong_recipient = include_str!("../../tests/fixtures/provider-age/hybrid.age"); + assert!( + fetch(&mut cache, response(200, "", wrong_recipient)) + .await + .is_err() + ); + assert_eq!(cache.contents, old); + assert_eq!(fs::read(&cache_path).unwrap(), ciphertext.as_bytes()); + assert!(cache.entries[0].state.available); + assert_eq!( + cache.entries[0].metadata.etag.as_deref(), + Some("\"fixture\"") + ); + let cancellation = CancellationToken::new(); + cancellation.cancel(); + let stopped = + ProviderCache::open_with_cancel(root.path(), cache.template.clone(), Some(&cancellation)); + assert_eq!(stopped.err().unwrap().kind(), io::ErrorKind::Interrupted); + let mut file_template = cache.template.as_ref().clone(); + file_template.proxies[0].1.kind = ProviderType::File; + file_template.proxies[0].1.url.clear(); + let mut file_cache = ProviderCache::open(root.path(), Arc::new(file_template)).unwrap(); + assert_eq!(file_cache.contents, old); + file_cache.entries[0].modified = None; + let candidate = fetch(&mut file_cache, Vec::new()).await.unwrap().unwrap(); + commit_valid(&mut file_cache, candidate); + assert_eq!(file_cache.contents, old); + fs::write(&cache_path, wrong_recipient).unwrap(); + file_cache.entries[0].modified = None; + assert!(fetch(&mut file_cache, Vec::new()).await.is_err()); + assert_eq!(file_cache.contents, old); + assert!(file_cache.entries[0].state.available); +} diff --git a/src/resource/mod.rs b/src/resource/mod.rs index b41bc9a..9674cfb 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -1,4 +1,5 @@ //! Resource acquisition shared by GeoData and providers. +pub(crate) mod age_provider; pub(crate) mod cache; pub(crate) mod control; pub(crate) mod http; diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index 143787e..e866285 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -745,7 +745,7 @@ mod tests { format!("proxies: [{{name: {name}, type: socks5, server: 192.0.2.1, port: 1080}}]\n") }; std::fs::write(&file, payload("old")).unwrap(); - let config = Config::parse_yaml( + let mut config = Config::parse_yaml( br#" mixed-port: 1080 proxy-providers: @@ -761,6 +761,13 @@ rules: ['MATCH,choice'] "#, ) .unwrap(); + Arc::make_mut(config.providers.as_mut().unwrap()).proxies[0] + .1 + .age_identities = + crate::resource::age_provider::Identities::from_value(serde_yaml_ng::Value::String( + include_str!("../../tests/fixtures/provider-age/x25519.key").into(), + )) + .unwrap(); let manager = GeoDataManager::open(&root.path().join("geodata"), Duration::from_secs(86400)).unwrap(); let prepared = PreparedCore::prepare_config( @@ -806,17 +813,29 @@ rules: ['MATCH,choice'] assert_eq!(&buffer, b"active"); live.reap().await; assert!(weak.upgrade().is_some()); + let encrypted = include_bytes!("../../tests/fixtures/provider-age/x25519.age"); + std::fs::write(&file, encrypted).unwrap(); + live.update(0).await.unwrap(); + assert_eq!(live.groups().state("choice").unwrap().now, "age-node"); + for bad_encrypted in [ + include_bytes!("../../tests/fixtures/provider-age/hybrid.age").as_slice(), + &encrypted[..encrypted.len() - 40], + ] { + std::fs::write(&file, bad_encrypted).unwrap(); + assert!(live.update(0).await.is_err()); + assert_eq!(live.groups().state("choice").unwrap().now, "age-node"); + } std::fs::write( &file, b"proxies: [{name: bad, type: socks5, server: missing}]\n", ) .unwrap(); assert!(live.update(0).await.is_err()); - assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); + assert_eq!(live.groups().state("choice").unwrap().now, "age-node"); assert!(live.providers(false)["providers"]["remote"]["error"].is_string()); std::fs::write(&file, payload(&"x".repeat(4097))).unwrap(); assert!(live.update(0).await.is_err()); - assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); + assert_eq!(live.groups().state("choice").unwrap().now, "age-node"); drop(old_session); live.reap().await; assert!(weak.upgrade().is_none()); diff --git a/tests/README.md b/tests/README.md index c68ef33..923acc3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -13,6 +13,7 @@ cargo test --locked --no-default-features --features cli --bin vole cli::tests:: cargo test --locked --no-default-features --features cli --lib invoke::foreground::tests:: cargo test --locked --lib config::tests:: cargo test --locked --lib config::credentials::tests:: +cargo test --locked --features cli --lib age_ cargo test --locked --lib dns::hosts::tests:: cargo test --locked --lib config::name_regex::tests:: cargo test --locked --lib config::tls_policy_tests:: @@ -124,6 +125,8 @@ container-benchmark compare --source vole=/path/to/Vole 维护,结果记录在对应运行或 PR 中;[验收边界](../docs/acceptance.md) 区分编译、互通与设备验证。 `runtime::providers::tests::` 使用临时文件与内存流验证候选发布、坏更新保留、旧会话资源和停止清理;`config::` 同时覆盖 provider、规则集合和 MRS 格式。 +`age_` 使用官方 Go AGE 生成的公开 X25519/Hybrid fixture、内存流和临时文件验证解密、 +私钥准入、认证/取消/大小预算、密文缓存与失败更新保留;不运行外部进程或打开网络 socket。 `resource::cache::tests::` 使用内存 HTTP 流和临时缓存验证订阅元信息的候选提交、304、失败保留与磁盘恢复;`resource::subscription::tests::` 验证有界数值解析。 `routing::proxy_group::tests::` 覆盖自动选择、容差、恢复与选择缓存;`resource::probe::tests::` 和 `invoke::measure_delay::tests::` 使用内存流验证共享健康探测、HTTP 状态和取消。 diff --git a/tests/fixtures/provider-age/README.md b/tests/fixtures/provider-age/README.md new file mode 100644 index 0000000..2032f7f --- /dev/null +++ b/tests/fixtures/provider-age/README.md @@ -0,0 +1,9 @@ +# AGE provider fixtures + +Synthetic X25519 and ML-KEM-768/X25519 Hybrid recipients were generated with +the public `filippo.io/age` v1.3.2 API (`GenerateX25519Identity`, +`GenerateHybridIdentity`, `Encrypt`, and `armor.NewWriter`). Both decrypt to +`provider.yaml`. The public fixture keys are only for tests. + +These fixtures check compatibility against an independent implementation; no +reference-client source or tests were copied. diff --git a/tests/fixtures/provider-age/hybrid.age b/tests/fixtures/provider-age/hybrid.age new file mode 100644 index 0000000..1db963a --- /dev/null +++ b/tests/fixtures/provider-age/hybrid.age @@ -0,0 +1,39 @@ +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IG1sa2VtNzY4eDI1NTE5IHJ3NHBabHky +NnZHMUR2Wko5WlhNclMyOHB1aS9OcCtuZHY1d0hLTjlmcGhYbGZoL3N4Sk1wYVpR +bm9PVDlyZDJtYzEvNG9mVXE3cUhCRzlTVDJWbXZ6WDlQY0trRUdqY0c1Wm1QUkNO +QUhEMXZNOEVjeC94TzJyQmhVOUJha0tqV244NjZUV2hXTFNlZW5xRDNLbXdEMmpZ +VmxScENYRWNMc043eEVaVkE2eEVJYTltWTZxd2JScURCc3FsaHlyVFI4VHFHS3NH +M0JTOXZKNjc5WXR2eEJnRlE0WGFXbFQ0V1hjc0xoUXM4TnlVN2RtWXBCcFpVY3lO +Vk9YZlI1bjdvNENZMlBoSm5DcC90NGZ3MCtmcjRUYWRsTjBTbW1BRG1seENiV1Rq +RHJITk9HYkpIODZPM2xkRDJWdkRBNE1QU280OUhlZjdobnBYU0dmUk5jc0ttQ0RY +YWJuQUFzbnRucHYyQlYxbE5uWVVvQnh1MzVlNWhpM1VKQUo3bFJFMnEyRWNwY1E4 +bGxWVmlnZUFGeThtdU13L3FGZlExN3ptUGtJVFlpbDJmYVZ5YWVCVWJ2RVNsc290 +aXd0WjR0by9tTzhIUEhIblBha0dlQVQxTXZoUG1oQTVFMzZPZTlkSEUvOXZJOFhV +MGdqa3EvT05NTktzcHVsL3RSRVBkTnJsdFJueHY5d2o5TkVhM0toNDl2cVY5WVdN +d0xTZDNiSWpXZXp2TEE0R1lhZVhNMHAvU3dEVk1qZVNsWGc0WkZ2QXdtSUhnanlV +ckJQak1RNndCc0l3VzFmdVBoQXpUcXBpSHBjdjgzdlNodnkzYVFMUmV1SmY1V0dL +UE8yYk02enh3b3pqQ0V0N3BkTVg0UzZ5TkJQcUpoVHhaeW4xOEJhM3F5VXhLdjJt +Yy9rUDN5dG1CSExwdjFZME1qRmZpa2pYSkdDcU1kWjlkdFBQck1TNzJxZDIzRU5j +TEFhc1FDY3pTUklrTU9aWTBxbHhWY0FQcTEwbVQxSElOajRwM3RpMERuQ2UwMmQy +Ni9JM2tMMGVRMlpVVm5XdytIdHl6TG9CKzk5TGh1eFl6amt5YWRoSFg1YStjY3NB +VDZDU1gzU0NCR3k5YnI1VEFSRGlRbFBsK21HZUJWQnRkM05ZVU9aQU5tRi9qOHhV +aUp6SjJlODQ1REpIR0Q1b0lsTk4xOWhBc2pvY3ZSQ1dFZnZjSHlPTmFJQ3UxSVJt +aXJlN3NHQ1NnRjJhZnVvdU40MnNCVkhEQy9IZE52MktHeWFZQXh0cGRGZ05lbHNn +UURBemxhcjVlUEl4WUp5MStjZEQyOUVZalNnd1hYcVVLZk42TjBLM2YrWWJvTklI +TkkxYWtVL1pyVTR4VzJZa0FCVWxxVkgvckNBSUNHcmY4WGIvM250ZGNXV2VaUW1X +UXBFOGExeE9BQWhvUVVvYVlGSGovTFhITTdoc2hsRFlWQmZkTXpLUEtCYS93aGM5 +bGkwdmlDZlVicjE3WVRPMjFLa3JHbGdGMElFaDVTUlRIcCtQc2xLV2ZlbjBiYXVo +YzZ5N2dGanNyMldaSStMRzV3QkZzbFduays4eWNxY1VHV1gyQlJpdW5ZUEtnMk1w +TzA2YWNyL1RPa1FzcjJsbGxHbWw1ZmRvTzF2WENudjZka0YxRWl2L0FIYTdMakk1 +Mk9teDcvSUpMdUxSdUtpaDhMZDc0OVFwSU1FSmI3NE9CUG10K1hXT3NBcTNFdHln +RGsxWERmamlYT3ZPdEp5LzVrTlJ4Zys0dUJjNWxEOUpobmdLd1p0THhySDhhUENH +VTVYbHZYemY1UmttMzRwdmJzT0xYcXg2QkJ5TmY5SE9iZmZqZlVHV0Vhdk5oYWti +RGNhbi9hWUNRSER1eHliV3lBMll6Mlh0VndZZjdWT1dnbUlyZWVGYnd6dlE2WGl1 +VHdxQXVTTzYvMVlqbkd4NThKbC9HYmZuR2o0dmdTTGxwV3NKL2x3cDYyaHdNUQo2 +d0NDOWptclU2OENrMnBOKzVlQ0hzLzBzVjlQempsZXF1c1EwTE90NGJNCi0tLSBH +ZGZpMXRhQnc5OWpBSXlUdzFoZTcrTlhvOXVHOXlFMGtyTGFxLzFqZSs4CnxernWI +srLuXbE9NMegaBwg8AqxDNAefJIicMTxeuL4Cxhx6rhHVY4IGV/T2sVn2kQBDjMr +MB4eI2VobiMLtlrueSTvtbNsdD3OQbvV+Nr1F72vSfySuVe1lGUi+SnFO/7hXC71 +0qwWbHaq85MnPoqoe7pbvOw2TP5nly0X+Pnd +-----END AGE ENCRYPTED FILE----- diff --git a/tests/fixtures/provider-age/hybrid.key b/tests/fixtures/provider-age/hybrid.key new file mode 100644 index 0000000..ac02554 --- /dev/null +++ b/tests/fixtures/provider-age/hybrid.key @@ -0,0 +1 @@ +AGE-SECRET-KEY-PQ-1J9R3YQYVZXQ699CY0X8HV7R3RE3ANEH26ATF343P820WPUULYDDQ5REECX diff --git a/tests/fixtures/provider-age/provider.yaml b/tests/fixtures/provider-age/provider.yaml new file mode 100644 index 0000000..2a2dd4e --- /dev/null +++ b/tests/fixtures/provider-age/provider.yaml @@ -0,0 +1,6 @@ +proxies: + - name: age-node + type: socks5 + server: 192.0.2.9 + port: 1080 + udp: true diff --git a/tests/fixtures/provider-age/x25519.age b/tests/fixtures/provider-age/x25519.age new file mode 100644 index 0000000..670e6c4 --- /dev/null +++ b/tests/fixtures/provider-age/x25519.age @@ -0,0 +1,9 @@ +-----BEGIN AGE ENCRYPTED FILE----- +YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5QlZFdk1RSEFITWhLQW5j +bStCQUJYTzZwN0lPOHpDTlVoTElGQ0xxdDB3CkYyZGFoYnpEalNZTHFUMXlHNktn +elo4enRmcjZsQzdwaVJDV3V2RGIxNHMKLS0tIFNwWnJhTXFuU2pjSmtabExOTUN2 +Slg4QTVSNm1WZHAyclNqRDlnbDhwbTgK5NbBvoKlT6jJcjgCysEPB6GebH7dMA9z +FcYNKEYDBYpIWsS3qUHkPrWkJDJwpind4YdsCEAHQicmuf/0DHD9vrQSGKNZNt2j +K3szUL85DnQmPmNEAKkK01k8wRC+X+jpF9GB660Jvq3xe+gr29TidiX+CV8oZrDo +b79RR610cmA= +-----END AGE ENCRYPTED FILE----- diff --git a/tests/fixtures/provider-age/x25519.key b/tests/fixtures/provider-age/x25519.key new file mode 100644 index 0000000..f8f1170 --- /dev/null +++ b/tests/fixtures/provider-age/x25519.key @@ -0,0 +1 @@ +AGE-SECRET-KEY-1VH0VLX6NFVM6Q0G2S0SZ8HJUUASJ2TTC64NPD458U3W82FGY0DMQQCL7CN From f2ccb93c29494741bb5abfcc7657db2f79606051 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:52:14 +0800 Subject: [PATCH 054/445] feat: load bounded rule-provider bundles --- Cargo.lock | 19 ++ Cargo.toml | 6 +- README.md | 1 + docs/config.yaml | 4 + docs/mihomo-compatibility.md | 2 +- docs/runtime-resource-policy.md | 20 ++ src/config/providers.rs | 12 + src/resource/bundle.rs | 211 ++++++++++++++++ src/resource/bundle_limits.rs | 423 ++++++++++++++++++++++++++++++++ src/resource/cache.rs | 70 +++++- src/resource/cache_tests.rs | 62 +++++ src/resource/mod.rs | 2 + tests/README.md | 2 + 13 files changed, 829 insertions(+), 5 deletions(-) create mode 100644 src/resource/bundle.rs create mode 100644 src/resource/bundle_limits.rs diff --git a/Cargo.lock b/Cargo.lock index 795d6ba..0796205 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1886,6 +1886,12 @@ version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" +[[package]] +name = "lzma-rust2" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde178a3caf126c440fa15628147772d8ee590a39477d711353fbe2e58a73a5b" + [[package]] name = "managed" version = "0.8.0" @@ -2935,6 +2941,18 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sevenz-rust2" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a4f883677093690e91fef8ae81fad8bce9e2c3a079b61054f05ce0d3ecf681e" +dependencies = [ + "crc32fast", + "js-sys", + "lzma-rust2", + "wasm-bindgen", +] + [[package]] name = "sha1" version = "0.11.0" @@ -3704,6 +3722,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", + "sevenz-rust2", "sha2 0.11.0", "shadowsocks", "shake", diff --git a/Cargo.toml b/Cargo.toml index e985539..5fff17d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ outbound-http = [] outbound-shadowsocks = ["dep:shadowsocks", "dep:log"] outbound-vless = ["tls-fingerprint", "boring/reality", "boring/jls", "boring/mlkem", "dep:blake3", "dep:vole-blake3-raw", "stream-transport", "quic-transport", "dep:hyper", "dep:hyper-util", "dep:h3", "dep:h3-quinn"] outbound-trojan = ["stream-transport", "tls-fingerprint"] -outbound-vmess = ["stream-transport", "tls-fingerprint", "dep:aes", "dep:crc32fast", "dep:md-5", "dep:ring", "dep:shake"] +outbound-vmess = ["stream-transport", "tls-fingerprint", "dep:aes", "dep:md-5", "dep:ring", "dep:shake"] tls-fingerprint = ["dep:boring", "dep:tokio-boring"] shadow-tls-v3 = ["tls-fingerprint", "boring/shadow-tls-v3", "dep:ring"] outbound-hysteria2 = ["quic-transport", "dep:quinn-proto", "dep:h3", "dep:h3-quinn", "dep:blake2"] @@ -105,7 +105,7 @@ blake3 = { version = "=1.8.7", optional = true } boringtun = { version = "=0.7.1", default-features = false, optional = true } boring = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0", features = ["client-fingerprint"], optional = true } bytes = "1" -crc32fast = { version = "1.5.2", optional = true } +crc32fast = "=1.5.2" domain = { version = "=0.12.3", default-features = false, features = ["std"] } fancy-regex = { version = "=0.19.2", default-features = false, features = ["std", "unicode"] } futures-util = { version = "0.3", features = ["sink"] } @@ -138,6 +138,7 @@ rustls-native-certs = "=0.8.4" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["raw_value"] } serde_yaml_ng = "0.10" +sevenz-rust2 = { version = "=0.23.0", default-features = false } sha2 = "0.11" shake = { version = "0.1.0", optional = true } shadowsocks = { version = "=1.25.0", optional = true, default-features = false, features = ["aead-cipher-2022"] } @@ -202,6 +203,7 @@ windows-collections = { version = "=0.3.2", optional = true } windows-core = { version = "=0.62.2", optional = true } [dev-dependencies] +sevenz-rust2 = { version = "=0.23.0", default-features = false, features = ["compress"] } # Native peer controls for bounded in-memory ALPS tests; same source as boring. boring-sys = { git = "https://github.com/YuanDevTeam/boring", branch = "release", version = "=5.2.0" } foreign-types = "=0.5.0" diff --git a/README.md b/README.md index b6c6402..21d7dc9 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ Vole builds on and learns from public dependencies, protocol implementations and - TUN dependencies: the local [`vole-netstack`](crates/vole-netstack/README.md) uses [smoltcp](https://github.com/smoltcp-rs/smoltcp); Unix and Windows Wintun packet I/O use [tun-rs](https://github.com/tun-rs/tun-rs) (Apache-2.0). - WireGuard: [boringtun](https://github.com/cloudflare/boringtun) (BSD-3-Clause), without host-device, FFI or JNI features. - [Wintun](https://www.wintun.net/): the runtime DLL is supplied by the host, not bundled. The upstream [API header](https://github.com/tun-rs/tun-rs/blob/2.8.11/src/platform/windows/tun/wintun.h) included by tun-rs is copyright 2018–2021 WireGuard LLC, licensed `GPL-2.0 OR MIT`; distribution of the linked API bindings must retain its copyright and MIT alternative license notice. +- Offline archives: [sevenz-rust2](https://github.com/hasenbanck/sevenz-rust2) and [lzma-rust2](https://github.com/hasenbanck/lzma-rust2) (Apache-2.0), used through a bounded member reader. - Name expressions: [fancy-regex](https://github.com/fancy-regex/fancy-regex) (MIT), used only for bounded provider/group lookaround and backreferences. - Networking and routing references: [clash-rs](https://github.com/Watfaq/clash-rs), [netstack-smoltcp](https://github.com/cavivie/netstack-smoltcp), [Mihomo](https://github.com/MetaCubeX/mihomo), [Xray-core](https://github.com/XTLS/Xray-core) and [Leaf](https://github.com/eycorsican/leaf). These reference projects are not netstack dependencies. - TLS and Shadowsocks: [rustls](https://github.com/rustls/rustls), [boring](https://github.com/cloudflare/boring), [BoringSSL](https://boringssl.googlesource.com/boringssl/) and [shadowsocks-rust](https://github.com/shadowsocks/shadowsocks-rust). Derived replay-window code retains its [MIT notices](src/outbound/shadowsocks/packet_window.rs). diff --git a/docs/config.yaml b/docs/config.yaml index ea0a00a..59646c7 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -584,6 +584,10 @@ proxy-groups: # YAML 规则文件为 {payload: [...]},text 每行一条,支持空行和 # 注释。 # classical 接受已支持的无动作规则,不接受 MATCH、RULE-SET、SUB-RULE。 # 文本/MRS 解压上限 64 MiB;YAML 沿用 8 MiB 输入和 32 MiB 展开预算。 +# 非inline规则provider可设置path-in-bundle(例如rules/cn.mrs),从dataDir/BundleMRS.7z读取成员。 +# 有效缓存优先,其次bundle,再用原file/http来源;坏缓存同样可以回退bundle。 +# bundle只读取指定压缩块,支持COPY/LZMA1/LZMA2及已列入资源契约的filter;超预算/坏成员回退原来源。 +# inline忽略path-in-bundle;proxy-providers不接受此字段。 proxy-providers: local-nodes: type: inline diff --git a/docs/mihomo-compatibility.md b/docs/mihomo-compatibility.md index 704483c..2c2ae9f 100644 --- a/docs/mihomo-compatibility.md +++ b/docs/mihomo-compatibility.md @@ -7,7 +7,7 @@ Vole 接受的字段与限制以 [config.yaml](config.yaml) 为准;兼容配 | 范围 | 当前支持 | | --- | --- | | 配置与选路 | YAML anchor/merge;rule/global/direct;内置 DIRECT/REJECT;域名、地址、端口、网络、GeoData、RULE-SET 和 AND/OR/NOT | -| 资源 | HTTP/file/inline proxy/rule providers;动态成员、筛选、覆盖、健康检查;Provider dialer-proxy 与 HTTP 订阅信息;YAML/Text 与 domain/ipcidr MRS;失败保留旧代 | +| 资源 | HTTP/file/inline proxy/rule providers;动态成员、筛选、覆盖、健康检查;Provider dialer-proxy 与 HTTP 订阅信息;YAML/Text 与 domain/ipcidr MRS;有界离线规则bundle;失败保留旧代 | | 策略组 | select/url-test/fallback/load-balance;默认和持久选择;延迟容差、恢复、三种负载均衡策略;旧连接保持原路径 | | DNS | hosts(含启动 LAN 地址快照)、bootstrap/节点/直连解析角色、policy、respect-rules;UDP/TCP/DoH/DoT;fallback;redir-host/fake-IP 与映射持久化 | | mixed-port | HTTP/SOCKS5 TCP 与 SOCKS5 UDP;LAN 地址限制、多用户认证、认证跳过网段;allow-lan 不强制认证 | diff --git a/docs/runtime-resource-policy.md b/docs/runtime-resource-policy.md index fd9eebc..271353f 100644 --- a/docs/runtime-resource-policy.md +++ b/docs/runtime-resource-policy.md @@ -170,6 +170,26 @@ IP-only协议接点持有`ResolutionContext`,runtime DNS通过Weak绑定,不 完整接口见 [运行时 Controller](controller-api.md)。 +## 离线规则 bundle + +`rule-providers.*.path-in-bundle` 使用 `dataDir/BundleMRS.7z` 中的精确成员名; +不展开到目录、不预置资产、不对其他 Provider 生效。初始化依次尝试有效缓存、bundle、 +原 file/http 来源;缺失或无效 bundle 沿用原来源。有效成员按原格式解析,原始字节与 +无 ETag 的本地来源标识原子写入缓存;本地来源不算已完成 HTTP 获取,重启也保留这一语义。 + +单次初始化按顺序读取各资源,同一读取只有一个单线程解码器。先检查签名与 CRC、 +128 KiB header/decoded header、单 encoded-header packed stream(16 MiB)及至多8个 coder, +再让官方 `sevenz-rust2` 读取 metadata。拒绝加密、外置 metadata 与未审核 codec; +支持 COPY、LZMA1/2、Delta 及 x86/ARM/ARM Thumb BCJ。随后检查所选压缩块的单输入链、 +4096项文件表、128 MiB压缩输入和256 MiB解码总量;仅保留目标成员,最多64 MiB, +并受 size-limit 进一步约束。solid前置成员流式丢弃,目标读至EOF后才接受校验结果。 + +字典、输出和保守metadata计费共享96 MiB准入估计;该估计不包括allocator、解析后的规则 +及整个进程其他资源,不能当RSS上界。检查在官方 decoder分配之前执行,metadata中的 +计数由受限header长度约束。超过局部边界的bundle使用原来源,不静默截断成员。 +读取与解码检查取消、30秒局部期限、累计256 MiB来源读取和65536次I/O操作。 +使用同一个打开的文件句柄,异步调用者等待阻塞任务完成,不以丢弃JoinHandle替代停止。 + ## Provider 名称表达式 普通筛选表达式继续使用线性时间的 `regex`。必要的 lookaround/backreference 使用 diff --git a/src/config/providers.rs b/src/config/providers.rs index 3000349..0631da7 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -43,6 +43,8 @@ pub struct ProviderSpec { #[serde(default)] pub path: String, #[serde(default)] + pub path_in_bundle: String, + #[serde(default)] pub interval: u64, #[serde(default)] pub proxy: String, @@ -86,6 +88,16 @@ impl ProviderSpec { if self.behavior == Some(RuleBehavior::Classical) && self.format == RuleFormat::Mrs { return invalid("classical providers do not support mrs"); } + if !is_rule && !self.path_in_bundle.is_empty() { + return invalid("path-in-bundle is only supported by rule providers"); + } + if is_rule + && self.kind != ProviderType::Inline + && !self.path_in_bundle.is_empty() + && !crate::resource::bundle::valid_member(&self.path_in_bundle) + { + return invalid("invalid rule provider bundle member"); + } if !is_rule && self.format != RuleFormat::Yaml { return invalid("proxy providers use YAML"); } diff --git a/src/resource/bundle.rs b/src/resource/bundle.rs new file mode 100644 index 0000000..6ee096e --- /dev/null +++ b/src/resource/bundle.rs @@ -0,0 +1,211 @@ +//! Bounded, streamed access to one member of the user's offline rules bundle. +use super::bundle_limits::{CheckedSource, inspect, inspect_block}; +use std::{ + io::{self, Read, Seek}, + path::Path, +}; +use tokio_util::sync::CancellationToken; + +const MAX_MEMBER: u64 = 64 * 1024 * 1024; + +pub(crate) fn valid_member(name: &str) -> bool { + !name.is_empty() + && name.len() <= 1024 + && !name.contains('\0') + && name.split('/').all(|part| !matches!(part, "" | "." | "..")) +} + +pub(crate) fn read_member( + root: &Path, + name: &str, + configured_limit: u64, + cancellation: &CancellationToken, +) -> io::Result> { + let path = super::cache::resource_path(root, "BundleMRS.7z", false)?; + let file = std::fs::File::open(path)?; + if !file.metadata()?.is_file() { + return Err(io::Error::other("rule bundle must be a regular file")); + } + decode_member(file, name, configured_limit, cancellation) +} + +fn decode_member( + source: impl Read + Seek, + name: &str, + configured_limit: u64, + cancellation: &CancellationToken, +) -> io::Result> { + if !valid_member(name) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid bundle member", + )); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let flag = || cancellation.is_cancelled(); + let mut source = CheckedSource::new(source, &flag)?; + let admission = inspect(&mut source)?; + let password = sevenz_rust2::Password::empty(); + // Header dictionary admission occurs before this call allocates a decoder. + let archive = sevenz_rust2::Archive::read(&mut source, &password).map_err(|_| invalid())?; + source.check()?; + let mut matches = archive + .files + .iter() + .enumerate() + .filter(|(_, entry)| entry.name == name); + let (file_index, target) = matches + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "bundle member unavailable"))?; + if matches.next().is_some() || !target.has_stream || target.is_directory || target.is_anti_item + { + return Err(invalid()); + } + let limit = if configured_limit == 0 { + MAX_MEMBER + } else { + configured_limit.min(MAX_MEMBER) + }; + if target.size > limit { + return Err(invalid()); + } + let block = archive + .stream_map + .file_block_index + .get(file_index) + .copied() + .flatten() + .ok_or_else(invalid)?; + // Charge retained output together with this decode's dictionary/metadata. + inspect_block(&archive, block, admission, target.size)?; + let mut output = Vec::new(); + output + .try_reserve_exact(usize::try_from(target.size).map_err(|_| invalid())?) + .map_err(|_| invalid())?; + let mut decoded = 0u64; + let mut found = false; + sevenz_rust2::BlockDecoder::new(1, block, &archive, &password, &mut source) + .for_each_entries(&mut |entry, input| { + let keep = entry.name == name; + let mut chunk = [0; 8192]; + loop { + if cancellation.is_cancelled() { + return Err(io::Error::from(io::ErrorKind::ConnectionAborted).into()); + } + if std::time::Instant::now() >= deadline { + return Err(io::Error::from(io::ErrorKind::TimedOut).into()); + } + let count = input.read(&mut chunk)?; + if std::time::Instant::now() >= deadline { + return Err(io::Error::from(io::ErrorKind::TimedOut).into()); + } + if count == 0 { + break; + } + decoded = decoded.checked_add(count as u64).ok_or_else(invalid)?; + if decoded > 256 * 1024 * 1024 || keep && output.len() as u64 + count as u64 > limit + { + return Err(invalid().into()); + } + if keep { + output.extend_from_slice(&chunk[..count]); + } + } + if keep { + found = true; + } + // Reading to EOF verifies this member's CRC before stopping. Prefix + // members have been streamed and discarded, never retained. + Ok(!keep) + }) + .map_err(|_| invalid())?; + source.check()?; + if !found || output.len() as u64 != target.size { + return Err(invalid()); + } + Ok(output) +} + +fn invalid() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "invalid or over-budget rule bundle", + ) +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + use crate::config::{ + RuleCollection, + providers::{RuleBehavior, RuleFormat}, + }; + use sevenz_rust2::{ArchiveEntry, ArchiveWriter, SourceReader}; + use std::io::Cursor; + + pub(crate) fn mrs() -> Vec { + let mut wire = b"MRS\x01".to_vec(); + wire.push(1); + wire.extend_from_slice(&1i64.to_be_bytes()); + wire.extend_from_slice(&0i64.to_be_bytes()); + wire.push(1); + wire.extend_from_slice(&1i64.to_be_bytes()); + for ip in [ + std::net::Ipv4Addr::new(192, 0, 2, 0), + std::net::Ipv4Addr::new(192, 0, 2, 255), + ] { + wire.extend_from_slice(&ip.to_ipv6_mapped().octets()); + } + zstd::encode_all(wire.as_slice(), 1).unwrap() + } + + pub(crate) fn archive(member: &[u8], encoded: bool) -> Vec { + let mut writer = ArchiveWriter::new(Cursor::new(Vec::new())).unwrap(); + writer.set_encrypt_header(encoded); + writer + .push_archive_entries( + vec![ + ArchiveEntry::new_file("prefix"), + ArchiveEntry::new_file("rules/cn.mrs"), + ], + vec![ + SourceReader::new(Cursor::new(b"discard without retaining".to_vec())), + SourceReader::new(Cursor::new(member.to_vec())), + ], + ) + .unwrap(); + writer.finish().unwrap().into_inner() + } + + #[test] + fn bundle_member_streams_real_mrs_and_observes_output_and_cancellation() { + let member = mrs(); + for encoded in [false, true] { + let wire = archive(&member, encoded); + let cancellation = CancellationToken::new(); + let data = decode_member(Cursor::new(&wire), "rules/cn.mrs", 0, &cancellation).unwrap(); + assert_eq!(data, member); + let rules = + RuleCollection::parse(&data, RuleBehavior::Ipcidr, RuleFormat::Mrs).unwrap(); + assert!(rules.matches_ip( + "192.0.2.1".parse().unwrap(), + &crate::routing::EmptyGeoMatcher + )); + assert!(!rules.matches_ip( + "198.51.100.1".parse().unwrap(), + &crate::routing::EmptyGeoMatcher + )); + assert!( + decode_member( + Cursor::new(&wire), + "rules/cn.mrs", + member.len() as u64 - 1, + &cancellation + ) + .is_err() + ); + cancellation.cancel(); + assert!(decode_member(Cursor::new(&wire), "rules/cn.mrs", 0, &cancellation).is_err()); + } + } +} diff --git a/src/resource/bundle_limits.rs b/src/resource/bundle_limits.rs new file mode 100644 index 0000000..8552640 --- /dev/null +++ b/src/resource/bundle_limits.rs @@ -0,0 +1,423 @@ +//! Independent admission parser for the public 7z header format. +//! Inspects the same opened source used by the official archive decoder. +use std::{ + io::{self, Read, Seek, SeekFrom}, + time::{Duration, Instant}, +}; + +const HEADER_BYTES: u64 = 128 * 1024; +const HEADER_PACKED: u64 = 16 * 1024 * 1024; +const DECODER_BYTES: u64 = 96 * 1024 * 1024; +const METADATA_AMPLIFICATION: u64 = 1024; +const MAX_CODERS: usize = 8; +const SIGNATURE: &[u8; 6] = b"7z\xbc\xaf\x27\x1c"; + +#[derive(Debug, Clone, Copy)] +pub struct Admission { + pub file_len: u64, + pub metadata_bytes: u64, +} + +/// One opened handle for inspection and decode. Cooperative cancellation is +/// checked inside decoder reads, never by discarding a blocking JoinHandle. +pub struct CheckedSource<'a, R> { + inner: R, + length: u64, + read_bytes: u64, + operations: u32, + deadline: Instant, + cancelled: &'a dyn Fn() -> bool, +} +impl<'a, R: Read + Seek> CheckedSource<'a, R> { + pub fn new(mut inner: R, cancelled: &'a dyn Fn() -> bool) -> io::Result { + let length = inner.seek(SeekFrom::End(0))?; + inner.seek(SeekFrom::Start(0))?; + Ok(Self { + inner, + length, + read_bytes: 0, + operations: 0, + deadline: Instant::now() + Duration::from_secs(30), + cancelled, + }) + } + pub(super) fn check(&mut self) -> io::Result<()> { + // Interrupted is deliberately avoided: Read::read_exact retries it. + if (self.cancelled)() { + return Err(io::ErrorKind::ConnectionAborted.into()); + } + if Instant::now() >= self.deadline || self.operations >= 65536 { + return Err(io::ErrorKind::TimedOut.into()); + } + self.operations += 1; + Ok(()) + } +} +impl Read for CheckedSource<'_, R> { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + self.check()?; + if bytes.is_empty() { + return Ok(0); + } + let remaining = (256 * 1024 * 1024u64) + .checked_sub(self.read_bytes) + .ok_or_else(invalid)?; + if remaining == 0 { + return Err(invalid()); + } + let length = bytes.len().min(8192).min(remaining as usize); + let read = self.inner.read(&mut bytes[..length])?; + self.read_bytes = add(self.read_bytes, read as u64)?; + Ok(read) + } +} +impl Seek for CheckedSource<'_, R> { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.check()?; + let absolute = match position { + SeekFrom::Start(value) => i128::from(value), + SeekFrom::End(offset) => i128::from(self.length) + i128::from(offset), + SeekFrom::Current(offset) => { + i128::from(self.inner.stream_position()?) + i128::from(offset) + } + }; + if absolute < 0 || absolute > i128::from(self.length) { + return Err(invalid()); + } + self.inner.seek(SeekFrom::Start(absolute as u64)) + } +} + +fn invalid() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "rule bundle failed admission") +} + +fn add(a: u64, b: u64) -> io::Result { + a.checked_add(b).ok_or_else(invalid) +} + +/// Validate signature, both CRCs and any encoded-header decoder before +/// permitting the official library to instantiate its header decoder. +pub fn inspect(reader: &mut (impl Read + Seek)) -> io::Result { + let file_len = reader.seek(SeekFrom::End(0))?; + reader.seek(SeekFrom::Start(0))?; + let mut signature = [0u8; 32]; + reader.read_exact(&mut signature)?; + if &signature[..6] != SIGNATURE || signature[6] != 0 { + return Err(invalid()); + } + let number = |offset| u64::from_le_bytes(signature[offset..offset + 8].try_into().unwrap()); + let crc = u32::from_le_bytes(signature[8..12].try_into().unwrap()); + if crc32fast::hash(&signature[12..]) != crc { + return Err(invalid()); + } + let offset = add(32, number(12))?; + let size = number(20); + if size == 0 || size > HEADER_BYTES || add(offset, size)? > file_len { + return Err(invalid()); + } + reader.seek(SeekFrom::Start(offset))?; + let mut bytes = vec![0; size as usize]; + reader.read_exact(&mut bytes)?; + if crc32fast::hash(&bytes) != u32::from_le_bytes(signature[28..32].try_into().unwrap()) { + return Err(invalid()); + } + let mut input = Input(&bytes); + let metadata_bytes = match input.byte()? { + 1 => add(8 * 1024 * 1024, size * METADATA_AMPLIFICATION)?, + 23 => encoded_header(&mut input, file_len)?, + _ => return Err(invalid()), + }; + if metadata_bytes > DECODER_BYTES { + return Err(invalid()); + } + reader.seek(SeekFrom::Start(0))?; + Ok(Admission { + file_len, + metadata_bytes, + }) +} + +struct Input<'a>(&'a [u8]); +impl<'a> Input<'a> { + fn bytes(&mut self, n: usize) -> io::Result<&'a [u8]> { + let (part, remainder) = self.0.split_at_checked(n).ok_or_else(invalid)?; + self.0 = remainder; + Ok(part) + } + fn byte(&mut self) -> io::Result { + Ok(self.bytes(1)?[0]) + } + fn expect(&mut self, value: u8) -> io::Result<()> { + if self.byte()? == value { + Ok(()) + } else { + Err(invalid()) + } + } + // 7z integers carry low bytes after a prefix byte, not LEB128. + fn integer(&mut self) -> io::Result { + let prefix = self.byte()?; + let mut value = 0; + for count in 0..8 { + let mask = 0x80u8 >> count; + if prefix & mask == 0 { + value |= u64::from(prefix & (mask - 1)) << (count * 8); + return Ok(value); + } + value |= u64::from(self.byte()?) << (count * 8); + } + Ok(value) + } + fn count(&mut self, maximum: usize) -> io::Result { + usize::try_from(self.integer()?) + .ok() + .filter(|n| *n <= maximum) + .ok_or_else(invalid) + } + fn crc(&mut self, count: usize) -> io::Result { + let all = self.byte()?; + let present = if all == 0 { + self.bytes(count.div_ceil(8))? + .iter() + .enumerate() + .map(|(index, byte)| { + let remaining = count.saturating_sub(index * 8).min(8); + (byte & (0xff << (8 - remaining))).count_ones() as usize + }) + .sum() + } else if all == 1 { + count + } else { + return Err(invalid()); + }; + self.bytes(present * 4)?; + Ok(present) + } +} + +fn encoded_header(input: &mut Input<'_>, file_len: u64) -> io::Result { + input.expect(6)?; // PackInfo + let offset = add(32, input.integer()?)?; + // Metadata headers only require a single packed stream. Additional + // archived codecs/forms can later be audited without expanding admission. + if input.count(1)? != 1 { + return Err(invalid()); + } + input.expect(9)?; + let packed = input.integer()?; + if packed > HEADER_PACKED || add(offset, packed)? > file_len { + return Err(invalid()); + } + let mut tag = input.byte()?; + if tag == 10 { + input.crc(1)?; + tag = input.byte()?; + } + if tag != 0 { + return Err(invalid()); + } + input.expect(7)?; // UnpackInfo + input.expect(11)?; // Folder + if input.count(1)? != 1 { + return Err(invalid()); + } + input.expect(0)?; // no external folder metadata + let coders = input.count(MAX_CODERS)?; + if coders == 0 { + return Err(invalid()); + } + let mut memory = 8 * 1024 * 1024; + for _ in 0..coders { + let descriptor = input.byte()?; + let id_length = usize::from(descriptor & 15); + if id_length == 0 || descriptor & 0xc0 != 0 { + return Err(invalid()); + } + let method = input.bytes(id_length)?; + if descriptor & 16 != 0 && (input.integer()? != 1 || input.integer()? != 1) { + return Err(invalid()); + } + let properties = if descriptor & 32 != 0 { + let length = input.count(16)?; + input.bytes(length)? + } else { + &[] + }; + memory = add(memory, codec_memory(method, properties)?)?; + } + let mut inputs = 0u16; + let mut outputs = 0u16; + for _ in 1..coders { + let source = input.count(coders - 1)?; + let destination = input.count(coders - 1)?; + let source = 1u16 << source; + let destination = 1u16 << destination; + if inputs & source != 0 || outputs & destination != 0 { + return Err(invalid()); + } + inputs |= source; + outputs |= destination; + } + input.expect(12)?; // CodersUnpackSize + let mut unpacked = 0; + for _ in 0..coders { + let size = input.integer()?; + if size > HEADER_BYTES { + return Err(invalid()); + } + unpacked = unpacked.max(size); + memory = add(memory, size)?; + } + memory = add(memory, unpacked * METADATA_AMPLIFICATION)?; + if memory > DECODER_BYTES { + return Err(invalid()); + } + let mut tag = input.byte()?; + let has_folder_crc = if tag == 10 { + let present = input.crc(1)?; + tag = input.byte()?; + present != 0 + } else { + false + }; + if tag != 0 { + return Err(invalid()); + } + // With one folder and one substream the CRC can reside in UnpackInfo. + let tag = input.byte()?; + if tag == 8 { + let mut tag = input.byte()?; + if tag == 13 { + if input.count(1)? != 1 { + return Err(invalid()); + } + tag = input.byte()?; + } + if tag == 10 { + input.crc(usize::from(!has_folder_crc))?; + tag = input.byte()?; + } + if tag != 0 { + return Err(invalid()); + } + input.expect(0)?; + } else if tag != 0 { + return Err(invalid()); + } + if !input.0.is_empty() { + return Err(invalid()); + } + Ok(memory) +} + +fn codec_memory(method: &[u8], props: &[u8]) -> io::Result { + // These constants describe publicly specified codec identifiers/properties. + match method { + [0] if props.is_empty() => Ok(64 * 1024), // COPY + [0x21] if props.len() == 1 && props[0] <= 40 => { + let property = props[0]; + let dictionary = if property == 40 { + u32::MAX as u64 + } else { + (2u64 | u64::from(property & 1)) << (property / 2 + 11) + }; + add(dictionary, 2 * 1024 * 1024) + } + [3, 1, 1] if props.len() == 5 && props[0] < 225 => { + let lc = u32::from(props[0] % 9); + let lp = u32::from(props[0] / 9 % 5); + let dictionary = + u64::from(u32::from_le_bytes(props[1..].try_into().unwrap())).max(4096); + add( + add(dictionary, (0x300u64 << (lc + lp)) * 2)?, + 2 * 1024 * 1024, + ) + } + [3, 3, 1, 3] | [3, 3, 5, 1] | [3, 3, 7, 1] if props.is_empty() || props.len() == 4 => { + Ok(64 * 1024) + } // BCJ filters + [3] if props.len() == 1 => Ok(64 * 1024), // Delta + _ => Err(invalid()), + } +} + +/// Validate the selected solid block before member decoder construction. +pub fn inspect_block( + archive: &sevenz_rust2::Archive, + index: usize, + admission: Admission, + retained_bytes: u64, +) -> io::Result<()> { + let block = archive.blocks.get(index).ok_or_else(invalid)?; + if block.coders.is_empty() || block.coders.len() > MAX_CODERS || archive.files.len() > 4096 { + return Err(invalid()); + } + let mut memory = add(admission.metadata_bytes, retained_bytes)?; + let mut visited = 0u16; + for (coder_index, _) in block.ordered_coder_iter() { + let bit = 1u16 << coder_index; + if visited & bit != 0 { + return Err(invalid()); + } + visited |= bit; + } + if visited.count_ones() as usize != block.coders.len() { + return Err(invalid()); + } + for (index, coder) in block.coders.iter().enumerate() { + memory = add( + memory, + codec_memory(coder.encoder_method_id(), coder.properties())?, + )?; + if block.get_unpack_size_at_index(index) > 256 * 1024 * 1024 { + return Err(invalid()); + } + } + if memory > DECODER_BYTES { + return Err(invalid()); + } + let first = *archive + .stream_map + .block_first_pack_stream_index() + .get(index) + .ok_or_else(invalid)?; + let after = archive + .stream_map + .block_first_pack_stream_index() + .get(index + 1) + .copied() + .unwrap_or(archive.pack_sizes().len()); + if after.checked_sub(first) != Some(1) { + return Err(invalid()); + } + // Only one packed input and an acyclic chain of audited codecs proceed. + // The library additionally rejects non-1:1 coder descriptors before decode. + let packed = *archive.pack_sizes().get(first).ok_or_else(invalid)?; + if packed > 128 * 1024 * 1024 { + return Err(invalid()); + } + let relative = archive.pack_sizes()[..first] + .iter() + .try_fold(archive.pack_pos(), |sum, n| add(sum, *n))?; + if add(add(32, relative)?, packed)? > admission.file_len { + return Err(invalid()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn hostile_encoded_header_dictionary_fails_before_a_decoder_exists() { + // Public 7z encoded-header shape, no library decoder constructed here. + let wire = [ + 6, 0, 1, 9, 1, 0, 7, 11, 1, 0, 1, 0x21, 0x21, 1, 40, 12, 1, 0, 0, + ]; + assert!(encoded_header(&mut Input(&wire), 33).is_err()); + let mut safe = wire; + safe[14] = 0; + assert!(encoded_header(&mut Input(&safe), 33).is_ok()); + } +} diff --git a/src/resource/cache.rs b/src/resource/cache.rs index ca3a867..ec127e6 100644 --- a/src/resource/cache.rs +++ b/src/resource/cache.rs @@ -29,6 +29,8 @@ struct Metadata { etag: Option, hash: String, checked: u64, + #[serde(default)] + bundled: bool, #[serde(default, skip_serializing_if = "Option::is_none")] subscription_info: Option, } @@ -158,6 +160,7 @@ impl ProviderCache { if metadata.url == spec.url && metadata.hash == hex(&Sha256::digest(&bytes)) { + entry.state.downloaded &= !metadata.bundled; entry.metadata = metadata; if !rule && spec.kind == ProviderType::Http { entry.state.subscription_info = @@ -166,8 +169,8 @@ impl ProviderCache { entry.metadata.subscription_info = None; } } - contents.insert(key, content); - if spec.kind == ProviderType::Http { + contents.insert(key.clone(), content); + if spec.kind == ProviderType::Http && entry.state.downloaded { entry.next = next_update( spec.interval, entry @@ -180,6 +183,68 @@ impl ProviderCache { Err(_) => entry.state.error = Some("invalid cached content"), } } + if rule && !spec.path_in_bundle.is_empty() && !contents.contains_key(&key) { + let fallback_cancellation = CancellationToken::new(); + let cancellation = cancellation.unwrap_or(&fallback_cancellation); + if let Ok((bytes, content)) = super::bundle::read_member( + root, + &spec.path_in_bundle, + spec.size_limit, + cancellation, + ) + .and_then(|bytes| { + template + .parse_content_with_control(spec, &bytes, &control) + .map(|content| (bytes, content)) + .map_err(|_| io::Error::other("invalid bundled rule content")) + }) { + if cancellation.is_cancelled() { + return Err(io::ErrorKind::Interrupted.into()); + } + let write_path = resource_path( + root, + if spec.path.is_empty() { + &fallback + } else { + &spec.path + }, + true, + )?; + let metadata = Metadata { + url: spec.url.clone(), + etag: None, + hash: hex(&Sha256::digest(&bytes)), + checked: 0, + bundled: true, + subscription_info: None, + }; + let temporary = TemporaryFile(Some(temporary_path(&write_path))); + fs::write(temporary.0.as_ref().unwrap(), &bytes)?; + if cancellation.is_cancelled() { + return Err(io::ErrorKind::Interrupted.into()); + } + commit_download(&write_path, temporary.0.as_ref().unwrap(), &metadata)?; + entry.state.available = true; + entry.state.downloaded = false; + entry.state.count = content_len(&content); + entry.state.updated_at = fs::metadata(&write_path) + .ok() + .and_then(|m| m.modified().ok()) + .map(unix_time); + entry.modified = fs::metadata(&write_path) + .ok() + .and_then(|m| m.modified().ok()); + entry.state.error = None; + entry.metadata = metadata; + contents.insert(key.clone(), content); + // A bundle is an initial local source, never an ETag or + // proof that the HTTP vehicle has already been fetched. + entry.next = tokio::time::Instant::now(); + } + } + if cancellation.is_some_and(CancellationToken::is_cancelled) { + return Err(io::ErrorKind::Interrupted.into()); + } entry.path = Some(path); } entries.push(entry); @@ -336,6 +401,7 @@ impl ProviderCache { etag, hash: sha256.iter().map(|byte| format!("{byte:02x}")).collect(), checked: unix_time(SystemTime::now()), + bundled: false, subscription_info: if entry.state.rule { None } else { diff --git a/src/resource/cache_tests.rs b/src/resource/cache_tests.rs index 8fb829c..8c7f4ea 100644 --- a/src/resource/cache_tests.rs +++ b/src/resource/cache_tests.rs @@ -89,6 +89,68 @@ fn commit_valid(cache: &mut ProviderCache, candidate: Candidate) { .unwrap(); } +#[tokio::test] +async fn bundle_cache_precedes_archive_and_bad_cache_falls_back_before_http() { + let root = tempfile::tempdir().unwrap(); + let template = Config::parse_yaml(br#" +mixed-port: 1080 +rule-providers: + offline: {type: http, url: 'http://rules.invalid/current.mrs', path: rules.mrs, behavior: ipcidr, format: mrs, path-in-bundle: rules/cn.mrs} +rules: ['RULE-SET,offline,DIRECT'] +"#).unwrap().providers.unwrap(); + let member = crate::resource::bundle::tests::mrs(); + let bundle = crate::resource::bundle::tests::archive(&member, true); + let archive_path = root.path().join("BundleMRS.7z"); + let cache_path = root.path().join("rules.mrs"); + fs::write(&archive_path, &bundle).unwrap(); + fs::write(&cache_path, b"invalid cached MRS").unwrap(); + let mut cache = ProviderCache::open(root.path(), template.clone()).unwrap(); + assert!(cache.entries[0].state.available); + assert!(!cache.entries[0].state.downloaded); + assert!(cache.entries[0].metadata.bundled); + assert_eq!(cache.entries[0].metadata.etag, None); + assert_eq!(cache.next_due(), Some(0)); + assert_eq!(fs::read(&cache_path).unwrap(), member); + assert_eq!(cache.entries[0].state.error, None); + cache.template.materialize(&cache.contents).unwrap(); + + // A valid local cache avoids archive parsing and still owes its first real + // HTTP fetch when the cached bytes were extracted from the bundle. + fs::write(&archive_path, b"broken archive").unwrap(); + cache = ProviderCache::open(root.path(), template.clone()).unwrap(); + assert!(!cache.entries[0].state.downloaded); + assert_eq!(cache.next_due(), Some(0)); + + // Bad cache and invalid bundled MRS leave the HTTP vehicle available. + fs::write(&cache_path, b"bad cache").unwrap(); + fs::write( + &archive_path, + crate::resource::bundle::tests::archive(b"bad MRS", false), + ) + .unwrap(); + cache = ProviderCache::open(root.path(), template.clone()).unwrap(); + assert!(!cache.entries[0].state.available); + assert_eq!(cache.next_due(), Some(0)); + let mut reply = format!( + "HTTP/1.1 200 fixture\r\nETag: \"remote\"\r\nContent-Length: {}\r\n\r\n", + member.len() + ) + .into_bytes(); + reply.extend_from_slice(&member); + let candidate = fetch(&mut cache, reply).await.unwrap().unwrap(); + commit_valid(&mut cache, candidate); + assert!(cache.entries[0].state.downloaded); + assert!(!cache.entries[0].metadata.bundled); + assert_eq!( + cache.entries[0].metadata.etag.as_deref(), + Some("\"remote\"") + ); + assert_eq!(cache.next_refresh(), None); + let cache = ProviderCache::open(root.path(), template).unwrap(); + assert!(cache.entries[0].state.downloaded); + assert!(!cache.entries[0].metadata.bundled); +} + #[tokio::test(start_paused = true)] async fn refresh_deadline_preserves_fetch_completion_phase_and_zero_disables_polling() { let root = tempfile::tempdir().unwrap(); diff --git a/src/resource/mod.rs b/src/resource/mod.rs index 9674cfb..0814336 100644 --- a/src/resource/mod.rs +++ b/src/resource/mod.rs @@ -1,5 +1,7 @@ //! Resource acquisition shared by GeoData and providers. pub(crate) mod age_provider; +pub(crate) mod bundle; +mod bundle_limits; pub(crate) mod cache; pub(crate) mod control; pub(crate) mod http; diff --git a/tests/README.md b/tests/README.md index 923acc3..b466def 100644 --- a/tests/README.md +++ b/tests/README.md @@ -132,3 +132,5 @@ container-benchmark compare --source vole=/path/to/Vole `routing::proxy_group::tests::` 覆盖自动选择、容差、恢复与选择缓存;`resource::probe::tests::` 和 `invoke::measure_delay::tests::` 使用内存流验证共享健康探测、HTTP 状态和取消。 Controller 主动探测与内存使用纯内存过滤:`manual_probes_keep_one_history`、`delay_query_preserves_reference`、`admits_mixed_only_direct_controller`、`current_process_has_resident_memory`。真实 HTTP 对端由 benchmark 隔离运行。 + +规则bundle的定向内存/临时文件验证使用 `resource::bundle` 与 `resource::cache::tests::bundle_`,覆盖真实MRS成员、解码预算/取消、缓存优先及原HTTP来源回退,不启动宿主服务。 From 76f4e18610524d86609cc70065a395ec93bfdcfd Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:54:23 +0800 Subject: [PATCH 055/445] feat: evaluate bounded provider override expressions --- docs/config.yaml | 17 +- src/config/mod.rs | 1 + src/config/override_expr.rs | 2686 +++++++++++++++++++++++++++++++++++ src/config/providers.rs | 113 +- src/runtime/providers.rs | 20 +- tests/README.md | 2 + 6 files changed, 2832 insertions(+), 7 deletions(-) create mode 100644 src/config/override_expr.rs diff --git a/docs/config.yaml b/docs/config.yaml index 59646c7..b19e06d 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -565,8 +565,21 @@ proxy-groups: # provider 的 dialer-proxy 覆盖每个节点的同名字段,override 最后应用; # 它只控制节点上游,资源下载仍使用独立的 proxy 字段。 # override 支持 udp、udp-over-tcp、up/down、dialer-proxy、ip-version、skip-cert-verify、name-cert-verify、 -# additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]; +# additional-prefix/suffix、proxy-name: [{pattern: '表达式', target: '替换文本'}]、override-expr 字符串数组; # 协议专属覆盖只用于支持该字段的节点。未实现的覆盖字段严格拒绝。 +# override-expr 为原生有限 yq 方言,校验时预编译:原名称过滤 → 结构化 override / 改名 / 前后缀 +# → 表达式按列表顺序执行 → 严格节点及引用图校验。每项须产出一个 mapping,失败保留旧资源。 +# 支持根/字段/引号字段、正负数组索引、[] 通配、select 目标、= / |= / += / -= / *=、del、 +# pipeline / 逗号流 / 数组收集 / mapping / 插值、标量、算术/比较/and/or/默认值 //。 +# 函数:length/keys/has/contains/select;reverse/sort/unique/flatten/any/all; +# map/map_values/filter/to_entries/from_entries/with_entries/any_c/all_c; +# test/sub/split/join/upcase/downcase/trim/tostring/tonumber/type/not。 +# = 右侧读取根,|= 读取旧目标;赋值右侧的 pipeline/and/or 须带括号,多结果须收集。 +# mapping 值取最后结果,无结果省略;插值取首结果。sub 全局替换,使用 Go 的 $capture。 +# 每项最多 16 KiB/4096 tokens 与节点、深度 32;整份配置编译最多 512 KiB/65536 tokens +# 与节点/1 秒。整份候选共享取消、20 万步、100ms、32 MiB 累积值分配估算、值深度 64 +# 与 32 个有界线性正则预算。无 shell/yq、变量、reduce、动态路径、递归下降、文件/环境或文档流。 +# 最终节点仍须符合本文协议范围;表达式不提供任意脚本或额外配置字段。 # proxy providers 的 health-check 接受 enable/url/interval/timeout/lazy/expected-status; # enable 默认 false,开启后 interval 缺省/0 为 300 秒。空 URL 不发起探测。 # 相同节点/URL 的检查共享结果,每批最多 10 个并发,超时与停止均取消 I/O。 @@ -591,6 +604,8 @@ proxy-groups: proxy-providers: local-nodes: type: inline + # override: + # override-expr: ['.name |= (trim | upcase)', '.udp = true'] payload: [] health-check: enable: false diff --git a/src/config/mod.rs b/src/config/mod.rs index 6cf1c99..32a42d2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -37,6 +37,7 @@ use crate::{Result, VoleError}; mod dns; mod identity_files; mod name_regex; +mod override_expr; pub mod providers; mod rule_provider; pub(crate) mod rules; diff --git a/src/config/override_expr.rs b/src/config/override_expr.rs new file mode 100644 index 0000000..8aa7465 --- /dev/null +++ b/src/config/override_expr.rs @@ -0,0 +1,2686 @@ +//! Native, bounded provider transformations; no external interpreter is invoked. +//! Independently designed against the public finite-yq behavior contract. +use super::{Result, VoleError}; +use serde_yaml_ng::{Mapping, Number, Value}; +use std::{ + cmp::Ordering, + collections::BTreeMap, + time::{Duration, Instant}, +}; +use tokio_util::sync::CancellationToken; + +const SOURCE_LIMIT: usize = 16 * 1024; +const NODE_LIMIT: usize = 4096; +const COMPILE_NODES: usize = 64 * 1024; +const COMPILE_BYTES: usize = 512 * 1024; +const PARSE_DEPTH: usize = 32; +const VALUE_DEPTH: usize = 64; +const BYTE_LIMIT: usize = 32 * 1024 * 1024; +const STEP_LIMIT: usize = 200_000; +fn error(message: &'static str) -> VoleError { + VoleError::InvalidConfig(message.into()) +} + +pub(super) struct Budget<'a> { + cancel: Option<&'a CancellationToken>, + started: Instant, + steps: usize, + bytes: usize, + regexes: BTreeMap, +} +impl<'a> Budget<'a> { + pub fn new(cancel: Option<&'a CancellationToken>) -> Self { + Self { + cancel, + started: Instant::now(), + steps: 0, + bytes: 0, + regexes: BTreeMap::new(), + } + } + fn tick(&mut self, amount: usize) -> Result<()> { + if self.cancel.is_some_and(CancellationToken::is_cancelled) { + return Err(error("provider override expression cancelled")); + } + self.steps = self + .steps + .checked_add(amount) + .ok_or_else(|| error("provider override execution budget exceeded"))?; + if self.steps > STEP_LIMIT || self.started.elapsed() > Duration::from_millis(100) { + return Err(error("provider override execution budget exceeded")); + } + Ok(()) + } + fn reserve(&mut self, bytes: usize) -> Result<()> { + self.tick(1)?; + self.bytes = self + .bytes + .checked_add(bytes) + .ok_or_else(|| error("provider override output budget exceeded"))?; + if self.bytes > BYTE_LIMIT { + return Err(error("provider override output budget exceeded")); + } + Ok(()) + } + fn clone_value(&mut self, value: &Value) -> Result { + let bytes = self.measure(value, 0)?; + self.reserve(bytes)?; + Ok(value.clone()) + } + fn measure(&mut self, value: &Value, depth: usize) -> Result { + self.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + let mut bytes = std::mem::size_of::(); + match value { + Value::String(text) => bytes = bytes.saturating_add(text.len()), + Value::Sequence(values) => { + for value in values { + bytes = bytes.saturating_add(self.measure(value, depth + 1)?); + } + } + Value::Mapping(map) => { + for (key, value) in map { + bytes = bytes + .saturating_add(self.measure(key, depth + 1)?) + .saturating_add(self.measure(value, depth + 1)?); + } + } + Value::Tagged(_) => return Err(error("provider override does not support YAML tags")), + _ => {} + } + if bytes > BYTE_LIMIT { + return Err(error("provider override output budget exceeded")); + } + Ok(bytes) + } + fn regex(&mut self, pattern: &str) -> Result { + self.tick(1)?; + if pattern.len() > 4096 { + return Err(error("provider override regex compilation budget exceeded")); + } + if let Some(regex) = self.regexes.get(pattern) { + return Ok(regex.clone()); + } + if self.regexes.len() >= 32 { + return Err(error("provider override regex compilation budget exceeded")); + } + self.reserve(pattern.len().saturating_add(512 * 1024))?; + let regex = compile_regex(pattern)?; + self.regexes.insert(pattern.into(), regex.clone()); + Ok(regex) + } +} + +fn compile_regex(pattern: &str) -> Result { + if pattern.len() > 4096 { + return Err(error("provider override regex compilation budget exceeded")); + } + // RE2's Perl shorthand classes are ASCII. Keep Unicode literal/range matching + // while expanding those classes before using Rust's bounded linear engine. + let mut adapted = String::new(); + let mut characters = pattern.chars().peekable(); + while let Some(character) = characters.next() { + if character != '\\' { + adapted.push(character); + continue; + } + match characters.next() { + Some('d') => adapted.push_str("[0-9]"), + Some('D') => adapted.push_str("[^0-9]"), + Some('w') => adapted.push_str("[A-Za-z0-9_]"), + Some('W') => adapted.push_str("[^A-Za-z0-9_]"), + Some('s') => adapted.push_str(r"[\t\n\f\r ]"), + Some('S') => adapted.push_str(r"[^\t\n\f\r ]"), + Some('b') => adapted.push_str(r"(?-u:\b)"), + Some('B') => adapted.push_str(r"(?-u:\B)"), + Some('Q') => { + let mut quoted = String::new(); + while let Some(character) = characters.next() { + if character == '\\' && characters.peek() == Some(&'E') { + characters.next(); + break; + } + quoted.push(character); + } + adapted.push_str(®ex::escape("ed)); + } + Some(character) => { + adapted.push('\\'); + adapted.push(character); + } + None => adapted.push('\\'), + } + } + regex::RegexBuilder::new(&adapted) + .octal(true) + .size_limit(512 * 1024) + .dfa_size_limit(512 * 1024) + .build() + .map_err(|_| error("invalid provider override regular expression")) +} + +#[derive(Clone)] +pub(super) struct Expr { + root: Node, + regexes: BTreeMap, +} +impl PartialEq for Expr { + fn eq(&self, other: &Self) -> bool { + self.root == other.root + } +} +impl Eq for Expr {} +impl std::fmt::Debug for Expr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderExpr").finish_non_exhaustive() + } +} +impl Expr { + #[cfg(test)] + pub fn parse(source: &str) -> Result { + Self::parse_with_budget(source, &mut CompileBudget::default()) + } + pub fn parse_with_budget(source: &str, limits: &mut CompileBudget) -> Result { + limits.check()?; + let initial_nodes = limits.nodes; + let initial_tokens = limits.tokens; + let root = parse(source, limits, 0)?; + if limits.nodes.saturating_sub(initial_nodes) > NODE_LIMIT + || limits.tokens.saturating_sub(initial_tokens) > NODE_LIMIT + { + return Err(error("provider override compilation budget exceeded")); + } + validate(&root, 0)?; + let mut regexes = BTreeMap::new(); + compile_literal_regexes(&root, &mut regexes, limits)?; + Ok(Self { root, regexes }) + } + pub fn apply(&self, mapping: &mut Value, budget: &mut Budget<'_>) -> Result<()> { + if !mapping.is_mapping() { + return Err(error("provider override input must be a mapping")); + } + for (pattern, regex) in &self.regexes { + budget.tick(1)?; + if !budget.regexes.contains_key(pattern) { + if budget.regexes.len() >= 32 { + return Err(error("provider override regex compilation budget exceeded")); + } + budget.reserve(pattern.len().saturating_add(512 * 1024))?; + budget.regexes.insert(pattern.clone(), regex.clone()); + } + } + let input = budget.clone_value(mapping)?; + let mut result = evaluate(&self.root, vec![input], budget, 0)?; + if result.len() != 1 || !result[0].is_mapping() { + return Err(error("provider override must produce exactly one mapping")); + } + budget.measure(&result[0], 0)?; + *mapping = result.pop().expect("one result"); + Ok(()) + } +} +#[derive(Clone, PartialEq, Eq)] +enum Segment { + Key(String), + Index(i64), + Each, +} +#[derive(Clone, PartialEq, Eq)] +enum Part { + Text(String), + Expression(Node), +} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Op { + Add, + Sub, + Mul, + Div, + Mod, + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + And, + Or, + Default, +} +#[derive(Clone, Copy, PartialEq, Eq)] +enum Update { + Assign, + Replace, + Add, + Sub, + Mul, +} +#[derive(Clone, PartialEq, Eq)] +enum Node { + Literal(Value), + Path(Vec), + String(Vec), + Array(Box), + Object(Vec<(String, Node)>), + Binary(Op, Box, Box), + Neg(Box), + Pipe(Box, Box), + Union(Box, Box), + Call(String, Vec), + Update(Update, Box, Box), + Delete(Vec), + Group(Box), +} +#[derive(Clone, PartialEq, Eq)] +enum Token { + Dot, + Word(String), + String(String), + Number(String), + Symbol(&'static str), + End, +} +pub(super) struct CompileBudget { + tokens: usize, + nodes: usize, + bytes: usize, + started: Instant, + regexes: BTreeMap, +} +impl Default for CompileBudget { + fn default() -> Self { + Self { + tokens: 0, + nodes: 0, + bytes: 0, + started: Instant::now(), + regexes: BTreeMap::new(), + } + } +} +impl CompileBudget { + fn check(&self) -> Result<()> { + if self.started.elapsed() > Duration::from_secs(1) + || self.tokens > COMPILE_NODES + || self.nodes > COMPILE_NODES + { + return Err(error("provider override compilation budget exceeded")); + } + Ok(()) + } +} +fn lex(source: &str, limits: &mut CompileBudget) -> Result> { + limits.bytes = limits.bytes.saturating_add(source.len()); + limits.check()?; + if source.len() > SOURCE_LIMIT || limits.bytes > COMPILE_BYTES { + return Err(error("provider override compilation budget exceeded")); + } + let bytes = source.as_bytes(); + let mut cursor = 0; + let mut tokens = Vec::new(); + while cursor < bytes.len() { + let start = cursor; + let ch = source[cursor..].chars().next().expect("valid character"); + if ch.is_whitespace() { + cursor += ch.len_utf8(); + continue; + } + if ch == '#' { + break; + } + let token = if ch == '"' { + cursor += 1; + let content = cursor; + let mut escaped = false; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if !escaped && byte == b'"' { + break; + } + escaped = !escaped && byte == b'\\'; + cursor += 1; + } + if cursor == bytes.len() { + return Err(error("invalid provider override string")); + } + let text = source[content..cursor].to_owned(); + cursor += 1; + Token::String(text) + } else if ch.is_ascii_digit() { + cursor += 1; + while cursor < bytes.len() { + let byte = bytes[cursor]; + if byte.is_ascii_alphanumeric() + || byte == b'.' + || ((byte == b'+' || byte == b'-') && matches!(bytes[cursor - 1], b'e' | b'E')) + { + cursor += 1; + } else { + break; + } + } + Token::Number(source[start..cursor].into()) + } else if ch.is_alphabetic() || ch == '_' { + cursor += ch.len_utf8(); + while cursor < bytes.len() { + let ch = source[cursor..].chars().next().expect("valid character"); + if ch.is_alphanumeric() || matches!(ch, '_' | '-') { + cursor += ch.len_utf8(); + } else { + break; + } + } + Token::Word(source[start..cursor].into()) + } else { + let mut matched = None; + for symbol in [ + "|=", "+=", "-=", "*=", "//", "==", "!=", "<=", ">=", "(", ")", "[", "]", "{", "}", + ",", ";", ":", "|", "=", "+", "-", "*", "/", "%", "<", ">", "~", + ] { + if source[cursor..].starts_with(symbol) { + matched = Some(symbol); + break; + } + } + if ch == '.' { + cursor += 1; + Token::Dot + } else if let Some(symbol) = matched { + cursor += symbol.len(); + Token::Symbol(symbol) + } else { + return Err(error("unsupported provider override syntax")); + } + }; + limits.tokens += 1; + limits.check()?; + if tokens.len() >= NODE_LIMIT { + return Err(error("provider override compilation budget exceeded")); + } + tokens.push(token); + } + tokens.push(Token::End); + Ok(tokens) +} +struct Parser<'a> { + tokens: Vec, + cursor: usize, + limits: &'a mut CompileBudget, +} +fn parse(source: &str, limits: &mut CompileBudget, depth: usize) -> Result { + if depth > PARSE_DEPTH { + return Err(error("provider override expression depth exceeded")); + } + let tokens = lex(source, limits)?; + if tokens.len() == 1 { + limits.nodes += 1; + limits.check()?; + return Ok(Node::Path(Vec::new())); + } + let mut parser = Parser { + tokens, + cursor: 0, + limits, + }; + let node = parser.expression(0, depth + 1)?; + if parser.peek() != &Token::End { + return Err(error("invalid provider override expression")); + } + Ok(node) +} +impl Parser<'_> { + fn peek(&self) -> &Token { + &self.tokens[self.cursor] + } + fn next(&mut self) -> Token { + let result = self.tokens[self.cursor].clone(); + self.cursor += 1; + result + } + fn symbol(&mut self, symbol: &'static str) -> bool { + if self.peek() == &Token::Symbol(symbol) { + self.cursor += 1; + true + } else { + false + } + } + fn expect(&mut self, symbol: &'static str) -> Result<()> { + if self.symbol(symbol) { + Ok(()) + } else { + Err(error("invalid provider override expression")) + } + } + fn account(&mut self, node: Node) -> Result { + self.limits.nodes += 1; + if self.limits.nodes > COMPILE_NODES { + return Err(error("provider override compilation budget exceeded")); + } + Ok(node) + } + fn expression(&mut self, minimum: u8, depth: usize) -> Result { + if depth > PARSE_DEPTH { + return Err(error("provider override expression depth exceeded")); + } + let mut node = self.primary(depth + 1)?; + loop { + let (precedence, kind) = match self.peek() { + Token::Symbol(",") => (1, 0), + Token::Symbol("|") => (2, 1), + Token::Symbol("=" | "|=" | "+=" | "-=" | "*=") => (3, 2), + Token::Word(word) if word == "or" => (4, 3), + Token::Word(word) if word == "and" => (5, 3), + Token::Symbol("//") => (6, 3), + Token::Symbol("==" | "!=" | "<" | "<=" | ">" | ">=") => (7, 3), + Token::Symbol("+" | "-") => (8, 3), + Token::Symbol("*" | "/" | "%") => (9, 3), + _ => break, + }; + if precedence < minimum { + break; + } + if matches!(node, Node::Update(..)) + && matches!(self.peek(), Token::Word(word) if word == "and" || word == "or") + { + return Err(error( + "provider override boolean assignment values require parentheses", + )); + } + let operator = self.next(); + let right = self.expression(if kind == 2 { 6 } else { precedence + 1 }, depth + 1)?; + node = self.account(match kind { + 0 => Node::Union(Box::new(node), Box::new(right)), + 1 => Node::Pipe(Box::new(node), Box::new(right)), + 2 => Node::Update( + match operator { + Token::Symbol("=") => Update::Assign, + Token::Symbol("|=") => Update::Replace, + Token::Symbol("+=") => Update::Add, + Token::Symbol("-=") => Update::Sub, + _ => Update::Mul, + }, + Box::new(node), + Box::new(right), + ), + _ => Node::Binary( + match operator { + Token::Word(word) if word == "and" => Op::And, + Token::Word(_) => Op::Or, + Token::Symbol("//") => Op::Default, + Token::Symbol("+") => Op::Add, + Token::Symbol("-") => Op::Sub, + Token::Symbol("*") => Op::Mul, + Token::Symbol("/") => Op::Div, + Token::Symbol("%") => Op::Mod, + Token::Symbol("==") => Op::Eq, + Token::Symbol("!=") => Op::Ne, + Token::Symbol("<") => Op::Lt, + Token::Symbol("<=") => Op::Le, + Token::Symbol(">") => Op::Gt, + _ => Op::Ge, + }, + Box::new(node), + Box::new(right), + ), + })?; + } + Ok(node) + } + fn primary(&mut self, depth: usize) -> Result { + let node = match self.next() { + Token::Dot => { + let mut segments = Vec::new(); + if matches!(self.peek(), Token::Word(_) | Token::String(_)) { + segments.push(self.field()?); + } + loop { + if self.peek() == &Token::Dot { + self.next(); + segments.push(self.field()?); + } else if self.symbol("[") { + if self.symbol("]") { + segments.push(Segment::Each); + continue; + } + let segment = match self.next() { + Token::String(raw) => Segment::Key(decode_string(&raw)?), + Token::Number(number) => Segment::Index( + number + .parse() + .map_err(|_| error("invalid provider override index"))?, + ), + Token::Symbol("-") => match self.next() { + Token::Number(number) => Segment::Index( + number + .parse::() + .ok() + .and_then(i64::checked_neg) + .ok_or_else(|| error("invalid provider override index"))?, + ), + _ => return Err(error("invalid provider override index")), + }, + _ => return Err(error("unsupported provider override path")), + }; + self.expect("]")?; + segments.push(segment); + } else { + break; + } + } + if segments.len() > PARSE_DEPTH { + return Err(error("provider override path depth exceeded")); + } + Node::Path(segments) + } + Token::Number(number) => Node::Literal(number_value(&number, false)?), + Token::String(raw) => Node::String(interpolation(&raw, self.limits, depth + 1)?), + Token::Symbol("~") => Node::Literal(Value::Null), + Token::Symbol("-") => Node::Neg(Box::new(self.expression(10, depth + 1)?)), + Token::Symbol("(") => { + let node = self.expression(0, depth + 1)?; + self.expect(")")?; + Node::Group(Box::new(node)) + } + Token::Symbol("[") => { + if self.symbol("]") { + Node::Literal(Value::Sequence(Vec::new())) + } else { + let node = self.expression(0, depth + 1)?; + self.expect("]")?; + Node::Array(Box::new(node)) + } + } + Token::Symbol("{") => { + let mut entries = Vec::new(); + if !self.symbol("}") { + loop { + let Token::String(raw) = self.next() else { + return Err(error( + "provider override mapping keys must be quoted strings", + )); + }; + let key = decode_string(&raw)?; + self.expect(":")?; + entries.push((key, self.expression(2, depth + 1)?)); + if self.symbol("}") { + break; + } + self.expect(",")?; + } + } + Node::Object(entries) + } + Token::Word(name) => match name.as_str() { + "null" => Node::Literal(Value::Null), + "true" => Node::Literal(Value::Bool(true)), + "false" => Node::Literal(Value::Bool(false)), + _ => { + if ![ + "length", + "keys", + "has", + "contains", + "select", + "reverse", + "sort", + "unique", + "flatten", + "any", + "all", + "map", + "map_values", + "filter", + "to_entries", + "from_entries", + "with_entries", + "any_c", + "all_c", + "test", + "sub", + "split", + "join", + "upcase", + "downcase", + "trim", + "tostring", + "tonumber", + "type", + "not", + "del", + ] + .contains(&name.as_str()) + { + return Err(error("unsupported provider override function")); + } + let mut arguments = Vec::new(); + if self.symbol("(") && !self.symbol(")") { + loop { + let collecting = [ + "map", + "map_values", + "filter", + "with_entries", + "any_c", + "all_c", + ] + .contains(&name.as_str()); + arguments + .push(self.expression(if collecting { 0 } else { 2 }, depth + 1)?); + if self.symbol(")") { + break; + } + if !self.symbol(",") { + self.expect(";")?; + } + } + } + if name == "del" { + Node::Delete(arguments) + } else { + Node::Call(name, arguments) + } + } + }, + _ => return Err(error("invalid provider override value")), + }; + self.account(node) + } + fn field(&mut self) -> Result { + match self.next() { + Token::Word(name) => Ok(Segment::Key(name)), + Token::String(raw) => Ok(Segment::Key(decode_string(&raw)?)), + _ => Err(error("invalid provider override field")), + } + } +} +fn decode_string(raw: &str) -> Result { + let mut output = String::new(); + let mut chars = raw.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' { + match chars.next() { + Some('n') => output.push('\n'), + Some('"') => output.push('"'), + Some('\\') => output.push('\\'), + Some(other) => { + output.push('\\'); + output.push(other); + } + None => return Err(error("invalid provider override string")), + } + } else { + output.push(ch); + } + } + Ok(output) +} +fn interpolation(raw: &str, limits: &mut CompileBudget, depth: usize) -> Result> { + let mut parts = Vec::new(); + let mut plain = String::new(); + let mut cursor = 0; + while cursor < raw.len() { + if raw[cursor..].starts_with("\\\\") { + plain.push_str("\\\\"); + cursor += 2; + continue; + } + if raw[cursor..].starts_with("\\(") { + if !plain.is_empty() { + parts.push(Part::Text(decode_string(&plain)?)); + plain.clear(); + } + cursor += 2; + let start = cursor; + let mut nested = 1; + let mut quoted = false; + let mut escaped = false; + while cursor < raw.len() { + let byte = raw.as_bytes()[cursor]; + if !escaped && byte == b'"' { + quoted = !quoted; + } + if !quoted && !escaped { + if byte == b'(' { + nested += 1; + } else if byte == b')' { + nested -= 1; + if nested == 0 { + break; + } + } + } + escaped = !escaped && byte == b'\\'; + cursor += 1; + } + if nested != 0 { + return Err(error("invalid provider override interpolation")); + } + let expression = raw[start..cursor].replace("\\\"", "\""); + parts.push(Part::Expression(parse(&expression, limits, depth + 1)?)); + cursor += 1; + } else { + let ch = raw[cursor..].chars().next().expect("valid character"); + plain.push(ch); + cursor += ch.len_utf8(); + } + } + if !plain.is_empty() { + parts.push(Part::Text(decode_string(&plain)?)); + } + Ok(parts) +} +fn number_value(text: &str, conversion: bool) -> Result { + let integer = if let Some(digits) = text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) + { + i64::from_str_radix(digits, 16).ok() + } else if conversion && let Some(digits) = text.strip_prefix("0o") { + i64::from_str_radix(digits, 8).ok() + } else { + text.parse::().ok() + }; + if let Some(integer) = integer { + return Ok(Value::Number(integer.into())); + } + if !conversion && !text.contains(['.', 'e', 'E']) { + return Err(error("invalid provider override integer")); + } + if text.starts_with("0b") + || text.starts_with("0B") + || text.starts_with("0o") + || text.starts_with("0O") + || text.starts_with("0x") + || text.starts_with("0X") + { + return Err(error("invalid provider override number")); + } + let number = text + .parse::() + .map_err(|_| error("invalid provider override number"))?; + if !number.is_finite() + && !(conversion + && matches!( + text, + "NaN" + | "nan" + | "Inf" + | "inf" + | "+Inf" + | "+inf" + | "-Inf" + | "-inf" + | "Infinity" + | "infinity" + | "+Infinity" + | "+infinity" + | "-Infinity" + | "-infinity" + )) + { + return Err(error("provider override number overflow")); + } + Ok(Value::Number(Number::from(number))) +} + +fn validate(node: &Node, depth: usize) -> Result<()> { + if depth > PARSE_DEPTH { + return Err(error("provider override expression depth exceeded")); + } + match node { + Node::Update(_, target, value) => { + validate_target(target)?; + validate(target, depth + 1)?; + validate(value, depth + 1)?; + } + Node::Delete(paths) => { + if paths.is_empty() { + return Err(error("invalid provider override deletion")); + } + for path in paths { + validate_target(path)?; + if matches!(path, Node::Path(path) if path.is_empty()) { + return Err(error("provider override cannot delete the root")); + } + validate(path, depth + 1)?; + } + } + Node::Call(name, arguments) => { + let expected = match name.as_str() { + "has" | "contains" | "select" | "map" | "map_values" | "filter" + | "with_entries" | "any_c" | "all_c" | "split" | "join" => 1..=1, + "sub" => 2..=2, + "test" => 1..=2, + "flatten" => 0..=1, + _ => 0..=0, + }; + if !expected.contains(&arguments.len()) { + return Err(error("invalid provider override function arguments")); + } + for argument in arguments { + validate(argument, depth + 1)?; + } + } + Node::Pipe(a, b) | Node::Union(a, b) | Node::Binary(_, a, b) => { + validate(a, depth + 1)?; + validate(b, depth + 1)?; + } + Node::Array(node) | Node::Neg(node) | Node::Group(node) => validate(node, depth + 1)?, + Node::Object(entries) => { + for (_, node) in entries { + validate(node, depth + 1)?; + } + } + Node::String(parts) => { + for part in parts { + if let Part::Expression(node) = part { + validate(node, depth + 1)?; + } + } + } + _ => {} + } + Ok(()) +} +fn validate_target(node: &Node) -> Result<()> { + match node { + Node::Path(_) => Ok(()), + Node::Group(node) => validate_target(node), + Node::Pipe(a, b) | Node::Union(a, b) => { + validate_target(a)?; + validate_target(b) + } + Node::Call(name, arguments) if name == "select" && arguments.len() == 1 => Ok(()), + _ => Err(error("invalid provider override update target")), + } +} +fn compile_literal_regexes( + node: &Node, + output: &mut BTreeMap, + limits: &mut CompileBudget, +) -> Result<()> { + limits.check()?; + match node { + Node::Call(name, arguments) => { + if matches!(name.as_str(), "test" | "sub") + && let Some(Node::String(parts)) = arguments.first() + && parts.iter().all(|part| matches!(part, Part::Text(_))) + { + let pattern = parts + .iter() + .filter_map(|part| { + if let Part::Text(text) = part { + Some(text.as_str()) + } else { + None + } + }) + .collect::(); + if pattern.len() > 4096 || output.len() >= 32 { + return Err(error("provider override regex compilation budget exceeded")); + } + let regex = if let Some(regex) = limits.regexes.get(&pattern) { + regex.clone() + } else { + if limits.regexes.len() >= 32 { + return Err(error("provider override regex compilation budget exceeded")); + } + let regex = compile_regex(&pattern)?; + limits.regexes.insert(pattern.clone(), regex.clone()); + regex + }; + output.insert(pattern, regex); + } + for node in arguments { + compile_literal_regexes(node, output, limits)?; + } + } + Node::Pipe(a, b) | Node::Union(a, b) | Node::Binary(_, a, b) | Node::Update(_, a, b) => { + compile_literal_regexes(a, output, limits)?; + compile_literal_regexes(b, output, limits)?; + } + Node::Array(node) | Node::Neg(node) | Node::Group(node) => { + compile_literal_regexes(node, output, limits)? + } + Node::Delete(nodes) => { + for node in nodes { + compile_literal_regexes(node, output, limits)?; + } + } + Node::Object(entries) => { + for (_, node) in entries { + compile_literal_regexes(node, output, limits)?; + } + } + Node::String(parts) => { + for part in parts { + if let Part::Expression(node) = part { + compile_literal_regexes(node, output, limits)?; + } + } + } + _ => {} + } + Ok(()) +} + +fn evaluate( + node: &Node, + inputs: Vec, + budget: &mut Budget<'_>, + depth: usize, +) -> Result> { + budget.tick(1)?; + if depth > PARSE_DEPTH { + return Err(error("provider override evaluation depth exceeded")); + } + match node { + Node::Pipe(left, right) => { + let values = evaluate(left, inputs, budget, depth + 1)?; + evaluate(right, values, budget, depth + 1) + } + Node::Union(left, right) => { + let copies = inputs + .iter() + .map(|value| budget.clone_value(value)) + .collect::>>()?; + let mut values = evaluate(left, copies, budget, depth + 1)?; + let other = evaluate(right, inputs, budget, depth + 1)?; + budget.reserve(other.len().saturating_mul(std::mem::size_of::()))?; + values.extend(other); + Ok(values) + } + Node::Group(node) => evaluate(node, inputs, budget, depth + 1), + _ => { + let mut output = Vec::new(); + for input in inputs { + budget.tick(1)?; + let values = single(node, &input, budget, depth + 1)?; + budget.reserve(values.len().saturating_mul(std::mem::size_of::()))?; + output.extend(values); + } + Ok(output) + } + } +} +fn one(node: &Node, input: &Value, budget: &mut Budget<'_>, depth: usize) -> Result> { + let input = budget.clone_value(input)?; + let mut values = evaluate(node, vec![input], budget, depth + 1)?; + if values.len() > 1 { + return Err(error( + "provider override value must collect multiple results", + )); + } + Ok(values.pop()) +} +fn single(node: &Node, input: &Value, budget: &mut Budget<'_>, depth: usize) -> Result> { + budget.tick(1)?; + match node { + Node::Literal(value) => Ok(vec![budget.clone_value(value)?]), + Node::Path(path) => lookup(input, path, budget, 0), + Node::String(parts) => { + let mut output = String::new(); + for part in parts { + let text = match part { + Part::Text(text) => { + budget.reserve(text.len())?; + text.clone() + } + Part::Expression(node) => { + let input = budget.clone_value(input)?; + let result = evaluate(node, vec![input], budget, depth + 1)?; + result + .first() + .map(|value| text(value, budget)) + .transpose()? + .unwrap_or_default() + } + }; + append(&mut output, &text, budget)?; + } + Ok(vec![Value::String(output)]) + } + Node::Array(node) => { + let input = budget.clone_value(input)?; + let values = evaluate(node, vec![input], budget, depth + 1)?; + Ok(vec![Value::Sequence(values)]) + } + Node::Object(entries) => { + let mut map = Mapping::new(); + for (key, node) in entries { + let copy = budget.clone_value(input)?; + let mut values = evaluate(node, vec![copy], budget, depth + 1)?; + if let Some(value) = values.pop() { + budget.reserve(key.len() + std::mem::size_of::() * 2)?; + map.insert(Value::String(key.clone()), value); + } + } + Ok(vec![Value::Mapping(map)]) + } + Node::Neg(node) => { + let Some(value) = one(node, input, budget, depth)? else { + return Ok(vec![]); + }; + if let Some(integer) = value.as_i64() { + Ok(vec![Value::Number(integer.wrapping_neg().into())]) + } else { + Ok(vec![Value::Number(Number::from(-numeric(&value)?))]) + } + } + Node::Binary(operator, left, right) => { + let copy = budget.clone_value(input)?; + let left = evaluate(left, vec![copy], budget, depth + 1)?; + if *operator == Op::Default { + let mut output = Vec::new(); + if left.is_empty() { + let copy = budget.clone_value(input)?; + return evaluate(right, vec![copy], budget, depth + 1); + } + for value in left { + if truthy(&value) { + output.push(value); + } else { + let copy = budget.clone_value(input)?; + output.extend(evaluate(right, vec![copy], budget, depth + 1)?); + } + } + return Ok(output); + } + let copy = budget.clone_value(input)?; + let right = evaluate(right, vec![copy], budget, depth + 1)?; + let count = left + .len() + .checked_mul(right.len()) + .ok_or_else(|| error("provider override output budget exceeded"))?; + budget.reserve(count.saturating_mul(std::mem::size_of::()))?; + let mut output = Vec::with_capacity(count); + for a in &left { + for b in &right { + output.push(binary(*operator, a, b, budget, 0)?); + } + } + Ok(output) + } + Node::Call(name, arguments) => call(name, arguments, input, budget, depth), + Node::Update(operation, target, value) => { + let paths = select_paths(target, input, vec![vec![]], budget, depth + 1)?; + let mut output = budget.clone_value(input)?; + for path in paths { + let old = get_path(input, &path).unwrap_or(&Value::Null); + let context = if *operation == Update::Replace { + old + } else { + input + }; + let Some(right) = one(value, context, budget, depth)? else { + continue; + }; + let replacement = match operation { + Update::Assign | Update::Replace => right, + Update::Add => binary(Op::Add, old, &right, budget, 0)?, + Update::Sub => binary(Op::Sub, old, &right, budget, 0)?, + Update::Mul => binary(Op::Mul, old, &right, budget, 0)?, + }; + set_path(&mut output, &path, replacement, budget, 0)?; + } + Ok(vec![output]) + } + Node::Delete(targets) => { + let mut paths = Vec::new(); + for target in targets { + paths.extend(select_paths( + target, + input, + vec![vec![]], + budget, + depth + 1, + )?); + } + if paths.iter().any(Vec::is_empty) { + return Err(error("provider override cannot delete the root")); + } + // Long paths first; equal-depth array indexes descend, preserving original selectors. + paths.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| b.cmp(a))); + paths.dedup(); + let mut output = budget.clone_value(input)?; + for path in paths { + delete_path(&mut output, &path, budget)?; + } + Ok(vec![output]) + } + Node::Pipe(_, _) | Node::Union(_, _) | Node::Group(_) => { + let input = budget.clone_value(input)?; + evaluate(node, vec![input], budget, depth + 1) + } + } +} +fn append(output: &mut String, text: &str, budget: &mut Budget<'_>) -> Result<()> { + budget.reserve(text.len())?; + output.push_str(text); + Ok(()) +} +fn lookup( + value: &Value, + path: &[Segment], + budget: &mut Budget<'_>, + depth: usize, +) -> Result> { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + let Some((segment, rest)) = path.split_first() else { + return Ok(vec![budget.clone_value(value)?]); + }; + match segment { + Segment::Key(key) => lookup( + value + .as_mapping() + .and_then(|map| map.get(key.as_str())) + .unwrap_or(&Value::Null), + rest, + budget, + depth + 1, + ), + Segment::Index(index) => lookup( + value + .as_sequence() + .and_then(|array| { + normalized_index(*index, array.len()).and_then(|index| array.get(index)) + }) + .unwrap_or(&Value::Null), + rest, + budget, + depth + 1, + ), + Segment::Each => { + let mut output = Vec::new(); + if !value.is_sequence() && !value.is_mapping() { + return Ok(output); + } + for (_, value) in members(value, budget)? { + output.extend(lookup(value, rest, budget, depth + 1)?); + } + Ok(output) + } + } +} +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord)] +enum Key { + Field(String), + Index(usize), +} +fn normalized_index(index: i64, length: usize) -> Option { + if index < 0 { + usize::try_from(i64::try_from(length).ok()?.checked_add(index)?).ok() + } else { + usize::try_from(index).ok() + } +} +fn members<'a>(value: &'a Value, budget: &mut Budget<'_>) -> Result> { + let length = match value { + Value::Sequence(values) => values.len(), + Value::Mapping(values) => values.len(), + _ => 0, + }; + budget.tick(length.saturating_mul(1 + length.checked_ilog2().unwrap_or(0) as usize))?; + budget.reserve(length.saturating_mul(std::mem::size_of::<(Key, &Value)>()))?; + if let Value::Mapping(map) = value { + for key in map.keys() { + if let Some(key) = key.as_str() { + budget.reserve(key.len())?; + } + } + } + match value { + Value::Sequence(values) => Ok(values + .iter() + .enumerate() + .map(|(index, value)| (Key::Index(index), value)) + .collect()), + Value::Mapping(map) => { + let mut values = map + .iter() + .map(|(key, value)| { + key.as_str() + .map(|key| (Key::Field(key.into()), value)) + .ok_or_else(|| error("provider override requires string mapping keys")) + }) + .collect::>>()?; + values.sort_by(|(a, _), (b, _)| a.cmp(b)); + Ok(values) + } + _ => Err(error( + "provider override collection function requires an array or mapping", + )), + } +} +fn get_path<'a>(value: &'a Value, path: &[Key]) -> Option<&'a Value> { + let mut current = value; + for key in path { + current = match key { + Key::Field(key) => current.as_mapping()?.get(key.as_str())?, + Key::Index(index) => current.as_sequence()?.get(*index)?, + }; + } + Some(current) +} +fn select_paths( + node: &Node, + root: &Value, + inputs: Vec>, + budget: &mut Budget<'_>, + depth: usize, +) -> Result>> { + budget.tick(1)?; + if depth > PARSE_DEPTH { + return Err(error("provider override target depth exceeded")); + } + match node { + Node::Group(node) => select_paths(node, root, inputs, budget, depth + 1), + Node::Pipe(left, right) => { + let inputs = select_paths(left, root, inputs, budget, depth + 1)?; + select_paths(right, root, inputs, budget, depth + 1) + } + Node::Union(left, right) => { + budget.reserve( + inputs + .iter() + .map(|path| { + path.len() * std::mem::size_of::() + + path + .iter() + .map(|key| match key { + Key::Field(key) => key.len(), + Key::Index(_) => 0, + }) + .sum::() + }) + .sum(), + )?; + let mut output = select_paths(left, root, inputs.clone(), budget, depth + 1)?; + output.extend(select_paths(right, root, inputs, budget, depth + 1)?); + Ok(output) + } + Node::Call(name, args) if name == "select" => { + let mut output = Vec::new(); + for path in inputs { + if let Some(value) = one( + &args[0], + get_path(root, &path).unwrap_or(&Value::Null), + budget, + depth, + )? && truthy(&value) + { + output.push(path); + } + } + Ok(output) + } + Node::Path(segments) => { + let mut paths = inputs; + for segment in segments { + let mut next = Vec::new(); + for path in paths { + budget.tick(1)?; + let old = get_path(root, &path).unwrap_or(&Value::Null); + let keys = match segment { + Segment::Key(key) if old.is_null() || old.is_mapping() => { + budget.reserve(key.len() + std::mem::size_of::())?; + vec![Key::Field(key.clone())] + } + Segment::Index(index) if old.is_null() || old.is_sequence() => { + normalized_index(*index, old.as_sequence().map_or(0, Vec::len)) + .map(|index| vec![Key::Index(index)]) + .unwrap_or_default() + } + Segment::Each if old.is_sequence() || old.is_mapping() => { + members(old, budget)? + .into_iter() + .map(|(key, _)| key) + .collect() + } + _ => vec![], + }; + for key in keys { + budget.reserve((path.len() + 1) * std::mem::size_of::())?; + for key in &path { + if let Key::Field(key) = key { + budget.reserve(key.len())?; + } + } + let mut selected = path.clone(); + selected.push(key); + next.push(selected); + } + } + paths = next; + } + Ok(paths) + } + _ => Err(error("invalid provider override update target")), + } +} +fn set_path( + value: &mut Value, + path: &[Key], + replacement: Value, + budget: &mut Budget<'_>, + depth: usize, +) -> Result<()> { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override target depth exceeded")); + } + let Some((key, rest)) = path.split_first() else { + *value = replacement; + return Ok(()); + }; + match key { + Key::Field(key) => { + if value.is_null() { + *value = Value::Mapping(Mapping::new()); + } + if let Some(map) = value.as_mapping_mut() { + if !map.contains_key(key.as_str()) { + budget.reserve(key.len() + 2 * std::mem::size_of::())?; + map.insert(Value::String(key.clone()), Value::Null); + } + set_path( + map.get_mut(key.as_str()).expect("inserted entry"), + rest, + replacement, + budget, + depth + 1, + )?; + } + } + Key::Index(index) => { + if value.is_null() { + *value = Value::Sequence(vec![]); + } + if let Some(array) = value.as_sequence_mut() { + if *index >= array.len() { + let length = index + .checked_add(1) + .ok_or_else(|| error("provider override array budget exceeded"))?; + budget.tick(length - array.len())?; + budget.reserve( + (length - array.len()) + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| error("provider override array budget exceeded"))?, + )?; + array.resize(length, Value::Null); + } + set_path(&mut array[*index], rest, replacement, budget, depth + 1)?; + } + } + } + Ok(()) +} +fn delete_path(value: &mut Value, path: &[Key], budget: &mut Budget<'_>) -> Result<()> { + budget.tick(1)?; + let Some((key, rest)) = path.split_first() else { + return Err(error("provider override cannot delete the root")); + }; + match key { + Key::Field(key) => { + if let Some(map) = value.as_mapping_mut() { + if rest.is_empty() { + map.remove(key.as_str()); + } else if let Some(value) = map.get_mut(key.as_str()) { + delete_path(value, rest, budget)?; + } + } + } + Key::Index(index) => { + if let Some(array) = value.as_sequence_mut() + && *index < array.len() + { + if rest.is_empty() { + array.remove(*index); + } else { + delete_path(&mut array[*index], rest, budget)?; + } + } + } + } + Ok(()) +} + +fn truthy(value: &Value) -> bool { + !matches!(value, Value::Null | Value::Bool(false)) +} +fn scalar_text(value: &Value) -> Option { + match value { + Value::Null => Some("null".into()), + Value::Bool(value) => Some(value.to_string()), + Value::Number(value) if value.is_f64() => value.as_f64().map(float_text), + Value::Number(value) => Some(value.to_string()), + Value::String(value) => Some(value.clone()), + _ => None, + } +} +fn float_text(value: f64) -> String { + if value.is_nan() { + return "NaN".into(); + } + if value.is_infinite() { + return if value.is_sign_negative() { + "-Inf" + } else { + "+Inf" + } + .into(); + } + let scientific = format!("{value:e}"); + let (mantissa, exponent) = scientific.split_once('e').expect("float exponent"); + let exponent: i32 = exponent.parse().expect("numeric exponent"); + if !(-4..6).contains(&exponent) { + format!("{mantissa}e{exponent:+03}") + } else { + value.to_string() + } +} +fn text(value: &Value, budget: &mut Budget<'_>) -> Result { + if let Value::String(value) = value { + budget.reserve(value.len())?; + return Ok(value.clone()); + } + if let Some(text) = scalar_text(value) { + budget.reserve(text.len())?; + return Ok(text); + } + budget.measure(value, 0)?; + let sorted = sorted_value(value, budget, 0)?; + render(&sorted, budget) +} +fn render(value: &Value, budget: &mut Budget<'_>) -> Result { + let mut output = String::new(); + render_node(value, 0, false, &mut output, budget, 0)?; + if output.ends_with('\n') { + output.pop(); + } + Ok(output) +} + +fn render_node( + value: &Value, + indentation: usize, + inline: bool, + output: &mut String, + budget: &mut Budget<'_>, + depth: usize, +) -> Result<()> { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + let pad = |output: &mut String, budget: &mut Budget<'_>| { + for _ in 0..indentation { + append(output, " ", budget)?; + } + Ok::<_, VoleError>(()) + }; + match value { + Value::Mapping(map) if !map.is_empty() => { + let mut fields = members(value, budget)?; + fields.sort_by(|(a, _), (b, _)| { + let (Key::Field(a), Key::Field(b)) = (a, b) else { + unreachable!() + }; + yaml_key_order(a, b) + }); + for (index, (key, value)) in fields.into_iter().enumerate() { + if !inline || index != 0 { + pad(output, budget)?; + } + let Key::Field(key) = key else { unreachable!() }; + let rendered = yaml_scalar(&Value::String(key), budget)?; + append(output, &rendered, budget)?; + append(output, ":", budget)?; + if matches!(value, Value::Mapping(map) if !map.is_empty()) + || matches!(value, Value::Sequence(items) if !items.is_empty()) + { + append(output, "\n", budget)?; + render_node( + value, + (indentation / 4 + 1) * 4, + false, + output, + budget, + depth + 1, + )?; + } else { + append(output, " ", budget)?; + render_node(value, indentation, true, output, budget, depth + 1)?; + } + } + } + Value::Sequence(items) if !items.is_empty() => { + for (index, value) in items.iter().enumerate() { + if !inline || index != 0 { + pad(output, budget)?; + } + append(output, "- ", budget)?; + render_node(value, indentation + 2, true, output, budget, depth + 1)?; + } + } + Value::String(text) + if text.contains('\n') + && !text + .chars() + .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\t')) => + { + if !inline { + pad(output, budget)?; + } + let suffix = if !text.ends_with('\n') { + "-" + } else if text.ends_with("\n\n") || text == "\n" { + "+" + } else { + "" + }; + let explicit_indent = text + .lines() + .find(|line| !line.is_empty()) + .is_some_and(|line| line.starts_with(' ')); + append(output, "|", budget)?; + if explicit_indent { + append( + output, + if indentation.is_multiple_of(4) { + "4" + } else { + "2" + }, + budget, + )?; + } + append(output, suffix, budget)?; + append(output, "\n", budget)?; + for line in text.split_inclusive('\n') { + if line != "\n" { + for _ in 0..(indentation / 4 + 1) * 4 { + append(output, " ", budget)?; + } + } + append(output, line, budget)?; + if !line.ends_with('\n') { + append(output, "\n", budget)?; + } + } + } + _ => { + if !inline { + pad(output, budget)?; + } + let rendered = yaml_scalar(value, budget)?; + append(output, &rendered, budget)?; + append(output, "\n", budget)?; + } + } + Ok(()) +} + +fn yaml_scalar(value: &Value, budget: &mut Budget<'_>) -> Result { + if let Value::Number(number) = value { + return Ok(if number.is_f64() { + let number = number.as_f64().expect("float"); + if number.is_nan() { + ".nan".into() + } else if number.is_infinite() { + if number.is_sign_negative() { + "-.inf" + } else { + ".inf" + } + .into() + } else { + float_text(number) + } + } else { + number.to_string() + }); + } + if let Value::String(text) = value { + budget.reserve(text.len().saturating_mul(6).saturating_add(2))?; + let lower = text.to_ascii_lowercase(); + let ambiguous = text.is_empty() + || matches!( + lower.as_str(), + "y" | "n" + | "yes" + | "no" + | "on" + | "off" + | "true" + | "false" + | "null" + | "~" + | ".inf" + | "+.inf" + | "-.inf" + | ".nan" + ) + || text.parse::().is_ok() + || ["0x", "0X", "0o", "0O", "0b", "0B"].iter().any(|prefix| { + text.strip_prefix(['+', '-']) + .unwrap_or(text) + .strip_prefix(prefix) + .is_some_and(|digits| { + !digits.is_empty() + && digits.bytes().all(|byte| match *prefix { + "0x" | "0X" => byte.is_ascii_hexdigit() || byte == b'_', + "0o" | "0O" => matches!(byte, b'0'..=b'7' | b'_'), + _ => matches!(byte, b'0' | b'1' | b'_'), + }) + }) + }) + || (text.len() >= 10 + && text.as_bytes()[4] == b'-' + && text.as_bytes()[7] == b'-' + && text[..4].bytes().all(|byte| byte.is_ascii_digit())) + || text + .chars() + .any(|ch| ch.is_control() || matches!(ch, '\u{a0}' | '\u{2028}' | '\u{2029}')); + if ambiguous { + let mut output = String::new(); + append(&mut output, "\"", budget)?; + for character in text.chars() { + let escaped = match character { + '\0' => Some(r"\0"), + '\u{7}' => Some(r"\a"), + '\u{8}' => Some(r"\b"), + '\t' => Some(r"\t"), + '\n' => Some(r"\n"), + '\u{b}' => Some(r"\v"), + '\u{c}' => Some(r"\f"), + '\r' => Some(r"\r"), + '\u{1b}' => Some(r"\e"), + '\u{85}' => Some(r"\N"), + '\u{a0}' => Some(r"\_"), + '\u{2028}' => Some(r"\L"), + '\u{2029}' => Some(r"\P"), + '"' => Some(r#"\""#), + '\\' => Some(r"\\"), + _ => None, + }; + if let Some(escaped) = escaped { + append(&mut output, escaped, budget)?; + } else if character.is_control() { + append(&mut output, &format!("\\x{:02X}", character as u32), budget)?; + } else { + let mut encoded = [0; 4]; + append(&mut output, character.encode_utf8(&mut encoded), budget)?; + } + } + append(&mut output, "\"", budget)?; + return Ok(output); + } + } + let rendered = serde_yaml_ng::to_string(value) + .map_err(|_| error("provider override value cannot be rendered"))?; + budget.reserve(rendered.len())?; + Ok(rendered.trim_end_matches('\n').into()) +} + +fn yaml_key_order(left: &str, right: &str) -> Ordering { + let (mut left, mut right) = (left.as_bytes(), right.as_bytes()); + while let (Some(a), Some(b)) = (left.first(), right.first()) { + if a.is_ascii_digit() && b.is_ascii_digit() { + let left_end = left + .iter() + .position(|byte| !byte.is_ascii_digit()) + .unwrap_or(left.len()); + let right_end = right + .iter() + .position(|byte| !byte.is_ascii_digit()) + .unwrap_or(right.len()); + let (a, b) = (&left[..left_end], &right[..right_end]); + let a = &a[a.iter().position(|byte| *byte != b'0').unwrap_or(a.len())..]; + let b = &b[b.iter().position(|byte| *byte != b'0').unwrap_or(b.len())..]; + let ordering = a + .len() + .cmp(&b.len()) + .then_with(|| a.cmp(b)) + .then_with(|| left_end.cmp(&right_end)); + if !ordering.is_eq() { + return ordering; + } + left = &left[left_end..]; + right = &right[right_end..]; + } else { + let ordering = a.cmp(b); + if !ordering.is_eq() { + return ordering; + } + left = &left[1..]; + right = &right[1..]; + } + } + left.len().cmp(&right.len()) +} +fn sorted_value(value: &Value, budget: &mut Budget<'_>, depth: usize) -> Result { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + match value { + Value::Mapping(_) => { + let mut map = Mapping::new(); + for (key, value) in members(value, budget)? { + let Key::Field(key) = key else { unreachable!() }; + budget.reserve(key.len() + 2 * std::mem::size_of::())?; + map.insert(Value::String(key), sorted_value(value, budget, depth + 1)?); + } + Ok(Value::Mapping(map)) + } + Value::Sequence(values) => Ok(Value::Sequence( + values + .iter() + .map(|value| sorted_value(value, budget, depth + 1)) + .collect::>>()?, + )), + _ => budget.clone_value(value), + } +} +fn equality(a: &Value, b: &Value) -> bool { + if a.is_null() || b.is_null() { + return a.is_null() && b.is_null(); + } + if let (Value::Number(a), Value::Number(b)) = (a, b) { + return a.is_f64() == b.is_f64() && a.as_f64() == b.as_f64(); + } + scalar_text(a) + .zip(scalar_text(b)) + .is_some_and(|(a, b)| a == b) +} +fn numeric(value: &Value) -> Result { + value + .as_f64() + .ok_or_else(|| error("provider override operator requires numbers")) +} +fn scalar_order(a: &Value, b: &Value) -> Result { + match (a, b) { + (Value::Null, Value::Null) => Ok(Ordering::Equal), + (Value::Null, _) => Ok(Ordering::Greater), + (_, Value::Null) => Ok(Ordering::Less), + (Value::Number(_), Value::Number(_)) => numeric(a)? + .partial_cmp(&numeric(b)?) + .ok_or_else(|| error("provider override cannot order nonfinite numbers")), + (Value::String(a), Value::String(b)) => Ok(a.cmp(b)), + _ => Err(error( + "provider override ordered comparison requires compatible scalars", + )), + } +} +fn sort_order(a: &Value, b: &Value) -> Ordering { + fn rank(value: &Value) -> u8 { + match value { + Value::Null => 0, + Value::Bool(_) => 1, + Value::Number(_) => 2, + Value::String(_) => 3, + _ => 4, + } + } + rank(a).cmp(&rank(b)).then_with(|| match (a, b) { + (Value::Bool(a), Value::Bool(b)) => a.cmp(b), + (Value::Number(_), Value::Number(_)) => a + .as_f64() + .unwrap_or(0.0) + .total_cmp(&b.as_f64().unwrap_or(0.0)), + (Value::String(a), Value::String(b)) => a.cmp(b), + _ => Ordering::Equal, + }) +} +fn binary( + operator: Op, + a: &Value, + b: &Value, + budget: &mut Budget<'_>, + depth: usize, +) -> Result { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + match operator { + Op::Eq => return Ok(Value::Bool(equality(a, b))), + Op::Ne => return Ok(Value::Bool(!equality(a, b))), + Op::And => return Ok(Value::Bool(truthy(a) && truthy(b))), + Op::Or => return Ok(Value::Bool(truthy(a) || truthy(b))), + Op::Lt | Op::Le | Op::Gt | Op::Ge => { + if a.is_null() || b.is_null() { + return Ok(Value::Bool( + a.is_null() && b.is_null() && matches!(operator, Op::Le | Op::Ge), + )); + } + let ordering = scalar_order(a, b)?; + return Ok(Value::Bool(match operator { + Op::Lt => ordering.is_lt(), + Op::Le => !ordering.is_gt(), + Op::Gt => ordering.is_gt(), + _ => !ordering.is_lt(), + })); + } + _ => {} + } + if matches!(operator, Op::Add | Op::Sub) && a.is_null() { + return budget.clone_value(b); + } + if operator == Op::Mul && b.is_null() { + return budget.clone_value(a); + } + if operator == Op::Mul && a.is_null() && (b.is_sequence() || b.is_mapping()) { + return budget.clone_value(b); + } + if operator == Op::Add && b.is_null() && (a.is_string() || a.is_sequence()) { + return budget.clone_value(a); + } + if operator == Op::Add && b.is_string() && !a.is_sequence() && !a.is_mapping() { + let left = scalar_text(a) + .ok_or_else(|| error("provider override string addition requires a scalar"))?; + let mut output = String::new(); + append(&mut output, &left, budget)?; + append(&mut output, b.as_str().expect("string"), budget)?; + return Ok(Value::String(output)); + } + if operator == Op::Add { + match (a, b) { + (Value::String(left), _) => { + let Some(right) = scalar_text(b) else { + return Err(error("provider override string addition requires a scalar")); + }; + let mut result = String::new(); + append(&mut result, left, budget)?; + append(&mut result, &right, budget)?; + return Ok(Value::String(result)); + } + (Value::Sequence(left), Value::Sequence(right)) => { + let mut values = left + .iter() + .map(|value| budget.clone_value(value)) + .collect::>>()?; + for value in right { + values.push(budget.clone_value(value)?); + } + return Ok(Value::Sequence(values)); + } + (Value::Sequence(left), _) => { + let mut values = left + .iter() + .map(|value| budget.clone_value(value)) + .collect::>>()?; + values.push(budget.clone_value(b)?); + return Ok(Value::Sequence(values)); + } + (Value::Mapping(left), Value::Mapping(right)) => { + let Value::Mapping(mut result) = budget.clone_value(a)? else { + unreachable!() + }; + for (key, value) in right { + result.insert(budget.clone_value(key)?, budget.clone_value(value)?); + } + let _ = left; + return Ok(Value::Mapping(result)); + } + _ => {} + } + } + if operator == Op::Sub + && let (Value::Sequence(left), Value::Sequence(right)) = (a, b) + { + let mut result = Vec::new(); + for value in left { + if !right.iter().any(|other| value == other) { + result.push(budget.clone_value(value)?); + } + } + return Ok(Value::Sequence(result)); + } + if operator == Op::Mul { + if let (Value::Mapping(_), Value::Mapping(right)) = (a, b) { + let Value::Mapping(mut result) = budget.clone_value(a)? else { + unreachable!() + }; + for (key, value) in right { + let merged = if let Some(old) = result.get(key) + && old.is_mapping() + && value.is_mapping() + { + binary(Op::Mul, old, value, budget, depth + 1)? + } else { + budget.clone_value(value)? + }; + result.insert(budget.clone_value(key)?, merged); + } + return Ok(Value::Mapping(result)); + } + if a.is_sequence() && b.is_sequence() { + return budget.clone_value(b); + } + let string_number = match (a, b) { + (Value::String(text), Value::Number(number)) + | (Value::Number(number), Value::String(text)) => Some((text, number)), + _ => None, + }; + if let Some((text, number)) = string_number { + let count = number + .as_i64() + .and_then(|number| usize::try_from(number).ok()) + .ok_or_else(|| { + error("provider override string repetition requires a nonnegative integer") + })?; + let bytes = text + .len() + .checked_mul(count) + .ok_or_else(|| error("provider override output budget exceeded"))?; + budget.reserve(bytes)?; + return Ok(Value::String(text.repeat(count))); + } + } + if operator == Op::Div + && let (Value::String(text), Value::String(separator)) = (a, b) + { + return split(text, separator, budget); + } + if !a.is_number() || !b.is_number() { + return Err(error( + "provider override operator has incompatible operands", + )); + } + if matches!(a, Value::Number(number) if !number.is_f64()) + && matches!(b, Value::Number(number) if !number.is_f64()) + && operator != Op::Div + { + // The reference dialect computes signed platform integers after a + // float64 projection. Preserve its 64-bit arithmetic and wrap behavior. + let integer = |value: &Value| -> Result { + let value = numeric(value)?; + Ok( + if value >= 9_223_372_036_854_775_808.0 || value < -9_223_372_036_854_775_808.0 { + i64::MIN + } else { + value as i64 + }, + ) + }; + let (a, b) = (integer(a)?, integer(b)?); + let result = match operator { + Op::Add => a.wrapping_add(b), + Op::Sub => a.wrapping_sub(b), + Op::Mul => a.wrapping_mul(b), + Op::Mod if b == 0 => return Err(error("provider override cannot modulo integer zero")), + Op::Mod if a == i64::MIN && b == -1 => 0, + Op::Mod => a % b, + _ => return Err(error("invalid provider override operator")), + }; + return Ok(Value::Number(result.into())); + } + let (a, b) = (numeric(a)?, numeric(b)?); + let number = match operator { + Op::Add => a + b, + Op::Sub => a - b, + Op::Mul => a * b, + Op::Div => a / b, + Op::Mod => a % b, + _ => return Err(error("invalid provider override operator")), + }; + Ok(Value::Number(Number::from(number))) +} +fn split(text: &str, separator: &str, budget: &mut Budget<'_>) -> Result { + let mut values = Vec::new(); + if text.is_empty() && separator.is_empty() { + return Ok(Value::Sequence(values)); + } + if separator.is_empty() { + for ch in text.chars() { + budget.reserve(ch.len_utf8() + std::mem::size_of::())?; + values.push(Value::String(ch.to_string())); + } + } else { + for part in text.split(separator) { + budget.reserve(part.len() + std::mem::size_of::())?; + values.push(Value::String(part.into())); + } + } + Ok(Value::Sequence(values)) +} +fn contains( + container: &Value, + wanted: &Value, + budget: &mut Budget<'_>, + depth: usize, +) -> Result { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + match (container, wanted) { + (Value::String(container), Value::String(wanted)) => Ok(container.contains(wanted)), + (Value::Mapping(container), Value::Mapping(wanted)) => { + for (key, value) in wanted { + let Some(other) = container.get(key) else { + return Ok(false); + }; + if !contains(other, value, budget, depth + 1)? { + return Ok(false); + } + } + Ok(true) + } + (Value::Sequence(container), Value::Sequence(wanted)) => { + for value in wanted { + let mut found = false; + for other in container { + if contains(other, value, budget, depth + 1)? { + found = true; + break; + } + } + if !found { + return Ok(false); + } + } + Ok(true) + } + (Value::Number(_), Value::Number(_)) => Ok(equality(container, wanted)), + _ => Ok(container == wanted), + } +} + +fn call( + name: &str, + args: &[Node], + input: &Value, + budget: &mut Budget<'_>, + depth: usize, +) -> Result> { + budget.tick(1)?; + if depth > PARSE_DEPTH { + return Err(error("provider override evaluation depth exceeded")); + } + if name == "select" { + return if one(&args[0], input, budget, depth)?.is_some_and(|value| truthy(&value)) { + Ok(vec![budget.clone_value(input)?]) + } else { + Ok(vec![]) + }; + } + if [ + "map", + "map_values", + "filter", + "any_c", + "all_c", + "with_entries", + ] + .contains(&name) + { + if name == "with_entries" { + if !input.is_mapping() { + return Err(error("provider override with_entries requires a mapping")); + } + let entries = entries(input, budget)?; + let mut result = Vec::new(); + for entry in entries { + result.extend(evaluate(&args[0], vec![entry], budget, depth + 1)?); + } + return Ok(vec![from_entries(&Value::Sequence(result), budget)?]); + } + if matches!(name, "any_c" | "all_c") && !input.is_sequence() { + return Err(error( + "provider override conditional query requires an array", + )); + } + let members = members(input, budget)?; + if name == "map" { + let inputs = members + .iter() + .map(|(_, value)| budget.clone_value(value)) + .collect::>>()?; + return Ok(vec![Value::Sequence(evaluate( + &args[0], + inputs, + budget, + depth + 1, + )?)]); + } + if matches!(name, "any_c" | "all_c") { + let mut result = name == "all_c"; + for (_, value) in members { + let value = budget.clone_value(value)?; + let outputs = evaluate(&args[0], vec![value], budget, depth + 1)?; + let Some(first) = outputs.first() else { + continue; + }; + let matched = truthy(first); + if name == "any_c" && matched { + result = true; + } else if name == "all_c" && !matched { + result = false; + } + } + return Ok(vec![Value::Bool(result)]); + } + if name == "filter" { + let mut output = Vec::new(); + for (_, value) in members { + let copy = budget.clone_value(value)?; + if evaluate(&args[0], vec![copy], budget, depth + 1)? + .iter() + .any(truthy) + { + output.push(budget.clone_value(value)?); + } + } + return Ok(vec![Value::Sequence(output)]); + } + let mut output = budget.clone_value(input)?; + for (key, value) in members { + let copy = budget.clone_value(value)?; + if let Some(result) = evaluate(&args[0], vec![copy], budget, depth + 1)? + .into_iter() + .next() + { + set_path(&mut output, &[key], result, budget, 0)?; + } + } + return Ok(vec![output]); + } + let mut arguments = Vec::new(); + for argument in args { + let Some(value) = one(argument, input, budget, depth)? else { + return Ok(vec![]); + }; + arguments.push(value); + } + let output = match name { + "length" => Value::Number( + match input { + Value::Null => 0, + Value::Sequence(value) => value.len(), + Value::Mapping(value) => value.len(), + _ => scalar_text(input) + .ok_or_else(|| error("invalid provider override length input"))? + .len(), + } + .into(), + ), + "keys" => Value::Sequence( + members(input, budget)? + .into_iter() + .map(|(key, _)| match key { + Key::Field(key) => Value::String(key), + Key::Index(index) => Value::Number(index.into()), + }) + .collect(), + ), + "has" => Value::Bool(match input { + Value::Mapping(map) => map.contains_key(Value::String( + arguments[0] + .as_str() + .ok_or_else(|| error("provider override mapping key must be a string"))? + .into(), + )), + Value::Sequence(values) => arguments[0] + .as_i64() + .and_then(|index| normalized_index(index, values.len())) + .is_some_and(|index| index < values.len()), + _ => false, + }), + "contains" => { + let compatible = match input { + Value::Mapping(_) => arguments[0].is_mapping(), + Value::Sequence(_) => arguments[0].is_sequence(), + Value::String(_) => arguments[0].is_string(), + _ => !arguments[0].is_mapping() && !arguments[0].is_sequence(), + }; + if !compatible { + return Err(error( + "provider override contains requires compatible value types", + )); + } + Value::Bool(contains(input, &arguments[0], budget, 0)?) + } + "reverse" => { + let Some(values) = input.as_sequence() else { + return Err(error("provider override reverse requires an array")); + }; + Value::Sequence( + values + .iter() + .rev() + .map(|value| budget.clone_value(value)) + .collect::>>()?, + ) + } + "sort" => { + let Some(values) = input.as_sequence() else { + return Err(error("provider override sort requires an array")); + }; + if values.iter().any(|value| { + value.is_sequence() || value.is_mapping() || matches!(value, Value::Tagged(_)) + }) { + return Err(error("provider override sort requires scalar values")); + } + budget.tick( + values + .len() + .saturating_mul(1 + values.len().checked_ilog2().unwrap_or(0) as usize), + )?; + let mut values = values + .iter() + .map(|value| budget.clone_value(value)) + .collect::>>()?; + values.sort_by(sort_order); + Value::Sequence(values) + } + "unique" => { + let Some(values) = input.as_sequence() else { + return Err(error("provider override unique requires an array")); + }; + let mut seen = std::collections::BTreeSet::new(); + let mut output = Vec::new(); + for value in values { + let key = text(value, budget)?; + if seen.insert(key) { + output.push(budget.clone_value(value)?); + } + } + Value::Sequence(output) + } + "flatten" => { + let Some(values) = input.as_sequence() else { + return Err(error("provider override flatten requires an array")); + }; + let flatten_depth = if arguments.is_empty() { + VALUE_DEPTH + } else { + arguments[0] + .as_i64() + .and_then(|value| usize::try_from(value).ok()) + .ok_or_else(|| { + error("provider override flatten depth must be a nonnegative integer") + })? + }; + let mut output = Vec::new(); + flatten(values, flatten_depth, &mut output, budget, 0)?; + Value::Sequence(output) + } + "any" | "all" => { + let Some(values) = input.as_sequence() else { + return Err(error("provider override query requires an array")); + }; + Value::Bool(if name == "any" { + values.iter().any(truthy) + } else { + values.iter().all(truthy) + }) + } + "to_entries" => { + if input.is_null() { + return Ok(vec![]); + } + Value::Sequence(entries(input, budget)?) + } + "from_entries" => from_entries(input, budget)?, + "test" => { + let pattern = arguments[0] + .as_str() + .ok_or_else(|| error("provider override test pattern must be a string"))?; + if arguments + .get(1) + .is_some_and(|flags| flags.as_str() != Some("g")) + { + return Err(error("unsupported provider override regex flags")); + } + let input = input + .as_str() + .ok_or_else(|| error("provider override test requires a string"))?; + let regex = budget.regex(pattern)?; + budget.tick(input.len() / 64 + 1)?; + Value::Bool(regex.is_match(input)) + } + "sub" => { + let pattern = arguments[0] + .as_str() + .ok_or_else(|| error("provider override replacement pattern must be a string"))?; + let target = arguments[1] + .as_str() + .ok_or_else(|| error("provider override replacement target must be a string"))?; + let input = input + .as_str() + .ok_or_else(|| error("provider override replacement requires a string"))?; + let regex = budget.regex(pattern)?; + let mut output = String::new(); + let mut last = 0; + for captures in regex.captures_iter(input) { + budget.tick(1)?; + let matched = captures.get(0).expect("whole regex match"); + append(&mut output, &input[last..matched.start()], budget)?; + expand_replacement(target, &captures, &mut output, budget)?; + last = matched.end(); + } + append(&mut output, &input[last..], budget)?; + Value::String(output) + } + "split" => { + if input.is_null() { + return Ok(vec![]); + } + let separator = arguments[0] + .as_str() + .ok_or_else(|| error("provider override separator must be a string"))?; + split( + input + .as_str() + .ok_or_else(|| error("provider override split requires a string"))?, + separator, + budget, + )? + } + "join" => { + let separator = arguments[0] + .as_str() + .ok_or_else(|| error("provider override separator must be a string"))?; + let values = input + .as_sequence() + .ok_or_else(|| error("provider override join requires an array"))?; + let mut output = String::new(); + for (index, value) in values.iter().enumerate() { + if index != 0 { + append(&mut output, separator, budget)?; + } + if !value.is_null() + && let Some(value) = scalar_text(value) + { + append(&mut output, &value, budget)?; + } + } + Value::String(output) + } + "upcase" | "downcase" | "trim" => { + let value = input + .as_str() + .ok_or_else(|| error("provider override string transform requires a string"))?; + budget.reserve(value.len().saturating_mul(3))?; + Value::String(match name { + "upcase" => value.to_uppercase(), + "downcase" => value.to_lowercase(), + _ => value.trim().to_owned(), + }) + } + "tostring" => { + if let Value::String(text) = input { + budget.reserve(text.len())?; + Value::String(text.clone()) + } else { + let sorted = sorted_value(input, budget, 0)?; + Value::String(render(&sorted, budget)?) + } + } + "tonumber" => { + if input.is_number() { + budget.clone_value(input)? + } else { + number_value( + input.as_str().ok_or_else(|| { + error("provider override tonumber requires a string or number") + })?, + true, + )? + } + } + "type" => Value::String( + match input { + Value::Null => "!!null", + Value::Bool(_) => "!!bool", + Value::Number(number) if number.is_f64() => "!!float", + Value::Number(_) => "!!int", + Value::String(_) => "!!str", + Value::Sequence(_) => "!!seq", + Value::Mapping(_) => "!!map", + Value::Tagged(_) => { + return Err(error("provider override does not support YAML tags")); + } + } + .into(), + ), + "not" => Value::Bool(!truthy(input)), + _ => return Err(error("unsupported provider override function")), + }; + budget.measure(&output, 0)?; + Ok(vec![output]) +} +fn entries(input: &Value, budget: &mut Budget<'_>) -> Result> { + let mut output = Vec::new(); + for (key, value) in members(input, budget)? { + budget.reserve(2 * std::mem::size_of::())?; + let key = match key { + Key::Field(key) => Value::String(key), + Key::Index(index) => Value::Number(index.into()), + }; + let mut entry = Mapping::new(); + entry.insert("key".into(), key); + entry.insert("value".into(), budget.clone_value(value)?); + output.push(Value::Mapping(entry)); + } + Ok(output) +} +fn from_entries(input: &Value, budget: &mut Budget<'_>) -> Result { + let array = input + .as_sequence() + .ok_or_else(|| error("provider override from_entries requires an array"))?; + let mut map = Mapping::new(); + for entry in array { + budget.tick(1)?; + let entry = entry + .as_mapping() + .ok_or_else(|| error("provider override entries must be mappings"))?; + let key = ["key", "Key", "name", "Name"] + .into_iter() + .find_map(|key| entry.get(key)) + .and_then(Value::as_str) + .ok_or_else(|| error("provider override entry key must be a string"))?; + let value = entry + .get("value") + .or_else(|| entry.get("Value")) + .unwrap_or(&Value::Null); + budget.reserve(key.len() + std::mem::size_of::() * 2)?; + map.insert(key.into(), budget.clone_value(value)?); + } + Ok(Value::Mapping(map)) +} +fn flatten( + values: &[Value], + limit: usize, + output: &mut Vec, + budget: &mut Budget<'_>, + depth: usize, +) -> Result<()> { + budget.tick(1)?; + if depth > VALUE_DEPTH { + return Err(error("provider override value depth exceeded")); + } + for value in values { + if limit != 0 + && let Value::Sequence(nested) = value + { + flatten(nested, limit - 1, output, budget, depth + 1)?; + } else { + output.push(budget.clone_value(value)?); + } + } + Ok(()) +} +fn expand_replacement( + target: &str, + captures: ®ex::Captures<'_>, + output: &mut String, + budget: &mut Budget<'_>, +) -> Result<()> { + let mut cursor = 0; + while cursor < target.len() { + let rest = &target[cursor..]; + if let Some(index) = rest.find('$') { + append(output, &rest[..index], budget)?; + cursor += index + 1; + if target[cursor..].starts_with('$') { + append(output, "$", budget)?; + cursor += 1; + continue; + } + let braced = target[cursor..].starts_with('{'); + let start = cursor + usize::from(braced); + let mut end = start; + for character in target[start..].chars() { + if !character.is_alphanumeric() && character != '_' { + break; + } + end += character.len_utf8(); + } + if end == start || (braced && !target[end..].starts_with('}')) { + append(output, "$", budget)?; + continue; + } + let key = &target[start..end]; + let capture = (!key.starts_with('0') || key.len() == 1) + .then(|| key.parse::().ok()) + .flatten() + .and_then(|index| captures.get(index)) + .or_else(|| captures.name(key)); + if let Some(capture) = capture { + append(output, capture.as_str(), budget)?; + } + cursor = end + usize::from(braced); + } else { + append(output, rest, budget)?; + break; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + fn run(input: &str, expression: &str) -> Value { + let mut value: Value = serde_yaml_ng::from_str(input).unwrap(); + Expr::parse(expression) + .unwrap() + .apply(&mut value, &mut Budget::new(None)) + .unwrap(); + value + } + #[test] + fn transforms_preserve_stream_order_and_update_context() { + let result = run( + "name: ' q '\nitems: [{name: one, port: 7, enabled: true}, {name: two, port: 9, enabled: false}]", + r#".name |= (trim | upcase) | .pairs = [.items[] | (.name, .port)] | (.items[] | select(.enabled) | .port) += 2 | .label = "\(.name):\(.items[-1].port)""#, + ); + assert_eq!(result["name"], Value::String("Q".into())); + assert_eq!( + result["pairs"], + serde_yaml_ng::from_str::("[one, two, 7, 9]").unwrap() + ); + assert_eq!(result["items"][0]["port"], Value::Number(9.into())); + assert_eq!(result["label"], Value::String("Q:9".into())); + } + #[test] + fn collection_operations_and_original_index_deletion() { + let result = run( + "values: [6, 8, 10, 12]\noptions: {c: 3, a: 1}\ngrid: [1, [2, [3]]]", + r#"del(.values[0], .values[2]) | .options |= with_entries(.key |= upcase) | .grid |= flatten(1) | .numbers = (.options | map(. + 4)) | .has = (.numbers | any_c(. > 5))"#, + ); + assert_eq!( + result["values"], + serde_yaml_ng::from_str::("[8,12]").unwrap() + ); + assert_eq!(result["options"]["A"], Value::Number(1.into())); + assert_eq!( + result["grid"], + serde_yaml_ng::from_str::("[1,2,[3]]").unwrap() + ); + assert_eq!( + result["numbers"], + serde_yaml_ng::from_str::("[5,7]").unwrap() + ); + assert_eq!(result["has"], Value::Bool(true)); + } + #[test] + fn budgets_are_checked_before_growth_and_candidates_are_atomic() { + let mut value: Value = serde_yaml_ng::from_str("name: keeper").unwrap(); + let original = value.clone(); + let expression = + Expr::parse(r#".name = "changed" | .slots[9223372036854775807] = true"#).unwrap(); + assert!( + expression + .apply(&mut value, &mut Budget::new(None)) + .is_err() + ); + assert_eq!(value, original); + let cancel = CancellationToken::new(); + cancel.cancel(); + assert!( + Expr::parse(".udp = true") + .unwrap() + .apply(&mut value, &mut Budget::new(Some(&cancel))) + .is_err() + ); + assert!(Expr::parse("del(.)").is_err()); + assert!(Expr::parse("env(SECRET)").is_err()); + } + #[test] + fn scalars_functions_and_uncollected_streams_have_explicit_behavior() { + let result = run( + "name: eu-7\nvalues: [2, '2', null, true]\noptions: {x: 4}", + r#".name |= sub("eu-", "EU/") | .parts = (.name | split("/")) | .joined = (.parts | join(":")) | .seen = (.joined | test("^EU", "g")) | .count = (.joined | length) | .kind = (.count | type) | .has = (.options | has("x")) | .contained = (.options | contains({"x": 4})) | .distinct = (.values | unique) | .entries = (.options | to_entries | from_entries) | .empty = ([] | sort) | .integer = ("0o21" | tonumber) | .repeat = "z" * 3 | .fallback = false // 9 | .ratio = 7 / 2 | .ok = (.count >= 3 and (.seen | not) == false) | .absent = (select(false)) | .stream = {"gone": select(false), "last": (.values[])}"#, + ); + assert_eq!(result["joined"], Value::String("EU:7".into())); + assert_eq!(result["seen"], Value::Bool(true)); + assert_eq!(result["kind"], Value::String("!!int".into())); + assert_eq!(result["entries"], result["options"]); + assert_eq!(result["integer"], Value::Number(17.into())); + assert_eq!(result["repeat"], Value::String("zzz".into())); + assert_eq!(result["ratio"], Value::Number(Number::from(3.5))); + assert_eq!(result["ok"], Value::Bool(true)); + assert!(result.get("absent").is_none()); + assert!(result["stream"].get("gone").is_none()); + assert_eq!(result["stream"]["last"], Value::Bool(true)); + assert!(Expr::parse(".enabled = true and false").is_err()); + assert!(Expr::parse(r#".name |= test("[")"#).is_err()); + let mut value: Value = serde_yaml_ng::from_str("values: [1, 2]").unwrap(); + assert!( + Expr::parse(".result = (.values[])") + .unwrap() + .apply(&mut value, &mut Budget::new(None)) + .is_err() + ); + } + + #[test] + fn scalar_rendering_and_regex_replacements_follow_the_value_contract() { + let result = run( + "name: eu-8\noptions: {z: [1, 2], a: true}\nnumber: 1000000.0", + r#".name |= sub("(?Peu)-([0-9])", "${area}/$2/$$/$02/$2z") | .yaml = (.options | tostring) | .text = "\(.number)" | .ascii = ("12" | test("\\d")) | .quoted = ("a.b" | test("\\Qa.b\\E")) | .tags = [(1 | type), (1.0 | type), ((1 / 0) | tostring), ((0 / 0) | tostring)] | .collected = [(.options[] | (., type))]"#, + ); + assert_eq!(result["name"], Value::String("eu/8/$//".into())); + assert_eq!( + result["yaml"], + Value::String("a: true\nz:\n - 1\n - 2".into()) + ); + assert_eq!(result["text"], Value::String("1e+06".into())); + assert_eq!(result["ascii"], Value::Bool(false)); + assert_eq!(result["quoted"], Value::Bool(true)); + assert_eq!( + result["tags"], + serde_yaml_ng::from_str::("['!!int', '!!float', '.inf', '.nan']").unwrap() + ); + assert_eq!(result["collected"].as_sequence().unwrap().len(), 4); + } +} diff --git a/src/config/providers.rs b/src/config/providers.rs index 0631da7..46c3635 100644 --- a/src/config/providers.rs +++ b/src/config/providers.rs @@ -36,6 +36,8 @@ pub enum RuleFormat { pub struct ProviderSpec { #[serde(skip)] pub(crate) age_identities: Option, + #[serde(skip)] + pub(super) compiled_override_exprs: Arc<[super::override_expr::Expr]>, #[serde(rename = "type")] pub kind: ProviderType, #[serde(default)] @@ -149,6 +151,7 @@ impl ProviderSpec { "additional-prefix", "additional-suffix", "proxy-name", + "override-expr", ] .contains(&key.as_str()) { @@ -203,6 +206,7 @@ impl ProviderTemplate { let map = base .as_mapping_mut() .ok_or_else(|| VoleError::InvalidConfig("configuration must be a mapping".into()))?; + let mut expression_compilation = super::override_expr::CompileBudget::default(); let mut take = |key: &str, is_rule| -> Result> { let Some(value) = map.remove(key) else { return Ok(Vec::new()); @@ -239,6 +243,22 @@ impl ProviderTemplate { .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; spec.age_identities = identities; spec.validate(is_rule)?; + if let Some(expressions) = spec + .overrides + .get("override-expr") + .and_then(Value::as_sequence) + { + spec.compiled_override_exprs = expressions + .iter() + .map(|source| { + super::override_expr::Expr::parse_with_budget( + source.as_str().expect("validated expression string"), + &mut expression_compilation, + ) + }) + .collect::>>()? + .into(); + } Ok((name, spec)) }) .collect() @@ -327,6 +347,11 @@ impl ProviderTemplate { cancellation: Option<&tokio_util::sync::CancellationToken>, ) -> Result { let mut budget = Budget::new(cancellation); + let mut expression_budget = super::override_expr::Budget::new(cancellation); + let has_expressions = self + .proxies + .iter() + .any(|(_, spec)| !spec.compiled_override_exprs.is_empty()); budget.check()?; let mut value = self.base.clone(); let base = value.as_mapping_mut().expect("validated mapping"); @@ -357,6 +382,9 @@ impl ProviderTemplate { .insert("dialer-proxy".into(), spec.dialer_proxy.clone().into()); } apply_overrides(&mut node, &spec.overrides, &mut budget)?; + for expression in spec.compiled_override_exprs.iter() { + expression.apply(&mut node, &mut expression_budget)?; + } names.push(node_name(&node)?); proxies.push(node); } @@ -474,9 +502,20 @@ impl ProviderTemplate { identities.inline_node(node)?; } base.insert("proxies".into(), Value::Sequence(proxies)); - let raw: RawVoleConfig = serde_yaml_ng::from_value(value) - .map_err(|error| VoleError::InvalidConfig(error.to_string()))?; - let mut config = raw.normalize()?; + let raw: RawVoleConfig = serde_yaml_ng::from_value(value).map_err(|error| { + if has_expressions { + VoleError::InvalidConfig("invalid provider override candidate schema".into()) + } else { + VoleError::InvalidConfig(error.to_string()) + } + })?; + let mut config = raw.normalize().map_err(|error| { + if has_expressions { + VoleError::InvalidConfig("invalid provider override candidate graph".into()) + } else { + error + } + })?; let mut sets = HashMap::new(); for (name, spec) in &self.rules { let fallback = self.bundled(spec)?; @@ -703,6 +742,9 @@ fn validate_overrides(overrides: &BTreeMap) -> Result<()> { for (key, value) in overrides { let valid = match key.as_str() { "udp" | "udp-over-tcp" | "skip-cert-verify" => value.is_bool(), + "override-expr" => value + .as_sequence() + .is_some_and(|values| values.iter().all(Value::is_string)), "proxy-name" => { let replacements: Vec = serde_yaml_ng::from_value(value.clone()) .map_err(|_| { @@ -732,7 +774,14 @@ fn apply_overrides( .ok_or_else(|| VoleError::InvalidConfig("provider node must be a mapping".into()))?; let mut name = original_name; for (key, value) in overrides { - if ["additional-prefix", "additional-suffix", "proxy-name"].contains(&key.as_str()) { + if [ + "additional-prefix", + "additional-suffix", + "proxy-name", + "override-expr", + ] + .contains(&key.as_str()) + { continue; } let kind = map.get("type").and_then(Value::as_str).unwrap_or_default(); @@ -825,6 +874,62 @@ mod tests { assert!(!error.to_string().contains("invalid-private-fixture")); } } + #[test] + fn override_expr_materializes_after_filters_and_structured_names() { + let yaml = br#" +mixed-port: 1080 +proxy-providers: + nodes: + type: inline + filter: '^keep' + override: + udp: true + proxy-name: [{pattern: '^keep-', target: ''}] + additional-prefix: 'PRE-' + additional-suffix: '-END' + override-expr: + - '.name = ([.name, (.udp | tostring)] | join("/"))' + - '.name |= (split("-") | map(downcase) | join("_"))' + - '.port += ([1, [2, 3]] | flatten | length)' + payload: + - {name: keep-east, type: socks5, server: 192.0.2.1, port: 1080} + - {name: omit-west, type: socks5, server: 192.0.2.1, port: 1080} +proxy-groups: [{name: choice, type: select, use: [nodes]}] +"#; + let config = Config::parse_yaml(yaml).unwrap(); + assert_eq!(config.proxies.len(), 1); + assert_eq!(config.proxies[0].tag, "pre_east_end/true"); + assert_eq!(config.proxy_groups[0].members[0].name, "pre_east_end/true"); + let template = config.providers.as_ref().unwrap(); + let expressions = &template.proxies[0].1.compiled_override_exprs; + assert_eq!(expressions.len(), 3); + let cloned = template.proxies[0].1.clone(); + assert!(Arc::ptr_eq(expressions, &cloned.compiled_override_exprs)); + let cancelled = tokio_util::sync::CancellationToken::new(); + cancelled.cancel(); + assert!( + template + .materialize_with_cancel(&ProviderContents::new(), Some(&cancelled)) + .is_err() + ); + + for expression in [ + ".port = .password", + ".dialer-proxy = .name", + ".type = .password", + ] { + let source = format!( + "proxy-providers:\n nodes:\n type: inline\n override:\n override-expr: ['{expression}']\n payload: [{{name: original, type: socks5, server: 192.0.2.1, port: 1080, username: account, password: private-node-value}}]\n" + ); + let error = Config::parse_yaml(source.as_bytes()) + .unwrap_err() + .to_string(); + assert!(!error.contains("private-node-value")); + assert!(!error.contains(expression)); + } + assert!(Config::parse_yaml(b"proxy-providers: {nodes: {type: inline, payload: [], override: {override-expr: ['.port = (']}}}").is_err()); + } + #[test] fn static_members_filtered_to_empty_use_the_declared_fallback() { for (fallback, expected) in [ diff --git a/src/runtime/providers.rs b/src/runtime/providers.rs index e866285..7791e42 100644 --- a/src/runtime/providers.rs +++ b/src/runtime/providers.rs @@ -749,7 +749,14 @@ mod tests { br#" mixed-port: 1080 proxy-providers: - remote: {type: file, path: nodes.yaml, filter: '(?=.)'} + remote: + type: file + path: nodes.yaml + filter: '(?=.)' + override: + override-expr: + - '.name |= sub("^old$", "old-renamed")' + - '(select(.name == "bad-expr") | .port) = ("private-expression-value" | tonumber)' proxy-groups: - {name: choice, type: select, use: [remote]} dns: @@ -802,11 +809,20 @@ rules: ['MATCH,choice'] io: Box::new(io), _generation: generation, }; - assert_eq!(live.groups().state("choice").unwrap().now, "old"); + assert_eq!(live.groups().state("choice").unwrap().now, "old-renamed"); std::fs::write(&file, payload("new-node")).unwrap(); live.update(0).await.unwrap(); assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); assert!(Arc::ptr_eq(®istry, &live.connections())); + std::fs::write(&file, payload("bad-expr")).unwrap(); + assert!(live.update(0).await.is_err()); + assert_eq!(live.groups().state("choice").unwrap().now, "new-node"); + assert!( + !live + .providers(false) + .to_string() + .contains("private-expression-value") + ); peer.write_all(b"active").await.unwrap(); let mut buffer = [0; 6]; old_session.read_exact(&mut buffer).await.unwrap(); diff --git a/tests/README.md b/tests/README.md index b466def..c39df62 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,6 +16,8 @@ cargo test --locked --lib config::credentials::tests:: cargo test --locked --features cli --lib age_ cargo test --locked --lib dns::hosts::tests:: cargo test --locked --lib config::name_regex::tests:: +cargo test --locked --lib config::override_expr::tests:: +cargo test --locked --lib override_expr_materializes_after_filters_and_structured_names cargo test --locked --lib config::tls_policy_tests:: cargo test --locked --lib config::tls_trust::tests:: cargo test --locked --lib security::context::tests:: From f23bccb004662b0308c19d9c1ba2370bb91f88e1 Mon Sep 17 00:00:00 2001 From: yiguo Date: Sat, 10 Oct 2026 22:57:22 +0800 Subject: [PATCH 056/445] feat: support IP suffix and bounded domain rules --- docs/config.yaml | 19 +- docs/geodata.md | 8 +- docs/tun-icmp-dns.md | 7 + src/config/domain_regex.rs | 652 ++++++++++++++++++++++++++++++++ src/config/ip_suffix.rs | 71 ++++ src/config/mod.rs | 8 +- src/config/providers.rs | 9 +- src/config/rule_provider.rs | 106 ++++-- src/config/rules.rs | 45 +-- src/dispatch.rs | 11 +- src/dns/fake_ip.rs | 89 +++-- src/dns/fallback.rs | 9 +- src/dns/policy.rs | 46 ++- src/dns/resolution.rs | 225 ++++++----- src/dns/runtime.rs | 224 +++++++++-- src/geodata/asn_tests.rs | 6 +- src/geodata/country_tests.rs | 10 +- src/geodata/manager.rs | 11 + src/geodata/mod.rs | 80 +++- src/geodata/tests.rs | 12 + src/inbound/http/server.rs | 11 +- src/inbound/socks5/handshake.rs | 2 +- src/inbound/socks5/server.rs | 5 +- src/resource/bundle.rs | 26 +- src/routing/diagnostics.rs | 2 + src/routing/dispatcher.rs | 48 ++- src/routing/mod.rs | 89 ++++- src/routing/program.rs | 88 ++++- src/runtime/providers.rs | 6 +- src/sniffing.rs | 96 +++-- src/tun_runtime.rs | 25 +- tests/README.md | 4 + 32 files changed, 1699 insertions(+), 351 deletions(-) create mode 100644 src/config/domain_regex.rs create mode 100644 src/config/ip_suffix.rs diff --git a/docs/config.yaml b/docs/config.yaml index b19e06d..ba7f479 100644 --- a/docs/config.yaml +++ b/docs/config.yaml @@ -714,16 +714,27 @@ dns: # rules 可省略或为空;rule 模式按顺序首条命中,未命中 DIRECT。MATCH 可放在任意 # 位置,普通目标命中后其后规则不再执行。DIRECT、REJECT 是内置出口,其他出口精确引用节点或组。 # 支持 DOMAIN、DOMAIN-SUFFIX、DOMAIN-KEYWORD、DOMAIN-WILDCARD、DOMAIN-REGEX、 -# GEOSITE、GEOIP、SRC-GEOIP、IP-ASN、SRC-IP-ASN、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、DST-PORT、SRC-PORT、NETWORK、 +# GEOSITE、GEOIP、SRC-GEOIP、IP-ASN、SRC-IP-ASN、IP-CIDR、IP-CIDR6、SRC-IP-CIDR、 +# IP-SUFFIX、SRC-IP-SUFFIX、DST-PORT、SRC-PORT、NETWORK、 # IN-TYPE、IN-USER、IN-PORT、RULE-SET、SUB-RULE、AND/OR/NOT 和 MATCH。来源信息来自实际入站会话。 -# CIDR 两种别名都按 payload 实际地址族解析;GEOIP/IP-ASN/CIDR/RULE-SET 可追加 src,自动禁止 DNS。 +# CIDR 两种别名都按 payload 实际地址族解析;GEOIP/IP-ASN/CIDR/IP-SUFFIX/RULE-SET 可追加 src,自动禁止 DNS。 +# IP-SUFFIX 的 address/bits 比较低 bits 位,保留原地址,按 IPv4/IPv6 分族;0 位匹配同族全部。 +# SRC-IP-SUFFIX 只使用真实来源地址;目的后缀支持 no-resolve,并复用既有惰性 DNS 结果。 # RULE-SET 的 src 在局部视图交换源/目标 IP 与端口,保留域名、入口类型和身份。 # IN-TYPE 为 / 分隔的 Mihomo 类型名,大小写无关,SOCKS 展开为 SOCKS4/SOCKS5; # IN-USER 为 / 分隔、区分大小写的已认证用户名。两种列表逐项 trim,空项报错。 # IN-PORT/DST-PORT/SRC-PORT 为 uint16 端口或范围,/ 分隔,含 0,逆序范围归一, # 最多 28 段;分隔中的空段跳过。真实入口和服务器端口仍须非零。 -# 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 忽略大小写,支持 regex crate -# 子集,不支持 lookaround/backreference,无法编译的模式明确报错。 +# 通配符 * 匹配任意长度、? 匹配一个字符;DOMAIN-REGEX 默认忽略大小写,普通表达式走线性引擎。 +# 有限复杂语法支持前向正/负断言、固定宽度后向断言、数字及 ASCII 命名 backreference、原子组, +# 命名捕获支持 (?...) 与 (?'name'...);引用支持 \k 与 \k'name'。 +# 不支持递归、条件/平衡组、possessive quantifier、变长后向断言或混合捕获的数字引用; +# 此有限方言不保证全部 regexp2/.NET Unicode、锚点或捕获语义等价。无法编译或越界明确报错。 +# 每模式限制 4 KiB、512 AST 节点/32 层、有限重复 1024,委托编译/搜索缓存有近似大小边界。 +# 一次决策跨逻辑/子规则、classical provider、候选地址和 DNS 续跑共享 sticky 执行预算: +# 每次复杂匹配预留 2049 回溯额度,总共 65536;所有谓词及正则共同消费 64 Mi work units; +# 累计实际正则调用耗时上限 2 ms,DNS 等待不计入。时间/取消仅在同步引擎调用前后检查, +# 不能中途抢占或保证严格 2 ms/Stop 上界。执行失败终止当前决策,不视作未匹配或 DIRECT。 # 逻辑规则例如 AND,((DOMAIN-SUFFIX,example.com),(SRC-PORT,1000-2000)),DIRECT; # NOT 恰好一个子规则,逻辑最多嵌套 32 层。主列表与所有 sub-rules 共用 65536 条、 # 单条 16 KiB、文本合计 8 MiB 的预算;子规则名计入文本。子规则调用最多 32 层, diff --git a/docs/geodata.md b/docs/geodata.md index fd78959..6cdb5ac 100644 --- a/docs/geodata.md +++ b/docs/geodata.md @@ -206,6 +206,11 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 不再承诺 Regex 热路径无分配。Domain/Full 的紧凑无分配查找保持不变。 库的局部保护不是全部正则合计内存或整进程峰值的上界。 +GeoSite 正则保持上述大小写敏感的 Go/RE2 类行为,不使用 `DOMAIN-REGEX` 的默认 IgnoreCase +或复杂引擎。业务规则、DNS selector、fake-IP filter 与嗅探决策把其线性搜索计入同一 +sticky 执行预算;预算失败向调用者传播,不能通过反选变为命中或默认直连。此预算限制 +单次决策的执行工作,不限制 GeoData 记录数量、加载量或资产总内存。 + ## GeoIP 与 `no-resolve` - 目标已经是 IP 时直接匹配同地址族 CIDR,`no-resolve` 不改变结果。 @@ -213,7 +218,8 @@ Domain/Full 在加载时原地排序紧凑记录,运行时对完整名称及 - 之后首条不带 `no-resolve` 的 IP 类规则最多触发一次运行时 DNS,结果只供当前和后续规则复用。 - A 结果先于 AAAA;第一个命中 CIDR 的有效地址成为当前会话或数据报的固定目标。 - `dns.ipv6: false` 时不请求或采用 AAAA。 -- 运行时 DNS 不可用或失败时继续下一条规则,不使用引导解析器替代。 +- 运行时 DNS 不可用或上游失败时继续下一条规则,不使用引导解析器替代; + 规则执行预算、取消或引擎错误终止本次决策。 - `GEOIP,!cn,...` 对同一次 CIDR 匹配结果取反,不执行反向 DNS。DAT 的 `reverse_match` 字段按 Mihomo loader 行为接受但忽略;是否取反只由 selector 的前置 `!` 决定。 diff --git a/docs/tun-icmp-dns.md b/docs/tun-icmp-dns.md index 2aed4f7..394973e 100644 --- a/docs/tun-icmp-dns.md +++ b/docs/tun-icmp-dns.md @@ -203,6 +203,13 @@ Policy 只决定当前 DNS exchange,不改写后续业务动作。分类、属 Regex selector 记录与业务规则共用 `regex::bytes::Regex`;其搜索缓存由正则库管理, 可能分配 scratch 或发生缓存竞争,不承诺 DNS policy 正则搜索零分配。 +业务规则中的 DOMAIN-REGEX 与 classical rule-set 使用有界的独立复杂正则入口;GeoSite +保留大小写敏感的线性入口。DNS 请求的 fake-IP filter、nameserver-policy、fallback domain +及 respect-rules 出口共用执行预算,并跨 A/AAAA、别名、引导和并发上游候选保留。规则 +执行错误不选择默认上游、fallback、系统 DNS 或 synthetic IP,不写入缓存/域名提示; +wire 查询返回 SERVFAIL,内部解析返回脱敏规则错误。嗅探计划的地址/force/skip 选择同样 +共享预算,错误关闭当前会话/数据报,不忽略该失败继续连接。 + ### 显式系统与 DHCP 来源 App 通过运行时级 [`updateDnsNetwork`](invoke-api.md#updatednsnetwork) 提供完整网络快照,地址必须是字面 IP,接口必须精确。快照优先于 OS 发现,空数组明确撤销来源。替换时原子推进 DNS epoch 并清理全部角色的类型化/wire 缓存及 redir-host 提示;保留 fake-IP 映射和活跃流。旧查询可返回原调用者,但不能回填新网络缓存,新查询不加入旧 singleflight。DNS TCP 连接池按来源代次、接口和地址区分复用。 diff --git a/src/config/domain_regex.rs b/src/config/domain_regex.rs new file mode 100644 index 0000000..4e2a2b4 --- /dev/null +++ b/src/config/domain_regex.rs @@ -0,0 +1,652 @@ +//! Independent bounded DOMAIN-REGEX adapter. No GeoSite or name-filter policy. +use super::{Result, VoleError}; +use std::{ + fmt, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio_util::sync::CancellationToken; + +const PATTERN_BYTES: usize = 4096; +const AST_NODES: usize = 512; +const AST_DEPTH: usize = 32; +const REPEAT_BOUND: usize = 1024; +const COMPILED_BYTES: usize = 512 * 1024; +const DOMAIN_BYTES: usize = 253; +const CALL_BACKTRACKS: usize = 2048; +const DECISION_BACKTRACKS: usize = 64 * 1024; +const DECISION_WORK: u64 = 64 * 1024 * 1024; +const DECISION_MATCH_TIME: Duration = Duration::from_millis(2); + +fn invalid(message: &'static str) -> VoleError { + VoleError::InvalidConfig(message.to_owned()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MatchFailure { + Cancelled, + Budget, + InvalidInput, + Engine, +} +impl fmt::Display for MatchFailure { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Cancelled => "domain rule evaluation cancelled", + Self::Budget => "domain rule execution budget exceeded", + Self::InvalidInput => "domain rule input exceeds the supported boundary", + Self::Engine => "domain rule evaluation failed", + }) + } +} +impl std::error::Error for MatchFailure {} + +/// One owned value per complete decision, including suspended DNS traversal. +/// No Clone: recursive rules, provider predicates and retries borrow this value. +pub(crate) struct DecisionBudget { + work: u64, + backtracks: usize, + match_time: Duration, + failure: Option, + cancellation: Vec, + deadline: Option, +} +impl Default for DecisionBudget { + fn default() -> Self { + Self::new() + } +} +impl DecisionBudget { + pub(crate) fn new() -> Self { + Self { + work: DECISION_WORK, + backtracks: DECISION_BACKTRACKS, + match_time: Duration::ZERO, + failure: None, + cancellation: Vec::new(), + deadline: None, + } + } + fn fail(&mut self, failure: MatchFailure) -> std::result::Result { + let failure = *self.failure.get_or_insert(failure); + Err(failure) + } + pub(crate) fn check( + &mut self, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + if let Some(failure) = self.failure { + return Err(failure); + } + if cancel.is_some_and(CancellationToken::is_cancelled) + || self + .cancellation + .iter() + .any(CancellationToken::is_cancelled) + { + return self.fail(MatchFailure::Cancelled); + } + if self + .deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + return self.fail(MatchFailure::Budget); + } + Ok(()) + } + /// Call once for every predicate/program visit, including non-regex rules. + pub(crate) fn visit( + &mut self, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + self.reserve(1, 0, cancel) + } + fn reserve( + &mut self, + work: u64, + backtracks: usize, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + self.check(cancel)?; + let (Some(work), Some(backtracks)) = ( + self.work.checked_sub(work), + self.backtracks.checked_sub(backtracks), + ) else { + return self.fail(MatchFailure::Budget); + }; + self.work = work; + self.backtracks = backtracks; + Ok(()) + } + fn finish( + &mut self, + duration: Duration, + cancel: Option<&CancellationToken>, + ) -> std::result::Result<(), MatchFailure> { + self.match_time = self.match_time.saturating_add(duration); + if self.match_time > DECISION_MATCH_TIME { + return self.fail(MatchFailure::Budget); + } + self.check(cancel) + } +} + +/// A decision-local budget shared by contexts, candidates and lazy DNS continuation. +/// Synchronous matching holds the lock only while consuming the budget. +#[derive(Clone)] +pub struct RuleDecision(Arc>); +tokio::task_local! { static ACTIVE_DECISION: RuleDecision; } +impl Default for RuleDecision { + fn default() -> Self { + ACTIVE_DECISION + .try_with(Clone::clone) + .unwrap_or_else(|_| Self(Arc::new(std::sync::Mutex::new(DecisionBudget::new())))) + } +} +impl fmt::Debug for RuleDecision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RuleDecision { .. }") + } +} +// Evaluation accounting does not change the value identity of a routing context. +impl PartialEq for RuleDecision { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl Eq for RuleDecision {} +impl RuleDecision { + pub(crate) async fn scope(&self, future: F) -> F::Output { + ACTIVE_DECISION.scope(self.clone(), future).await + } + pub(crate) fn check(&self) -> std::result::Result<(), MatchFailure> { + self.with(|budget| budget.check(None)) + } + pub(crate) fn visit(&self) -> std::result::Result<(), MatchFailure> { + self.with(|budget| budget.visit(None)) + } + pub(crate) fn with( + &self, + f: impl FnOnce(&mut DecisionBudget) -> std::result::Result, + ) -> std::result::Result { + let mut budget = self.0.lock().map_err(|_| MatchFailure::Engine)?; + f(&mut budget) + } + pub(crate) fn set_cancellation(&self, cancellation: CancellationToken) { + if let Ok(mut budget) = self.0.lock() + && !budget.cancellation.contains(&cancellation) + { + if budget.cancellation.len() >= 64 { + budget.failure.get_or_insert(MatchFailure::Budget); + } else { + budget.cancellation.push(cancellation); + } + } + } + pub(crate) fn set_deadline(&self, deadline: Instant) { + if let Ok(mut budget) = self.0.lock() { + budget.deadline = Some(budget.deadline.map_or(deadline, |old| old.min(deadline))); + } + } + pub(crate) fn linear( + &self, + source_bytes: usize, + input_bytes: usize, + f: impl FnOnce() -> bool, + ) -> std::result::Result { + self.with(|budget| { + budget.reserve( + (source_bytes as u64 + 1).saturating_mul(input_bytes as u64 + 1), + 0, + None, + )?; + let started = Instant::now(); + let matched = f(); + budget.finish(started.elapsed(), None)?; + Ok(matched) + }) + } +} + +/// Payload identity is preserved; pattern text is deliberately hidden in Debug/errors. +#[derive(Clone)] +pub struct DomainRegex(Arc); +struct Compiled { + source: Arc, + weight: u64, + engine: Engine, +} +enum Engine { + Linear(regex::Regex), + Complex(fancy_regex::Regex), +} +impl fmt::Debug for DomainRegex { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DomainRegex { .. }") + } +} +impl PartialEq for DomainRegex { + fn eq(&self, other: &Self) -> bool { + self.as_str() == other.as_str() + } +} +impl Eq for DomainRegex {} +impl DomainRegex { + pub fn new(pattern: &str) -> Result { + if pattern.len() > PATTERN_BYTES { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + let translated = common_dialect(pattern)?; + let tree = fancy_regex::Expr::parse_tree(&translated) + .map_err(|_| invalid("invalid or unsupported DOMAIN-REGEX syntax"))?; + let (nodes, weight, groups) = inspect(&tree.expr)?; + let named = !tree.named_groups.is_empty(); + let numbered = groups > tree.named_groups.len(); + // .NET numbers unnamed captures before named captures, unlike opening-order + // numbering in Rust. A mixed pattern with numeric references must be explicit + // unsupported, never accepted with different group identity. + if named && numbered && has_numeric_reference(&translated) { + return Err(invalid("unsupported DOMAIN-REGEX mixed capture numbering")); + } + let engine = if let Ok(regex) = regex::RegexBuilder::new(&translated) + .case_insensitive(true) + .nest_limit(AST_DEPTH as u32) + .size_limit(COMPILED_BYTES) + .dfa_size_limit(COMPILED_BYTES) + .build() + { + Engine::Linear(regex) + } else { + let fragment_bytes = (COMPILED_BYTES / nodes.max(1)).max(8192); + let regex = fancy_regex::RegexBuilder::new(&translated) + .case_insensitive(true) + .backtrack_limit(CALL_BACKTRACKS) + .seek(false) + .delegate_size_limit(fragment_bytes) + .delegate_dfa_size_limit(fragment_bytes) + .build() + .map_err(|_| invalid("invalid or unsupported DOMAIN-REGEX syntax"))?; + Engine::Complex(regex) + }; + Ok(Self(Arc::new(Compiled { + source: Arc::from(pattern), + weight, + engine, + }))) + } + pub fn as_str(&self) -> &str { + &self.0.source + } + pub fn is_match( + &self, + domain: &str, + decision: &RuleDecision, + ) -> std::result::Result { + decision.with(|budget| self.match_bounded(domain, budget, None)) + } + fn match_bounded( + &self, + domain: &str, + budget: &mut DecisionBudget, + cancel: Option<&CancellationToken>, + ) -> std::result::Result { + budget.check(cancel)?; + if domain.is_empty() + || domain.len() > DOMAIN_BYTES + || !domain.is_ascii() + || domain.bytes().any(|b| b.is_ascii_control()) + { + return budget.fail(MatchFailure::InvalidInput); + } + let complex = matches!(self.0.engine, Engine::Complex(_)); + let work = self.0.weight.saturating_mul(domain.len() as u64 + 1); + // fancy exposes only a per-regex backtrack bound, not actual steps or + // a caller-owned counter. Reserve its worst-case quota before execution. + // Reserving all of it prevents each rule from receiving a new total budget. + budget.reserve(work, if complex { CALL_BACKTRACKS + 1 } else { 0 }, cancel)?; + let started = Instant::now(); + let result = match &self.0.engine { + Engine::Linear(regex) => Ok(regex.is_match(domain)), + Engine::Complex(regex) => regex.is_match(domain).map_err(|error| { + if matches!( + error, + fancy_regex::Error::RuntimeError( + fancy_regex::RuntimeError::BacktrackLimitExceeded + ) + ) { + MatchFailure::Budget + } else { + MatchFailure::Engine + } + }), + }; + // Post-call accounting is not preemptive: cancellation/elapsed guards + // cannot interrupt the synchronous third-party VM in the current API. + budget.finish(started.elapsed(), cancel)?; + if let Err(failure) = result { + return budget.fail(failure); + } + result + } +} + +/// A small independent lexical compatibility fence, not a .NET parser. +/// The third-party public AST validates the accepted grammar afterward. +fn common_dialect(pattern: &str) -> Result { + let bytes = pattern.as_bytes(); + let mut output = String::with_capacity(pattern.len()); + let (mut index, mut in_class, mut depth) = (0, false, 0_usize); + let mut scopes = Vec::new(); + let mut verbose = false; + while index < bytes.len() { + let tail = &pattern[index..]; + if bytes[index] == b'\\' { + let Some(&next) = bytes.get(index + 1) else { + return Err(invalid("invalid DOMAIN-REGEX escape")); + }; + if !in_class && tail.starts_with("\\k'") { + let rest = &tail[3..]; + let end = rest + .find('\'') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX capture reference"))?; + capture_name(&rest[..end])?; + output.push_str("\\k<"); + output.push_str(&rest[..end]); + output.push('>'); + index += 4 + end; + continue; + } + if !in_class && matches!(next, b'g' | b'K' | b'R' | b'N' | b'O' | b'h' | b'H') { + return Err(invalid("unsupported DOMAIN-REGEX escape")); + } + output.push('\\'); + let next = pattern[index + 1..].chars().next().expect("byte checked"); + output.push(next); + index += 1 + next.len_utf8(); + continue; + } + if in_class { + if tail.starts_with("&&") + || tail.starts_with("--") + || tail.starts_with("~~") + || bytes[index] == b'[' + { + return Err(invalid("unsupported DOMAIN-REGEX character class syntax")); + } + if bytes[index] == b']' { + in_class = false; + } + } else { + if verbose && bytes[index] == b'#' { + let end = tail.find('\n').map_or(tail.len(), |end| end + 1); + output.push_str(&tail[..end]); + index += end; + continue; + } + if let Some(rest) = tail.strip_prefix("(?#") { + let end = rest + .find(')') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX comment"))?; + index += end + 4; + continue; + } + if bytes[index] == b'[' { + in_class = true; + } + if tail.starts_with("(?P") + || tail.starts_with("(*") + || tail.starts_with("(?~") + || tail.starts_with("(?|") + || tail.starts_with("(?(") + { + return Err(invalid("unsupported DOMAIN-REGEX group syntax")); + } + if let Some(rest) = tail.strip_prefix("(?'") { + let end = rest + .find('\'') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX capture name"))?; + capture_name(&rest[..end])?; + output.push_str("(?<"); + output.push_str(&rest[..end]); + output.push('>'); + depth += 1; + scopes.push(verbose); + if depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + index += end + 4; + continue; + } + if tail.starts_with("(?<") && !tail.starts_with("(?<=") && !tail.starts_with("(?') + .ok_or_else(|| invalid("invalid DOMAIN-REGEX capture name"))?; + capture_name(&rest[..end])?; + } + if matches!(bytes[index], b'*' | b'+' | b'?' | b'}') + && bytes.get(index + 1) == Some(&b'+') + { + return Err(invalid("unsupported DOMAIN-REGEX possessive quantifier")); + } + if bytes[index] == b'(' { + // Account lexical depth before calling a recursive third-party parser. + // Explicit common flags are accepted; Rust-only modes are rejected. + if let Some(rest) = tail.strip_prefix("(?") { + let flags = rest + .bytes() + .take_while(|b| b.is_ascii_alphabetic() || *b == b'-') + .count(); + if flags > 0 + && tail + .as_bytes() + .get(2 + flags) + .is_some_and(|b| matches!(b, b':' | b')')) + { + let mut on = true; + let old_verbose = verbose; + for flag in tail[2..2 + flags].bytes() { + match flag { + b'-' => on = false, + b'i' | b'm' | b's' => {} + b'x' => verbose = on, + _ => return Err(invalid("unsupported DOMAIN-REGEX inline mode")), + } + } + if bytes[index + 2 + flags] == b')' { + output.push_str(&tail[..3 + flags]); + index += 3 + flags; + continue; + } + depth += 1; + scopes.push(old_verbose); + if depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + output.push_str(&tail[..3 + flags]); + index += 3 + flags; + continue; + } + } + depth += 1; + scopes.push(verbose); + if depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + } else if bytes[index] == b')' { + depth = depth + .checked_sub(1) + .ok_or_else(|| invalid("invalid DOMAIN-REGEX group syntax"))?; + verbose = scopes + .pop() + .ok_or_else(|| invalid("invalid DOMAIN-REGEX group syntax"))?; + } + } + let next = tail.chars().next().expect("within input"); + output.push(next); + index += next.len_utf8(); + } + if in_class || depth != 0 { + return Err(invalid("invalid DOMAIN-REGEX syntax")); + } + Ok(output) +} +fn capture_name(name: &str) -> Result<()> { + if name.is_empty() + || !name + .bytes() + .next() + .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_') + || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + { + return Err(invalid("unsupported DOMAIN-REGEX capture name")); + } + Ok(()) +} + +fn has_numeric_reference(pattern: &str) -> bool { + let (mut escaped, mut in_class) = (false, false); + for byte in pattern.bytes() { + if escaped { + if !in_class && byte.is_ascii_digit() && byte != b'0' { + return true; + } + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'[' { + in_class = true; + } else if byte == b']' { + in_class = false; + } + } + false +} + +fn inspect(root: &fancy_regex::Expr) -> Result<(usize, u64, usize)> { + use fancy_regex::{AstNode, Expr}; + let mut pending = vec![(root, 0)]; + let (mut nodes, mut weight, mut groups) = (0_usize, 0_u64, 0_usize); + while let Some((expr, depth)) = pending.pop() { + nodes += 1; + if nodes > AST_NODES || depth > AST_DEPTH { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + weight = weight.saturating_add(match expr { + Expr::Literal { val, .. } => val.len().max(1) as u64, + Expr::Repeat { lo, hi, .. } => { + if *lo > REPEAT_BOUND || (*hi != usize::MAX && *hi > REPEAT_BOUND) { + return Err(invalid("DOMAIN-REGEX compilation budget exceeded")); + } + (*lo) + .max(if *hi == usize::MAX { DOMAIN_BYTES } else { *hi }) + .max(1) as u64 + } + _ => 1, + }); + match expr { + Expr::Empty + | Expr::Any { .. } + | Expr::Assertion(_) + | Expr::Literal { .. } + | Expr::Concat(_) + | Expr::Alt(_) + | Expr::LookAround(..) + | Expr::Repeat { .. } + | Expr::Delegate { .. } + | Expr::Backref { .. } + | Expr::AtomicGroup(_) => {} + Expr::Group(_) | Expr::AstNode(AstNode::AstGroup { .. }, _) => { + groups += 1; + } + Expr::AstNode( + AstNode::Backref { + relative_recursion_level: None, + .. + }, + _, + ) => {} + _ => return Err(invalid("unsupported DOMAIN-REGEX expression syntax")), + } + pending.extend(expr.children_iter().map(|child| (child, depth + 1))); + } + Ok((nodes, weight.max(1), groups)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn common_domain_expressions_compile_once_and_match() { + for (pattern, domain) in [ + (r"^(?!internal\.)[a-z0-9.-]+(?=\.test$)", "Api.Example.TEST"), + (r"(?<=edge\.)[a-z]+(?[a-z]+)\.\k