From f4b0236661df9a108c622b9f4a9142e14725c89f Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Sun, 9 Aug 2026 17:31:14 +0900 Subject: [PATCH 1/7] fix(geoip): preserve overlapping dat tags Index V2Ray GeoIP ranges per tag so named categories can overlap country codes. Route request, pipeline, and response matchers through tag membership while preserving MMDB precedence and legacy lookup behavior. --- README.md | 4 +- src/config.rs | 6 +- src/matcher/geoip.rs | 542 ++++++++++++++++++++++++++++--------------- src/matcher/mod.rs | 184 ++++++++------- tools/README.md | 2 +- 5 files changed, 469 insertions(+), 269 deletions(-) diff --git a/README.md b/README.md index 806c82b..e9e46f7 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ version is optional. settings, pipeline_select, and pipelines default to an empt | serve_stale_ttl_reset | true | Reset the stale-age window when stale data is served. | | serve_stale_client_timeout_ms | 0 | 0 serves stale immediately; a positive value tries the upstream for this many milliseconds first. | | geoip_db_path | null | MaxMind MMDB path. | -| geoip_dat_path | null | V2Ray GeoIP .dat or supported V2Ray JSON path. The current range loaders use IPv4 ranges. | +| geoip_dat_path | null | V2Ray GeoIP .dat or supported V2Ray JSON path. IPv4 and IPv6 ranges are indexed independently for each GeoIP tag. | | geosite_data_paths | [] | V2Ray GeoSite .dat or JSON paths; multiple files are accepted. | The following fields are deserialized by the current config type but are not read by the running engine: geoip_auto_convert and geoip_filter_countries. Use convert-geo-ip with --filter for conversion-time filtering. The top-level background_refresh_rule is also currently ignored by runtime configuration compilation. @@ -298,7 +298,7 @@ The same structure is used for response_matchers, response_matcher_operator, res Pipeline selectors support all of the rows above. Request rules support all rows except listener_label. -Domain suffix matching and GeoSite matching are case-insensitive. domain_regex and request_domain_regex use Rust regular-expression syntax. +Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoip_country.country_codes` accepts both ISO country codes and named V2Ray GeoIP tags such as `cloudflare`, `netflix`, or `telegram`. GeoIP `.dat`/JSON indexes preserve overlapping memberships, so one IP may match both a country code and one or more named tags. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. ### Response matchers diff --git a/src/config.rs b/src/config.rs index ecf5cf5..8763555 100644 --- a/src/config.rs +++ b/src/config.rs @@ -375,7 +375,7 @@ pub enum Matcher { ClientIp { cidr: String, }, - /// 匹配客户端IP的GeoIP国家代码(大小写不敏感)。 / Match client IP GeoIP country code (case insensitive) + /// 匹配客户端 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)。 / Match client IP GeoIP country code or named tag (case insensitive) GeoipCountry { country_codes: Vec, }, @@ -426,7 +426,7 @@ pub enum PipelineSelectorMatcher { GeoSite { value: String }, /// GeoSite 否定匹配(匹配不在该分类的域名)。 / GeoSite negation matching (match domains NOT in category) GeoSiteNot { value: String }, - /// 匹配客户端IP的GeoIP国家代码(大小写不敏感)。 / Match client IP GeoIP country code (case insensitive) + /// 匹配客户端 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)。 / Match client IP GeoIP country code or named tag (case insensitive) GeoipCountry { country_codes: Vec }, /// 匹配客户端IP是否为私有IP(内网)。 / Match whether client IP is private (internal network) GeoipPrivate { expect: bool }, @@ -488,7 +488,7 @@ pub enum ResponseMatcher { ResponseQclass { value: String }, /// 响应是否携带 EDNS。 / Whether response carries EDNS ResponseEdnsPresent { expect: bool }, - /// 匹配响应中 IP 的 GeoIP 国家代码(大小写不敏感)/ Match GeoIP country code of IPs in response (case insensitive) + /// 匹配响应中 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)/ Match GeoIP country code or named tag of response IPs (case insensitive) ResponseAnswerIpGeoipCountry { country_codes: Vec }, /// 匹配响应中 IP 是否为私有 IP / Match whether IPs in response are private IPs ResponseAnswerIpGeoipPrivate { expect: bool }, diff --git a/src/matcher/geoip.rs b/src/matcher/geoip.rs index ccf965e..8ee28da 100644 --- a/src/matcher/geoip.rs +++ b/src/matcher/geoip.rs @@ -7,6 +7,7 @@ use anyhow::Context; use moka::sync::Cache as MokaCache; use notify::Watcher; use prost::Message; +use rustc_hash::FxHashMap; use serde::Deserialize; // Re-export from geoip_converter module @@ -81,6 +82,63 @@ impl IpRangeV6 { } } +#[derive(Debug, Clone, Copy)] +struct Ipv4Range { + start: u32, + end: u32, +} + +#[derive(Debug, Clone, Copy)] +struct Ipv6Range { + start: u128, + end: u128, +} + +#[derive(Debug, Default)] +struct GeoIpTagIndex { + ipv4_ranges: Vec, + ipv6_ranges: Vec, +} + +impl GeoIpTagIndex { + fn finalize(&mut self) { + merge_ipv4_ranges(&mut self.ipv4_ranges); + merge_ipv6_ranges(&mut self.ipv6_ranges); + } + + #[inline] + fn contains(&self, ip: IpAddr) -> bool { + self.matching_range(ip).is_some() + } + + #[inline] + fn matching_range(&self, ip: IpAddr) -> Option<(u128, u128)> { + match ip { + IpAddr::V4(ip) => { + let ip = u32::from(ip); + let index = self.ipv4_ranges.partition_point(|range| range.start <= ip); + self.ipv4_ranges + .get(index.checked_sub(1)?) + .filter(|range| range.end >= ip) + .map(|range| (u128::from(range.start), u128::from(range.end))) + } + IpAddr::V6(ip) => { + let ip = u128::from(ip); + let index = self.ipv6_ranges.partition_point(|range| range.start <= ip); + self.ipv6_ranges + .get(index.checked_sub(1)?) + .filter(|range| range.end >= ip) + .map(|range| (range.start, range.end)) + } + } + } + + #[inline] + fn range_count(&self) -> usize { + self.ipv4_ranges.len() + self.ipv6_ranges.len() + } +} + /// V2Ray .dat 文件使用 protobuf 格式 /// MaxMind GeoIP 数据库管理器 / MaxMind GeoIP database manager pub struct GeoIpManager { @@ -90,10 +148,9 @@ pub struct GeoIpManager { /// Note: Reserved for future hot-reload functionality #[allow(dead_code)] db_path: Option, - /// IP 范围列表(从 .dat 文件加载)/ IP range list (loaded from .dat file) - ip_ranges: Vec, - /// IPv6 范围列表(从 .dat 文件加载)/ IPv6 range list (loaded from .dat file) - ipv6_ranges: Vec, + /// 按标签分组的 IP 范围索引(从 .dat/JSON 文件加载) + /// IP range indexes grouped by tag (loaded from .dat/JSON files) + tag_indexes: FxHashMap, GeoIpTagIndex>, /// 查询结果缓存(IP -> GeoIP 结果) / Query result cache (IP -> GeoIP result) cache: MokaCache, } @@ -144,8 +201,7 @@ impl GeoIpManager { Ok(Self { reader: Arc::new(reader), db_path, - ip_ranges: Vec::new(), - ipv6_ranges: Vec::new(), + tag_indexes: FxHashMap::default(), cache, }) } @@ -154,7 +210,7 @@ impl GeoIpManager { fn rebuild_cache(&mut self) { // 根据实际加载的 IP 范围数量设置缓存大小 // 缓存大小为实际条数的 2 倍,最小 10000,最大 1000000 - let entry_count = self.ip_ranges.len(); + let entry_count = self.ip_range_count(); let cache_capacity = (entry_count * 2).clamp(10_000, 1_000_000) as u64; tracing::info!( @@ -183,9 +239,9 @@ impl GeoIpManager { // 查询 MMDB 或 IP 范围 / Query MMDB or IP ranges let result = if let Some(reader) = self.reader.as_ref() { self.lookup_mmdb(reader, ip) - } else if !self.ip_ranges.is_empty() { - // 使用 .dat 文件的 IP 范围 / Use IP ranges from .dat file - self.lookup_dat(ip) + } else if !self.tag_indexes.is_empty() { + // 使用 .dat/JSON 文件中的国家标签 / Use country tags from .dat/JSON files + self.lookup_dat_country(ip) } else { // 没有 MMDB 文件,只检测私有 IP / No MMDB file, only check private IP GeoIpResult { @@ -231,102 +287,105 @@ impl GeoIpManager { } } - /// 从 .dat 文件的 IP 范围查询 / Lookup from .dat file IP ranges - fn lookup_dat(&self, ip: IpAddr) -> GeoIpResult { - match ip { - IpAddr::V4(ipv4) => { - let octets = ipv4.octets(); - let ip_u32 = (octets[0] as u32) << 24 - | (octets[1] as u32) << 16 - | (octets[2] as u32) << 8 - | (octets[3] as u32); - - // 使用二分查找 / Use binary search - match self - .ip_ranges - .binary_search_by_key(&ip_u32, |range| range.start) - { - Ok(idx) => { - // 完全匹配起始 IP / Exact match on start IP - let range = &self.ip_ranges[idx]; - if ip_u32 <= range.end { - return GeoIpResult { - country_code: Some(Arc::from(range.country_code.as_str())), - is_private: crate::matcher::geoip::is_private_ip(ip), - }; - } - } - Err(idx) => { - // 向前扫描:网段仅按 start 排序,end 不单调。较大的覆盖 - // 网段(如 CN 111.0.0.0/10)可能位于更靠前的位置,被其后 - // 不覆盖 ip 的小网段(嵌套的 US /23)遮挡——只检查 idx-1 - // 会漏判。嵌套重叠是 geoip.dat 的合法形态(CN 大网段内 - // 嵌更小的国外网段),必须扫描到覆盖网段为止。 - // 返回 start 最大且覆盖的网段 = 最具体匹配优先。 - let mut i = idx.saturating_sub(1); - loop { - let range = &self.ip_ranges[i]; - if ip_u32 >= range.start && ip_u32 <= range.end { - return GeoIpResult { - country_code: Some(Arc::from(range.country_code.as_str())), - is_private: crate::matcher::geoip::is_private_ip(ip), - }; - } - if i == 0 { - break; - } - i -= 1; - } - } - } - GeoIpResult { - country_code: None, - is_private: crate::matcher::geoip::is_private_ip(ip), - } + /// 检查 IP 是否属于指定标签 / Check whether an IP belongs to a tag. + #[inline] + pub fn matches_tag(&self, ip: IpAddr, tag: &str) -> bool { + if let Some(reader) = self.reader.as_ref().as_ref() { + return self + .lookup_mmdb(reader, ip) + .country_code + .is_some_and(|code| code.eq_ignore_ascii_case(tag)); + } + + let normalized; + let tag = if tag.bytes().all(|b| !b.is_ascii_lowercase()) { + tag + } else { + normalized = tag.to_ascii_uppercase(); + &normalized + }; + + self.tag_indexes + .get(tag) + .is_some_and(|index| index.contains(ip)) + } + + /// 检查 IP 是否属于任一指定标签 / Check whether an IP belongs to any requested tag. + #[inline] + pub fn matches_any_tag(&self, ip: IpAddr, tags: &[Arc]) -> bool { + if let Some(reader) = self.reader.as_ref().as_ref() { + return self + .lookup_mmdb(reader, ip) + .country_code + .is_some_and(|code| tags.iter().any(|tag| tag.eq_ignore_ascii_case(&code))); + } + + for tag in tags { + let normalized; + let tag = if tag.bytes().all(|b| !b.is_ascii_lowercase()) { + tag.as_ref() + } else { + normalized = tag.to_ascii_uppercase(); + &normalized + }; + + if self + .tag_indexes + .get(tag) + .is_some_and(|index| index.contains(ip)) + { + return true; } - IpAddr::V6(ipv6) => { - let ip_u128 = u128::from(ipv6); + } + false + } - // 使用二分查找 / Use binary search - match self - .ipv6_ranges - .binary_search_by_key(&ip_u128, |range| range.start) - { - Ok(idx) => { - // 完全匹配起始 IP / Exact match on start IP - let range = &self.ipv6_ranges[idx]; - if ip_u128 <= range.end { - return GeoIpResult { - country_code: Some(Arc::from(range.country_code.as_str())), - is_private: crate::matcher::geoip::is_private_ip(ip), - }; - } - } - Err(idx) => { - // 向前扫描:同 IPv4 分支,较大的覆盖网段可能位于更靠前 - // 的位置,只检查 idx-1 会漏判(嵌套重叠是合法形态)。 - let mut i = idx.saturating_sub(1); - loop { - let range = &self.ipv6_ranges[i]; - if ip_u128 >= range.start && ip_u128 <= range.end { - return GeoIpResult { - country_code: Some(Arc::from(range.country_code.as_str())), - is_private: crate::matcher::geoip::is_private_ip(ip), - }; - } - if i == 0 { - break; - } - i -= 1; - } - } - } - GeoIpResult { - country_code: None, - is_private: crate::matcher::geoip::is_private_ip(ip), - } + /// 返回 IP 命中的全部标签,主要用于诊断和管理接口。 + /// Return all tags matching an IP, primarily for diagnostics and management APIs. + pub fn lookup_tags(&self, ip: IpAddr) -> Vec> { + if let Some(reader) = self.reader.as_ref().as_ref() { + return self + .lookup_mmdb(reader, ip) + .country_code + .into_iter() + .collect(); + } + + let mut tags: Vec<_> = self + .tag_indexes + .iter() + .filter(|(_, index)| index.contains(ip)) + .map(|(tag, _)| Arc::clone(tag)) + .collect(); + tags.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref())); + tags + } + + /// 从 .dat/JSON 数据中查询传统单标签结果,优先返回两位国家代码。 + /// Lookup the legacy single-tag result, preferring a two-letter country code. + fn lookup_dat_country(&self, ip: IpAddr) -> GeoIpResult { + let mut country_match = None; + let mut named_match = None; + + for (tag, index) in &self.tag_indexes { + let Some((start, end)) = index.matching_range(ip) else { + continue; + }; + + let target = if is_country_tag(tag) { + &mut country_match + } else { + &mut named_match + }; + if is_more_specific(target, tag, start, end) { + *target = Some((Arc::clone(tag), start, end)); } } + + GeoIpResult { + country_code: country_match.or(named_match).map(|(tag, _, _)| tag), + is_private: is_private_ip(ip), + } } /// 重新加载 MMDB 数据库 / Reload MMDB database @@ -360,16 +419,20 @@ impl GeoIpManager { } } - /// 检查 MMDB 是否已加载 / Check if MMDB is loaded + /// 检查 GeoIP 数据是否已加载 / Check whether any GeoIP data is loaded. #[inline] pub fn is_loaded(&self) -> bool { - self.reader.is_some() + self.reader.is_some() || !self.tag_indexes.is_empty() } - /// 获取 IP 范围数量(仅用于调试)/ Get IP range count (debug only) + /// 获取去重合并后的 IP 范围数量(仅用于调试)。 + /// Get the deduplicated and merged IP range count (debug only). #[inline] pub fn ip_range_count(&self) -> usize { - self.ip_ranges.len() + self.tag_indexes + .values() + .map(GeoIpTagIndex::range_count) + .sum() } /// 从 V2Ray .dat 文件加载 GeoIP 数据 / Load GeoIP data from V2Ray .dat file @@ -394,12 +457,16 @@ impl GeoIpManager { let list = super::geoip_proto::GeoIPList::decode(data.as_slice()) .context("failed to decode geoip.dat as protobuf GeoIPList")?; - self.ip_ranges.clear(); - self.ipv6_ranges.clear(); + let mut tag_indexes: FxHashMap, GeoIpTagIndex> = FxHashMap::default(); let mut count = 0; + let mut ipv4_count = 0; + let mut ipv6_count = 0; for entry in list.entry { - let country_code = entry.country_code; + let country_code = entry.country_code.to_ascii_uppercase(); + if country_code.is_empty() { + continue; + } for cidr in entry.cidr { match cidr.ip.len() { 4 => { @@ -416,12 +483,11 @@ impl GeoIpManager { let host_count = 1u32.wrapping_shl(32 - cidr.prefix); start.saturating_add(host_count).saturating_sub(1) }; - self.ip_ranges.push(IpRange { - start, - end, - country_code: country_code.clone(), - }); + get_or_insert_tag_index(&mut tag_indexes, &country_code) + .ipv4_ranges + .push(Ipv4Range { start, end }); count += 1; + ipv4_count += 1; } 16 => { let mut ip_bytes = [0u8; 16]; @@ -435,12 +501,11 @@ impl GeoIpManager { let host_count = 1u128.wrapping_shl(128 - cidr.prefix); start.saturating_add(host_count).saturating_sub(1) }; - self.ipv6_ranges.push(IpRangeV6 { - start, - end, - country_code: country_code.clone(), - }); + get_or_insert_tag_index(&mut tag_indexes, &country_code) + .ipv6_ranges + .push(Ipv6Range { start, end }); count += 1; + ipv6_count += 1; } len => { tracing::debug!( @@ -453,22 +518,20 @@ impl GeoIpManager { } } + for index in tag_indexes.values_mut() { + index.finalize(); + } + self.tag_indexes = tag_indexes; + tracing::info!( - "loaded {} GeoIP entries from .dat file (IPv4: {}, IPv6: {})", - count, - self.ip_ranges.len(), - self.ipv6_ranges.len() + geoip_entries = count, + geoip_tags = self.tag_indexes.len(), + ipv4_entries = ipv4_count, + ipv6_entries = ipv6_count, + merged_ranges = self.ip_range_count(), + "loaded GeoIP tag indexes from .dat file" ); - // 排序 IP 范围以支持二分查找 / Sort IP ranges to support binary search - if !self.ip_ranges.is_empty() { - self.ip_ranges.sort_by_key(|r| r.start); - } - if !self.ipv6_ranges.is_empty() { - self.ipv6_ranges.sort_by_key(|r| r.start); - } - - // 根据实际加载的条数重建缓存 self.rebuild_cache(); Ok(count) @@ -476,55 +539,44 @@ impl GeoIpManager { pub fn load_from_v2ray_file(&mut self, path: &Path) -> anyhow::Result { let data = std::fs::read_to_string(path)?; let list: V2RayGeoIPList = serde_json::from_str(&data)?; - - self.ip_ranges.clear(); - self.ipv6_ranges.clear(); + let mut tag_indexes: FxHashMap, GeoIpTagIndex> = FxHashMap::default(); + let mut count = 0; for geoip in list.entries { + let tag = geoip.country_code.to_ascii_uppercase(); + if tag.is_empty() { + continue; + } + let index = get_or_insert_tag_index(&mut tag_indexes, &tag); + for ip_str in &geoip.ips { match ip_str.parse::() { Ok(ipnet::IpNet::V4(v4net)) => { - let start = u32::from(v4net.network()); - let prefix_len = v4net.prefix_len() as u32; - let end = start + (1u32 << (32 - prefix_len)) - 1; - - self.ip_ranges.push(IpRange { - start, - end, - country_code: geoip.country_code.clone(), + index.ipv4_ranges.push(Ipv4Range { + start: u32::from(v4net.network()), + end: u32::from(v4net.broadcast()), }); + count += 1; } Ok(ipnet::IpNet::V6(v6net)) => { - let start = u128::from(v6net.network()); - let prefix_len = v6net.prefix_len() as u32; - let end = if prefix_len >= 128 { - start - } else { - start + (1u128 << (128 - prefix_len)) - 1 - }; - - self.ipv6_ranges.push(IpRangeV6 { - start, - end, - country_code: geoip.country_code.clone(), + index.ipv6_ranges.push(Ipv6Range { + start: u128::from(v6net.network()), + end: u128::from(v6net.broadcast()), }); + count += 1; } - Err(_) => { - // 无法解析的 IP 段,跳过 / Skip unparseable CIDR - continue; - } + Err(_) => continue, } } } - // 排序 IP 范围以支持二分查找 / Sort IP ranges to support binary search - self.ip_ranges.sort_by_key(|r| r.start); - self.ipv6_ranges.sort_by_key(|r| r.start); - - // 根据实际加载的条数重建缓存 + for index in tag_indexes.values_mut() { + index.finalize(); + } + self.tag_indexes = tag_indexes; self.rebuild_cache(); - Ok(self.ip_ranges.len() + self.ipv6_ranges.len()) + Ok(count) } /// 转换 .dat 为 MMDB 格式 @@ -591,6 +643,69 @@ impl GeoIpManager { } } +fn get_or_insert_tag_index<'a>( + indexes: &'a mut FxHashMap, GeoIpTagIndex>, + tag: &str, +) -> &'a mut GeoIpTagIndex { + if !indexes.contains_key(tag) { + indexes.insert(Arc::from(tag), GeoIpTagIndex::default()); + } + indexes + .get_mut(tag) + .expect("GeoIP tag index must exist after insertion") +} + +fn merge_ipv4_ranges(ranges: &mut Vec) { + ranges.sort_unstable_by_key(|range| range.start); + let mut merged: Vec = Vec::with_capacity(ranges.len()); + for range in ranges.drain(..) { + if let Some(last) = merged.last_mut() + && range.start <= last.end.saturating_add(1) + { + last.end = last.end.max(range.end); + continue; + } + merged.push(range); + } + *ranges = merged; +} + +fn merge_ipv6_ranges(ranges: &mut Vec) { + ranges.sort_unstable_by_key(|range| range.start); + let mut merged: Vec = Vec::with_capacity(ranges.len()); + for range in ranges.drain(..) { + if let Some(last) = merged.last_mut() + && range.start <= last.end.saturating_add(1) + { + last.end = last.end.max(range.end); + continue; + } + merged.push(range); + } + *ranges = merged; +} + +#[inline] +fn is_country_tag(tag: &str) -> bool { + tag.len() == 2 && tag.bytes().all(|byte| byte.is_ascii_alphabetic()) +} + +fn is_more_specific( + current: &Option<(Arc, u128, u128)>, + tag: &str, + start: u128, + end: u128, +) -> bool { + match current { + None => true, + Some((current_tag, current_start, current_end)) => { + start > *current_start + || (start == *current_start && end < *current_end) + || (start == *current_start && end == *current_end && tag < current_tag.as_ref()) + } + } +} + /// 检测 IP 是否为私有地址 / Detect if IP is private address #[inline] pub fn is_private_ip(ip: IpAddr) -> bool { @@ -777,8 +892,7 @@ mod tests { let mut mgr = GeoIpManager::new(None).unwrap(); let count = mgr.load_from_dat_file(&path).unwrap(); assert_eq!(count, 2, "应加载 2 条(v4 + v6)"); - assert_eq!(mgr.ip_ranges.len(), 1, "IPv4 段"); - assert_eq!(mgr.ipv6_ranges.len(), 1, "IPv6 段不应被丢弃"); + assert_eq!(mgr.ip_range_count(), 2, "IPv4 和 IPv6 段都应保留"); // IPv6 查询应匹配国家 / IPv6 lookup should match the country let ip: std::net::IpAddr = "2001:db8::1".parse().unwrap(); @@ -813,8 +927,7 @@ mod tests { let mut mgr = GeoIpManager::new(None).unwrap(); let count = mgr.load_from_v2ray_file(&path).unwrap(); assert_eq!(count, 2, "JSON 应加载 v4 + v6"); - assert_eq!(mgr.ip_ranges.len(), 1); - assert_eq!(mgr.ipv6_ranges.len(), 1, "JSON IPv6 段不应被丢弃"); + assert_eq!(mgr.ip_range_count(), 2, "JSON IPv4 和 IPv6 段都应保留"); let ip: std::net::IpAddr = "2001:db8:1::1234".parse().unwrap(); let res = mgr.lookup(ip); @@ -822,6 +935,7 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// 构造嵌套重叠的 .dat(CN /8 大网段内嵌 US /24,IPv4+IPv6) /// Build a .dat with nested overlapping networks (CN /8 containing US /24) fn build_nested_dat() -> Vec { @@ -871,28 +985,92 @@ mod tests { let mut mgr = GeoIpManager::new(None).unwrap(); mgr.load_from_dat_file(&path).unwrap(); - // 内嵌 US /24 内 → 最具体匹配 US - let us: std::net::IpAddr = "10.1.2.5".parse().unwrap(); + let us: IpAddr = "10.1.2.5".parse().unwrap(); assert_eq!(mgr.lookup(us).country_code.as_deref(), Some("US")); - // US /24 末边界 - let b1: std::net::IpAddr = "10.1.2.255".parse().unwrap(); + let b1: IpAddr = "10.1.2.255".parse().unwrap(); assert_eq!(mgr.lookup(b1).country_code.as_deref(), Some("US")); - // CN /8 内、US /24 外 → 向前扫描命中 CN - // (旧代码只查 idx-1 = US /24,不覆盖 → None,漏判) - let cn: std::net::IpAddr = "10.1.3.5".parse().unwrap(); + let cn: IpAddr = "10.1.3.5".parse().unwrap(); assert_eq!(mgr.lookup(cn).country_code.as_deref(), Some("CN")); - let b2: std::net::IpAddr = "10.1.3.0".parse().unwrap(); + let b2: IpAddr = "10.1.3.0".parse().unwrap(); assert_eq!(mgr.lookup(b2).country_code.as_deref(), Some("CN")); - // 远离嵌套段的 CN /8 内部 - let cn2: std::net::IpAddr = "10.200.1.1".parse().unwrap(); + let cn2: IpAddr = "10.200.1.1".parse().unwrap(); assert_eq!(mgr.lookup(cn2).country_code.as_deref(), Some("CN")); - // IPv6 同样:内嵌 US /48 → US;外部 → CN - let us6: std::net::IpAddr = "2001:db8:1::1".parse().unwrap(); + let us6: IpAddr = "2001:db8:1::1".parse().unwrap(); assert_eq!(mgr.lookup(us6).country_code.as_deref(), Some("US")); - let cn6: std::net::IpAddr = "2001:db8:2::1".parse().unwrap(); + let cn6: IpAddr = "2001:db8:2::1".parse().unwrap(); assert_eq!(mgr.lookup(cn6).country_code.as_deref(), Some("CN")); let _ = std::fs::remove_file(&path); } + + #[test] + fn test_overlapping_dat_tags_match_independently() { + let mut dat = build_dat("AU", Some(([1, 1, 1, 0], 24)), None); + dat.extend(build_dat("cloudflare", Some(([1, 1, 1, 0], 24)), None)); + + let mut path = std::env::temp_dir(); + path.push("geoip_test_overlapping_tags.dat"); + std::fs::write(&path, &dat).unwrap(); + + let mut mgr = GeoIpManager::new(None).unwrap(); + assert_eq!(mgr.load_from_dat_file(&path).unwrap(), 2); + + let ip: IpAddr = "1.1.1.1".parse().unwrap(); + assert!(mgr.matches_tag(ip, "AU")); + assert!(mgr.matches_tag(ip, "cloudflare")); + assert!(!mgr.matches_tag(ip, "netflix")); + assert!(mgr.matches_any_tag(ip, &[Arc::from("NETFLIX"), Arc::from("CLOUDFLARE")])); + assert_eq!(mgr.lookup(ip).country_code.as_deref(), Some("AU")); + assert_eq!( + mgr.lookup_tags(ip), + vec![Arc::from("AU"), Arc::from("CLOUDFLARE")] + ); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_lookup_preserves_named_tag_only_dat_behavior() { + let dat = build_dat("cloudflare", Some(([1, 1, 1, 0], 24)), None); + let mut path = std::env::temp_dir(); + path.push("geoip_test_named_tag_only.dat"); + std::fs::write(&path, &dat).unwrap(); + + let mut mgr = GeoIpManager::new(None).unwrap(); + mgr.load_from_dat_file(&path).unwrap(); + + let ip: IpAddr = "1.1.1.1".parse().unwrap(); + assert_eq!(mgr.lookup(ip).country_code.as_deref(), Some("CLOUDFLARE")); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_mmdb_keeps_precedence_over_dat_tags() { + let network = [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + let mmdb_source = build_dat("US", None, Some((network, 32))); + let dat = build_dat("CLOUDFLARE", None, Some((network, 32))); + let temp_dir = std::env::temp_dir(); + let mmdb_source_path = temp_dir.join("geoip_test_mmdb_source.dat"); + let dat_path = temp_dir.join("geoip_test_mmdb_precedence.dat"); + let mmdb_path = temp_dir.join("geoip_test_mmdb_precedence.mmdb"); + std::fs::write(&mmdb_source_path, mmdb_source).unwrap(); + std::fs::write(&dat_path, dat).unwrap(); + GeoIpManager::convert_dat_to_mmdb(&mmdb_source_path, &mmdb_path, None).unwrap(); + + let mut mgr = GeoIpManager::new(Some(mmdb_path.to_string_lossy().into_owned())).unwrap(); + mgr.load_from_dat_file(&dat_path).unwrap(); + + let ip: IpAddr = "2001:db8::1".parse().unwrap(); + assert!(mgr.matches_tag(ip, "US")); + assert!(!mgr.matches_tag(ip, "CLOUDFLARE")); + assert!(mgr.matches_any_tag(ip, &[Arc::from("US"), Arc::from("CLOUDFLARE")])); + assert_eq!(mgr.lookup_tags(ip), vec![Arc::from("US")]); + + drop(mgr); + let _ = std::fs::remove_file(mmdb_source_path); + let _ = std::fs::remove_file(dat_path); + let _ = std::fs::remove_file(mmdb_path); + } } diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index feeeaae..3736ef5 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -58,13 +58,13 @@ impl TxtMatchMode { mod matcher_helpers { use super::*; - /// 检查 IP 的 GeoIP 国家代码是否匹配指定的国家代码列表(大小写不敏感) - /// Check if IP's GeoIP country code matches the specified list (case insensitive) + /// 检查 IP 是否匹配指定的 GeoIP 标签列表(大小写不敏感) + /// Check whether an IP matches the specified GeoIP tags (case insensitive) /// /// # 参数 / Parameters /// - `manager`: GeoIpManager 引用 / GeoIpManager reference /// - `ip`: 要检查的 IP 地址 / IP address to check - /// - `country_codes`: 允许的国家代码列表(使用 Arc 零拷贝)/ Allowed country codes (Arc for zero-copy) + /// - `country_codes`: 允许的国家代码或命名标签 / Allowed country codes or named tags /// /// # 返回 / Returns /// - `true`: IP 属于指定的国家之一 / IP belongs to one of the specified countries @@ -75,23 +75,7 @@ mod matcher_helpers { ip: IpAddr, country_codes: &[Arc], ) -> bool { - let result = manager.lookup(ip); - - // ✅ 优化:提前返回,避免闭包分配 - // ✅ Optimization: Early return to avoid closure allocation - let country_code = match result.country_code.as_ref() { - Some(code) => code, - None => return false, - }; - - // ✅ 优化:直接迭代,避免 any() 的闭包开销 - // ✅ Optimization: Direct iteration to avoid closure overhead of any() - for code in country_codes { - if code.eq_ignore_ascii_case(country_code.as_ref()) { - return true; - } - } - false + manager.matches_any_tag(ip, country_codes) } /// 检查域名是否属于指定的 GeoSite 分类 @@ -283,7 +267,7 @@ pub enum RuntimeResponseMatcher { ResponseEdnsPresent { expect: bool, }, - /// 匹配响应中 IP 的 GeoIP 国家代码 / Match GeoIP country code of IPs in response + /// 匹配响应中 IP 的 GeoIP 国家代码或命名标签 / Match GeoIP country codes or named tags of response IPs ResponseAnswerIpGeoipCountry { country_codes: Vec>, }, @@ -728,7 +712,10 @@ impl RuntimeMatcher { .build()?, }, config::Matcher::GeoipCountry { country_codes } => RuntimeMatcher::GeoipCountry { - country_codes: country_codes.into_iter().map(Arc::from).collect(), + country_codes: country_codes + .into_iter() + .map(|code| Arc::from(code.to_ascii_uppercase())) + .collect(), }, config::Matcher::GeoipPrivate { expect } => RuntimeMatcher::GeoipPrivate { expect }, config::Matcher::Qclass { value } => RuntimeMatcher::Qclass { @@ -788,15 +775,7 @@ impl RuntimeMatcher { }) } RuntimeMatcher::GeoipPrivate { expect } => { - // 按需获取锁:只在GeoIP matcher时才获取 - if let Some(manager) = geoip_manager { - let guard = manager.read(); - let result = guard.lookup(client_ip); - result.is_private == *expect - } else { - // Fallback to basic private IP check - crate::matcher::geoip::is_private_ip(client_ip) == *expect - } + crate::matcher::geoip::is_private_ip(client_ip) == *expect } RuntimeMatcher::Qclass { value } => &qclass == value, RuntimeMatcher::EdnsPresent { expect } => *expect == edns_present, @@ -850,15 +829,7 @@ impl RuntimeMatcher { }) } RuntimeMatcher::GeoipPrivate { expect } => { - // 按需获取锁:只在GeoIP matcher时才获取 - if let Some(manager) = geoip_manager { - let guard = manager.read(); - let result = guard.lookup(client_ip); - result.is_private == *expect - } else { - // Fallback to basic private IP check - crate::matcher::geoip::is_private_ip(client_ip) == *expect - } + crate::matcher::geoip::is_private_ip(client_ip) == *expect } RuntimeMatcher::Qclass { value } => &qclass == value, RuntimeMatcher::EdnsPresent { expect } => *expect == edns_present, @@ -937,7 +908,10 @@ impl RuntimePipelineSelectorMatcher { } config::PipelineSelectorMatcher::GeoipCountry { country_codes } => { RuntimePipelineSelectorMatcher::GeoipCountry { - country_codes: country_codes.into_iter().map(Arc::from).collect(), + country_codes: country_codes + .into_iter() + .map(|code| Arc::from(code.to_ascii_uppercase())) + .collect(), } } config::PipelineSelectorMatcher::GeoipPrivate { expect } => { @@ -996,11 +970,7 @@ impl RuntimePipelineSelectorMatcher { /// 如果此匹配器需要 GeoIP 管理器才能评估则返回 true。 #[inline] pub const fn needs_geoip(&self) -> bool { - matches!( - self, - RuntimePipelineSelectorMatcher::GeoipCountry { .. } - | RuntimePipelineSelectorMatcher::GeoipPrivate { .. } - ) + matches!(self, RuntimePipelineSelectorMatcher::GeoipCountry { .. }) } #[inline] @@ -1042,25 +1012,10 @@ impl RuntimePipelineSelectorMatcher { } } RuntimePipelineSelectorMatcher::GeoipCountry { country_codes } => { - if let Some(mgr) = geoip_ready { - let result = mgr.lookup(client_ip); - if let Some(cc) = result.country_code { - country_codes.iter().any(|c| c.eq_ignore_ascii_case(&cc)) - } else { - false - } - } else { - false - } + geoip_ready.is_some_and(|mgr| mgr.matches_any_tag(client_ip, country_codes)) } RuntimePipelineSelectorMatcher::GeoipPrivate { expect } => { - if let Some(mgr) = geoip_ready { - let result = mgr.lookup(client_ip); - result.is_private == *expect - } else { - // Fallback to basic private IP check - crate::matcher::geoip::is_private_ip(client_ip) == *expect - } + crate::matcher::geoip::is_private_ip(client_ip) == *expect } RuntimePipelineSelectorMatcher::Qtype { value } => *value == qtype, } @@ -1115,25 +1070,12 @@ impl RuntimePipelineSelectorMatcher { } RuntimePipelineSelectorMatcher::GeoipCountry { country_codes } => { // 按需获取锁:只在GeoIP matcher时才获取 / On-demand lock: only acquire for GeoIP matcher - geoip_manager.map(|mgr| mgr.read()).is_some_and(|guard| { - let result = guard.lookup(client_ip); - if let Some(cc) = result.country_code { - country_codes.iter().any(|c| c.eq_ignore_ascii_case(&cc)) - } else { - false - } - }) + geoip_manager + .map(|mgr| mgr.read()) + .is_some_and(|guard| guard.matches_any_tag(client_ip, country_codes)) } RuntimePipelineSelectorMatcher::GeoipPrivate { expect } => { - // 按需获取锁:只在GeoIP matcher时才获取 - if let Some(manager) = geoip_manager { - let guard = manager.read(); - let result = guard.lookup(client_ip); - result.is_private == *expect - } else { - // Fallback to basic private IP check - crate::matcher::geoip::is_private_ip(client_ip) == *expect - } + crate::matcher::geoip::is_private_ip(client_ip) == *expect } RuntimePipelineSelectorMatcher::Qtype { value } => *value == qtype, } @@ -1283,7 +1225,10 @@ impl RuntimeResponseMatcher { } config::ResponseMatcher::ResponseAnswerIpGeoipCountry { country_codes } => { RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { - country_codes: country_codes.into_iter().map(Arc::from).collect(), + country_codes: country_codes + .into_iter() + .map(|code| Arc::from(code.to_ascii_uppercase())) + .collect(), } } config::ResponseMatcher::ResponseAnswerIpGeoipPrivate { expect } => { @@ -1533,3 +1478,80 @@ fn parse_dns_type(v: &str) -> anyhow::Result { }; Ok(parsed) } + +#[cfg(test)] +mod tests { + use super::*; + use hickory_proto::{ + op::{MessageType, OpCode}, + rr::{Name, RData, Record, rdata::A}, + }; + use std::net::Ipv4Addr; + + #[test] + fn overlapping_geoip_tag_reaches_all_runtime_matchers() { + let json = r#"{ + "entries": [ + { "country_code": "US", "ips": ["104.16.0.0/12"] }, + { "country_code": "cloudflare", "ips": ["104.16.0.0/12"] } + ] + }"#; + let path = std::env::temp_dir().join("geoip_runtime_matchers.json"); + std::fs::write(&path, json).unwrap(); + + let mut manager = geoip::GeoIpManager::new(None).unwrap(); + manager.load_from_v2ray_file(&path).unwrap(); + let manager = Arc::new(crate::lock::RwLock::new(manager)); + let ip: IpAddr = "104.16.0.1".parse().unwrap(); + let tags = vec![Arc::from("cloudflare")]; + + let request = RuntimeMatcher::GeoipCountry { + country_codes: tags.clone(), + }; + assert!(request.matches_with_qtype( + "example.com", + DNSClass::IN, + ip, + false, + RecordType::A, + Some(&manager), + None, + )); + + let pipeline = RuntimePipelineSelectorMatcher::GeoipCountry { + country_codes: tags.clone(), + }; + let query = PipelineSelectorQuery { + listener_label: "test", + client_ip: ip, + qname: "example.com", + qclass: DNSClass::IN, + edns_present: false, + qtype: RecordType::A, + }; + let guard = manager.read(); + assert!(pipeline.matches_with_ready_managers(&query, Some(&guard), None)); + + let response = RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { + country_codes: tags, + }; + let mut message = Message::new(0, MessageType::Response, OpCode::Query); + message.add_answer(Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::A(A(Ipv4Addr::new(104, 16, 0, 1))), + )); + assert!(response.matches( + "udp:test", + "example.com", + RecordType::A, + DNSClass::IN, + &message, + Some(&guard), + None, + )); + + drop(guard); + let _ = std::fs::remove_file(path); + } +} diff --git a/tools/README.md b/tools/README.md index 7606690..ae6cd3e 100644 --- a/tools/README.md +++ b/tools/README.md @@ -141,7 +141,7 @@ Pipeline selector 支持: - response_request_domain_geosite_not - response_txt_content -GeoSite 和 GeoIP 的输入值在编辑器中可以使用普通字段。编辑器还会识别 geosite:tag 和 geoip:COUNTRY 形式并将参数写入对应字段;服务端最终仍按 Rust 配置枚举解析。 +GeoSite 和 GeoIP 的输入值在编辑器中可以使用普通字段。编辑器还会识别 geosite:tag 和 geoip:COUNTRY 形式并将参数写入对应字段;`COUNTRY` 也可以是 `cloudflare`、`netflix` 等 V2Ray GeoIP 标签。GeoIP 标签大小写不敏感,且同一 IP 可以同时匹配国家代码和命名标签;服务端最终仍按 Rust 配置枚举解析。 ### 动作 From e51927aca0aac8f4cca0d6852e89a1518b9d75ce Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Sun, 9 Aug 2026 19:05:54 +0900 Subject: [PATCH 2/7] fix(config): accept scalar GeoIP country codes --- README.md | 2 +- README.zh-CN.md | 2 +- src/config.rs | 76 +++++++++++++++++++++++++++++++++++----- tools/config_editor.html | 19 ++++++++++ 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index e9e46f7..691ac4b 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ The same structure is used for response_matchers, response_matcher_operator, res Pipeline selectors support all of the rows above. Request rules support all rows except listener_label. -Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoip_country.country_codes` accepts both ISO country codes and named V2Ray GeoIP tags such as `cloudflare`, `netflix`, or `telegram`. GeoIP `.dat`/JSON indexes preserve overlapping memberships, so one IP may match both a country code and one or more named tags. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. +Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoip_country.country_codes` accepts both ISO country codes and named V2Ray GeoIP tags such as `cloudflare`, `netflix`, or `telegram`. The canonical JSON form is a string array; a single string or comma-separated string is also accepted for backward compatibility. GeoIP `.dat`/JSON indexes preserve overlapping memberships, so one IP may match both a country code and one or more named tags. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. ### Response matchers diff --git a/README.zh-CN.md b/README.zh-CN.md index 794eb82..65bca89 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -297,7 +297,7 @@ pipeline_select 的每项包含 Pipeline id、可选的匹配器列表和可选 Pipeline selector 支持上表全部类型;请求规则支持除 listener_label 之外的全部类型。 -域名后缀和 GeoSite 匹配不区分大小写。domain_regex 和 request_domain_regex 使用 Rust 正则语法。 +域名后缀、GeoSite 标签和 GeoIP 标签匹配不区分大小写。`geoip_country.country_codes` 可使用 ISO 国家代码或 `cloudflare`、`netflix`、`telegram` 等 V2Ray GeoIP 命名标签。规范 JSON 形式为字符串数组;为兼容旧配置,也接受单个字符串或逗号分隔字符串。GeoIP `.dat`/JSON 索引会保留重叠成员关系,因此同一 IP 可以同时属于国家代码和一个或多个命名标签。domain_regex 和 request_domain_regex 使用 Rust 正则语法。 ### 响应匹配器 diff --git a/src/config.rs b/src/config.rs index 8763555..753a75e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -377,6 +377,7 @@ pub enum Matcher { }, /// 匹配客户端 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)。 / Match client IP GeoIP country code or named tag (case insensitive) GeoipCountry { + #[serde(deserialize_with = "deserialize_string_or_vec")] country_codes: Vec, }, /// 匹配客户端IP是否为私有IP(内网)。 / Match whether client IP is private (internal network) @@ -427,7 +428,10 @@ pub enum PipelineSelectorMatcher { /// GeoSite 否定匹配(匹配不在该分类的域名)。 / GeoSite negation matching (match domains NOT in category) GeoSiteNot { value: String }, /// 匹配客户端 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)。 / Match client IP GeoIP country code or named tag (case insensitive) - GeoipCountry { country_codes: Vec }, + GeoipCountry { + #[serde(deserialize_with = "deserialize_string_or_vec")] + country_codes: Vec, + }, /// 匹配客户端IP是否为私有IP(内网)。 / Match whether client IP is private (internal network) GeoipPrivate { expect: bool }, /// 请求 QTYPE(如 A/AAAA/CNAME/TXT/MX 等)。 / Request QTYPE (e.g., A/AAAA/CNAME/TXT/MX, etc.) @@ -489,7 +493,10 @@ pub enum ResponseMatcher { /// 响应是否携带 EDNS。 / Whether response carries EDNS ResponseEdnsPresent { expect: bool }, /// 匹配响应中 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)/ Match GeoIP country code or named tag of response IPs (case insensitive) - ResponseAnswerIpGeoipCountry { country_codes: Vec }, + ResponseAnswerIpGeoipCountry { + #[serde(deserialize_with = "deserialize_string_or_vec")] + country_codes: Vec, + }, /// 匹配响应中 IP 是否为私有 IP / Match whether IPs in response are private IPs ResponseAnswerIpGeoipPrivate { expect: bool }, /// 匹配响应中的请求域名是否属于指定 GeoSite 分类 / Match if request domain in response belongs to specified GeoSite category @@ -1035,13 +1042,34 @@ where } } +/// Deserialize a string field that also accepts an array of strings. +fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrVec { + String(String), + Array(Vec), + } + + match StringOrVec::deserialize(deserializer)? { + StringOrVec::String(value) => Ok(value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .collect()), + StringOrVec::Array(values) => Ok(values), + } +} + /// 反序列化TXT文本字段,支持单个字符串或字符串数组 / Deserialize TXT text field, supports single string or string array fn deserialize_txt_text<'de, D>(deserializer: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { - use serde::Deserialize; - #[derive(Deserialize)] #[serde(untagged)] enum TxtTextInput { @@ -1049,11 +1077,9 @@ where Array(Vec), } - let input = TxtTextInput::deserialize(deserializer)?; - - match input { - TxtTextInput::String(s) => Ok(vec![s]), - TxtTextInput::Array(arr) => Ok(arr), + match TxtTextInput::deserialize(deserializer)? { + TxtTextInput::String(value) => Ok(vec![value]), + TxtTextInput::Array(values) => Ok(values), } } @@ -1111,3 +1137,35 @@ fn default_ecs_prefix_v4() -> u8 { fn default_ecs_prefix_v6() -> u8 { 56 // Common ISP allocation boundary } + +#[cfg(test)] +mod tests { + use super::{Matcher, PipelineSelectorMatcher, ResponseMatcher}; + + #[test] + fn geoip_country_codes_accept_string_or_array() { + let Matcher::GeoipCountry { country_codes } = + serde_json::from_str(r#"{"type":"geoip_country","country_codes":"cloudflare"}"#) + .expect("request matcher should accept a string") + else { + panic!("unexpected request matcher variant"); + }; + assert_eq!(country_codes, ["cloudflare"]); + + let PipelineSelectorMatcher::GeoipCountry { country_codes } = + serde_json::from_str(r#"{"type":"geoip_country","country_codes":"CN, cloudflare"}"#) + .expect("pipeline selector should accept a comma-separated string") + else { + panic!("unexpected pipeline selector matcher variant"); + }; + assert_eq!(country_codes, ["CN", "cloudflare"]); + + let ResponseMatcher::ResponseAnswerIpGeoipCountry { country_codes } = serde_json::from_str( + r#"{"type":"response_answer_ip_geoip_country","country_codes":["CN","cloudflare"]}"#, + ) + .expect("response matcher should accept an array") else { + panic!("unexpected response matcher variant"); + }; + assert_eq!(country_codes, ["CN", "cloudflare"]); + } +} diff --git a/tools/config_editor.html b/tools/config_editor.html index 100cd7a..794e40d 100644 --- a/tools/config_editor.html +++ b/tools/config_editor.html @@ -1060,9 +1060,25 @@

JSON 预览 / 编辑

delete obj.ecs_prefix_v4; delete obj.ecs_prefix_v6; }; + const serializeMatcherCountryCodes = (items) => { + (items || []).forEach(matcher => { + if (!Object.prototype.hasOwnProperty.call(matcher, 'country_codes')) return; + const values = Array.isArray(matcher.country_codes) + ? matcher.country_codes + : String(matcher.country_codes || '').split(','); + matcher.country_codes = values + .map(value => value.trim()) + .filter(value => value); + }); + }; + (clean.pipeline_select || []).forEach(selector => { + serializeMatcherCountryCodes(selector.matchers); + }); (clean.pipelines || []).forEach(pipe => { processEcs(pipe); (pipe.rules || []).forEach(rule => { + serializeMatcherCountryCodes(rule.matchers); + serializeMatcherCountryCodes(rule.response_matchers); [rule.actions, rule.response_actions_on_match, rule.response_actions_on_miss] .forEach(actions => (actions || []).forEach(a => { if (a.type === 'forward') processEcs(a); @@ -1265,6 +1281,9 @@

JSON 预览 / 编辑

const normalizeMatcherOps = (items) => { (items || []).forEach(m => { if (!m.operator) m.operator = DEFAULT_MATCH_OPERATOR; + if (Array.isArray(m.country_codes)) { + m.country_codes = m.country_codes.join(','); + } }); }; From 9161872d5c578ebca8edc93918b5eb59b368e328 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Sun, 9 Aug 2026 19:06:42 +0900 Subject: [PATCH 3/7] fix(geoip): match HTTPS and SVCB address hints --- README.md | 2 + README.zh-CN.md | 2 + src/matcher/mod.rs | 312 ++++++++++++++++++++++++++++++++------------- 3 files changed, 229 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 691ac4b..f4199d0 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,8 @@ Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoi | response_request_domain_geosite_not | value: GeoSite tag | | response_txt_content | mode: exact, prefix, or regex; value is the text/pattern | +Response IP and GeoIP matchers inspect A/AAAA records and the `ipv4hint`/`ipv6hint` parameters carried by HTTPS/SVCB Answer records. They do not synthesize or rewrite HTTPS/SVCB records. + The successful upstream label currently includes the transport prefix, for example udp:1.1.1.1:53 or tcp:1.1.1.1:53. Therefore upstream_equals values must include that prefix. response_upstream_ip currently parses a raw IP or host:port value; it does not strip the transport prefix. ### Logical operators diff --git a/README.zh-CN.md b/README.zh-CN.md index 65bca89..22ade00 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -318,6 +318,8 @@ Pipeline selector 支持上表全部类型;请求规则支持除 listener_labe | response_request_domain_geosite_not | value:GeoSite tag | | response_txt_content | mode:exact、prefix 或 regex;value 为文本/模式 | +响应 IP 和 GeoIP 匹配器会检查 A/AAAA 记录,以及 HTTPS/SVCB Answer 记录携带的 `ipv4hint`/`ipv6hint` 参数;它们不会合成或改写 HTTPS/SVCB 记录。 + 当前成功上游标签包含传输前缀,例如 udp:1.1.1.1:53 或 tcp:1.1.1.1:53。因此 upstream_equals 的 value 必须包含该前缀。response_upstream_ip 当前解析原始 IP 或 host:port,不会剥离传输前缀。 ### 逻辑运算符 diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 3736ef5..ca48806 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -98,6 +98,76 @@ mod matcher_helpers { manager.matches(tag, domain) } + fn all_svc_param_ips_match( + svc_params: &[( + hickory_proto::rr::rdata::svcb::SvcParamKey, + hickory_proto::rr::rdata::svcb::SvcParamValue, + )], + has_ip: &mut bool, + predicate: &mut F, + ) -> bool + where + F: FnMut(IpAddr) -> bool, + { + use hickory_proto::rr::rdata::svcb::SvcParamValue; + + svc_params.iter().all(|(_, value)| match value { + SvcParamValue::Ipv4Hint(hints) => hints.0.iter().all(|hint| { + *has_ip = true; + predicate(IpAddr::V4(hint.0)) + }), + SvcParamValue::Ipv6Hint(hints) => hints.0.iter().all(|hint| { + *has_ip = true; + predicate(IpAddr::V6(hint.0)) + }), + _ => true, + }) + } + + /// Apply a predicate to every address represented directly or as an HTTPS/SVCB IP hint. + pub fn all_rdata_ips_match( + data: &hickory_proto::rr::RData, + has_ip: &mut bool, + predicate: &mut F, + ) -> bool + where + F: FnMut(IpAddr) -> bool, + { + use hickory_proto::rr::RData; + + match data { + RData::A(address) => { + *has_ip = true; + predicate(IpAddr::V4(address.0)) + } + RData::AAAA(address) => { + *has_ip = true; + predicate(IpAddr::V6(address.0)) + } + RData::SVCB(svcb) => all_svc_param_ips_match(&svcb.svc_params, has_ip, predicate), + RData::HTTPS(https) => all_svc_param_ips_match(&https.svc_params, has_ip, predicate), + _ => true, + } + } + + pub fn rdata_has_matching_ip(data: &hickory_proto::rr::RData, predicate: &mut F) -> bool + where + F: FnMut(IpAddr) -> bool, + { + let mut has_ip = false; + let mut matched = false; + let mut stop_on_match = |ip| { + if predicate(ip) { + matched = true; + false + } else { + true + } + }; + let _ = all_rdata_ips_match(data, &mut has_ip, &mut stop_on_match); + matched + } + /// 检查响应消息中是否有任意 IP 匹配指定的 CIDR 列表 /// Check if any IP in response message matches the specified CIDR list /// @@ -109,25 +179,16 @@ mod matcher_helpers { /// - `true`: 至少有一个 IP 匹配 / At least one IP matches /// - `false`: 没有IP匹配 / No IP matches pub fn any_ip_matches_nets(msg: &Message, nets: &[IpNet]) -> bool { - use hickory_proto::rr::RData; + let mut matches_net = |ip| nets.iter().any(|net| net.contains(&ip)); - // 先检查 Answer / Check Answer first - let found = msg.answers.iter().any(|record| match &record.data { - RData::A(a) => nets.iter().any(|net| net.contains(&IpAddr::V4(a.0))), - RData::AAAA(aaaa) => nets.iter().any(|net| net.contains(&IpAddr::V6(aaaa.0))), - _ => false, - }); - - if found { - return true; - } - - // 再检查 Additionals / Check Additionals - msg.additionals.iter().any(|record| match &record.data { - RData::A(a) => nets.iter().any(|net| net.contains(&IpAddr::V4(a.0))), - RData::AAAA(aaaa) => nets.iter().any(|net| net.contains(&IpAddr::V6(aaaa.0))), - _ => false, - }) + // Check Answer first, then Additionals. HTTPS/SVCB hints are part of their RDATA. + msg.answers + .iter() + .any(|record| rdata_has_matching_ip(&record.data, &mut matches_net)) + || msg + .additionals + .iter() + .any(|record| rdata_has_matching_ip(&record.data, &mut matches_net)) } } @@ -247,7 +308,7 @@ pub enum RuntimeResponseMatcher { ResponseUpstreamIp { nets: Vec, }, - /// 匹配 Answer 中任意 A/AAAA 记录的 IP / Match IPs of any A/AAAA records in the Answer + /// Match Answer A/AAAA addresses and HTTPS/SVCB IP hints. ResponseAnswerIp { nets: Vec, }, @@ -1333,77 +1394,29 @@ impl RuntimeResponseMatcher { edns == *expect } RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { country_codes } => { - // 优化:直接迭代 DNS 记录以避免分配 Vec,并在首次不匹配时短路 - // Optimization: Iterate DNS records directly to avoid Vec allocation, and short-circuit on first mismatch - use hickory_proto::rr::RData; - + let Some(manager) = geoip_manager else { + return false; + }; let mut has_ip = false; - - // 检查 Answers - let all_match_answers = msg.answers.iter().all(|record| { - let ip = match &record.data { - RData::A(a) => Some(IpAddr::V4(a.0)), - RData::AAAA(aaaa) => Some(IpAddr::V6(aaaa.0)), - _ => None, - }; - - if let Some(ip) = ip { - has_ip = true; - // 如果没有管理器,则无法匹配,视为失败 - // If no manager, cannot match, consider failure - if let Some(manager) = geoip_manager { - matcher_helpers::match_geoip_country(manager, ip, country_codes) - } else { - false - } - } else { - // 非 IP 记录忽略,继续检查其他记录 - // Ignore non-IP records, continue checking others - true - } + let mut matches_country = + |ip| matcher_helpers::match_geoip_country(manager, ip, country_codes); + let all_match = msg.answers.iter().all(|record| { + matcher_helpers::all_rdata_ips_match( + &record.data, + &mut has_ip, + &mut matches_country, + ) }); - - if !all_match_answers { - return false; - } - - // 也检查 Additionals (如果策略要求检查整个消息中的 IP) - // Check Additionals as well (if policy requires checking IPs in entire message) - // 注意:通常 ResponseAnswerIp 只关注 Answers。这里保持与原 collect_ips_from_message 行为一致吗? - // 原 collect_ips_from_message 只迭代了 msg.answers()! - // Check matcher_helpers code: "for record in msg.answers() { ... }" - YES, only answers. - // 原代码逻辑:如果 answers 为空,返回 false (all_ips.is_empty check) - - if !has_ip { - return false; - } - - true + all_match && has_ip } RuntimeResponseMatcher::ResponseAnswerIpGeoipPrivate { expect } => { - // 检查 Answer 中是否有任意 IP 为私有 IP - use hickory_proto::rr::RData; - let mut has_private_ip = msg.answers.iter().any(|record| match &record.data { - RData::A(a) => crate::matcher::geoip::is_private_ip(std::net::IpAddr::V4(a.0)), - RData::AAAA(aaaa) => { - crate::matcher::geoip::is_private_ip(std::net::IpAddr::V6(aaaa.0)) - } - _ => false, + let mut is_private = crate::matcher::geoip::is_private_ip; + let has_private_ip = msg.answers.iter().any(|record| { + matcher_helpers::rdata_has_matching_ip(&record.data, &mut is_private) + }) || msg.additionals.iter().any(|record| { + matcher_helpers::rdata_has_matching_ip(&record.data, &mut is_private) }); - if !has_private_ip { - // 检查 additionals - has_private_ip = msg.additionals.iter().any(|record| match &record.data { - RData::A(a) => { - crate::matcher::geoip::is_private_ip(std::net::IpAddr::V4(a.0)) - } - RData::AAAA(aaaa) => { - crate::matcher::geoip::is_private_ip(std::net::IpAddr::V6(aaaa.0)) - } - _ => false, - }); - } - has_private_ip == *expect } RuntimeResponseMatcher::ResponseRequestDomainGeoSite { value } => { @@ -1484,16 +1497,22 @@ mod tests { use super::*; use hickory_proto::{ op::{MessageType, OpCode}, - rr::{Name, RData, Record, rdata::A}, + rr::{ + Name, RData, Record, + rdata::{ + A, AAAA, HTTPS, SVCB, + svcb::{IpHint, SvcParamKey, SvcParamValue}, + }, + }, }; - use std::net::Ipv4Addr; + use std::net::{Ipv4Addr, Ipv6Addr}; #[test] fn overlapping_geoip_tag_reaches_all_runtime_matchers() { let json = r#"{ "entries": [ { "country_code": "US", "ips": ["104.16.0.0/12"] }, - { "country_code": "cloudflare", "ips": ["104.16.0.0/12"] } + { "country_code": "cloudflare", "ips": ["104.16.0.0/12", "2606:4700::/32"] } ] }"#; let path = std::env::temp_dir().join("geoip_runtime_matchers.json"); @@ -1551,6 +1570,125 @@ mod tests { None, )); + let mut https_message = Message::new(0, MessageType::Response, OpCode::Query); + https_message.add_answer(Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::HTTPS(HTTPS(SVCB::new( + 1, + Name::root(), + vec![ + ( + SvcParamKey::Ipv4Hint, + SvcParamValue::Ipv4Hint(IpHint(vec![A(Ipv4Addr::new(104, 21, 11, 126))])), + ), + ( + SvcParamKey::Ipv6Hint, + SvcParamValue::Ipv6Hint(IpHint(vec![AAAA( + "2606:4700:3034::6815:b7e".parse::().unwrap(), + )])), + ), + ], + ))), + )); + assert!(response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &https_message, + Some(&guard), + None, + )); + assert!(!response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &https_message, + None, + None, + )); + + let cidr_response = RuntimeResponseMatcher::ResponseAnswerIp { + nets: vec!["104.21.0.0/16".parse().unwrap()], + }; + assert!(cidr_response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &https_message, + Some(&guard), + None, + )); + + let mut mixed_message = https_message.clone(); + mixed_message.add_answer(Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::A(A(Ipv4Addr::new(203, 0, 113, 1))), + )); + assert!(!response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &mixed_message, + Some(&guard), + None, + )); + + let mut additional_message = Message::new(0, MessageType::Response, OpCode::Query); + additional_message.add_additional(Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::A(A(Ipv4Addr::new(104, 21, 11, 126))), + )); + assert!(!response.matches( + "udp:test", + "example.com", + RecordType::A, + DNSClass::IN, + &additional_message, + Some(&guard), + None, + )); + assert!(cidr_response.matches( + "udp:test", + "example.com", + RecordType::A, + DNSClass::IN, + &additional_message, + Some(&guard), + None, + )); + + let mut svcb_message = Message::new(0, MessageType::Response, OpCode::Query); + svcb_message.add_additional(Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::SVCB(SVCB::new( + 1, + Name::root(), + vec![( + SvcParamKey::Ipv4Hint, + SvcParamValue::Ipv4Hint(IpHint(vec![A(Ipv4Addr::new(192, 168, 1, 1))])), + )], + )), + )); + let private_response = + RuntimeResponseMatcher::ResponseAnswerIpGeoipPrivate { expect: true }; + assert!(private_response.matches( + "udp:test", + "example.com", + RecordType::SVCB, + DNSClass::IN, + &svcb_message, + Some(&guard), + None, + )); + drop(guard); let _ = std::fs::remove_file(path); } From 6e41986e8d4523478408f53c30543bd379c0b448 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Tue, 11 Aug 2026 20:16:17 +0900 Subject: [PATCH 4/7] fix(geoip): correct hybrid and hint matching --- README.md | 4 +- README.zh-CN.md | 6 +- src/config.rs | 8 ++- src/engine/core.rs | 21 +++---- src/engine/utils.rs | 55 +++++++++++++++++- src/matcher/geoip.rs | 80 +++++++++++++++----------- src/matcher/mod.rs | 133 ++++++++++++++++++++++++++++++++++--------- tools/README.md | 4 +- 8 files changed, 228 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index f4199d0..bdc6329 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,7 @@ The same structure is used for response_matchers, response_matcher_operator, res Pipeline selectors support all of the rows above. Request rules support all rows except listener_label. -Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoip_country.country_codes` accepts both ISO country codes and named V2Ray GeoIP tags such as `cloudflare`, `netflix`, or `telegram`. The canonical JSON form is a string array; a single string or comma-separated string is also accepted for backward compatibility. GeoIP `.dat`/JSON indexes preserve overlapping memberships, so one IP may match both a country code and one or more named tags. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. +Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoip_country.country_codes` accepts both ISO country codes and named V2Ray GeoIP tags such as `cloudflare`, `netflix`, or `telegram`. The canonical JSON form is a string array; a single string or comma-separated string is also accepted for backward compatibility. GeoIP `.dat`/JSON indexes preserve overlapping memberships, so one IP may match both a country code and one or more named tags. When MMDB and `.dat`/JSON are both configured, MMDB remains authoritative for two-letter country codes while named tags continue to use the `.dat`/JSON index. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. ### Response matchers @@ -319,7 +319,7 @@ Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoi | response_request_domain_geosite_not | value: GeoSite tag | | response_txt_content | mode: exact, prefix, or regex; value is the text/pattern | -Response IP and GeoIP matchers inspect A/AAAA records and the `ipv4hint`/`ipv6hint` parameters carried by HTTPS/SVCB Answer records. They do not synthesize or rewrite HTTPS/SVCB records. +`response_answer_ip_geoip_country` inspects Answer records only: every Answer record that represents IPs must match, while alternative `ipv4hint`/`ipv6hint` addresses within one HTTPS/SVCB record use any-match semantics. CIDR and private-IP response matchers inspect both Answers and Additionals with any-match semantics. These matchers read A/AAAA records and HTTPS/SVCB hints; they do not synthesize or rewrite HTTPS/SVCB records. The successful upstream label currently includes the transport prefix, for example udp:1.1.1.1:53 or tcp:1.1.1.1:53. Therefore upstream_equals values must include that prefix. response_upstream_ip currently parses a raw IP or host:port value; it does not strip the transport prefix. diff --git a/README.zh-CN.md b/README.zh-CN.md index 22ade00..b2368cb 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -263,7 +263,7 @@ version 可省略。settings、pipeline_select 和 pipelines 省略时分别使 | serve_stale_ttl_reset | true | 返回过期数据时重置过期时间窗口。 | | serve_stale_client_timeout_ms | 0 | 0 表示立即返回过期数据;大于 0 时先尝试上游指定毫秒数。 | | geoip_db_path | null | MaxMind MMDB 路径。 | -| geoip_dat_path | null | V2Ray GeoIP .dat 或支持的 V2Ray JSON 路径;当前范围加载器使用 IPv4 范围。 | +| geoip_dat_path | null | V2Ray GeoIP .dat 或支持的 V2Ray JSON 路径;IPv4 和 IPv6 范围按 GeoIP 标签分别建立索引。 | | geosite_data_paths | [] | V2Ray GeoSite .dat 或 JSON 路径列表;支持多个文件。 | 当前配置类型会反序列化 geoip_auto_convert 和 geoip_filter_countries,但运行引擎没有读取它们,因此它们不会改变运行行为。转换时的国家过滤请使用 convert-geo-ip 的 --filter。顶层 background_refresh_rule 也会被读取,但当前运行时配置编译会忽略它。 @@ -297,7 +297,7 @@ pipeline_select 的每项包含 Pipeline id、可选的匹配器列表和可选 Pipeline selector 支持上表全部类型;请求规则支持除 listener_label 之外的全部类型。 -域名后缀、GeoSite 标签和 GeoIP 标签匹配不区分大小写。`geoip_country.country_codes` 可使用 ISO 国家代码或 `cloudflare`、`netflix`、`telegram` 等 V2Ray GeoIP 命名标签。规范 JSON 形式为字符串数组;为兼容旧配置,也接受单个字符串或逗号分隔字符串。GeoIP `.dat`/JSON 索引会保留重叠成员关系,因此同一 IP 可以同时属于国家代码和一个或多个命名标签。domain_regex 和 request_domain_regex 使用 Rust 正则语法。 +域名后缀、GeoSite 标签和 GeoIP 标签匹配不区分大小写。`geoip_country.country_codes` 可使用 ISO 国家代码或 `cloudflare`、`netflix`、`telegram` 等 V2Ray GeoIP 命名标签。规范 JSON 形式为字符串数组;为兼容旧配置,也接受单个字符串或逗号分隔字符串。GeoIP `.dat`/JSON 索引会保留重叠成员关系,因此同一 IP 可以同时属于国家代码和一个或多个命名标签。同时配置 MMDB 与 `.dat`/JSON 时,两位国家代码以 MMDB 为准,命名标签仍通过 `.dat`/JSON 索引匹配。domain_regex 和 request_domain_regex 使用 Rust 正则语法。 ### 响应匹配器 @@ -318,7 +318,7 @@ Pipeline selector 支持上表全部类型;请求规则支持除 listener_labe | response_request_domain_geosite_not | value:GeoSite tag | | response_txt_content | mode:exact、prefix 或 regex;value 为文本/模式 | -响应 IP 和 GeoIP 匹配器会检查 A/AAAA 记录,以及 HTTPS/SVCB Answer 记录携带的 `ipv4hint`/`ipv6hint` 参数;它们不会合成或改写 HTTPS/SVCB 记录。 +`response_answer_ip_geoip_country` 只检查 Answer:所有表示 IP 的 Answer 记录都必须匹配,但同一 HTTPS/SVCB 记录内作为候选地址的 `ipv4hint`/`ipv6hint` 使用任一匹配语义。CIDR 和私有 IP 响应匹配器会以任一匹配语义检查 Answer 与 Additional。它们会读取 A/AAAA 和 HTTPS/SVCB hints,但不会合成或改写 HTTPS/SVCB 记录。 当前成功上游标签包含传输前缀,例如 udp:1.1.1.1:53 或 tcp:1.1.1.1:53。因此 upstream_equals 的 value 必须包含该前缀。response_upstream_ip 当前解析原始 IP 或 host:port,不会剥离传输前缀。 diff --git a/src/config.rs b/src/config.rs index 753a75e..4135ce9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1061,7 +1061,11 @@ where .filter(|value| !value.is_empty()) .map(str::to_owned) .collect()), - StringOrVec::Array(values) => Ok(values), + StringOrVec::Array(values) => Ok(values + .into_iter() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .collect()), } } @@ -1161,7 +1165,7 @@ mod tests { assert_eq!(country_codes, ["CN", "cloudflare"]); let ResponseMatcher::ResponseAnswerIpGeoipCountry { country_codes } = serde_json::from_str( - r#"{"type":"response_answer_ip_geoip_country","country_codes":["CN","cloudflare"]}"#, + r#"{"type":"response_answer_ip_geoip_country","country_codes":[" CN ",""," cloudflare"]}"#, ) .expect("response matcher should accept an array") else { panic!("unexpected response matcher variant"); diff --git a/src/engine/core.rs b/src/engine/core.rs index 606d202..4bee71e 100644 --- a/src/engine/core.rs +++ b/src/engine/core.rs @@ -145,7 +145,11 @@ impl Engine { } else { None }; - let geoip_dat_path = cfg.settings.geoip_dat_path.clone(); + let geoip_dat_path = if uses_geoip { + cfg.settings.geoip_dat_path.clone() + } else { + None + }; // Extract GeoSite settings before moving cfg / 在 move cfg 之前提取 GeoSite 设置 let geosite_data_paths = cfg.settings.geosite_data_paths.clone(); @@ -193,20 +197,9 @@ impl Engine { .unwrap_or(false); let load_result = if is_dat { - if uses_geoip { - geoip_manager.load_from_dat_file(&path) - } else { - info!("No GeoIP matchers used in config, skipping GeoIP .dat data loading"); - Ok(0) - } + geoip_manager.load_from_dat_file(&path) } else { - // JSON 格式:检查是否需要加载 / JSON format: check if loading is needed - if uses_geoip { - geoip_manager.load_from_v2ray_file(&path) - } else { - info!("No GeoIP matchers used in config, skipping GeoIP JSON data loading"); - Ok(0) - } + geoip_manager.load_from_v2ray_file(&path) }; match load_result { diff --git a/src/engine/utils.rs b/src/engine/utils.rs index 3a013a5..41417d5 100644 --- a/src/engine/utils.rs +++ b/src/engine/utils.rs @@ -324,6 +324,17 @@ pub fn extract_geosite_tags_from_config(cfg: &RuntimePipelineConfig) -> Vec bool { + if cfg.pipeline_select.iter().any(|rule| { + rule.matchers.iter().any(|matcher| { + matches!( + matcher.matcher, + crate::matcher::RuntimePipelineSelectorMatcher::GeoipCountry { .. } + ) + }) + }) { + return true; + } + // Scan all pipeline rules / 扫描所有 pipeline 规则 for pipeline in &cfg.pipelines { for rule in &pipeline.rules { @@ -332,7 +343,6 @@ pub fn uses_geoip_matchers(cfg: &RuntimePipelineConfig) -> bool { if matches!( matcher.matcher, crate::matcher::RuntimeMatcher::GeoipCountry { .. } - | crate::matcher::RuntimeMatcher::GeoipPrivate { .. } ) { return true; } @@ -343,7 +353,6 @@ pub fn uses_geoip_matchers(cfg: &RuntimePipelineConfig) -> bool { if matches!( matcher.matcher, crate::matcher::RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { .. } - | crate::matcher::RuntimeResponseMatcher::ResponseAnswerIpGeoipPrivate { .. } ) { return true; } @@ -371,3 +380,45 @@ pub(crate) fn parse_rcode(rcode: &str) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::uses_geoip_matchers; + use crate::{config::PipelineConfig, matcher::RuntimePipelineConfig}; + + fn runtime_config(json: &str) -> RuntimePipelineConfig { + let config: PipelineConfig = serde_json::from_str(json).unwrap(); + RuntimePipelineConfig::from_config(config).unwrap() + } + + #[test] + fn geoip_loading_detection_covers_selectors_but_not_private_matchers() { + let selector = runtime_config( + r#"{ + "pipeline_select": [{ + "pipeline": "default", + "matchers": [{"type":"geoip_country","country_codes":["CN"]}] + }], + "pipelines": [{"id":"default"}] + }"#, + ); + assert!(uses_geoip_matchers(&selector)); + + let private_only = runtime_config( + r#"{ + "pipelines": [{ + "id":"default", + "rules": [{ + "name":"private-only", + "matchers": [{"type":"geoip_private","expect":true}], + "response_matchers": [{ + "type":"response_answer_ip_geoip_private", + "expect":true + }] + }] + }] + }"#, + ); + assert!(!uses_geoip_matchers(&private_only)); + } +} diff --git a/src/matcher/geoip.rs b/src/matcher/geoip.rs index 8ee28da..5bdf6f2 100644 --- a/src/matcher/geoip.rs +++ b/src/matcher/geoip.rs @@ -290,13 +290,6 @@ impl GeoIpManager { /// 检查 IP 是否属于指定标签 / Check whether an IP belongs to a tag. #[inline] pub fn matches_tag(&self, ip: IpAddr, tag: &str) -> bool { - if let Some(reader) = self.reader.as_ref().as_ref() { - return self - .lookup_mmdb(reader, ip) - .country_code - .is_some_and(|code| code.eq_ignore_ascii_case(tag)); - } - let normalized; let tag = if tag.bytes().all(|b| !b.is_ascii_lowercase()) { tag @@ -305,6 +298,13 @@ impl GeoIpManager { &normalized }; + if self.reader.as_ref().is_some() && is_country_tag(tag) { + return self + .lookup(ip) + .country_code + .is_some_and(|code| code.eq_ignore_ascii_case(tag)); + } + self.tag_indexes .get(tag) .is_some_and(|index| index.contains(ip)) @@ -313,12 +313,8 @@ impl GeoIpManager { /// 检查 IP 是否属于任一指定标签 / Check whether an IP belongs to any requested tag. #[inline] pub fn matches_any_tag(&self, ip: IpAddr, tags: &[Arc]) -> bool { - if let Some(reader) = self.reader.as_ref().as_ref() { - return self - .lookup_mmdb(reader, ip) - .country_code - .is_some_and(|code| tags.iter().any(|tag| tag.eq_ignore_ascii_case(&code))); - } + let has_mmdb = self.reader.as_ref().is_some(); + let mmdb_country = has_mmdb.then(|| self.lookup(ip).country_code).flatten(); for tag in tags { let normalized; @@ -329,6 +325,16 @@ impl GeoIpManager { &normalized }; + if has_mmdb && is_country_tag(tag) { + if mmdb_country + .as_ref() + .is_some_and(|country_code| country_code.eq_ignore_ascii_case(tag)) + { + return true; + } + continue; + } + if self .tag_indexes .get(tag) @@ -343,21 +349,16 @@ impl GeoIpManager { /// 返回 IP 命中的全部标签,主要用于诊断和管理接口。 /// Return all tags matching an IP, primarily for diagnostics and management APIs. pub fn lookup_tags(&self, ip: IpAddr) -> Vec> { - if let Some(reader) = self.reader.as_ref().as_ref() { - return self - .lookup_mmdb(reader, ip) - .country_code - .into_iter() - .collect(); - } - - let mut tags: Vec<_> = self - .tag_indexes - .iter() - .filter(|(_, index)| index.contains(ip)) - .map(|(tag, _)| Arc::clone(tag)) - .collect(); + let has_mmdb = self.reader.as_ref().is_some(); + let mut tags: Vec<_> = self.lookup(ip).country_code.into_iter().collect(); + tags.extend( + self.tag_indexes + .iter() + .filter(|(tag, index)| index.contains(ip) && (!has_mmdb || !is_country_tag(tag))) + .map(|(tag, _)| Arc::clone(tag)), + ); tags.sort_unstable_by(|a, b| a.as_ref().cmp(b.as_ref())); + tags.dedup(); tags } @@ -1047,10 +1048,11 @@ mod tests { } #[test] - fn test_mmdb_keeps_precedence_over_dat_tags() { + fn test_mmdb_country_precedence_keeps_named_dat_tags() { let network = [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; let mmdb_source = build_dat("US", None, Some((network, 32))); - let dat = build_dat("CLOUDFLARE", None, Some((network, 32))); + let mut dat = build_dat("CN", None, Some((network, 16))); + dat.extend(build_dat("CLOUDFLARE", None, Some((network, 16)))); let temp_dir = std::env::temp_dir(); let mmdb_source_path = temp_dir.join("geoip_test_mmdb_source.dat"); let dat_path = temp_dir.join("geoip_test_mmdb_precedence.dat"); @@ -1064,9 +1066,23 @@ mod tests { let ip: IpAddr = "2001:db8::1".parse().unwrap(); assert!(mgr.matches_tag(ip, "US")); - assert!(!mgr.matches_tag(ip, "CLOUDFLARE")); - assert!(mgr.matches_any_tag(ip, &[Arc::from("US"), Arc::from("CLOUDFLARE")])); - assert_eq!(mgr.lookup_tags(ip), vec![Arc::from("US")]); + assert!(!mgr.matches_tag(ip, "CN")); + assert!(mgr.matches_tag(ip, "CLOUDFLARE")); + assert!(mgr.matches_any_tag(ip, &[Arc::from("CN"), Arc::from("CLOUDFLARE")])); + assert_eq!( + mgr.lookup_tags(ip), + vec![Arc::from("CLOUDFLARE"), Arc::from("US")] + ); + assert!( + mgr.cache.get(&ip).is_some(), + "MMDB matches should use cache" + ); + + let mmdb_miss: IpAddr = "2001:db9::1".parse().unwrap(); + assert!(!mgr.matches_tag(mmdb_miss, "CN")); + assert!(mgr.matches_tag(mmdb_miss, "CLOUDFLARE")); + assert_eq!(mgr.lookup(mmdb_miss).country_code, None); + assert_eq!(mgr.lookup_tags(mmdb_miss), vec![Arc::from("CLOUDFLARE")]); drop(mgr); let _ = std::fs::remove_file(mmdb_source_path); diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index ca48806..2f774ab 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -98,7 +98,7 @@ mod matcher_helpers { manager.matches(tag, domain) } - fn all_svc_param_ips_match( + fn svc_param_ips_match( svc_params: &[( hickory_proto::rr::rdata::svcb::SvcParamKey, hickory_proto::rr::rdata::svcb::SvcParamValue, @@ -111,20 +111,30 @@ mod matcher_helpers { { use hickory_proto::rr::rdata::svcb::SvcParamValue; - svc_params.iter().all(|(_, value)| match value { - SvcParamValue::Ipv4Hint(hints) => hints.0.iter().all(|hint| { - *has_ip = true; - predicate(IpAddr::V4(hint.0)) - }), - SvcParamValue::Ipv6Hint(hints) => hints.0.iter().all(|hint| { - *has_ip = true; - predicate(IpAddr::V6(hint.0)) - }), - _ => true, - }) + let mut has_hint = false; + let mut matched = false; + for (_, value) in svc_params { + match value { + SvcParamValue::Ipv4Hint(hints) => { + for hint in &hints.0 { + has_hint = true; + matched |= predicate(IpAddr::V4(hint.0)); + } + } + SvcParamValue::Ipv6Hint(hints) => { + for hint in &hints.0 { + has_hint = true; + matched |= predicate(IpAddr::V6(hint.0)); + } + } + _ => {} + } + } + *has_ip |= has_hint; + !has_hint || matched } - /// Apply a predicate to every address represented directly or as an HTTPS/SVCB IP hint. + /// Apply a predicate to direct addresses or any alternative HTTPS/SVCB IP hint. pub fn all_rdata_ips_match( data: &hickory_proto::rr::RData, has_ip: &mut bool, @@ -144,8 +154,8 @@ mod matcher_helpers { *has_ip = true; predicate(IpAddr::V6(address.0)) } - RData::SVCB(svcb) => all_svc_param_ips_match(&svcb.svc_params, has_ip, predicate), - RData::HTTPS(https) => all_svc_param_ips_match(&https.svc_params, has_ip, predicate), + RData::SVCB(svcb) => svc_param_ips_match(&svcb.svc_params, has_ip, predicate), + RData::HTTPS(https) => svc_param_ips_match(&https.svc_params, has_ip, predicate), _ => true, } } @@ -155,17 +165,7 @@ mod matcher_helpers { F: FnMut(IpAddr) -> bool, { let mut has_ip = false; - let mut matched = false; - let mut stop_on_match = |ip| { - if predicate(ip) { - matched = true; - false - } else { - true - } - }; - let _ = all_rdata_ips_match(data, &mut has_ip, &mut stop_on_match); - matched + all_rdata_ips_match(data, &mut has_ip, predicate) && has_ip } /// 检查响应消息中是否有任意 IP 匹配指定的 CIDR 列表 @@ -1600,6 +1600,47 @@ mod tests { Some(&guard), None, )); + let country_response = RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { + country_codes: vec![Arc::from("US")], + }; + assert!(country_response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &https_message, + Some(&guard), + None, + )); + + let no_hint_record = Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::HTTPS(HTTPS(SVCB::new(1, Name::root(), vec![]))), + ); + let mut no_hint_message = Message::new(0, MessageType::Response, OpCode::Query); + no_hint_message.add_answer(no_hint_record.clone()); + assert!(!country_response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &no_hint_message, + Some(&guard), + None, + )); + let mut mixed_no_hint_message = https_message.clone(); + mixed_no_hint_message.add_answer(no_hint_record); + assert!(country_response.matches( + "udp:test", + "example.com", + RecordType::HTTPS, + DNSClass::IN, + &mixed_no_hint_message, + Some(&guard), + None, + )); + assert!(!response.matches( "udp:test", "example.com", @@ -1623,6 +1664,46 @@ mod tests { None, )); + let mut svcb_answer_message = Message::new(0, MessageType::Response, OpCode::Query); + svcb_answer_message.add_answer(Record::from_rdata( + Name::from_ascii("example.com.").unwrap(), + 300, + RData::SVCB(SVCB::new( + 1, + Name::root(), + vec![ + ( + SvcParamKey::Ipv4Hint, + SvcParamValue::Ipv4Hint(IpHint(vec![A(Ipv4Addr::new(104, 21, 11, 126))])), + ), + ( + SvcParamKey::Ipv6Hint, + SvcParamValue::Ipv6Hint(IpHint(vec![AAAA( + "2606:4700:3034::6815:b7e".parse::().unwrap(), + )])), + ), + ], + )), + )); + assert!(country_response.matches( + "udp:test", + "example.com", + RecordType::SVCB, + DNSClass::IN, + &svcb_answer_message, + Some(&guard), + None, + )); + assert!(cidr_response.matches( + "udp:test", + "example.com", + RecordType::SVCB, + DNSClass::IN, + &svcb_answer_message, + Some(&guard), + None, + )); + let mut mixed_message = https_message.clone(); mixed_message.add_answer(Record::from_rdata( Name::from_ascii("example.com.").unwrap(), diff --git a/tools/README.md b/tools/README.md index ae6cd3e..518b5df 100644 --- a/tools/README.md +++ b/tools/README.md @@ -83,13 +83,13 @@ kixdns run -c /path/to/pipeline.json 编辑器只是静态前端,不会启动 KixDNS,也不会检查证书、数据文件、CIDR、正则或上游连通性。下载后应以 KixDNS 启动时的解析和校验结果为准。 -当前 Rust 配置类型对 geoip_country 和 response_answer_ip_geoip_country 的 country_codes 要求字符串数组,例如: +`geoip_country` 和 `response_answer_ip_geoip_country` 的 `country_codes` 规范格式是字符串数组,例如: ~~~json { "type": "geoip_country", "country_codes": ["CN", "US"] } ~~~ -如果手动编辑右侧 JSON,请保持这个数组格式。编辑器界面中的国家代码输入是逗号分隔文本;生成文件后应检查 JSON 中是否为数组。 +服务端为兼容旧配置也接受单个字符串或逗号分隔字符串,并会去除首尾空白和空项。编辑器界面使用逗号分隔文本,导出时生成规范数组格式。 以下字段目前虽然会被编辑器显示或输出,但不会改变运行引擎行为: From 5f8f06357cf2eb41422062feb4531f81937bc318 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Tue, 11 Aug 2026 20:46:04 +0900 Subject: [PATCH 5/7] fix(geoip): preserve nested lookup specificity --- src/matcher/geoip.rs | 110 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 97 insertions(+), 13 deletions(-) diff --git a/src/matcher/geoip.rs b/src/matcher/geoip.rs index 5bdf6f2..db46fb5 100644 --- a/src/matcher/geoip.rs +++ b/src/matcher/geoip.rs @@ -98,41 +98,50 @@ struct Ipv6Range { struct GeoIpTagIndex { ipv4_ranges: Vec, ipv6_ranges: Vec, + raw_ipv4_ranges: Vec, + raw_ipv6_ranges: Vec, } impl GeoIpTagIndex { fn finalize(&mut self) { + self.raw_ipv4_ranges = self.ipv4_ranges.clone(); + self.raw_ipv4_ranges + .sort_unstable_by_key(|range| range.start); + self.raw_ipv6_ranges = self.ipv6_ranges.clone(); + self.raw_ipv6_ranges + .sort_unstable_by_key(|range| range.start); merge_ipv4_ranges(&mut self.ipv4_ranges); merge_ipv6_ranges(&mut self.ipv6_ranges); } #[inline] fn contains(&self, ip: IpAddr) -> bool { - self.matching_range(ip).is_some() - } - - #[inline] - fn matching_range(&self, ip: IpAddr) -> Option<(u128, u128)> { match ip { IpAddr::V4(ip) => { let ip = u32::from(ip); let index = self.ipv4_ranges.partition_point(|range| range.start <= ip); - self.ipv4_ranges - .get(index.checked_sub(1)?) - .filter(|range| range.end >= ip) - .map(|range| (u128::from(range.start), u128::from(range.end))) + index > 0 && self.ipv4_ranges[index - 1].end >= ip } IpAddr::V6(ip) => { let ip = u128::from(ip); let index = self.ipv6_ranges.partition_point(|range| range.start <= ip); - self.ipv6_ranges - .get(index.checked_sub(1)?) - .filter(|range| range.end >= ip) - .map(|range| (range.start, range.end)) + index > 0 && self.ipv6_ranges[index - 1].end >= ip } } } + fn matching_range(&self, ip: IpAddr) -> Option<(u128, u128)> { + match ip { + IpAddr::V4(ip) => { + let ip = u32::from(ip); + most_specific_ipv4(&self.raw_ipv4_ranges, ip) + .map(|range| (u128::from(range.start), u128::from(range.end))) + } + IpAddr::V6(ip) => most_specific_ipv6(&self.raw_ipv6_ranges, u128::from(ip)) + .map(|range| (range.start, range.end)), + } + } + #[inline] fn range_count(&self) -> usize { self.ipv4_ranges.len() + self.ipv6_ranges.len() @@ -686,6 +695,44 @@ fn merge_ipv6_ranges(ranges: &mut Vec) { *ranges = merged; } +fn most_specific_ipv4(ranges: &[Ipv4Range], ip: u32) -> Option { + let index = ranges.partition_point(|range| range.start <= ip); + let mut best: Option = None; + for range in ranges[..index].iter().rev() { + if best.is_some_and(|best| range.start < best.start) { + break; + } + if range.end >= ip + && match best { + None => true, + Some(best) => range.end < best.end, + } + { + best = Some(*range); + } + } + best +} + +fn most_specific_ipv6(ranges: &[Ipv6Range], ip: u128) -> Option { + let index = ranges.partition_point(|range| range.start <= ip); + let mut best: Option = None; + for range in ranges[..index].iter().rev() { + if best.is_some_and(|best| range.start < best.start) { + break; + } + if range.end >= ip + && match best { + None => true, + Some(best) => range.end < best.end, + } + { + best = Some(*range); + } + } + best +} + #[inline] fn is_country_tag(tag: &str) -> bool { tag.len() == 2 && tag.bytes().all(|byte| byte.is_ascii_alphabetic()) @@ -1005,6 +1052,43 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn test_lookup_preserves_same_tag_nested_specificity() { + let json = r#"{ + "entries": [ + { + "country_code": "CN", + "ips": ["10.0.0.0/8", "10.1.0.0/16", "2001:db8::/32", "2001:db8:1::/48"] + }, + { + "country_code": "US", + "ips": ["10.0.0.0/12", "2001:db8::/40"] + } + ] + }"#; + let path = std::env::temp_dir().join("geoip_test_same_tag_nested.json"); + std::fs::write(&path, json).unwrap(); + + let mut mgr = GeoIpManager::new(None).unwrap(); + assert_eq!(mgr.load_from_v2ray_file(&path).unwrap(), 6); + + let cn4: IpAddr = "10.1.1.1".parse().unwrap(); + assert!(mgr.matches_tag(cn4, "CN")); + assert!(mgr.matches_tag(cn4, "US")); + assert_eq!(mgr.lookup(cn4).country_code.as_deref(), Some("CN")); + let us4: IpAddr = "10.2.1.1".parse().unwrap(); + assert_eq!(mgr.lookup(us4).country_code.as_deref(), Some("US")); + + let cn6: IpAddr = "2001:db8:1::1".parse().unwrap(); + assert!(mgr.matches_tag(cn6, "CN")); + assert!(mgr.matches_tag(cn6, "US")); + assert_eq!(mgr.lookup(cn6).country_code.as_deref(), Some("CN")); + let us6: IpAddr = "2001:db8:2::1".parse().unwrap(); + assert_eq!(mgr.lookup(us6).country_code.as_deref(), Some("US")); + + let _ = std::fs::remove_file(path); + } + #[test] fn test_overlapping_dat_tags_match_independently() { let mut dat = build_dat("AU", Some(([1, 1, 1, 0], 24)), None); From 90759d6f438186d6c402aed810bc7bafd3a0fb17 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Tue, 11 Aug 2026 20:56:29 +0900 Subject: [PATCH 6/7] docs: clarify GeoIP data sources and matching --- README.md | 20 +++++++++++++++++--- README.zh-CN.md | 20 +++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bdc6329..902cccc 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,7 @@ version is optional. settings, pipeline_select, and pipelines default to an empt | serve_stale_ttl_reset | true | Reset the stale-age window when stale data is served. | | serve_stale_client_timeout_ms | 0 | 0 serves stale immediately; a positive value tries the upstream for this many milliseconds first. | | geoip_db_path | null | MaxMind MMDB path. | -| geoip_dat_path | null | V2Ray GeoIP .dat or supported V2Ray JSON path. IPv4 and IPv6 ranges are indexed independently for each GeoIP tag. | +| geoip_dat_path | null | V2Ray GeoIP data path: either a protobuf `.dat` file or the compatible JSON format described below. | | geosite_data_paths | [] | V2Ray GeoSite .dat or JSON paths; multiple files are accepted. | The following fields are deserialized by the current config type but are not read by the running engine: geoip_auto_convert and geoip_filter_countries. Use convert-geo-ip with --filter for conversion-time filtering. The top-level background_refresh_rule is also currently ignored by runtime configuration compilation. @@ -298,7 +298,15 @@ The same structure is used for response_matchers, response_matcher_operator, res Pipeline selectors support all of the rows above. Request rules support all rows except listener_label. -Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoip_country.country_codes` accepts both ISO country codes and named V2Ray GeoIP tags such as `cloudflare`, `netflix`, or `telegram`. The canonical JSON form is a string array; a single string or comma-separated string is also accepted for backward compatibility. GeoIP `.dat`/JSON indexes preserve overlapping memberships, so one IP may match both a country code and one or more named tags. When MMDB and `.dat`/JSON are both configured, MMDB remains authoritative for two-letter country codes while named tags continue to use the `.dat`/JSON index. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. +#### GeoIP data sources and tags + +- `geoip_db_path` loads a MaxMind MMDB database. It supplies two-letter country codes such as `CN` and `US`. +- `geoip_dat_path` loads either a V2Ray protobuf `.dat` file or a compatible JSON file. The JSON file is GeoIP data, not the main KixDNS configuration. Its shape is `{"entries":[{"country_code":"CLOUDFLARE","ips":["1.1.1.0/24"]}]}`. +- V2Ray data may contain country codes and named tags such as `CLOUDFLARE`, `NETFLIX`, or `TELEGRAM`. One IP may belong to several tags. +- When both sources are configured, MMDB decides two-letter country codes; named tags still come from `geoip_dat_path`. +- Tag matching is case-insensitive. `country_codes` should normally be a JSON string array, for example `["CN", "cloudflare"]`; a single or comma-separated string is also accepted for compatibility. + +Domain suffix and GeoSite matching are also case-insensitive. `domain_regex` and `request_domain_regex` use Rust regular-expression syntax. ### Response matchers @@ -319,7 +327,13 @@ Domain suffix matching, GeoSite tags, and GeoIP tags are case-insensitive. `geoi | response_request_domain_geosite_not | value: GeoSite tag | | response_txt_content | mode: exact, prefix, or regex; value is the text/pattern | -`response_answer_ip_geoip_country` inspects Answer records only: every Answer record that represents IPs must match, while alternative `ipv4hint`/`ipv6hint` addresses within one HTTPS/SVCB record use any-match semantics. CIDR and private-IP response matchers inspect both Answers and Additionals with any-match semantics. These matchers read A/AAAA records and HTTPS/SVCB hints; they do not synthesize or rewrite HTTPS/SVCB records. +Response address matching uses these scopes: + +- `response_answer_ip_geoip_country` checks Answers only. Every Answer that contains an address must match one of the requested GeoIP tags. +- `response_answer_ip` and `response_answer_ip_geoip_private` check both Answers and Additionals and succeed when any address matches. +- A/AAAA addresses and HTTPS/SVCB `ipv4hint`/`ipv6hint` values are inspected. Hints in one HTTPS/SVCB record are alternative endpoints, so any matching hint satisfies that record. + +These matchers only inspect existing records; they do not create or rewrite HTTPS/SVCB records. The successful upstream label currently includes the transport prefix, for example udp:1.1.1.1:53 or tcp:1.1.1.1:53. Therefore upstream_equals values must include that prefix. response_upstream_ip currently parses a raw IP or host:port value; it does not strip the transport prefix. diff --git a/README.zh-CN.md b/README.zh-CN.md index b2368cb..ead1947 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -263,7 +263,7 @@ version 可省略。settings、pipeline_select 和 pipelines 省略时分别使 | serve_stale_ttl_reset | true | 返回过期数据时重置过期时间窗口。 | | serve_stale_client_timeout_ms | 0 | 0 表示立即返回过期数据;大于 0 时先尝试上游指定毫秒数。 | | geoip_db_path | null | MaxMind MMDB 路径。 | -| geoip_dat_path | null | V2Ray GeoIP .dat 或支持的 V2Ray JSON 路径;IPv4 和 IPv6 范围按 GeoIP 标签分别建立索引。 | +| geoip_dat_path | null | V2Ray GeoIP 数据文件路径:可以是 protobuf `.dat`,也可以是下文说明的兼容 JSON。 | | geosite_data_paths | [] | V2Ray GeoSite .dat 或 JSON 路径列表;支持多个文件。 | 当前配置类型会反序列化 geoip_auto_convert 和 geoip_filter_countries,但运行引擎没有读取它们,因此它们不会改变运行行为。转换时的国家过滤请使用 convert-geo-ip 的 --filter。顶层 background_refresh_rule 也会被读取,但当前运行时配置编译会忽略它。 @@ -297,7 +297,15 @@ pipeline_select 的每项包含 Pipeline id、可选的匹配器列表和可选 Pipeline selector 支持上表全部类型;请求规则支持除 listener_label 之外的全部类型。 -域名后缀、GeoSite 标签和 GeoIP 标签匹配不区分大小写。`geoip_country.country_codes` 可使用 ISO 国家代码或 `cloudflare`、`netflix`、`telegram` 等 V2Ray GeoIP 命名标签。规范 JSON 形式为字符串数组;为兼容旧配置,也接受单个字符串或逗号分隔字符串。GeoIP `.dat`/JSON 索引会保留重叠成员关系,因此同一 IP 可以同时属于国家代码和一个或多个命名标签。同时配置 MMDB 与 `.dat`/JSON 时,两位国家代码以 MMDB 为准,命名标签仍通过 `.dat`/JSON 索引匹配。domain_regex 和 request_domain_regex 使用 Rust 正则语法。 +#### GeoIP 数据源与标签 + +- `geoip_db_path` 加载 MaxMind MMDB,用于 `CN`、`US` 等两位国家代码。 +- `geoip_dat_path` 加载 V2Ray protobuf `.dat` 或兼容 JSON。这里的 JSON 是 GeoIP 数据文件,不是 KixDNS 主配置文件;格式示例:`{"entries":[{"country_code":"CLOUDFLARE","ips":["1.1.1.0/24"]}]}`。 +- V2Ray 数据既可以包含国家代码,也可以包含 `CLOUDFLARE`、`NETFLIX`、`TELEGRAM` 等命名标签;同一 IP 可以属于多个标签。 +- 同时配置两种数据源时,两位国家代码以 MMDB 为准;命名标签仍从 `geoip_dat_path` 读取。 +- 标签匹配不区分大小写。`country_codes` 建议使用 JSON 字符串数组,例如 `["CN", "cloudflare"]`;为兼容旧配置,也接受单个字符串或逗号分隔字符串。 + +域名后缀和 GeoSite 标签匹配也不区分大小写。domain_regex 和 request_domain_regex 使用 Rust 正则语法。 ### 响应匹配器 @@ -318,7 +326,13 @@ Pipeline selector 支持上表全部类型;请求规则支持除 listener_labe | response_request_domain_geosite_not | value:GeoSite tag | | response_txt_content | mode:exact、prefix 或 regex;value 为文本/模式 | -`response_answer_ip_geoip_country` 只检查 Answer:所有表示 IP 的 Answer 记录都必须匹配,但同一 HTTPS/SVCB 记录内作为候选地址的 `ipv4hint`/`ipv6hint` 使用任一匹配语义。CIDR 和私有 IP 响应匹配器会以任一匹配语义检查 Answer 与 Additional。它们会读取 A/AAAA 和 HTTPS/SVCB hints,但不会合成或改写 HTTPS/SVCB 记录。 +响应地址匹配范围如下: + +- `response_answer_ip_geoip_country` 只检查 Answer;每个包含地址的 Answer 都必须命中请求的某个 GeoIP 标签。 +- `response_answer_ip` 和 `response_answer_ip_geoip_private` 同时检查 Answer 与 Additional,只要任一地址匹配即可。 +- 匹配器会检查 A/AAAA 地址以及 HTTPS/SVCB 的 `ipv4hint`/`ipv6hint`。同一 HTTPS/SVCB 记录中的 hints 是候选端点,任一 hint 匹配即可。 + +这些匹配器只读取已有记录,不会创建或改写 HTTPS/SVCB 记录。 当前成功上游标签包含传输前缀,例如 udp:1.1.1.1:53 或 tcp:1.1.1.1:53。因此 upstream_equals 的 value 必须包含该前缀。response_upstream_ip 当前解析原始 IP 或 host:port,不会剥离传输前缀。 From 2638cd7d7eb4b1648d7f18481bfbe1d233a7c278 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Wed, 12 Aug 2026 17:18:33 +0900 Subject: [PATCH 7/7] fix(geoip): handle crossing range overlaps --- README.md | 2 +- src/matcher/geoip.rs | 65 ++++++++++++++++++++++---------------------- src/matcher/mod.rs | 31 +++++++++++++-------- 3 files changed, 52 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 902cccc..6df0281 100644 --- a/README.md +++ b/README.md @@ -329,7 +329,7 @@ Domain suffix and GeoSite matching are also case-insensitive. `domain_regex` and Response address matching uses these scopes: -- `response_answer_ip_geoip_country` checks Answers only. Every Answer that contains an address must match one of the requested GeoIP tags. +- `response_answer_ip_geoip_country` checks Answers only. Every Answer that contains an address must match one of the requested GeoIP tags, and at least one Answer must contain an address. - `response_answer_ip` and `response_answer_ip_geoip_private` check both Answers and Additionals and succeed when any address matches. - A/AAAA addresses and HTTPS/SVCB `ipv4hint`/`ipv6hint` values are inspected. Hints in one HTTPS/SVCB record are alternative endpoints, so any matching hint satisfies that record. diff --git a/src/matcher/geoip.rs b/src/matcher/geoip.rs index db46fb5..ac37144 100644 --- a/src/matcher/geoip.rs +++ b/src/matcher/geoip.rs @@ -697,40 +697,20 @@ fn merge_ipv6_ranges(ranges: &mut Vec) { fn most_specific_ipv4(ranges: &[Ipv4Range], ip: u32) -> Option { let index = ranges.partition_point(|range| range.start <= ip); - let mut best: Option = None; - for range in ranges[..index].iter().rev() { - if best.is_some_and(|best| range.start < best.start) { - break; - } - if range.end >= ip - && match best { - None => true, - Some(best) => range.end < best.end, - } - { - best = Some(*range); - } - } - best + ranges[..index] + .iter() + .filter(|range| range.end >= ip) + .min_by_key(|range| (range.end - range.start, std::cmp::Reverse(range.start))) + .copied() } fn most_specific_ipv6(ranges: &[Ipv6Range], ip: u128) -> Option { let index = ranges.partition_point(|range| range.start <= ip); - let mut best: Option = None; - for range in ranges[..index].iter().rev() { - if best.is_some_and(|best| range.start < best.start) { - break; - } - if range.end >= ip - && match best { - None => true, - Some(best) => range.end < best.end, - } - { - best = Some(*range); - } - } - best + ranges[..index] + .iter() + .filter(|range| range.end >= ip) + .min_by_key(|range| (range.end - range.start, std::cmp::Reverse(range.start))) + .copied() } #[inline] @@ -747,9 +727,11 @@ fn is_more_specific( match current { None => true, Some((current_tag, current_start, current_end)) => { - start > *current_start - || (start == *current_start && end < *current_end) - || (start == *current_start && end == *current_end && tag < current_tag.as_ref()) + let span = end - start; + let current_span = *current_end - *current_start; + span < current_span + || (span == current_span && start > *current_start) + || (span == current_span && start == *current_start && tag < current_tag.as_ref()) } } } @@ -1023,6 +1005,23 @@ mod tests { list.encode_to_vec() } + #[test] + fn test_most_specific_handles_crossing_overlaps() { + let ipv4_ranges = [ + Ipv4Range { start: 40, end: 55 }, + Ipv4Range { start: 45, end: 70 }, + ]; + let ipv4_match = most_specific_ipv4(&ipv4_ranges, 50).unwrap(); + assert_eq!((ipv4_match.start, ipv4_match.end), (40, 55)); + + let ipv6_ranges = [ + Ipv6Range { start: 40, end: 55 }, + Ipv6Range { start: 45, end: 70 }, + ]; + let ipv6_match = most_specific_ipv6(&ipv6_ranges, 50).unwrap(); + assert_eq!((ipv6_match.start, ipv6_match.end), (40, 55)); + } + #[test] fn test_lookup_nested_overlap_most_specific() { let dat = build_nested_dat(); diff --git a/src/matcher/mod.rs b/src/matcher/mod.rs index 2f774ab..249d878 100644 --- a/src/matcher/mod.rs +++ b/src/matcher/mod.rs @@ -758,6 +758,19 @@ fn normalize_upstream_addr(addr: &str) -> String { } } +fn normalize_geoip_country_codes(country_codes: Vec, matcher: &str) -> Vec> { + if country_codes.is_empty() { + tracing::warn!( + matcher, + "GeoIP country matcher has no country codes and will never match" + ); + } + country_codes + .into_iter() + .map(|code| Arc::from(code.to_ascii_uppercase())) + .collect() +} + impl RuntimeMatcher { fn from_config(m: config::Matcher) -> anyhow::Result { Ok(match m { @@ -773,10 +786,7 @@ impl RuntimeMatcher { .build()?, }, config::Matcher::GeoipCountry { country_codes } => RuntimeMatcher::GeoipCountry { - country_codes: country_codes - .into_iter() - .map(|code| Arc::from(code.to_ascii_uppercase())) - .collect(), + country_codes: normalize_geoip_country_codes(country_codes, "geoip_country"), }, config::Matcher::GeoipPrivate { expect } => RuntimeMatcher::GeoipPrivate { expect }, config::Matcher::Qclass { value } => RuntimeMatcher::Qclass { @@ -969,10 +979,7 @@ impl RuntimePipelineSelectorMatcher { } config::PipelineSelectorMatcher::GeoipCountry { country_codes } => { RuntimePipelineSelectorMatcher::GeoipCountry { - country_codes: country_codes - .into_iter() - .map(|code| Arc::from(code.to_ascii_uppercase())) - .collect(), + country_codes: normalize_geoip_country_codes(country_codes, "geoip_country"), } } config::PipelineSelectorMatcher::GeoipPrivate { expect } => { @@ -1286,10 +1293,10 @@ impl RuntimeResponseMatcher { } config::ResponseMatcher::ResponseAnswerIpGeoipCountry { country_codes } => { RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { - country_codes: country_codes - .into_iter() - .map(|code| Arc::from(code.to_ascii_uppercase())) - .collect(), + country_codes: normalize_geoip_country_codes( + country_codes, + "response_answer_ip_geoip_country", + ), } } config::ResponseMatcher::ResponseAnswerIpGeoipPrivate { expect } => {