diff --git a/README.md b/README.md index 086ba96..806c82b 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 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_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`). Addresses are returned as-is; a very large list may exceed the UDP response size limit. | | 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 278bb4d..794eb82 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 | 按查询类型返回 A 或 AAAA 记录(ANY 查询返回两者);HTTPS、SVCB 等其他查询类型返回 NODATA,且不会合成 IP hints。`ip` 支持单个地址或逗号分隔的 IPv4/IPv6 地址列表(例如 `192.0.2.1,2001:db8::1`)。 | +| static_ip_response | ip | 按查询类型返回 A 或 AAAA 记录(ANY 查询返回两者);HTTPS、SVCB 等其他查询类型返回 NODATA,且不会合成 IP hints。`ip` 支持单个地址或逗号分隔的 IPv4/IPv6 地址列表(例如 `192.0.2.1,2001:db8::1`)。地址列表原样返回;列表过大时响应可能超过 UDP 大小限制。 | | static_txt_response | text、ttl(可选) | 返回 TXT 响应;text 支持字符串或字符串数组,ttl 默认 300。 | | jump_to_pipeline | pipeline | 开始处理指定 Pipeline。 | | allow | 无 | 请求阶段:使用全局默认 UDP 上游;响应阶段:保留当前上游响应。 | diff --git a/src/engine/execution.rs b/src/engine/execution.rs index 216d30b..f22e2ef 100644 --- a/src/engine/execution.rs +++ b/src/engine/execution.rs @@ -791,6 +791,7 @@ impl Engine { let qname = qname_cow; let mut skip_rules: FxHashSet> = FxHashSet::default(); let mut current_pipeline_id = pipeline_id.clone(); + let mut current_uses_client_ip = pipeline_opt.map(|p| p.uses_client_ip).unwrap_or(false); // Convert qname to bytes for hash calculation / 将 qname 转换为 bytes 进行哈希计算 let qname_bytes = qname.as_bytes(); // Reuse dedupe_hash from earlier computation (includes ECS key isolation) @@ -879,6 +880,7 @@ impl Engine { if let Some(&idx) = cfg.pipeline_id_index.get(pipeline.as_ref()) { let p = &cfg.pipelines[idx]; current_pipeline_id = p.id.clone(); + current_uses_client_ip = p.uses_client_ip; // Must recompute ECS key + dedupe_hash: pipeline changed via Jump, // so the target pipeline's ECS config may differ from the source. // 必须重算 ECS key + dedupe_hash:pipeline 因 Jump 改变, @@ -934,6 +936,7 @@ impl Engine { min_ttl, start, peer: &peer, + uses_client_ip: current_uses_client_ip, }, rcode, answers, @@ -1990,6 +1993,145 @@ mod tests { } } + #[tokio::test] + async fn response_actions_static_ip_filters_answers_by_query_type() { + // Arrange: Build test engine with a multi-address static IP response action + let engine = build_test_engine(); + let req = Message::new(0, MessageType::Query, OpCode::Query); + let actions = [Action::StaticIpResponse { + ip: "192.0.2.1,2001:db8::1,192.0.2.2".to_string(), + }]; + let response_matchers: Vec = Vec::new(); + let packet = [0u8]; + let client_ip: IpAddr = "10.0.0.1".parse().unwrap(); + + let run = |qtype: RecordType| { + let ctx = crate::engine::rules::ApplyResponseActionsContext { + engine: &engine, + actions: &actions, + ctx_opt: None, + req: &req, + packet: &packet, + upstream_timeout: Duration::from_secs(1), + response_matchers: &response_matchers, + qname: "example.com", + qtype, + qclass: DNSClass::IN, + client_ip, + upstream_default: TEST_UPSTREAM, + pipeline_id: "pipeline", + rule_name: "rule", + remaining_jumps: 10, + }; + apply_response_actions(ctx) + }; + let answer_ips = |resp: &Message| -> Vec { + resp.answers + .iter() + .map(|answer| match &answer.data { + RData::A(a) => IpAddr::V4(a.0), + RData::AAAA(a) => IpAddr::V6(a.0), + other => panic!("unexpected static IP answer: {other:?}"), + }) + .collect() + }; + + // Act & Assert: A query returns only the IPv4 addresses + match run(RecordType::A).await.expect("static ip") { + ResponseActionResult::Static { bytes, rcode, .. } => { + assert_eq!(rcode, ResponseCode::NoError); + assert_eq!( + answer_ips(&Message::from_bytes(&bytes).unwrap()), + vec![ + "192.0.2.1".parse::().unwrap(), + "192.0.2.2".parse().unwrap() + ] + ); + } + _ => panic!("expected static result"), + } + + // Act & Assert: AAAA query returns only the IPv6 address + match run(RecordType::AAAA).await.expect("static ip") { + ResponseActionResult::Static { bytes, rcode, .. } => { + assert_eq!(rcode, ResponseCode::NoError); + assert_eq!( + answer_ips(&Message::from_bytes(&bytes).unwrap()), + vec!["2001:db8::1".parse::().unwrap()] + ); + } + _ => panic!("expected static result"), + } + + // Act & Assert: HTTPS query returns NODATA (no synthesized address records) + match run(RecordType::HTTPS).await.expect("static ip") { + ResponseActionResult::Static { bytes, rcode, .. } => { + assert_eq!(rcode, ResponseCode::NoError); + assert!( + Message::from_bytes(&bytes).unwrap().answers.is_empty(), + "HTTPS should get NODATA, not synthesized A/AAAA records" + ); + } + _ => panic!("expected static result"), + } + } + + #[tokio::test] + async fn static_decision_skips_dns_cache_when_pipeline_uses_client_ip() { + // Arrange: Build engine, request packet, and a static IP answer + let engine = build_test_engine(); + let mut request = Message::new(0xBE, MessageType::Query, OpCode::Query); + request.metadata.recursion_desired = true; + request.add_query(Query::query( + Name::from_str("example.com").unwrap(), + RecordType::A, + )); + let packet = request.to_vec().unwrap(); + let peer = "10.0.0.1:53000".parse().unwrap(); + let (rcode, answers) = make_static_ip_answer("example.com", RecordType::A, "192.0.2.1"); + + // Act: uses_client_ip=true must NOT write into dns_cache (client isolation) + let ctx = phases::StaticDecisionContext { + packet: &packet, + qname: "example.com", + qtype: RecordType::A, + pipeline_id: &Arc::from("p1"), + dedupe_hash: 4242, + min_ttl: Duration::from_secs(60), + start: Instant::now(), + peer: &peer, + uses_client_ip: true, + }; + phases::handle_static_decision(&engine, &ctx, rcode, answers.clone()) + .expect("static decision"); + + // Assert: no dns_cache entry written + assert!( + engine.cache_get(&4242).is_none(), + "static decision with client_ip matcher must not pollute dns_cache" + ); + + // Control: uses_client_ip=false DOES write into dns_cache + let ctx_plain = phases::StaticDecisionContext { + packet: &packet, + qname: "example.com", + qtype: RecordType::A, + pipeline_id: &Arc::from("p1"), + dedupe_hash: 4243, + min_ttl: Duration::from_secs(60), + start: Instant::now(), + peer: &peer, + uses_client_ip: false, + }; + phases::handle_static_decision(&engine, &ctx_plain, rcode, answers) + .expect("static decision"); + + assert!( + engine.cache_get(&4243).is_some(), + "static decision without client_ip matcher should still be cached" + ); + } + #[test] fn test_calculate_rule_hash_respects_uses_client_ip() { // Arrange: Define test data with different IPs diff --git a/src/engine/phases.rs b/src/engine/phases.rs index 6bc15f2..0045467 100644 --- a/src/engine/phases.rs +++ b/src/engine/phases.rs @@ -356,6 +356,9 @@ pub struct StaticDecisionContext<'a> { pub min_ttl: Duration, pub start: Instant, pub peer: &'a std::net::SocketAddr, + /// Pipeline uses a client_ip matcher: skip dns_cache to avoid cross-client + /// reuse (rule_cache already isolates by client IP in that case). + pub uses_client_ip: bool, } /// Handles Decision::Static. @@ -375,12 +378,16 @@ pub fn handle_static_decision( min_ttl, start, peer, + uses_client_ip, } = *context; // Need full request for building response / 需要完整请求来构建响应 let req = Message::from_bytes(packet).context("parse request for static")?; let resp_bytes = build_response(&req, rcode, answers)?; - if min_ttl > Duration::from_secs(0) { + // Skip dns_cache when the pipeline matches on client_ip: dedupe_hash has no + // client dimension, so a cached static answer could leak across clients. + // rule_cache already caches static decisions with client-IP isolation. + if !uses_client_ip && min_ttl > Duration::from_secs(0) { let ttl = crate::proto_utils::saturating_u64_to_u32(min_ttl.as_secs()); engine.insert_dns_cache_entry( dedupe_hash,