From 36dff94b984c0f133a24e6c45f3c7f984d2ddc03 Mon Sep 17 00:00:00 2001 From: JohnsonRan Date: Sun, 9 Aug 2026 13:35:40 +0900 Subject: [PATCH 1/4] feat(config): support multiple static IP addresses Allow static_ip_response to accept comma-separated IPv4 and IPv6 addresses. Validate the list atomically and share response construction across normal, response-action, and precomputed paths. --- README.md | 2 +- README.zh-CN.md | 2 +- src/config.rs | 2 +- src/engine/execution.rs | 34 +++++++++++++++++++++++++--------- src/engine/pipeline.rs | 30 ++++-------------------------- src/engine/response.rs | 21 ++++++++++++++------- tools/config_editor.html | 2 +- 7 files changed, 47 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index fcc3f92..5cd326b 100644 --- a/README.md +++ b/README.md @@ -341,7 +341,7 @@ The aliases and-not, andnot, or-not, and ornot are also accepted. |---|---|---| | log | level (optional) | Emits a tracing event for the matched rule. Supported levels are trace, debug, info, warn, and error. | | static_response | rcode | Returns NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, or REFUSED. | -| static_ip_response | ip | Returns an A or AAAA response based on the IP address. | +| static_ip_response | ip | Returns A and/or AAAA records. `ip` accepts one address or a comma-separated list of IPv4/IPv6 addresses (for example, `192.0.2.1,2001:db8::1`). | | static_txt_response | text, ttl (optional) | Returns a TXT response. text accepts a string or string array; ttl defaults to 300. | | jump_to_pipeline | pipeline | Starts processing the referenced pipeline. | | allow | none | Request phase: forward with the global default UDP upstream. Response phase: keep the current upstream response. | diff --git a/README.zh-CN.md b/README.zh-CN.md index fdc6afc..7b76ed1 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -340,7 +340,7 @@ Pipeline selector 支持上表全部类型;请求规则支持除 listener_labe |---|---|---| | log | level(可选) | 输出匹配规则的 tracing 事件;支持 trace、debug、info、warn、error。 | | static_response | rcode | 返回 NOERROR、FORMERR、SERVFAIL、NXDOMAIN、NOTIMP 或 REFUSED。 | -| static_ip_response | ip | 根据 IP 地址返回 A 或 AAAA 响应。 | +| static_ip_response | ip | 返回 A 和/或 AAAA 记录;`ip` 支持单个地址或逗号分隔的 IPv4/IPv6 地址列表(例如 `192.0.2.1,2001:db8::1`)。 | | static_txt_response | text、ttl(可选) | 返回 TXT 响应;text 支持字符串或字符串数组,ttl 默认 300。 | | jump_to_pipeline | pipeline | 开始处理指定 Pipeline。 | | allow | 无 | 请求阶段:使用全局默认 UDP 上游;响应阶段:保留当前上游响应。 | diff --git a/src/config.rs b/src/config.rs index 8b13923..ecf5cf5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -512,7 +512,7 @@ pub enum Action { Log { level: Option }, /// 固定响应rcode(如 NXDOMAIN/NOERROR)。 / Static response rcode (e.g., NXDOMAIN/NOERROR) StaticResponse { rcode: String }, - /// 返回固定 IP (A/AAAA)。 / Return static IP (A/AAAA) + /// 返回一个或逗号分隔的多个固定 IPv4/IPv6 地址。 / Return one or more comma-separated static IPv4/IPv6 addresses. StaticIpResponse { ip: String }, /// 返回固定 TXT 记录。支持单个字符串或字符串数组。 / Return static TXT record. Supports single string or string array. StaticTxtResponse { diff --git a/src/engine/execution.rs b/src/engine/execution.rs index 0b163bd..bfd8fd5 100644 --- a/src/engine/execution.rs +++ b/src/engine/execution.rs @@ -1359,21 +1359,37 @@ mod tests { } #[test] - fn make_static_ip_answer_rejects_invalid_input() { - // Arrange: Define test domain and invalid IP - let domain = "example.com"; - let invalid_ip = "not-an-ip"; + fn make_static_ip_answer_returns_comma_separated_ipv4_and_ipv6_records() { + let (rcode, answers) = + make_static_ip_answer("example.com", " 192.0.2.1, 2001:db8::1,192.0.2.2 "); + + assert_eq!(rcode, ResponseCode::NoError); + assert_eq!(answers.len(), 3); + assert_eq!(answers[0].record_type(), RecordType::A); + assert_eq!(answers[1].record_type(), RecordType::AAAA); + assert_eq!(answers[2].record_type(), RecordType::A); + } - // Act: Generate static IP answer with invalid input - let (rcode, answers) = make_static_ip_answer(domain, invalid_ip); + #[test] + fn make_static_ip_answer_rejects_invalid_input_atomically() { + let (rcode, answers) = + make_static_ip_answer("example.com", "192.0.2.1,not-an-ip,2001:db8::1"); - // Assert: Verify ServFail response and empty answers assert_eq!( rcode, ResponseCode::ServFail, - "Should return ServFail for invalid IP" + "Should return ServFail when any IP is invalid" ); - assert!(answers.is_empty(), "Should have no answers for invalid IP"); + assert!(answers.is_empty(), "Should not return a partial answer"); + } + + #[test] + fn make_static_ip_answer_rejects_empty_entries() { + for ips in ["", "192.0.2.1,", ",192.0.2.1", "192.0.2.1,,2001:db8::1"] { + let (rcode, answers) = make_static_ip_answer("example.com", ips); + assert_eq!(rcode, ResponseCode::ServFail, "input: {ips:?}"); + assert!(answers.is_empty(), "input: {ips:?}"); + } } #[test] diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index d948163..fc110a5 100644 --- a/src/engine/pipeline.rs +++ b/src/engine/pipeline.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use hickory_proto::op::ResponseCode; -use hickory_proto::rr::rdata::{A, AAAA, TXT}; +use hickory_proto::rr::rdata::TXT; use hickory_proto::rr::{DNSClass, RData, Record, RecordType}; use smallvec::SmallVec; @@ -19,6 +19,7 @@ use crate::matcher::{ }; use super::core::Engine; +use super::make_static_ip_answer; use super::matcher_adapter::{MatcherContext, matcher_matches}; use super::rules::Decision; use super::rules::{RuleCacheEntry, calculate_rule_hash, contains_continue, fast_hash_str}; @@ -383,31 +384,8 @@ impl Engine { return d; } Action::StaticIpResponse { ip } => { - if let Ok(ip_addr) = ip.parse::() - && let Ok(name) = std::str::FromStr::from_str(qname) - { - let rdata = match ip_addr { - IpAddr::V4(v4) => RData::A(A(v4)), - IpAddr::V6(v6) => RData::AAAA(AAAA(v6)), - }; - let record = Record::from_rdata(name, 300, rdata); - let d = Decision::Static { - rcode: ResponseCode::NoError, - answers: vec![record], - }; - self.insert_rule_cache( - rule_hash, - pipeline.id.clone(), - request, - d.clone(), - include_ip, - ); - return d; - } - let d = Decision::Static { - rcode: ResponseCode::ServFail, - answers: Vec::new(), - }; + let (rcode, answers) = make_static_ip_answer(qname, ip); + let d = Decision::Static { rcode, answers }; self.insert_rule_cache( rule_hash, pipeline.id.clone(), diff --git a/src/engine/response.rs b/src/engine/response.rs index 2943e73..591d68e 100644 --- a/src/engine/response.rs +++ b/src/engine/response.rs @@ -45,18 +45,25 @@ pub(crate) fn build_fast_static_response( Ok(Bytes::from(out)) } -pub(crate) fn make_static_ip_answer(qname: &str, ip: &str) -> (ResponseCode, Vec) { - if let Ok(ip_addr) = ip.parse::() - && let Ok(name) = Name::from_str(qname) - { +pub(crate) fn make_static_ip_answer(qname: &str, ips: &str) -> (ResponseCode, Vec) { + let Ok(name) = Name::from_str(qname) else { + return (ResponseCode::ServFail, Vec::new()); + }; + + let mut answers = Vec::new(); + for ip in ips.split(',') { + let Ok(ip_addr) = ip.trim().parse::() else { + // Treat the configured list atomically: never return a partial answer. + return (ResponseCode::ServFail, Vec::new()); + }; let rdata = match ip_addr { IpAddr::V4(v4) => RData::A(A(v4)), IpAddr::V6(v6) => RData::AAAA(AAAA(v6)), }; - let record = Record::from_rdata(name, 300, rdata); - return (ResponseCode::NoError, vec![record]); + answers.push(Record::from_rdata(name.clone(), 300, rdata)); } - (ResponseCode::ServFail, Vec::new()) + + (ResponseCode::NoError, answers) } /// 创建静态TXT记录响应 / Create static TXT record response diff --git a/tools/config_editor.html b/tools/config_editor.html index 6780d10..100cd7a 100644 --- a/tools/config_editor.html +++ b/tools/config_editor.html @@ -582,7 +582,7 @@

JSON 预览 / 编辑

- +