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 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. |
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 | 根据 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 上游;响应阶段:保留当前上游响应。 |
Expand Down
2 changes: 1 addition & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ pub enum Action {
Log { level: Option<String> },
/// 固定响应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 {
Expand Down
144 changes: 133 additions & 11 deletions src/engine/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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!(
Expand All @@ -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<IpAddr> = 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]
Expand Down
30 changes: 4 additions & 26 deletions src/engine/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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};
Expand Down Expand Up @@ -383,31 +384,8 @@ impl Engine {
return d;
}
Action::StaticIpResponse { ip } => {
if let Ok(ip_addr) = ip.parse::<IpAddr>()
&& 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(),
Expand Down
40 changes: 29 additions & 11 deletions src/engine/response.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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<Record>) {
if let Ok(ip_addr) = ip.parse::<IpAddr>()
&& 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<Record>) {
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::<IpAddr>() 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
Expand Down
2 changes: 1 addition & 1 deletion src/engine/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/matcher/advanced_rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
}
Expand Down
2 changes: 1 addition & 1 deletion tools/config_editor.html
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,7 @@ <h4 class="mb-3">JSON 预览 / 编辑</h4>
</select>

<!-- Static IP -->
<input v-if="a.type === 'static_ip_response'" type="text" class="form-control" v-model="a.ip" placeholder="IP Address">
<input v-if="a.type === 'static_ip_response'" type="text" class="form-control" v-model="a.ip" placeholder="IP address(es), comma-separated">

<!-- Jump -->
<select v-if="a.type === 'jump_to_pipeline'" class="form-select" v-model="a.pipeline">
Expand Down