diff --git a/README.md b/README.md index 806c82b..6df0281 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 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 and GeoSite matching are case-insensitive. 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,6 +327,14 @@ Domain suffix matching and GeoSite matching are case-insensitive. domain_regex a | response_request_domain_geosite_not | value: GeoSite tag | | response_txt_content | mode: exact, prefix, or regex; value is the text/pattern | +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, 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. + +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. ### Logical operators diff --git a/README.zh-CN.md b/README.zh-CN.md index 794eb82..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 范围。 | +| 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 匹配不区分大小写。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,6 +326,14 @@ 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;每个包含地址的 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,不会剥离传输前缀。 ### 逻辑运算符 diff --git a/src/config.rs b/src/config.rs index ecf5cf5..4135ce9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -375,8 +375,9 @@ 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 { + #[serde(deserialize_with = "deserialize_string_or_vec")] country_codes: Vec, }, /// 匹配客户端IP是否为私有IP(内网)。 / Match whether client IP is private (internal network) @@ -426,8 +427,11 @@ 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) - GeoipCountry { country_codes: Vec }, + /// 匹配客户端 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) GeoipPrivate { expect: bool }, /// 请求 QTYPE(如 A/AAAA/CNAME/TXT/MX 等)。 / Request QTYPE (e.g., A/AAAA/CNAME/TXT/MX, etc.) @@ -488,8 +492,11 @@ 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) - ResponseAnswerIpGeoipCountry { country_codes: Vec }, + /// 匹配响应中 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)/ Match GeoIP country code or named tag of response IPs (case insensitive) + 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,38 @@ 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 + .into_iter() + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + .collect()), + } +} + /// 反序列化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 +1081,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 +1141,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/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 ccf965e..ac37144 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,72 @@ 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, + 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 { + match ip { + IpAddr::V4(ip) => { + let ip = u32::from(ip); + let index = self.ipv4_ranges.partition_point(|range| range.start <= ip); + 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); + 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() + } +} + /// V2Ray .dat 文件使用 protobuf 格式 /// MaxMind GeoIP 数据库管理器 / MaxMind GeoIP database manager pub struct GeoIpManager { @@ -90,10 +157,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 +210,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 +219,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 +248,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 +296,106 @@ 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) + /// 检查 IP 是否属于指定标签 / Check whether an IP belongs to a tag. + #[inline] + pub fn matches_tag(&self, ip: IpAddr, tag: &str) -> bool { + let normalized; + let tag = if tag.bytes().all(|b| !b.is_ascii_lowercase()) { + tag + } else { + normalized = tag.to_ascii_uppercase(); + &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)) + } + + /// 检查 IP 是否属于任一指定标签 / Check whether an IP belongs to any requested tag. + #[inline] + pub fn matches_any_tag(&self, ip: IpAddr, tags: &[Arc]) -> bool { + 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; + let tag = if tag.bytes().all(|b| !b.is_ascii_lowercase()) { + tag.as_ref() + } else { + normalized = tag.to_ascii_uppercase(); + &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)) { - 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), + return true; } + continue; } - IpAddr::V6(ipv6) => { - let ip_u128 = u128::from(ipv6); - // 使用二分查找 / 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), - } + if self + .tag_indexes + .get(tag) + .is_some_and(|index| index.contains(ip)) + { + return true; + } + } + false + } + + /// 返回 IP 命中的全部标签,主要用于诊断和管理接口。 + /// Return all tags matching an IP, primarily for diagnostics and management APIs. + pub fn lookup_tags(&self, ip: IpAddr) -> Vec> { + 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 + } + + /// 从 .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 +429,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 +467,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 +493,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 +511,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 +528,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 +549,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 +653,89 @@ 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; +} + +fn most_specific_ipv4(ranges: &[Ipv4Range], ip: u32) -> Option { + let index = ranges.partition_point(|range| range.start <= ip); + 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); + ranges[..index] + .iter() + .filter(|range| range.end >= ip) + .min_by_key(|range| (range.end - range.start, std::cmp::Reverse(range.start))) + .copied() +} + +#[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)) => { + 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()) + } + } +} + /// 检测 IP 是否为私有地址 / Detect if IP is private address #[inline] pub fn is_private_ip(ip: IpAddr) -> bool { @@ -777,8 +922,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 +957,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 +965,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 { @@ -861,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(); @@ -871,28 +1032,144 @@ 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_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); + 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_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 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"); + 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, "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); + 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..249d878 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 分类 @@ -114,6 +98,76 @@ mod matcher_helpers { manager.matches(tag, domain) } + fn 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; + + 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 direct addresses or any alternative 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) => svc_param_ips_match(&svcb.svc_params, has_ip, predicate), + RData::HTTPS(https) => 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; + all_rdata_ips_match(data, &mut has_ip, predicate) && has_ip + } + /// 检查响应消息中是否有任意 IP 匹配指定的 CIDR 列表 /// Check if any IP in response message matches the specified CIDR list /// @@ -125,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)) } } @@ -263,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, }, @@ -283,7 +328,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>, }, @@ -713,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 { @@ -728,7 +786,7 @@ impl RuntimeMatcher { .build()?, }, config::Matcher::GeoipCountry { country_codes } => RuntimeMatcher::GeoipCountry { - country_codes: country_codes.into_iter().map(Arc::from).collect(), + country_codes: normalize_geoip_country_codes(country_codes, "geoip_country"), }, config::Matcher::GeoipPrivate { expect } => RuntimeMatcher::GeoipPrivate { expect }, config::Matcher::Qclass { value } => RuntimeMatcher::Qclass { @@ -788,15 +846,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 +900,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 +979,7 @@ impl RuntimePipelineSelectorMatcher { } config::PipelineSelectorMatcher::GeoipCountry { country_codes } => { RuntimePipelineSelectorMatcher::GeoipCountry { - country_codes: country_codes.into_iter().map(Arc::from).collect(), + country_codes: normalize_geoip_country_codes(country_codes, "geoip_country"), } } config::PipelineSelectorMatcher::GeoipPrivate { expect } => { @@ -996,11 +1038,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 +1080,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 +1138,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 +1293,10 @@ impl RuntimeResponseMatcher { } config::ResponseMatcher::ResponseAnswerIpGeoipCountry { country_codes } => { RuntimeResponseMatcher::ResponseAnswerIpGeoipCountry { - country_codes: country_codes.into_iter().map(Arc::from).collect(), + country_codes: normalize_geoip_country_codes( + country_codes, + "response_answer_ip_geoip_country", + ), } } config::ResponseMatcher::ResponseAnswerIpGeoipPrivate { expect } => { @@ -1388,77 +1401,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 } => { @@ -1533,3 +1498,286 @@ 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, AAAA, HTTPS, SVCB, + svcb::{IpHint, SvcParamKey, SvcParamValue}, + }, + }, + }; + 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", "2606:4700::/32"] } + ] + }"#; + 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, + )); + + 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, + )); + 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", + 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 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(), + 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); + } +} diff --git a/tools/README.md b/tools/README.md index 7606690..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 中是否为数组。 +服务端为兼容旧配置也接受单个字符串或逗号分隔字符串,并会去除首尾空白和空项。编辑器界面使用逗号分隔文本,导出时生成规范数组格式。 以下字段目前虽然会被编辑器显示或输出,但不会改变运行引擎行为: @@ -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 配置枚举解析。 ### 动作 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(','); + } }); };