Skip to content

Commit d4ce74f

Browse files
authored
feat(config): support multiple static IP addresses (#36)
* 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. * fix(dns): filter static IP answers by query type * test(dns): cover HTTPS static IP behavior * test(dns): cover multi-address static IP fast path
1 parent cb34025 commit d4ce74f

9 files changed

Lines changed: 172 additions & 54 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ The aliases and-not, andnot, or-not, and ornot are also accepted.
341341
|---|---|---|
342342
| log | level (optional) | Emits a tracing event for the matched rule. Supported levels are trace, debug, info, warn, and error. |
343343
| static_response | rcode | Returns NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, or REFUSED. |
344-
| static_ip_response | ip | Returns an A or AAAA response based on the IP address. |
344+
| 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`). |
345345
| static_txt_response | text, ttl (optional) | Returns a TXT response. text accepts a string or string array; ttl defaults to 300. |
346346
| jump_to_pipeline | pipeline | Starts processing the referenced pipeline. |
347347
| allow | none | Request phase: forward with the global default UDP upstream. Response phase: keep the current upstream response. |

README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ Pipeline selector 支持上表全部类型;请求规则支持除 listener_labe
340340
|---|---|---|
341341
| log | level(可选) | 输出匹配规则的 tracing 事件;支持 trace、debug、info、warn、error。 |
342342
| static_response | rcode | 返回 NOERROR、FORMERR、SERVFAIL、NXDOMAIN、NOTIMP 或 REFUSED。 |
343-
| static_ip_response | ip | 根据 IP 地址返回 A 或 AAAA 响应|
343+
| static_ip_response | ip | 按查询类型返回 A 或 AAAA 记录(ANY 查询返回两者);HTTPS、SVCB 等其他查询类型返回 NODATA,且不会合成 IP hints。`ip` 支持单个地址或逗号分隔的 IPv4/IPv6 地址列表(例如 `192.0.2.1,2001:db8::1`|
344344
| static_txt_response | text、ttl(可选) | 返回 TXT 响应;text 支持字符串或字符串数组,ttl 默认 300。 |
345345
| jump_to_pipeline | pipeline | 开始处理指定 Pipeline。 |
346346
| allow || 请求阶段:使用全局默认 UDP 上游;响应阶段:保留当前上游响应。 |

src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ pub enum Action {
512512
Log { level: Option<String> },
513513
/// 固定响应rcode(如 NXDOMAIN/NOERROR)。 / Static response rcode (e.g., NXDOMAIN/NOERROR)
514514
StaticResponse { rcode: String },
515-
/// 返回固定 IP (A/AAAA)。 / Return static IP (A/AAAA)
515+
/// 返回一个或逗号分隔的多个固定 IPv4/IPv6 地址。 / Return one or more comma-separated static IPv4/IPv6 addresses.
516516
StaticIpResponse { ip: String },
517517
/// 返回固定 TXT 记录。支持单个字符串或字符串数组。 / Return static TXT record. Supports single string or string array.
518518
StaticTxtResponse {

src/engine/execution.rs

Lines changed: 133 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1319,7 +1319,7 @@ mod tests {
13191319
let ipv4 = "1.2.3.4";
13201320

13211321
// Act: Generate static IP answer
1322-
let (rcode, answers) = make_static_ip_answer(domain, ipv4);
1322+
let (rcode, answers) = make_static_ip_answer(domain, RecordType::A, ipv4);
13231323

13241324
// Assert: Verify response code and record type
13251325
assert_eq!(
@@ -1342,7 +1342,7 @@ mod tests {
13421342
let ipv6 = "2001:db8::1";
13431343

13441344
// Act: Generate static IP answer
1345-
let (rcode, answers) = make_static_ip_answer(domain, ipv6);
1345+
let (rcode, answers) = make_static_ip_answer(domain, RecordType::AAAA, ipv6);
13461346

13471347
// Assert: Verify response code and record type
13481348
assert_eq!(
@@ -1359,21 +1359,143 @@ mod tests {
13591359
}
13601360

13611361
#[test]
1362-
fn make_static_ip_answer_rejects_invalid_input() {
1363-
// Arrange: Define test domain and invalid IP
1364-
let domain = "example.com";
1365-
let invalid_ip = "not-an-ip";
1362+
fn make_static_ip_answer_filters_comma_separated_addresses_by_query_type() {
1363+
let ips = " 192.0.2.1, 2001:db8::1,192.0.2.2 ";
1364+
1365+
let (a_rcode, a_answers) = make_static_ip_answer("example.com", RecordType::A, ips);
1366+
assert_eq!(a_rcode, ResponseCode::NoError);
1367+
assert_eq!(a_answers.len(), 2);
1368+
assert!(
1369+
a_answers
1370+
.iter()
1371+
.all(|answer| answer.record_type() == RecordType::A)
1372+
);
1373+
1374+
let (aaaa_rcode, aaaa_answers) =
1375+
make_static_ip_answer("example.com", RecordType::AAAA, ips);
1376+
assert_eq!(aaaa_rcode, ResponseCode::NoError);
1377+
assert_eq!(aaaa_answers.len(), 1);
1378+
assert_eq!(aaaa_answers[0].record_type(), RecordType::AAAA);
1379+
}
1380+
1381+
#[test]
1382+
fn make_static_ip_answer_returns_nodata_for_unconfigured_query_family() {
1383+
let (rcode, answers) = make_static_ip_answer("example.com", RecordType::AAAA, "192.0.2.1");
1384+
1385+
assert_eq!(rcode, ResponseCode::NoError);
1386+
assert!(answers.is_empty());
1387+
}
1388+
1389+
#[test]
1390+
fn make_static_ip_answer_returns_nodata_for_https_query() {
1391+
let (rcode, answers) =
1392+
make_static_ip_answer("example.com", RecordType::HTTPS, "192.0.2.1,2001:db8::1");
1393+
1394+
assert_eq!(rcode, ResponseCode::NoError);
1395+
assert!(answers.is_empty());
1396+
}
13661397

1367-
// Act: Generate static IP answer with invalid input
1368-
let (rcode, answers) = make_static_ip_answer(domain, invalid_ip);
1398+
#[test]
1399+
fn make_static_ip_answer_returns_both_families_for_any_query() {
1400+
let (rcode, answers) =
1401+
make_static_ip_answer("example.com", RecordType::ANY, "192.0.2.1,2001:db8::1");
1402+
1403+
assert_eq!(rcode, ResponseCode::NoError);
1404+
assert_eq!(answers.len(), 2);
1405+
assert_eq!(answers[0].record_type(), RecordType::A);
1406+
assert_eq!(answers[1].record_type(), RecordType::AAAA);
1407+
}
1408+
1409+
#[test]
1410+
fn make_static_ip_answer_rejects_invalid_input_atomically() {
1411+
let (rcode, answers) = make_static_ip_answer(
1412+
"example.com",
1413+
RecordType::A,
1414+
"192.0.2.1,not-an-ip,2001:db8::1",
1415+
);
13691416

1370-
// Assert: Verify ServFail response and empty answers
13711417
assert_eq!(
13721418
rcode,
13731419
ResponseCode::ServFail,
1374-
"Should return ServFail for invalid IP"
1420+
"Should return ServFail when any IP is invalid"
1421+
);
1422+
assert!(answers.is_empty(), "Should not return a partial answer");
1423+
}
1424+
1425+
#[test]
1426+
fn make_static_ip_answer_rejects_empty_entries() {
1427+
for ips in ["", "192.0.2.1,", ",192.0.2.1", "192.0.2.1,,2001:db8::1"] {
1428+
let (rcode, answers) = make_static_ip_answer("example.com", RecordType::A, ips);
1429+
assert_eq!(rcode, ResponseCode::ServFail, "input: {ips:?}");
1430+
assert!(answers.is_empty(), "input: {ips:?}");
1431+
}
1432+
}
1433+
1434+
#[tokio::test]
1435+
async fn static_ip_fast_path_serializes_multiple_answers_by_query_type() {
1436+
let raw = serde_json::json!({
1437+
"settings": { "default_upstream": "1.1.1.1:53" },
1438+
"pipelines": [{
1439+
"id": "static",
1440+
"rules": [{
1441+
"name": "static-ip",
1442+
"matchers": [{ "type": "domain_suffix", "value": "example.com" }],
1443+
"actions": [{
1444+
"type": "static_ip_response",
1445+
"ip": "192.0.2.1,2001:db8::1,192.0.2.2"
1446+
}]
1447+
}]
1448+
}]
1449+
});
1450+
let cfg: crate::config::PipelineConfig = serde_json::from_value(raw).expect("parse config");
1451+
let runtime = RuntimePipelineConfig::from_config(cfg).expect("runtime config");
1452+
let engine = Engine::new(runtime, "test".to_string()).expect("initialize engine");
1453+
let peer = "127.0.0.1:53000".parse().unwrap();
1454+
1455+
let assert_response = |qtype, expected_ips: &[IpAddr]| {
1456+
let mut request = Message::new(0xCAFE, MessageType::Query, OpCode::Query);
1457+
request.metadata.recursion_desired = true;
1458+
request.add_query(Query::query(
1459+
Name::from_str("www.example.com").unwrap(),
1460+
qtype,
1461+
));
1462+
1463+
let response = match engine
1464+
.handle_packet_fast(&request.to_vec().unwrap(), peer)
1465+
.expect("fast path")
1466+
{
1467+
Some(FastPathResponse::Direct(bytes)) => Message::from_bytes(&bytes).unwrap(),
1468+
other => panic!("expected direct fast-path response, got {other:?}"),
1469+
};
1470+
1471+
assert_eq!(response.metadata.id, 0xCAFE);
1472+
assert_eq!(response.metadata.message_type, MessageType::Response);
1473+
assert_eq!(response.metadata.response_code, ResponseCode::NoError);
1474+
assert!(response.metadata.recursion_desired);
1475+
assert!(response.metadata.recursion_available);
1476+
assert_eq!(response.queries.len(), 1);
1477+
assert_eq!(response.queries[0].query_type(), qtype);
1478+
1479+
let actual_ips: Vec<IpAddr> = response
1480+
.answers
1481+
.iter()
1482+
.map(|answer| {
1483+
assert_eq!(answer.ttl, 300);
1484+
match &answer.data {
1485+
RData::A(address) => IpAddr::V4(address.0),
1486+
RData::AAAA(address) => IpAddr::V6(address.0),
1487+
other => panic!("unexpected static IP answer: {other:?}"),
1488+
}
1489+
})
1490+
.collect();
1491+
assert_eq!(actual_ips, expected_ips);
1492+
};
1493+
1494+
assert_response(
1495+
RecordType::A,
1496+
&["192.0.2.1".parse().unwrap(), "192.0.2.2".parse().unwrap()],
13751497
);
1376-
assert!(answers.is_empty(), "Should have no answers for invalid IP");
1498+
assert_response(RecordType::AAAA, &["2001:db8::1".parse().unwrap()]);
13771499
}
13781500

13791501
#[test]

src/engine/pipeline.rs

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::sync::Arc;
44
use std::time::{Duration, Instant};
55

66
use hickory_proto::op::ResponseCode;
7-
use hickory_proto::rr::rdata::{A, AAAA, TXT};
7+
use hickory_proto::rr::rdata::TXT;
88
use hickory_proto::rr::{DNSClass, RData, Record, RecordType};
99
use smallvec::SmallVec;
1010

@@ -19,6 +19,7 @@ use crate::matcher::{
1919
};
2020

2121
use super::core::Engine;
22+
use super::make_static_ip_answer;
2223
use super::matcher_adapter::{MatcherContext, matcher_matches};
2324
use super::rules::Decision;
2425
use super::rules::{RuleCacheEntry, calculate_rule_hash, contains_continue, fast_hash_str};
@@ -383,31 +384,8 @@ impl Engine {
383384
return d;
384385
}
385386
Action::StaticIpResponse { ip } => {
386-
if let Ok(ip_addr) = ip.parse::<IpAddr>()
387-
&& let Ok(name) = std::str::FromStr::from_str(qname)
388-
{
389-
let rdata = match ip_addr {
390-
IpAddr::V4(v4) => RData::A(A(v4)),
391-
IpAddr::V6(v6) => RData::AAAA(AAAA(v6)),
392-
};
393-
let record = Record::from_rdata(name, 300, rdata);
394-
let d = Decision::Static {
395-
rcode: ResponseCode::NoError,
396-
answers: vec![record],
397-
};
398-
self.insert_rule_cache(
399-
rule_hash,
400-
pipeline.id.clone(),
401-
request,
402-
d.clone(),
403-
include_ip,
404-
);
405-
return d;
406-
}
407-
let d = Decision::Static {
408-
rcode: ResponseCode::ServFail,
409-
answers: Vec::new(),
410-
};
387+
let (rcode, answers) = make_static_ip_answer(qname, request.qtype, ip);
388+
let d = Decision::Static { rcode, answers };
411389
self.insert_rule_cache(
412390
rule_hash,
413391
pipeline.id.clone(),

src/engine/response.rs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use bytes::Bytes;
22
use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode};
33
use hickory_proto::rr::{
4-
DNSClass, Name, RData, Record,
4+
DNSClass, Name, RData, Record, RecordType,
55
rdata::{A, AAAA, TXT},
66
};
77
use hickory_proto::serialize::binary::{BinEncodable, BinEncoder};
@@ -45,18 +45,36 @@ pub(crate) fn build_fast_static_response(
4545
Ok(Bytes::from(out))
4646
}
4747

48-
pub(crate) fn make_static_ip_answer(qname: &str, ip: &str) -> (ResponseCode, Vec<Record>) {
49-
if let Ok(ip_addr) = ip.parse::<IpAddr>()
50-
&& let Ok(name) = Name::from_str(qname)
51-
{
52-
let rdata = match ip_addr {
53-
IpAddr::V4(v4) => RData::A(A(v4)),
54-
IpAddr::V6(v6) => RData::AAAA(AAAA(v6)),
48+
pub(crate) fn make_static_ip_answer(
49+
qname: &str,
50+
qtype: RecordType,
51+
ips: &str,
52+
) -> (ResponseCode, Vec<Record>) {
53+
let Ok(name) = Name::from_str(qname) else {
54+
return (ResponseCode::ServFail, Vec::new());
55+
};
56+
57+
// Parse the complete list first so invalid entries still fail atomically,
58+
// even when their address family is not relevant to this query.
59+
let mut parsed_ips = Vec::new();
60+
for ip in ips.split(',') {
61+
let Ok(ip_addr) = ip.trim().parse::<IpAddr>() else {
62+
return (ResponseCode::ServFail, Vec::new());
5563
};
56-
let record = Record::from_rdata(name, 300, rdata);
57-
return (ResponseCode::NoError, vec![record]);
64+
parsed_ips.push(ip_addr);
5865
}
59-
(ResponseCode::ServFail, Vec::new())
66+
67+
let answers = parsed_ips
68+
.into_iter()
69+
.filter_map(|ip_addr| match (qtype, ip_addr) {
70+
(RecordType::A | RecordType::ANY, IpAddr::V4(v4)) => Some(RData::A(A(v4))),
71+
(RecordType::AAAA | RecordType::ANY, IpAddr::V6(v6)) => Some(RData::AAAA(AAAA(v6))),
72+
_ => None,
73+
})
74+
.map(|rdata| Record::from_rdata(name.clone(), 300, rdata))
75+
.collect();
76+
77+
(ResponseCode::NoError, answers)
6078
}
6179

6280
/// 创建静态TXT记录响应 / Create static TXT record response

src/engine/rules.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ pub(crate) async fn apply_response_actions(
268268
});
269269
}
270270
Action::StaticIpResponse { ip } => {
271-
let (rcode, answers) = make_static_ip_answer(ctx.qname, ip);
271+
let (rcode, answers) = make_static_ip_answer(ctx.qname, ctx.qtype, ip);
272272
let bytes = build_response(ctx.req, rcode, answers)?;
273273
return Ok(ResponseActionResult::Static {
274274
bytes,

src/matcher/advanced_rule.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ pub(crate) fn fast_static_match(
291291
});
292292
}
293293
PrecomputedAction::StaticIp { ip } => {
294-
let (rcode, answers) = make_static_ip_answer(qname, ip);
294+
let (rcode, answers) = make_static_ip_answer(qname, qtype, ip);
295295
return Some(Decision::Static { rcode, answers });
296296
}
297297
}

tools/config_editor.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -582,7 +582,7 @@ <h4 class="mb-3">JSON 预览 / 编辑</h4>
582582
</select>
583583

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

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

0 commit comments

Comments
 (0)