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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 上游;响应阶段:保留当前上游响应。 |
Expand Down
142 changes: 142 additions & 0 deletions src/engine/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,7 @@ impl Engine {
let qname = qname_cow;
let mut skip_rules: FxHashSet<Arc<str>> = 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)
Expand Down Expand Up @@ -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 改变,
Expand Down Expand Up @@ -934,6 +936,7 @@ impl Engine {
min_ttl,
start,
peer: &peer,
uses_client_ip: current_uses_client_ip,
},
rcode,
answers,
Expand Down Expand Up @@ -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<RuntimeResponseMatcherWithOp> = 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<IpAddr> {
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::<IpAddr>().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::<IpAddr>().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
Expand Down
9 changes: 8 additions & 1 deletion src/engine/phases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand Down