Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
20 changes: 18 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 也会被读取,但当前运行时配置编译会忽略它。
Expand Down Expand Up @@ -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 正则语法。

### 响应匹配器

Expand All @@ -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,不会剥离传输前缀。

### 逻辑运算符
Expand Down
86 changes: 74 additions & 12 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
},
/// 匹配客户端IP是否为私有IP(内网)。 / Match whether client IP is private (internal network)
Expand Down Expand Up @@ -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<String> },
/// 匹配客户端 IP 的 GeoIP 国家代码或命名标签(大小写不敏感)。 / Match client IP GeoIP country code or named tag (case insensitive)
GeoipCountry {
#[serde(deserialize_with = "deserialize_string_or_vec")]
country_codes: Vec<String>,
},
/// 匹配客户端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.)
Expand Down Expand Up @@ -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<String> },
/// 匹配响应中 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<String>,
},
/// 匹配响应中 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
Expand Down Expand Up @@ -1035,25 +1042,48 @@ where
}
}

/// Deserialize a string field that also accepts an array of strings.
fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrVec {
String(String),
Array(Vec<String>),
}

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<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;

#[derive(Deserialize)]
#[serde(untagged)]
enum TxtTextInput {
String(String),
Array(Vec<String>),
}

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),
}
}

Expand Down Expand Up @@ -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"]);
}
}
21 changes: 7 additions & 14 deletions src/engine/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
55 changes: 53 additions & 2 deletions src/engine/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,17 @@ pub fn extract_geosite_tags_from_config(cfg: &RuntimePipelineConfig) -> Vec<Stri
///
/// 扫描配置以确定是否使用了GeoIP匹配器,这样我们可以对MMDB文件实现延迟加载 / Scans the configuration to determine if any GeoIP matchers are used, so we can implement lazy loading for the MMDB file.
pub fn uses_geoip_matchers(cfg: &RuntimePipelineConfig) -> 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 {
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -371,3 +380,45 @@ pub(crate) fn parse_rcode(rcode: &str) -> Option<ResponseCode> {
_ => 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));
}
}
Loading