Skip to content

Commit 811c528

Browse files
authored
feat: add static CNAME responses (#44)
* feat: add static CNAME responses * docs: clarify static CNAME behavior
1 parent 2da3a2d commit 811c528

9 files changed

Lines changed: 197 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,7 @@ The aliases and-not, andnot, or-not, and ornot are also accepted.
358358
| log | level (optional) | Emits a tracing event for the matched rule. Supported levels are trace, debug, info, warn, and error. |
359359
| static_response | rcode | Returns NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, or REFUSED. |
360360
| 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. |
361+
| static_cname_response | target, ttl (optional) | Returns a CNAME record for every query type that maps the queried name to `target`; ttl defaults to 300. The target is not resolved server-side and its address records are not included, so the client or resolver must follow the alias. Clients that do not follow a bare CNAME may report no data. Invalid owner or target names return SERVFAIL. |
361362
| static_txt_response | text, ttl (optional) | Returns a TXT response. text accepts a string or string array; ttl defaults to 300. |
362363
| jump_to_pipeline | pipeline | Starts processing the referenced pipeline. |
363364
| 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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ Pipeline selector 支持上表全部类型;请求规则支持除 listener_labe
357357
| log | level(可选) | 输出匹配规则的 tracing 事件;支持 trace、debug、info、warn、error。 |
358358
| static_response | rcode | 返回 NOERROR、FORMERR、SERVFAIL、NXDOMAIN、NOTIMP 或 REFUSED。 |
359359
| static_ip_response | ip | 按查询类型返回 A 或 AAAA 记录(ANY 查询返回两者);HTTPS、SVCB 等其他查询类型返回 NODATA,且不会合成 IP hints。`ip` 支持单个地址或逗号分隔的 IPv4/IPv6 地址列表(例如 `192.0.2.1,2001:db8::1`)。地址列表原样返回;列表过大时响应可能超过 UDP 大小限制。 |
360+
| static_cname_response | target、ttl(可选) | 对所有查询类型返回将查询域名映射到 `target` 的 CNAME 记录;ttl 默认 300。服务端不会继续解析目标域名,也不会附带目标地址记录,客户端或解析器必须自行追踪别名;不能追踪裸 CNAME 的客户端可能返回无数据。查询域名或目标域名无效时返回 SERVFAIL。 |
360361
| static_txt_response | text、ttl(可选) | 返回 TXT 响应;text 支持字符串或字符串数组,ttl 默认 300。 |
361362
| jump_to_pipeline | pipeline | 开始处理指定 Pipeline。 |
362363
| allow || 请求阶段:使用全局默认 UDP 上游;响应阶段:保留当前上游响应。 |

src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,14 @@ pub enum Action {
526526
StaticResponse { rcode: String },
527527
/// 返回一个或逗号分隔的多个固定 IPv4/IPv6 地址。 / Return one or more comma-separated static IPv4/IPv6 addresses.
528528
StaticIpResponse { ip: String },
529+
/// 返回固定 CNAME 记录。 / Return a static CNAME record.
530+
StaticCnameResponse {
531+
/// CNAME 目标域名 / Canonical target domain name
532+
target: String,
533+
/// TTL (可选,默认 300) / TTL (optional, default 300)
534+
#[serde(default)]
535+
ttl: Option<u32>,
536+
},
529537
/// 返回固定 TXT 记录。支持单个字符串或字符串数组。 / Return static TXT record. Supports single string or string array.
530538
StaticTxtResponse {
531539
/// TXT 记录内容 / TXT record content

src/engine/execution.rs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,57 @@ mod tests {
15011501
assert_response(RecordType::AAAA, &["2001:db8::1".parse().unwrap()]);
15021502
}
15031503

1504+
#[tokio::test]
1505+
async fn static_cname_fast_path_maps_query_name_to_target() {
1506+
let raw = serde_json::json!({
1507+
"settings": { "default_upstream": "1.1.1.1:53" },
1508+
"pipelines": [{
1509+
"id": "static",
1510+
"rules": [{
1511+
"name": "static-cname",
1512+
"matchers": [{ "type": "domain_suffix", "value": "alias.example" }],
1513+
"actions": [{
1514+
"type": "static_cname_response",
1515+
"target": "origin.example.",
1516+
"ttl": 120
1517+
}]
1518+
}]
1519+
}]
1520+
});
1521+
let cfg: crate::config::PipelineConfig = serde_json::from_value(raw).expect("parse config");
1522+
let runtime = RuntimePipelineConfig::from_config(cfg).expect("runtime config");
1523+
let engine = Engine::new(runtime, "test".to_string()).expect("initialize engine");
1524+
let peer = "127.0.0.1:53000".parse().unwrap();
1525+
1526+
let mut request = Message::new(0xCAFE, MessageType::Query, OpCode::Query);
1527+
request.metadata.recursion_desired = true;
1528+
request.add_query(Query::query(
1529+
Name::from_str("alias.example").unwrap(),
1530+
RecordType::A,
1531+
));
1532+
1533+
let response = match engine
1534+
.handle_packet_fast(&request.to_vec().unwrap(), peer)
1535+
.expect("fast path")
1536+
{
1537+
Some(FastPathResponse::Direct(bytes)) => Message::from_bytes(&bytes).unwrap(),
1538+
other => panic!("expected direct fast-path response, got {other:?}"),
1539+
};
1540+
1541+
assert_eq!(response.metadata.response_code, ResponseCode::NoError);
1542+
assert_eq!(response.queries[0].query_type(), RecordType::A);
1543+
assert_eq!(response.answers.len(), 1);
1544+
assert_eq!(&response.answers[0].name, response.queries[0].name());
1545+
assert_eq!(response.answers[0].record_type(), RecordType::CNAME);
1546+
assert_eq!(response.answers[0].ttl, 120);
1547+
match &response.answers[0].data {
1548+
RData::CNAME(cname) => {
1549+
assert_eq!(cname.0, Name::from_str("origin.example.").unwrap());
1550+
}
1551+
other => panic!("unexpected static CNAME answer: {other:?}"),
1552+
}
1553+
}
1554+
15041555
#[test]
15051556
fn pipeline_select_picks_matching_pipeline() {
15061557
// Arrange: Create configuration with pipeline selection rules
@@ -2076,6 +2127,56 @@ mod tests {
20762127
}
20772128
}
20782129

2130+
#[tokio::test]
2131+
async fn response_actions_static_cname_returns_configured_alias() {
2132+
let engine = build_test_engine();
2133+
let mut req = Message::new(0xCAFE, MessageType::Query, OpCode::Query);
2134+
req.add_query(Query::query(
2135+
Name::from_str("alias.example").unwrap(),
2136+
RecordType::A,
2137+
));
2138+
let actions = [Action::StaticCnameResponse {
2139+
target: "origin.example.".to_string(),
2140+
ttl: Some(60),
2141+
}];
2142+
let response_matchers: Vec<RuntimeResponseMatcherWithOp> = Vec::new();
2143+
let packet = req.to_vec().unwrap();
2144+
let ctx = crate::engine::rules::ApplyResponseActionsContext {
2145+
engine: &engine,
2146+
actions: &actions,
2147+
ctx_opt: None,
2148+
req: &req,
2149+
packet: &packet,
2150+
upstream_timeout: Duration::from_secs(1),
2151+
response_matchers: &response_matchers,
2152+
qname: "alias.example",
2153+
qtype: RecordType::A,
2154+
qclass: DNSClass::IN,
2155+
client_ip: "10.0.0.1".parse().unwrap(),
2156+
upstream_default: TEST_UPSTREAM,
2157+
pipeline_id: "pipeline",
2158+
rule_name: "rule",
2159+
remaining_jumps: 10,
2160+
};
2161+
2162+
match apply_response_actions(ctx).await.expect("static cname") {
2163+
ResponseActionResult::Static { bytes, rcode, .. } => {
2164+
assert_eq!(rcode, ResponseCode::NoError);
2165+
let response = Message::from_bytes(&bytes).unwrap();
2166+
assert_eq!(response.answers.len(), 1);
2167+
assert_eq!(&response.answers[0].name, response.queries[0].name());
2168+
assert_eq!(response.answers[0].ttl, 60);
2169+
match &response.answers[0].data {
2170+
RData::CNAME(cname) => {
2171+
assert_eq!(cname.0, Name::from_str("origin.example.").unwrap());
2172+
}
2173+
other => panic!("unexpected static CNAME answer: {other:?}"),
2174+
}
2175+
}
2176+
_ => panic!("expected static result"),
2177+
}
2178+
}
2179+
20792180
#[tokio::test]
20802181
async fn static_decision_skips_dns_cache_when_pipeline_uses_client_ip() {
20812182
// Arrange: Build engine, request packet, and a static IP answer

src/engine/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub use matcher_adapter::*;
1919
pub use pipeline::select_pipeline;
2020
pub use types::{EngineInner, FastPathResponse};
2121

22-
pub(crate) use response::make_static_ip_answer;
2322
pub use response::{extract_ttl, extract_ttl_for_refresh};
23+
pub(crate) use response::{make_static_cname_answer, make_static_ip_answer};
2424
pub use rules::Decision;
2525
pub use utils::engine_helpers;

src/engine/pipeline.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,11 @@ use crate::matcher::{
1919
};
2020

2121
use super::core::Engine;
22-
use super::make_static_ip_answer;
2322
use super::matcher_adapter::{MatcherContext, matcher_matches};
2423
use super::rules::Decision;
2524
use super::rules::{RuleCacheEntry, calculate_rule_hash, contains_continue, fast_hash_str};
2625
use super::types::EngineInner;
26+
use super::{make_static_cname_answer, make_static_ip_answer};
2727

2828
/// Request and manager references required to select a runtime pipeline.
2929
pub struct PipelineSelectionContext<'a> {
@@ -395,6 +395,19 @@ impl Engine {
395395
);
396396
return d;
397397
}
398+
Action::StaticCnameResponse { target, ttl } => {
399+
let (rcode, answers) =
400+
make_static_cname_answer(qname, target, ttl.unwrap_or(300));
401+
let d = Decision::Static { rcode, answers };
402+
self.insert_rule_cache(
403+
rule_hash,
404+
pipeline.id.clone(),
405+
request,
406+
d.clone(),
407+
include_ip,
408+
);
409+
return d;
410+
}
398411
Action::JumpToPipeline { pipeline: target } => {
399412
let d = Decision::Jump {
400413
pipeline: Arc::from(target.as_str()),

src/engine/response.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use bytes::Bytes;
22
use hickory_proto::op::{Message, MessageType, OpCode, Query, ResponseCode};
33
use hickory_proto::rr::{
44
DNSClass, Name, RData, Record, RecordType,
5-
rdata::{A, AAAA, TXT},
5+
rdata::{A, AAAA, CNAME, TXT},
66
};
77
use hickory_proto::serialize::binary::{BinEncodable, BinEncoder};
88
use std::net::IpAddr;
@@ -77,6 +77,25 @@ pub(crate) fn make_static_ip_answer(
7777
(ResponseCode::NoError, answers)
7878
}
7979

80+
/// 创建静态 CNAME 记录响应 / Create a static CNAME record response
81+
pub(crate) fn make_static_cname_answer(
82+
qname: &str,
83+
target: &str,
84+
ttl: u32,
85+
) -> (ResponseCode, Vec<Record>) {
86+
let target = target.trim();
87+
if target.is_empty() {
88+
return (ResponseCode::ServFail, Vec::new());
89+
}
90+
91+
let (Ok(name), Ok(target)) = (Name::from_str(qname), Name::from_str(target)) else {
92+
return (ResponseCode::ServFail, Vec::new());
93+
};
94+
95+
let record = Record::from_rdata(name, ttl, RData::CNAME(CNAME(target)));
96+
(ResponseCode::NoError, vec![record])
97+
}
98+
8099
/// 创建静态TXT记录响应 / Create static TXT record response
81100
///
82101
/// RFC 1035 TXT记录规范:
@@ -176,6 +195,35 @@ mod tests {
176195
use super::*;
177196
use hickory_proto::rr::rdata::SOA;
178197

198+
#[test]
199+
fn static_cname_answer_uses_configured_target_and_ttl() {
200+
let (rcode, answers) = make_static_cname_answer("alias.example.", "origin.example.", 120);
201+
202+
assert_eq!(rcode, ResponseCode::NoError);
203+
assert_eq!(answers.len(), 1);
204+
assert_eq!(&answers[0].name, &Name::from_str("alias.example.").unwrap());
205+
assert_eq!(answers[0].ttl, 120);
206+
match &answers[0].data {
207+
RData::CNAME(CNAME(target)) => {
208+
assert_eq!(target, &Name::from_str("origin.example.").unwrap());
209+
}
210+
other => panic!("unexpected static CNAME answer: {other:?}"),
211+
}
212+
}
213+
214+
#[test]
215+
fn static_cname_answer_rejects_invalid_names() {
216+
for (qname, target) in [
217+
("invalid name", "origin.example."),
218+
("alias.example.", "invalid name"),
219+
("alias.example.", ""),
220+
] {
221+
let (rcode, answers) = make_static_cname_answer(qname, target, 300);
222+
assert_eq!(rcode, ResponseCode::ServFail);
223+
assert!(answers.is_empty());
224+
}
225+
}
226+
179227
#[test]
180228
fn test_extract_ttl_positive_response() {
181229
let mut msg = Message::new(0, MessageType::Query, OpCode::Query);

src/engine/rules.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use crate::engine::core::Engine;
1717
use crate::engine::matcher_adapter::log_match;
1818
use crate::engine::pipeline::RuleEvaluationContext;
1919
use crate::engine::response::{
20-
extract_ttl, extract_ttl_for_refresh, make_static_ip_answer, make_static_txt_answer,
20+
extract_ttl, extract_ttl_for_refresh, make_static_cname_answer, make_static_ip_answer,
21+
make_static_txt_answer,
2122
};
2223
use crate::engine::types::EngineInner;
2324
use crate::engine::types::InflightMap;
@@ -276,6 +277,16 @@ pub(crate) async fn apply_response_actions(
276277
source: "response_action",
277278
});
278279
}
280+
Action::StaticCnameResponse { target, ttl } => {
281+
let (rcode, answers) =
282+
make_static_cname_answer(ctx.qname, target, ttl.unwrap_or(300));
283+
let bytes = build_response(ctx.req, rcode, answers)?;
284+
return Ok(ResponseActionResult::Static {
285+
bytes,
286+
rcode,
287+
source: "response_action",
288+
});
289+
}
279290
Action::StaticTxtResponse { text, ttl } => {
280291
let ttl = ttl.unwrap_or(300);
281292
let (rcode, answers) = make_static_txt_answer(ctx.qname, text, ttl);

src/matcher/advanced_rule.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use smallvec::SmallVec;
1010

1111
use crate::config::{Action, MatchOperator};
1212
use crate::engine::utils::parse_rcode;
13-
use crate::engine::{Decision, make_static_ip_answer};
13+
use crate::engine::{Decision, make_static_cname_answer, make_static_ip_answer};
1414
use crate::matcher::eval_match_chain;
1515
use crate::matcher::{RuntimeMatcher, RuntimePipeline, RuntimePipelineConfig, RuntimeRule};
1616

@@ -68,6 +68,7 @@ pub enum CompiledMatcher {
6868
pub enum PrecomputedAction {
6969
Static { rcode: ResponseCode },
7070
StaticIp { ip: String },
71+
StaticCname { target: String, ttl: u32 },
7172
}
7273

7374
#[derive(Debug, Clone, Default)]
@@ -254,6 +255,10 @@ fn precompute_action(rule: &RuntimeRule) -> Option<PrecomputedAction> {
254255
parse_rcode(rcode).map(|rc| PrecomputedAction::Static { rcode: rc })
255256
}
256257
Action::StaticIpResponse { ip } => Some(PrecomputedAction::StaticIp { ip: ip.clone() }),
258+
Action::StaticCnameResponse { target, ttl } => Some(PrecomputedAction::StaticCname {
259+
target: target.clone(),
260+
ttl: ttl.unwrap_or(300),
261+
}),
257262
Action::Deny => Some(PrecomputedAction::Static {
258263
rcode: ResponseCode::Refused,
259264
}),
@@ -294,6 +299,10 @@ pub(crate) fn fast_static_match(
294299
let (rcode, answers) = make_static_ip_answer(qname, qtype, ip);
295300
return Some(Decision::Static { rcode, answers });
296301
}
302+
PrecomputedAction::StaticCname { target, ttl } => {
303+
let (rcode, answers) = make_static_cname_answer(qname, target, *ttl);
304+
return Some(Decision::Static { rcode, answers });
305+
}
297306
}
298307
} else {
299308
// 第一个匹配的规则不可预计算(如 Forward、Jump 等)

0 commit comments

Comments
 (0)