diff --git a/README.md b/README.md index fcc3f92..086ba96 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 or AAAA records matching the query type (both for ANY queries). Other query types, including HTTPS and SVCB, receive NODATA; this action does not synthesize IP hints. `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..278bb4d 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 记录(ANY 查询返回两者);HTTPS、SVCB 等其他查询类型返回 NODATA,且不会合成 IP hints。`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..216d30b 100644 --- a/src/engine/execution.rs +++ b/src/engine/execution.rs @@ -1319,7 +1319,7 @@ mod tests { let ipv4 = "1.2.3.4"; // Act: Generate static IP answer - let (rcode, answers) = make_static_ip_answer(domain, ipv4); + let (rcode, answers) = make_static_ip_answer(domain, RecordType::A, ipv4); // Assert: Verify response code and record type assert_eq!( @@ -1342,7 +1342,7 @@ mod tests { let ipv6 = "2001:db8::1"; // Act: Generate static IP answer - let (rcode, answers) = make_static_ip_answer(domain, ipv6); + let (rcode, answers) = make_static_ip_answer(domain, RecordType::AAAA, ipv6); // Assert: Verify response code and record type assert_eq!( @@ -1359,21 +1359,143 @@ 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_filters_comma_separated_addresses_by_query_type() { + let ips = " 192.0.2.1, 2001:db8::1,192.0.2.2 "; + + let (a_rcode, a_answers) = make_static_ip_answer("example.com", RecordType::A, ips); + assert_eq!(a_rcode, ResponseCode::NoError); + assert_eq!(a_answers.len(), 2); + assert!( + a_answers + .iter() + .all(|answer| answer.record_type() == RecordType::A) + ); + + let (aaaa_rcode, aaaa_answers) = + make_static_ip_answer("example.com", RecordType::AAAA, ips); + assert_eq!(aaaa_rcode, ResponseCode::NoError); + assert_eq!(aaaa_answers.len(), 1); + assert_eq!(aaaa_answers[0].record_type(), RecordType::AAAA); + } + + #[test] + fn make_static_ip_answer_returns_nodata_for_unconfigured_query_family() { + let (rcode, answers) = make_static_ip_answer("example.com", RecordType::AAAA, "192.0.2.1"); + + assert_eq!(rcode, ResponseCode::NoError); + assert!(answers.is_empty()); + } + + #[test] + fn make_static_ip_answer_returns_nodata_for_https_query() { + let (rcode, answers) = + make_static_ip_answer("example.com", RecordType::HTTPS, "192.0.2.1,2001:db8::1"); + + assert_eq!(rcode, ResponseCode::NoError); + assert!(answers.is_empty()); + } - // Act: Generate static IP answer with invalid input - let (rcode, answers) = make_static_ip_answer(domain, invalid_ip); + #[test] + fn make_static_ip_answer_returns_both_families_for_any_query() { + let (rcode, answers) = + make_static_ip_answer("example.com", RecordType::ANY, "192.0.2.1,2001:db8::1"); + + assert_eq!(rcode, ResponseCode::NoError); + assert_eq!(answers.len(), 2); + assert_eq!(answers[0].record_type(), RecordType::A); + assert_eq!(answers[1].record_type(), RecordType::AAAA); + } + + #[test] + fn make_static_ip_answer_rejects_invalid_input_atomically() { + let (rcode, answers) = make_static_ip_answer( + "example.com", + RecordType::A, + "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 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", RecordType::A, ips); + assert_eq!(rcode, ResponseCode::ServFail, "input: {ips:?}"); + assert!(answers.is_empty(), "input: {ips:?}"); + } + } + + #[tokio::test] + async fn static_ip_fast_path_serializes_multiple_answers_by_query_type() { + let raw = serde_json::json!({ + "settings": { "default_upstream": "1.1.1.1:53" }, + "pipelines": [{ + "id": "static", + "rules": [{ + "name": "static-ip", + "matchers": [{ "type": "domain_suffix", "value": "example.com" }], + "actions": [{ + "type": "static_ip_response", + "ip": "192.0.2.1,2001:db8::1,192.0.2.2" + }] + }] + }] + }); + let cfg: crate::config::PipelineConfig = serde_json::from_value(raw).expect("parse config"); + let runtime = RuntimePipelineConfig::from_config(cfg).expect("runtime config"); + let engine = Engine::new(runtime, "test".to_string()).expect("initialize engine"); + let peer = "127.0.0.1:53000".parse().unwrap(); + + let assert_response = |qtype, expected_ips: &[IpAddr]| { + let mut request = Message::new(0xCAFE, MessageType::Query, OpCode::Query); + request.metadata.recursion_desired = true; + request.add_query(Query::query( + Name::from_str("www.example.com").unwrap(), + qtype, + )); + + let response = match engine + .handle_packet_fast(&request.to_vec().unwrap(), peer) + .expect("fast path") + { + Some(FastPathResponse::Direct(bytes)) => Message::from_bytes(&bytes).unwrap(), + other => panic!("expected direct fast-path response, got {other:?}"), + }; + + assert_eq!(response.metadata.id, 0xCAFE); + assert_eq!(response.metadata.message_type, MessageType::Response); + assert_eq!(response.metadata.response_code, ResponseCode::NoError); + assert!(response.metadata.recursion_desired); + assert!(response.metadata.recursion_available); + assert_eq!(response.queries.len(), 1); + assert_eq!(response.queries[0].query_type(), qtype); + + let actual_ips: Vec = response + .answers + .iter() + .map(|answer| { + assert_eq!(answer.ttl, 300); + match &answer.data { + RData::A(address) => IpAddr::V4(address.0), + RData::AAAA(address) => IpAddr::V6(address.0), + other => panic!("unexpected static IP answer: {other:?}"), + } + }) + .collect(); + assert_eq!(actual_ips, expected_ips); + }; + + assert_response( + RecordType::A, + &["192.0.2.1".parse().unwrap(), "192.0.2.2".parse().unwrap()], ); - assert!(answers.is_empty(), "Should have no answers for invalid IP"); + assert_response(RecordType::AAAA, &["2001:db8::1".parse().unwrap()]); } #[test] diff --git a/src/engine/pipeline.rs b/src/engine/pipeline.rs index d948163..761a01b 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, request.qtype, 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..88971e0 100644 --- a/src/engine/response.rs +++ b/src/engine/response.rs @@ -1,7 +1,7 @@ use bytes::Bytes; use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode}; use hickory_proto::rr::{ - DNSClass, Name, RData, Record, + DNSClass, Name, RData, Record, RecordType, rdata::{A, AAAA, TXT}, }; use hickory_proto::serialize::binary::{BinEncodable, BinEncoder}; @@ -45,18 +45,36 @@ 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) - { - let rdata = match ip_addr { - IpAddr::V4(v4) => RData::A(A(v4)), - IpAddr::V6(v6) => RData::AAAA(AAAA(v6)), +pub(crate) fn make_static_ip_answer( + qname: &str, + qtype: RecordType, + ips: &str, +) -> (ResponseCode, Vec) { + let Ok(name) = Name::from_str(qname) else { + return (ResponseCode::ServFail, Vec::new()); + }; + + // Parse the complete list first so invalid entries still fail atomically, + // even when their address family is not relevant to this query. + let mut parsed_ips = Vec::new(); + for ip in ips.split(',') { + let Ok(ip_addr) = ip.trim().parse::() else { + return (ResponseCode::ServFail, Vec::new()); }; - let record = Record::from_rdata(name, 300, rdata); - return (ResponseCode::NoError, vec![record]); + parsed_ips.push(ip_addr); } - (ResponseCode::ServFail, Vec::new()) + + let answers = parsed_ips + .into_iter() + .filter_map(|ip_addr| match (qtype, ip_addr) { + (RecordType::A | RecordType::ANY, IpAddr::V4(v4)) => Some(RData::A(A(v4))), + (RecordType::AAAA | RecordType::ANY, IpAddr::V6(v6)) => Some(RData::AAAA(AAAA(v6))), + _ => None, + }) + .map(|rdata| Record::from_rdata(name.clone(), 300, rdata)) + .collect(); + + (ResponseCode::NoError, answers) } /// 创建静态TXT记录响应 / Create static TXT record response diff --git a/src/engine/rules.rs b/src/engine/rules.rs index 192beee..3d60ca4 100644 --- a/src/engine/rules.rs +++ b/src/engine/rules.rs @@ -268,7 +268,7 @@ pub(crate) async fn apply_response_actions( }); } Action::StaticIpResponse { ip } => { - let (rcode, answers) = make_static_ip_answer(ctx.qname, ip); + let (rcode, answers) = make_static_ip_answer(ctx.qname, ctx.qtype, ip); let bytes = build_response(ctx.req, rcode, answers)?; return Ok(ResponseActionResult::Static { bytes, diff --git a/src/matcher/advanced_rule.rs b/src/matcher/advanced_rule.rs index 1305cad..80442ae 100644 --- a/src/matcher/advanced_rule.rs +++ b/src/matcher/advanced_rule.rs @@ -291,7 +291,7 @@ pub(crate) fn fast_static_match( }); } PrecomputedAction::StaticIp { ip } => { - let (rcode, answers) = make_static_ip_answer(qname, ip); + let (rcode, answers) = make_static_ip_answer(qname, qtype, ip); return Some(Decision::Static { rcode, answers }); } } 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 预览 / 编辑

- +