1use crate::llm::actions::{
4 protocol_trait::{ActionResult, Protocol, Server},
5 ActionDefinition, Parameter, ParameterDefinition,
6};
7use crate::protocol::log_template::LogTemplate;
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use hickory_proto::op::{Header, Message as DnsMessage, MessageType, OpCode, Query, ResponseCode};
12use hickory_proto::rr::{rdata, Name, RData, Record, RecordType};
13use serde_json::json;
14use std::net::{Ipv4Addr, Ipv6Addr};
15use std::str::FromStr;
16use std::sync::LazyLock;
17
18pub const MAX_CHARACTER_STRING_LEN: usize = 255;
33
34pub const MAX_ANSWER_RECORDS: usize = 100;
36
37pub const RESULT_ZONE: &str = "dns_zone";
41pub const RESULT_IXFR: &str = "dns_ixfr";
42
43pub struct DnsProtocol;
45
46impl Default for DnsProtocol {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl DnsProtocol {
53 pub fn new() -> Self {
54 Self
55 }
56
57 pub fn query_actions() -> Vec<ActionDefinition> {
60 vec![
61 send_dns_a_response_action(),
62 send_dns_aaaa_response_action(),
63 send_dns_cname_response_action(),
64 send_dns_mx_response_action(),
65 send_dns_txt_response_action(),
66 send_dns_nxdomain_action(),
67 send_dns_records_action(),
68 send_dns_refused_action(),
69 send_dns_response_action(),
70 ignore_query_action(),
71 ]
72 }
73}
74
75impl Protocol for DnsProtocol {
77 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
78 Vec::new() }
80 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
81 let mut actions = Self::query_actions();
82 actions.push(send_dns_zone_action());
83 actions.push(send_dns_ixfr_action());
84 actions
85 }
86
87 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
88 vec![
89 ParameterDefinition {
90 name: "dnssec_zone".to_string(),
91 type_hint: "string".to_string(),
92 description: "Sign this zone with DNSSEC, e.g. \"example.com.\". The server \
93 generates an ECDSA P-256 key at startup, answers the zone's DNSKEY query \
94 itself, signs every answer under the zone for a client that sets the DO \
95 bit, proves non-existence with NSEC, and signs zone transfers with a full \
96 NSEC chain. Omitted, nothing is signed."
97 .to_string(),
98 required: false,
99 example: json!("example.com."),
100 default: None,
101 },
102 ParameterDefinition {
103 name: "tcp_idle_timeout_secs".to_string(),
104 type_hint: "number".to_string(),
105 description: "Seconds a DNS-over-TCP connection may sit idle between queries \
106 before the server closes it (RFC 7766 ยง6.2.3). 1-300."
107 .to_string(),
108 required: false,
109 example: json!(10),
110 default: Some(json!(super::DEFAULT_TCP_IDLE_TIMEOUT.as_secs())),
111 },
112 ParameterDefinition {
113 name: "tcp_connections_per_ip".to_string(),
114 type_hint: "number".to_string(),
115 description: "DNS-over-TCP connections one client address may hold open at \
116 once (IPv6 counted per /64); past it a new connection from that address is \
117 closed unread. 1-64, the server's cap on all TCP connections."
118 .to_string(),
119 required: false,
120 example: json!(8),
121 default: Some(json!(super::MAX_TCP_CONNECTIONS_PER_IP)),
122 },
123 ]
124 }
125 fn protocol_name(&self) -> &'static str {
126 "DNS"
127 }
128 fn get_event_types(&self) -> Vec<EventType> {
129 get_dns_event_types()
130 }
131 fn stack_name(&self) -> &'static str {
132 "ETH>IP>UDP>DNS"
133 }
134 fn keywords(&self) -> Vec<&'static str> {
135 vec!["dns"]
136 }
137 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
138 use crate::protocol::metadata::{
139 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
140 };
141
142 ProtocolMetadataV2::builder()
143 .connectionless()
144 .answers_on_failure()
146 .state(DevelopmentState::Stable)
151 .privilege_requirement(PrivilegeRequirement::PrivilegedPort(53))
152 .well_known_udp_port(53)
153 .implementation(
154 "hickory-proto for parsing and construction; UDP and TCP (RFC 7766) on the same \
155 port; EDNS0 with the TC bit; AXFR (RFC 5936) and IXFR (RFC 1995) zone \
156 transfers; DNSSEC signing of one zone (RFC 4033-4035) with a key generated at \
157 startup, compact NSEC denial in answers and a full NSEC chain in transfers",
158 )
159 .llm_control(
160 "Response records (A, AAAA, MX, TXT, CNAME, NS, PTR, SRV, SOA, NXDOMAIN, \
161 REFUSED), and whole zones or their changes for a transfer. Keys, signatures, \
162 NSEC, EDNS and truncation are server-side",
163 )
164 .e2e_testing(
165 "STABLE rests on all six conditions, each checked against source and by \
166 running it on 16 September 2026. (1) TWO independent resolvers from different \
167 projects, neither #[ignore]d and neither able to skip -- each FAILS naming its \
168 package when absent: dig_test.rs drives ISC BIND's dig and kdig_test.rs drives \
169 Knot DNS's kdig (CZ.NIC). Between them they decode EVERY record type this \
170 server can produce -- A, AAAA, CNAME, MX (preference 4660 = 0x1234, so a \
171 byte-swap reads 13330 rather than something plausible), the TXT \
172 character-string, and RCODE 3 for NXDOMAIN -- each through that resolver's own \
173 presentation writer, and each asserts no transaction-id mismatch. Verified by \
174 answering with a fixed id instead of the client's, at which point kdig \
175 discards the reply. dig_test.rs additionally runs ONE query as a person would \
176 actually invoke it, with EDNS offered and no flags, and it resolves: RFC 6891 \
177 ยง6.1.1 says a server that does not understand EDNS answers without an OPT \
178 record and the requestor treats it as non-EDNS. Without that query the whole \
179 rating would rest on a +noedns configuration nobody uses -- the \
180 mysql_native_password situation. (2) The pcap oracle reads both SERVFAIL paths \
181 in llm_failure_test.rs and a NOERROR answer with A rdata in bounds_test.rs, \
182 and hard-fails when tshark is missing. (3) fuzz/fuzz_targets/dns_message.rs: \
183 2,777,883 runs in 91s, clean; the corpus includes compression_pointer_loop, \
184 which is the depth bomb for a format whose recursion is the compression \
185 pointer. It fuzzes hickory-proto rather than NetGet code because NetGet has no \
186 DNS parser -- three registered protocols reach that decoder from an \
187 unauthenticated first datagram. (4) Every declared bound has a test in \
188 bounds_test.rs -- the 4096-byte receive buffer, query_id and MX preference as \
189 u16, the 12-octet floor on a raw message, the 255-octet character-string -- \
190 each verified by REMOVING the bound and recording what failed. (5) Both \
191 CLAUDE.md files were re-read against source in that pass; several claims were \
192 false and the corrections are in them. (6) No #[ignore] and no skip gate \
193 anywhere in tests/server/dns/. \
194 \
195 tests/server/dns/test.rs covers the same ground with hickory-client -- useful, \
196 but circular on its own, since hickory-client decodes with the same codec \
197 hickory-proto encoded with. It is not part of the evidence above. \
198 \
199 TCP, EDNS0, ZONE TRANSFERS AND DNSSEC, added October 2026, carry the six \
200 conditions on their own surface. (1) dig and kdig each transfer a 611-record \
201 zone in two TCP messages (transfer_test.rs), apply an IXFR difference and read \
202 the single-SOA current answer, see the TC bit and retry over TCP, read the \
203 OPT record, and decode NS, SOA, SRV and PTR from send_dns_records; delv and \
204 drill each validate a signed positive answer, and delv a compact-denial \
205 negative one and a NODATA (dnssec_test.rs), against a trust anchor built from \
206 the DNSKEY the server serves; ldns-verify-zone and BIND's dnssec-verify each \
207 verify the whole signed zone received by AXFR, signatures and NSEC chain. \
208 Every one of those tools fails rather than skips when absent. (2) The pcap \
209 oracle reads the UDP and TCP exchanges of transport_bounds_test.rs, truncated \
210 answers, transfers and refusals included. (3) dns_message.rs also reads the OPT \
211 record and the authority SOA every query now feeds the server, with two new \
212 seeds. (4) The TCP connection cap, the TCP idle timeout, the 512/EDNS/4096 UDP \
213 limits, 100 answer records, 10,000 zone records and the transfer message size \
214 each have a test; the TCP cap, the UDP cap, the answer signatures and the NSEC \
215 chain were each removed and a test failed. \
216 \
217 UNPROVEN, because not implemented: NSEC3, more than one signed zone, a \
218 separate ZSK and KSK (one key acts as both; dnssec-verify is run with -z), \
219 delegations in a signed zone (refused), DNS COOKIEs, and TSIG on transfers.",
220 )
221 .notes(
222 "Stable means the evidence for the implemented surface is complete, NOT that \
223 RFC 1035 is. Every action the model is offered is decoded by two \
224 independent resolvers; DNSSEC is validated by two validators and two zone \
225 verifiers. What is missing is listed in e2e_testing and in \
226 src/server/dns/AGENTS.md. \
227 A query that produces no answer fails closed with SERVFAIL through one exit, \
228 `send_servfail`, tagged decision=fail_closed_llm_error / _llm_overload / \
229 _no_action; `ignore_query` is decision=model_silent and is the only path that \
230 writes nothing. The third of those did not exist before September 2026 and its \
231 absence was a fail-silent: a model answering with only common actions, or with \
232 a DNS action execute_action refused, produced no packet at all. \
233 Excellent scripting candidate; static handlers cannot echo the client's \
234 transaction ID, so use script mode for deterministic answers. \
235 Re-check rather than inherit: no CI job builds fuzz/ (it is its own \
236 workspace), and dig and kdig must be on PATH wherever the suite runs, since \
237 both tests hard-fail by design.",
238 )
239 .build()
240 }
241 fn description(&self) -> &'static str {
242 "Domain name resolution server"
243 }
244 fn example_prompt(&self) -> &'static str {
245 "DNS server on port 53 and resolve everything to 93.184.216.34"
246 }
247 fn group_name(&self) -> &'static str {
248 "Core"
249 }
250
251 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
252 use crate::llm::actions::StartupExamples;
253 use serde_json::json;
254
255 let script = r#"import json, sys
260data = json.load(sys.stdin)
261event = data["event"]
262if data["event_type_id"] == "dns_query":
263 domain = event.get("domain", "").rstrip(".")
264 if domain.endswith("example.com"):
265 actions = [{"type": "send_dns_a_response", "query_id": event["query_id"],
266 "domain": event["domain"], "ip": "93.184.216.34", "ttl": 300}]
267 else:
268 actions = [{"type": "send_dns_nxdomain", "query_id": event["query_id"],
269 "domain": event["domain"],
270 "query_type": event.get("query_type", "A")}]
271else:
272 actions = []
273print(json.dumps({"actions": actions}))"#;
274
275 StartupExamples::new(
276 json!({
278 "type": "open_server",
279 "port": 53,
280 "base_stack": "dns",
281 "instruction": "Act as a DNS resolver that returns a different IP per query: map each queried hostname to a stable address in 10.0.0.0/8 derived from the name, but return the real public IP for well-known names such as www.example.com. Always echo the query's transaction id."
282 }),
283 json!({
286 "type": "open_server",
287 "port": 53,
288 "base_stack": "dns",
289 "event_handlers": [{
290 "event_pattern": "dns_query",
291 "handler": {
292 "type": "script",
293 "language": "python",
294 "code": script
295 }
296 }]
297 }),
298 json!({
305 "type": "open_server",
306 "port": 53,
307 "base_stack": "dns",
308 "event_handlers": [{
309 "event_pattern": "dns_query",
310 "handler": {
311 "type": "static",
312 "actions": [{
313 "type": "ignore_query"
314 }]
315 }
316 }]
317 }),
318 )
319 }
320}
321
322impl Server for DnsProtocol {
324 fn spawn(
325 &self,
326 ctx: crate::protocol::SpawnContext,
327 ) -> std::pin::Pin<
328 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
329 > {
330 Box::pin(async move {
331 use crate::server::dns::{DnsServer, DnsSettings};
332 let params = ctx.startup_params.as_ref();
333 let dnssec_zone = params
334 .map(|p| p.get_optional_string("dnssec_zone"))
335 .transpose()?
336 .flatten();
337 let idle = params
338 .map(|p| p.get_optional_u64("tcp_idle_timeout_secs"))
339 .transpose()?
340 .flatten()
341 .unwrap_or(super::DEFAULT_TCP_IDLE_TIMEOUT.as_secs());
342 if !(1..=300).contains(&idle) {
343 anyhow::bail!("tcp_idle_timeout_secs must be between 1 and 300, got {idle}");
344 }
345 let per_ip = params
346 .map(|p| p.get_optional_u64("tcp_connections_per_ip"))
347 .transpose()?
348 .flatten()
349 .unwrap_or(super::MAX_TCP_CONNECTIONS_PER_IP as u64);
350 if !(1..=super::MAX_TCP_CONNECTIONS as u64).contains(&per_ip) {
351 anyhow::bail!(
352 "tcp_connections_per_ip must be between 1 and {}, got {per_ip}",
353 super::MAX_TCP_CONNECTIONS
354 );
355 }
356 DnsServer::spawn_with_llm_actions(
357 ctx.legacy_listen_addr(),
358 ctx.llm_client,
359 ctx.state,
360 ctx.status_tx,
361 ctx.server_id,
362 DnsSettings {
363 dnssec_zone,
364 tcp_idle_timeout: std::time::Duration::from_secs(idle),
365 tcp_connections_per_ip: per_ip as usize,
366 },
367 )
368 .await
369 })
370 }
371 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
372 let action_type = action
373 .get("type")
374 .and_then(|v| v.as_str())
375 .context("Missing 'type' field in action")?;
376
377 match action_type {
378 "send_dns_a_response" => self.execute_send_dns_a_response(action),
379 "send_dns_aaaa_response" => self.execute_send_dns_aaaa_response(action),
380 "send_dns_cname_response" => self.execute_send_dns_cname_response(action),
381 "send_dns_mx_response" => self.execute_send_dns_mx_response(action),
382 "send_dns_txt_response" => self.execute_send_dns_txt_response(action),
383 "send_dns_nxdomain" => self.execute_send_dns_nxdomain(action),
384 "send_dns_response" => self.execute_send_dns_response(action),
385 "send_dns_records" => execute_send_dns_records(&action),
386 "send_dns_refused" => execute_send_dns_refused(&action),
387 "send_dns_zone" => {
388 super::transfer::zone_from_action(&action)?;
389 Ok(ActionResult::Custom {
390 name: RESULT_ZONE.to_string(),
391 data: action,
392 })
393 }
394 "send_dns_ixfr" => {
395 super::transfer::ixfr_from_action(&action, 0)?;
398 Ok(ActionResult::Custom {
399 name: RESULT_IXFR.to_string(),
400 data: action,
401 })
402 }
403 "ignore_query" => Ok(ActionResult::NoAction),
404 _ => Err(anyhow::anyhow!("Unknown DNS action: {}", action_type)),
405 }
406 }
407}
408
409fn parse_query_id(action: &serde_json::Value) -> Result<u16> {
416 let raw = action
417 .get("query_id")
418 .and_then(|v| v.as_u64())
419 .context("Missing 'query_id' parameter (echo the query_id from the dns_query event)")?;
420
421 u16::try_from(raw).map_err(|_| {
422 anyhow::anyhow!(
423 "'query_id' must be a 16-bit DNS transaction ID (0-65535), got {}. \
424 Echo the query_id from the dns_query event verbatim.",
425 raw
426 )
427 })
428}
429
430fn parse_record_type(name: &str) -> Result<RecordType> {
432 RecordType::from_str(&name.to_ascii_uppercase())
433 .with_context(|| format!("Unknown DNS record type: {name}"))
434}
435
436fn new_response(
445 query_id: u16,
446 domain: &str,
447 query_type: RecordType,
448 response_code: ResponseCode,
449) -> Result<(DnsMessage, Name)> {
450 let name =
451 Name::from_str(domain).with_context(|| format!("Invalid domain name: '{domain}'"))?;
452
453 let mut message = DnsMessage::new();
454 let mut header = Header::new();
455 header.set_id(query_id);
456 header.set_message_type(MessageType::Response);
457 header.set_op_code(OpCode::Query);
458 header.set_authoritative(true);
459 header.set_response_code(response_code);
460 message.set_header(header);
461
462 message.add_query(Query::query(name.clone(), query_type));
464
465 Ok((message, name))
466}
467
468pub fn build_servfail(query: &DnsMessage) -> Result<Vec<u8>> {
485 let mut message = DnsMessage::new();
486 let mut header = Header::new();
487 header.set_id(query.id());
488 header.set_message_type(MessageType::Response);
489 header.set_op_code(query.op_code());
490 header.set_recursion_desired(query.recursion_desired());
491 header.set_response_code(ResponseCode::ServFail);
492 message.set_header(header);
493
494 for question in query.queries() {
495 message.add_query(question.clone());
496 }
497
498 message
499 .to_vec()
500 .context("Failed to serialize DNS SERVFAIL response")
501}
502
503fn finish_response(message: DnsMessage) -> Result<ActionResult> {
505 let bytes = message
506 .to_vec()
507 .context("Failed to serialize DNS message")?;
508 Ok(ActionResult::Output(bytes))
509}
510
511fn ttl_of(action: &serde_json::Value) -> u32 {
512 action
513 .get("ttl")
514 .and_then(|v| v.as_u64())
515 .unwrap_or(300)
516 .min(u32::MAX as u64) as u32
517}
518
519fn required_str<'a>(action: &'a serde_json::Value, key: &str) -> Result<&'a str> {
520 action
521 .get(key)
522 .and_then(|v| v.as_str())
523 .with_context(|| format!("Missing '{key}' parameter"))
524}
525
526impl DnsProtocol {
527 fn execute_send_dns_a_response(&self, action: serde_json::Value) -> Result<ActionResult> {
528 let query_id = parse_query_id(&action)?;
529 let domain = required_str(&action, "domain")?;
530 let ip = required_str(&action, "ip")?;
531 let ttl = ttl_of(&action);
532
533 let ipv4 =
534 Ipv4Addr::from_str(ip).with_context(|| format!("Invalid IPv4 address: '{ip}'"))?;
535
536 let (mut message, name) =
537 new_response(query_id, domain, RecordType::A, ResponseCode::NoError)?;
538
539 let mut record = Record::with(name, RecordType::A, ttl);
540 record.set_data(Some(RData::A(rdata::A(ipv4))));
541 message.add_answer(record);
542
543 finish_response(message)
544 }
545
546 fn execute_send_dns_aaaa_response(&self, action: serde_json::Value) -> Result<ActionResult> {
547 let query_id = parse_query_id(&action)?;
548 let domain = required_str(&action, "domain")?;
549 let ip = required_str(&action, "ip")?;
550 let ttl = ttl_of(&action);
551
552 let ipv6 =
553 Ipv6Addr::from_str(ip).with_context(|| format!("Invalid IPv6 address: '{ip}'"))?;
554
555 let (mut message, name) =
556 new_response(query_id, domain, RecordType::AAAA, ResponseCode::NoError)?;
557
558 let mut record = Record::with(name, RecordType::AAAA, ttl);
559 record.set_data(Some(RData::AAAA(rdata::AAAA(ipv6))));
560 message.add_answer(record);
561
562 finish_response(message)
563 }
564
565 fn execute_send_dns_cname_response(&self, action: serde_json::Value) -> Result<ActionResult> {
566 let query_id = parse_query_id(&action)?;
567 let domain = required_str(&action, "domain")?;
568 let target = required_str(&action, "target")?;
569 let ttl = ttl_of(&action);
570
571 let target_name = Name::from_str(target)
572 .with_context(|| format!("Invalid target domain name: '{target}'"))?;
573
574 let query_type = match action.get("query_type").and_then(|v| v.as_str()) {
577 Some(qt) => parse_record_type(qt)?,
578 None => RecordType::CNAME,
579 };
580
581 let (mut message, name) =
582 new_response(query_id, domain, query_type, ResponseCode::NoError)?;
583
584 let mut record = Record::with(name, RecordType::CNAME, ttl);
585 record.set_data(Some(RData::CNAME(rdata::CNAME(target_name))));
586 message.add_answer(record);
587
588 finish_response(message)
589 }
590
591 fn execute_send_dns_mx_response(&self, action: serde_json::Value) -> Result<ActionResult> {
592 let query_id = parse_query_id(&action)?;
593 let domain = required_str(&action, "domain")?;
594 let exchange = required_str(&action, "exchange")?;
595 let ttl = ttl_of(&action);
596
597 let preference_raw = action
598 .get("preference")
599 .and_then(|v| v.as_u64())
600 .unwrap_or(10);
601 let preference = u16::try_from(preference_raw).map_err(|_| {
602 anyhow::anyhow!("'preference' must be in the range 0-65535, got {preference_raw}")
603 })?;
604
605 let exchange_name = Name::from_str(exchange)
606 .with_context(|| format!("Invalid exchange domain name: '{exchange}'"))?;
607
608 let (mut message, name) =
609 new_response(query_id, domain, RecordType::MX, ResponseCode::NoError)?;
610
611 let mut record = Record::with(name, RecordType::MX, ttl);
612 record.set_data(Some(RData::MX(rdata::MX::new(preference, exchange_name))));
613 message.add_answer(record);
614
615 finish_response(message)
616 }
617
618 fn execute_send_dns_txt_response(&self, action: serde_json::Value) -> Result<ActionResult> {
619 let query_id = parse_query_id(&action)?;
620 let domain = required_str(&action, "domain")?;
621 let text = required_str(&action, "text")?;
622 let ttl = ttl_of(&action);
623
624 if text.len() > MAX_CHARACTER_STRING_LEN {
625 anyhow::bail!(
626 "'text' is {} octets, over the {}-octet limit. RFC 1035 ยง3.3 defines a DNS \
627 <character-string> as one length octet followed by that many characters, so \
628 {} is what a single length octet can describe and this server emits the TXT \
629 record as one string. Shorten it.",
630 text.len(),
631 MAX_CHARACTER_STRING_LEN,
632 MAX_CHARACTER_STRING_LEN
633 );
634 }
635
636 let (mut message, name) =
637 new_response(query_id, domain, RecordType::TXT, ResponseCode::NoError)?;
638
639 let mut record = Record::with(name, RecordType::TXT, ttl);
640 record.set_data(Some(RData::TXT(rdata::TXT::new(vec![text.to_string()]))));
641 message.add_answer(record);
642
643 finish_response(message)
644 }
645
646 fn execute_send_dns_nxdomain(&self, action: serde_json::Value) -> Result<ActionResult> {
647 let query_id = parse_query_id(&action)?;
648 let domain = required_str(&action, "domain")?;
649
650 let query_type = match action.get("query_type").and_then(|v| v.as_str()) {
653 Some(qt) => parse_record_type(qt)?,
654 None => RecordType::A,
655 };
656
657 let (message, _name) = new_response(query_id, domain, query_type, ResponseCode::NXDomain)?;
658
659 finish_response(message)
660 }
661
662 fn execute_send_dns_response(&self, action: serde_json::Value) -> Result<ActionResult> {
663 let data = required_str(&action, "data")?;
664
665 let bytes = hex::decode(data.trim()).map_err(|e| {
669 anyhow::anyhow!(
670 "'data' must be a hex-encoded DNS message ({e}). \
671 Prefer the structured actions (send_dns_a_response, send_dns_nxdomain, ...) \
672 which build the wire format for you."
673 )
674 })?;
675
676 if bytes.len() < 12 {
677 anyhow::bail!(
678 "'data' decoded to {} bytes; a DNS message needs at least a 12-byte header",
679 bytes.len()
680 );
681 }
682
683 Ok(ActionResult::Output(bytes))
684 }
685}
686
687fn parse_rcode(name: &str) -> Result<ResponseCode> {
689 Ok(match name.to_ascii_uppercase().as_str() {
690 "NOERROR" => ResponseCode::NoError,
691 "NXDOMAIN" => ResponseCode::NXDomain,
692 "REFUSED" => ResponseCode::Refused,
693 "SERVFAIL" => ResponseCode::ServFail,
694 "NOTIMP" => ResponseCode::NotImp,
695 "FORMERR" => ResponseCode::FormErr,
696 other => anyhow::bail!(
697 "'rcode' {other:?} is not one of NOERROR, NXDOMAIN, REFUSED, SERVFAIL, NOTIMP, FORMERR"
698 ),
699 })
700}
701
702fn execute_send_dns_records(action: &serde_json::Value) -> Result<ActionResult> {
704 let query_id = parse_query_id(action)?;
705 let domain = required_str(action, "domain")?;
706 let query_type = parse_record_type(required_str(action, "query_type")?)?;
707 let rcode = match action.get("rcode").and_then(|v| v.as_str()) {
708 Some(r) => parse_rcode(r)?,
709 None => ResponseCode::NoError,
710 };
711 let (mut message, name) = new_response(query_id, domain, query_type, rcode)?;
712 let mut total = 0;
713 for (section, key) in [(0, "answers"), (1, "authority"), (2, "additional")] {
714 let default_name = (section == 0).then_some(&name);
715 let records = super::records::records_from_json(
716 action.get(key),
717 None,
718 default_name,
719 key,
720 MAX_ANSWER_RECORDS,
721 )?;
722 total += records.len();
723 if total > MAX_ANSWER_RECORDS {
724 anyhow::bail!("the answer holds more than {MAX_ANSWER_RECORDS} records");
725 }
726 for r in records {
727 match section {
728 0 => message.add_answer(r),
729 1 => message.add_name_server(r),
730 _ => message.add_additional(r),
731 };
732 }
733 }
734 finish_response(message)
735}
736
737fn execute_send_dns_refused(action: &serde_json::Value) -> Result<ActionResult> {
740 let query_id = parse_query_id(action)?;
741 let domain = required_str(action, "domain")?;
742 let query_type = match action.get("query_type").and_then(|v| v.as_str()) {
743 Some(qt) => parse_record_type(qt)?,
744 None => RecordType::A,
745 };
746 let (message, _) = new_response(query_id, domain, query_type, ResponseCode::Refused)?;
747 finish_response(message)
748}
749
750fn send_dns_a_response_action() -> ActionDefinition {
751 ActionDefinition {
752 name: "send_dns_a_response".to_string(),
753 description: "Answer a query whose query_type is A with an IPv4 address. Only for query_type A: a TXT query is answered with send_dns_txt_response, AAAA with send_dns_aaaa_response, MX with send_dns_mx_response. This is the correct DNS-specific action - do NOT use generic 'send_data' or 'show_message' actions for DNS responses. Always include the query_id from the request, the domain name, and the IPv4 address to return.".to_string(),
754 parameters: vec![
755 Parameter {
756 name: "query_id".to_string(),
757 type_hint: "number".to_string(),
758 description: "DNS query ID from the request (MUST match the request)".to_string(),
759 required: true,
760 },
761 Parameter {
762 name: "domain".to_string(),
763 type_hint: "string".to_string(),
764 description: "Domain name being queried (e.g., 'example.com')".to_string(),
765 required: true,
766 },
767 Parameter {
768 name: "ip".to_string(),
769 type_hint: "string".to_string(),
770 description: "IPv4 address to return (e.g., '192.0.2.1' or '93.184.216.34')".to_string(),
771 required: true,
772 },
773 Parameter {
774 name: "ttl".to_string(),
775 type_hint: "number".to_string(),
776 description: "Time-to-live in seconds (how long clients should cache this response). Default: 300".to_string(),
777 required: false,
778 },
779 ],
780 example: json!({
781 "type": "send_dns_a_response",
782 "query_id": 12345,
783 "domain": "example.com",
784 "ip": "192.0.2.1",
785 "ttl": 300
786 }),
787 log_template: Some(
788 LogTemplate::new()
789 .with_info("DNS A {domain} -> {ip}")
790 .with_debug("DNS A response: {domain} -> {ip} (TTL={ttl})")
791 .with_trace("DNS A response: {json_pretty(.)}"),
792 ),
793 }
794}
795
796fn send_dns_aaaa_response_action() -> ActionDefinition {
797 ActionDefinition {
798 name: "send_dns_aaaa_response".to_string(),
799 description: "Send DNS AAAA record response (IPv6 address)".to_string(),
800 parameters: vec![
801 Parameter {
802 name: "query_id".to_string(),
803 type_hint: "number".to_string(),
804 description: "DNS query ID from the request".to_string(),
805 required: true,
806 },
807 Parameter {
808 name: "domain".to_string(),
809 type_hint: "string".to_string(),
810 description: "Domain name being queried (e.g., 'example.com')".to_string(),
811 required: true,
812 },
813 Parameter {
814 name: "ip".to_string(),
815 type_hint: "string".to_string(),
816 description: "IPv6 address to return (e.g., '2001:db8::1')".to_string(),
817 required: true,
818 },
819 Parameter {
820 name: "ttl".to_string(),
821 type_hint: "number".to_string(),
822 description: "Time-to-live in seconds. Default: 300".to_string(),
823 required: false,
824 },
825 ],
826 example: json!({
827 "type": "send_dns_aaaa_response",
828 "query_id": 12345,
829 "domain": "example.com",
830 "ip": "2001:db8::1",
831 "ttl": 300
832 }),
833 log_template: Some(
834 LogTemplate::new()
835 .with_info("DNS AAAA {domain} -> {ip}")
836 .with_debug("DNS AAAA response: {domain} -> {ip} (TTL={ttl})"),
837 ),
838 }
839}
840
841fn send_dns_cname_response_action() -> ActionDefinition {
842 ActionDefinition {
843 name: "send_dns_cname_response".to_string(),
844 description: "Send DNS CNAME record response (canonical name/alias)".to_string(),
845 parameters: vec![
846 Parameter {
847 name: "query_id".to_string(),
848 type_hint: "number".to_string(),
849 description: "DNS query ID from the request".to_string(),
850 required: true,
851 },
852 Parameter {
853 name: "domain".to_string(),
854 type_hint: "string".to_string(),
855 description: "Domain name being queried (e.g., 'www.example.com')".to_string(),
856 required: true,
857 },
858 Parameter {
859 name: "target".to_string(),
860 type_hint: "string".to_string(),
861 description: "Target domain name (e.g., 'example.com')".to_string(),
862 required: true,
863 },
864 Parameter {
865 name: "query_type".to_string(),
866 type_hint: "string".to_string(),
867 description: "Record type the client actually asked for (A, AAAA, ...). Echoed back in the question section. Default: CNAME".to_string(),
868 required: false,
869 },
870 Parameter {
871 name: "ttl".to_string(),
872 type_hint: "number".to_string(),
873 description: "Time-to-live in seconds. Default: 300".to_string(),
874 required: false,
875 },
876 ],
877 example: json!({
878 "type": "send_dns_cname_response",
879 "query_id": 12345,
880 "domain": "www.example.com",
881 "target": "example.com",
882 "ttl": 300
883 }),
884 log_template: Some(
885 LogTemplate::new()
886 .with_info("DNS CNAME {domain} -> {target}")
887 .with_debug("DNS CNAME response: {domain} -> {target} (TTL={ttl})"),
888 ),
889 }
890}
891
892fn send_dns_mx_response_action() -> ActionDefinition {
893 ActionDefinition {
894 name: "send_dns_mx_response".to_string(),
895 description: "Send DNS MX record response (mail exchange)".to_string(),
896 parameters: vec![
897 Parameter {
898 name: "query_id".to_string(),
899 type_hint: "number".to_string(),
900 description: "DNS query ID from the request".to_string(),
901 required: true,
902 },
903 Parameter {
904 name: "domain".to_string(),
905 type_hint: "string".to_string(),
906 description: "Domain name being queried (e.g., 'example.com')".to_string(),
907 required: true,
908 },
909 Parameter {
910 name: "exchange".to_string(),
911 type_hint: "string".to_string(),
912 description: "Mail server domain (e.g., 'mail.example.com')".to_string(),
913 required: true,
914 },
915 Parameter {
916 name: "preference".to_string(),
917 type_hint: "number".to_string(),
918 description: "MX preference (priority, lower = higher priority). Default: 10"
919 .to_string(),
920 required: false,
921 },
922 Parameter {
923 name: "ttl".to_string(),
924 type_hint: "number".to_string(),
925 description: "Time-to-live in seconds. Default: 300".to_string(),
926 required: false,
927 },
928 ],
929 example: json!({
930 "type": "send_dns_mx_response",
931 "query_id": 12345,
932 "domain": "example.com",
933 "exchange": "mail.example.com",
934 "preference": 10,
935 "ttl": 300
936 }),
937 log_template: Some(
938 LogTemplate::new()
939 .with_info("DNS MX {domain} -> {exchange} (pref={preference})")
940 .with_debug(
941 "DNS MX response: {domain} -> {exchange} (pref={preference}, TTL={ttl})",
942 ),
943 ),
944 }
945}
946
947fn send_dns_txt_response_action() -> ActionDefinition {
948 ActionDefinition {
949 name: "send_dns_txt_response".to_string(),
950 description: "Answer a query whose query_type is TXT with a text record - the only action for TXT queries. A TXT question asks for text, never for an address: when the instruction says to answer text queries with some text, this carries that text.".to_string(),
951 parameters: vec![
952 Parameter {
953 name: "query_id".to_string(),
954 type_hint: "number".to_string(),
955 description: "DNS query ID from the request".to_string(),
956 required: true,
957 },
958 Parameter {
959 name: "domain".to_string(),
960 type_hint: "string".to_string(),
961 description: "Domain name being queried (e.g., 'example.com')".to_string(),
962 required: true,
963 },
964 Parameter {
965 name: "text".to_string(),
966 type_hint: "string".to_string(),
967 description: "Text data to return. At most 255 octets: it is emitted as a \
968 single DNS <character-string>, whose length is one octet (RFC 1035 \
969 ยง3.3). Longer text is refused rather than split."
970 .to_string(),
971 required: true,
972 },
973 Parameter {
974 name: "ttl".to_string(),
975 type_hint: "number".to_string(),
976 description: "Time-to-live in seconds. Default: 300".to_string(),
977 required: false,
978 },
979 ],
980 example: json!({
981 "type": "send_dns_txt_response",
982 "query_id": 12345,
983 "domain": "example.com",
984 "text": "v=spf1 include:_spf.example.com ~all",
985 "ttl": 300
986 }),
987 log_template: Some(
988 LogTemplate::new()
989 .with_info("DNS TXT {domain}")
990 .with_debug("DNS TXT response: {domain} -> \"{text}\" (TTL={ttl})"),
991 ),
992 }
993}
994
995fn send_dns_nxdomain_action() -> ActionDefinition {
996 ActionDefinition {
997 name: "send_dns_nxdomain".to_string(),
998 description: "IMPORTANT: Use this action to respond when a DNS domain does not exist. This is the correct DNS-specific action for NXDOMAIN responses - do NOT use generic error actions. This tells the client that the requested domain name does not exist in the DNS system.".to_string(),
999 parameters: vec![
1000 Parameter {
1001 name: "query_id".to_string(),
1002 type_hint: "number".to_string(),
1003 description: "DNS query ID from the request (MUST match the request)".to_string(),
1004 required: true,
1005 },
1006 Parameter {
1007 name: "domain".to_string(),
1008 type_hint: "string".to_string(),
1009 description: "Domain name being queried (the nonexistent domain)".to_string(),
1010 required: true,
1011 },
1012 Parameter {
1013 name: "query_type".to_string(),
1014 type_hint: "string".to_string(),
1015 description: "Record type from the request (A, AAAA, MX, TXT, ...). Echoed back in the question section so the client can match the response to its query. Default: A".to_string(),
1016 required: false,
1017 },
1018 ],
1019 example: json!({
1020 "type": "send_dns_nxdomain",
1021 "query_id": 12345,
1022 "domain": "nonexistent.example.com",
1023 "query_type": "A"
1024 }),
1025 log_template: Some(
1026 LogTemplate::new()
1027 .with_info("DNS NXDOMAIN {domain}")
1028 .with_debug("DNS NXDOMAIN response: {domain}"),
1029 ),
1030 }
1031}
1032
1033fn send_dns_response_action() -> ActionDefinition {
1034 ActionDefinition {
1035 name: "send_dns_response".to_string(),
1036 description: "ESCAPE HATCH - only for record types that have no dedicated action (NS, SOA, PTR, SRV, CAA, ...). Prefer send_dns_a_response / send_dns_aaaa_response / send_dns_cname_response / send_dns_mx_response / send_dns_txt_response / send_dns_nxdomain, which build the wire format for you. This action requires you to hand-assemble the full DNS message, including echoing the query ID and the question section.".to_string(),
1037 parameters: vec![Parameter {
1038 name: "data".to_string(),
1039 type_hint: "string".to_string(),
1040 description: "Complete DNS response message in RFC 1035 wire format, hex-encoded (at least 12 bytes / 24 hex characters). Not base64, not plain text - invalid hex is rejected.".to_string(),
1041 required: true,
1042 }],
1043 example: json!({
1049 "type": "send_dns_response",
1050 "data": "123481800001000100000000076578616d706c6503636f6d0000010001\
1051 c00c000100010000012c00045db8d822"
1052 }),
1053 log_template: Some(
1054 LogTemplate::new()
1055 .with_info("DNS raw response {output_bytes}B")
1056 .with_debug("DNS send_dns_response: {output_bytes}B"),
1057 ),
1058 }
1059}
1060
1061fn ignore_query_action() -> ActionDefinition {
1062 ActionDefinition {
1063 name: "ignore_query".to_string(),
1064 description: "Ignore this DNS query and don't send a response".to_string(),
1065 parameters: vec![],
1066 example: json!({
1067 "type": "ignore_query"
1068 }),
1069 log_template: Some(LogTemplate::new().with_debug("DNS ignore_query")),
1070 }
1071}
1072
1073fn record_shape() -> &'static str {
1074 "Each record is an object: {\"name\": \"www.example.com.\", \"type\": \"A\", \"ttl\": 300, \
1075 \"ip\": \"192.0.2.1\"}. Fields by type: A/AAAA ip; CNAME/NS/PTR target; MX preference, \
1076 exchange; TXT text (a string, or an array of strings of at most 255 octets each); SRV \
1077 priority, weight, port, target; SOA mname, rname, serial, refresh, retry, expire, minimum. \
1078 ttl defaults to 300. In a zone, a name may be relative to the zone (\"www\") or \"@\" for \
1079 the apex."
1080}
1081
1082fn send_dns_records_action() -> ActionDefinition {
1083 ActionDefinition {
1084 name: "send_dns_records".to_string(),
1085 description: format!(
1086 "Answer a query with any records: several at once (a round-robin of A records, \
1087 all of a name's MX records), types with no dedicated action (NS, SOA, PTR, SRV), \
1088 or an authority section (the SOA of a negative answer). An answer record with no \
1089 'name' takes the queried domain. 'rcode' defaults to NOERROR; NOERROR with no \
1090 answers is the right reply when the name exists but has no record of that type. \
1091 At most {MAX_ANSWER_RECORDS} records. {}",
1092 record_shape()
1093 ),
1094 parameters: vec![
1095 Parameter {
1096 name: "query_id".to_string(),
1097 type_hint: "number".to_string(),
1098 description: "Echo query_id from the event".to_string(),
1099 required: true,
1100 },
1101 Parameter {
1102 name: "domain".to_string(),
1103 type_hint: "string".to_string(),
1104 description: "The queried domain, from the event".to_string(),
1105 required: true,
1106 },
1107 Parameter {
1108 name: "query_type".to_string(),
1109 type_hint: "string".to_string(),
1110 description: "The queried type, from the event".to_string(),
1111 required: true,
1112 },
1113 Parameter {
1114 name: "answers".to_string(),
1115 type_hint: "array".to_string(),
1116 description: "The records that answer the question, each an object with \
1117 'type' and its fields; a record without 'name' takes the queried domain. \
1118 Leave empty (with NOERROR) when the name exists but has no record of this \
1119 type"
1120 .to_string(),
1121 required: false,
1122 },
1123 Parameter {
1124 name: "authority".to_string(),
1125 type_hint: "array".to_string(),
1126 description: "Authority records, e.g. the zone's SOA for a negative answer"
1127 .to_string(),
1128 required: false,
1129 },
1130 Parameter {
1131 name: "additional".to_string(),
1132 type_hint: "array".to_string(),
1133 description: "Additional records, e.g. the A records of an MX target".to_string(),
1134 required: false,
1135 },
1136 Parameter {
1137 name: "rcode".to_string(),
1138 type_hint: "string".to_string(),
1139 description: "NOERROR (default), NXDOMAIN, REFUSED, SERVFAIL, NOTIMP or FORMERR"
1140 .to_string(),
1141 required: false,
1142 },
1143 ],
1144 example: json!({
1145 "type": "send_dns_records",
1146 "query_id": 12345,
1147 "domain": "example.com",
1148 "query_type": "NS",
1149 "answers": [
1150 {"type": "NS", "target": "ns1.example.com."},
1151 {"type": "NS", "target": "ns2.example.com."}
1152 ]
1153 }),
1154 log_template: Some(
1155 LogTemplate::new()
1156 .with_info("DNS {query_type} {domain} -> records")
1157 .with_debug("DNS send_dns_records {domain} ({output_bytes}B)"),
1158 ),
1159 }
1160}
1161
1162fn send_dns_refused_action() -> ActionDefinition {
1163 ActionDefinition {
1164 name: "send_dns_refused".to_string(),
1165 description: "Refuse the query (RCODE REFUSED): this server will not answer it for \
1166 this client. The answer to a zone transfer your instructions do not allow."
1167 .to_string(),
1168 parameters: vec![
1169 Parameter {
1170 name: "query_id".to_string(),
1171 type_hint: "number".to_string(),
1172 description: "Echo query_id from the event".to_string(),
1173 required: true,
1174 },
1175 Parameter {
1176 name: "domain".to_string(),
1177 type_hint: "string".to_string(),
1178 description: "The queried domain or zone, from the event".to_string(),
1179 required: true,
1180 },
1181 Parameter {
1182 name: "query_type".to_string(),
1183 type_hint: "string".to_string(),
1184 description: "The queried type, from the event (AXFR or IXFR for a transfer)"
1185 .to_string(),
1186 required: false,
1187 },
1188 ],
1189 example: json!({
1190 "type": "send_dns_refused",
1191 "query_id": 12345,
1192 "domain": "example.com",
1193 "query_type": "AXFR"
1194 }),
1195 log_template: Some(LogTemplate::new().with_info("DNS {domain} -> REFUSED")),
1196 }
1197}
1198
1199fn send_dns_zone_action() -> ActionDefinition {
1200 ActionDefinition {
1201 name: "send_dns_zone".to_string(),
1202 description: format!(
1203 "Transfer the whole zone (AXFR). Give the zone's SOA and every other record in it; \
1204 the server sends SOA, the records, SOA again, split over as many messages as it \
1205 takes, and for a signed zone adds the DNSKEY, every signature and the NSEC chain. \
1206 Also a valid answer to IXFR (RFC 1995 allows a full zone in reply); over UDP the \
1207 server sends the SOA alone instead, and the client asks again over TCP. Every record \
1208 must be inside the zone; the SOA goes in 'soa', not among the records. {}",
1209 record_shape()
1210 ),
1211 parameters: vec![
1212 Parameter {
1213 name: "query_id".to_string(),
1214 type_hint: "number".to_string(),
1215 description: "Echo query_id from the event".to_string(),
1216 required: true,
1217 },
1218 Parameter {
1219 name: "zone".to_string(),
1220 type_hint: "string".to_string(),
1221 description: "The zone's name, from the event".to_string(),
1222 required: true,
1223 },
1224 Parameter {
1225 name: "soa".to_string(),
1226 type_hint: "object".to_string(),
1227 description: "The zone's SOA: mname, rname, serial, refresh, retry, expire, \
1228 minimum"
1229 .to_string(),
1230 required: true,
1231 },
1232 Parameter {
1233 name: "records".to_string(),
1234 type_hint: "array".to_string(),
1235 description: "Every other record in the zone".to_string(),
1236 required: true,
1237 },
1238 ],
1239 example: json!({
1240 "type": "send_dns_zone",
1241 "query_id": 12345,
1242 "zone": "example.com.",
1243 "soa": {"mname": "ns1.example.com.", "rname": "hostmaster.example.com.",
1244 "serial": 2026101101, "refresh": 3600, "retry": 600,
1245 "expire": 604800, "minimum": 300},
1246 "records": [
1247 {"name": "@", "type": "NS", "target": "ns1.example.com."},
1248 {"name": "ns1", "type": "A", "ip": "192.0.2.53"},
1249 {"name": "www", "type": "A", "ip": "192.0.2.80"}
1250 ]
1251 }),
1252 log_template: Some(
1253 LogTemplate::new()
1254 .with_info("DNS AXFR {zone}")
1255 .with_debug("DNS send_dns_zone {zone}"),
1256 ),
1257 }
1258}
1259
1260fn send_dns_ixfr_action() -> ActionDefinition {
1261 ActionDefinition {
1262 name: "send_dns_ixfr".to_string(),
1263 description: "Answer an IXFR with the changes since the client's serial \
1264 (client_serial in the event). Each change names the serial it ends at, the \
1265 records it deleted and the records it added, oldest first; the last change's \
1266 serial must equal the new SOA's. With no changes, the client is told it is \
1267 current. If you cannot list the changes, answer with send_dns_zone instead. Not \
1268 for a signed zone: answer its IXFR with send_dns_zone."
1269 .to_string(),
1270 parameters: vec![
1271 Parameter {
1272 name: "query_id".to_string(),
1273 type_hint: "number".to_string(),
1274 description: "Echo query_id from the event".to_string(),
1275 required: true,
1276 },
1277 Parameter {
1278 name: "zone".to_string(),
1279 type_hint: "string".to_string(),
1280 description: "The zone's name, from the event".to_string(),
1281 required: true,
1282 },
1283 Parameter {
1284 name: "soa".to_string(),
1285 type_hint: "object".to_string(),
1286 description: "The zone's current SOA".to_string(),
1287 required: true,
1288 },
1289 Parameter {
1290 name: "changes".to_string(),
1291 type_hint: "array".to_string(),
1292 description: "[{\"serial\": N, \"deleted\": [records], \"added\": [records]}], \
1293 oldest first"
1294 .to_string(),
1295 required: false,
1296 },
1297 ],
1298 example: json!({
1299 "type": "send_dns_ixfr",
1300 "query_id": 12345,
1301 "zone": "example.com.",
1302 "soa": {"mname": "ns1.example.com.", "rname": "hostmaster.example.com.",
1303 "serial": 2026101102},
1304 "changes": [{
1305 "serial": 2026101102,
1306 "deleted": [{"name": "www", "type": "A", "ip": "192.0.2.80"}],
1307 "added": [{"name": "www", "type": "A", "ip": "192.0.2.81"}]
1308 }]
1309 }),
1310 log_template: Some(
1311 LogTemplate::new()
1312 .with_info("DNS IXFR {zone}")
1313 .with_debug("DNS send_dns_ixfr {zone}"),
1314 ),
1315 }
1316}
1317
1318pub static DNS_QUERY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1324 EventType::new(
1325 "dns_query",
1326 "DNS client sent a query for domain resolution",
1327 serde_json::json!({
1328 "type": "send_dns_a_response",
1329 "query_id": 12345,
1330 "domain": "example.com",
1331 "ip": "93.184.216.34",
1332 "ttl": 300
1333 }),
1334 )
1335 .with_parameters(vec![
1336 Parameter {
1337 name: "query_id".to_string(),
1338 type_hint: "number".to_string(),
1339 description: "DNS query ID from the request packet".to_string(),
1340 required: true,
1341 },
1342 Parameter {
1343 name: "domain".to_string(),
1344 type_hint: "string".to_string(),
1345 description: "Domain name being queried".to_string(),
1346 required: true,
1347 },
1348 Parameter {
1349 name: "query_type".to_string(),
1350 type_hint: "string".to_string(),
1351 description: "DNS query type (A, AAAA, MX, TXT, CNAME, etc.). Decides the action: A -> send_dns_a_response, AAAA -> send_dns_aaaa_response, MX -> send_dns_mx_response, TXT -> send_dns_txt_response, CNAME -> send_dns_cname_response; NS, SOA, PTR, SRV, or several records at once -> send_dns_records.".to_string(),
1352 required: true,
1353 },
1354 Parameter {
1355 name: "transport".to_string(),
1356 type_hint: "string".to_string(),
1357 description: "\"udp\" or \"tcp\". Informational: an answer too large for the \
1358 client's UDP buffer is truncated for you and the client retries over TCP."
1359 .to_string(),
1360 required: false,
1361 },
1362 Parameter {
1363 name: "dnssec_ok".to_string(),
1364 type_hint: "boolean".to_string(),
1365 description: "The client set the DO bit. Informational: signatures are added for \
1366 you when the server signs this zone."
1367 .to_string(),
1368 required: false,
1369 },
1370 Parameter {
1371 name: "client_ip".to_string(),
1372 type_hint: "string".to_string(),
1373 description: "The address the query came from. Over UDP it is unauthenticated: \
1374 anyone can put another host's address on a datagram."
1375 .to_string(),
1376 required: true,
1377 },
1378 Parameter {
1379 name: "client_port".to_string(),
1380 type_hint: "number".to_string(),
1381 description: "The source port the query came from".to_string(),
1382 required: true,
1383 },
1384 ])
1385 .with_actions(DnsProtocol::query_actions())
1386 .with_alternative_example(serde_json::json!({
1387 "type": "send_dns_nxdomain",
1388 "query_id": 12345,
1389 "domain": "unknown.example.com"
1390 }))
1391 .with_alternative_example(serde_json::json!({
1392 "type": "send_dns_aaaa_response",
1393 "query_id": 12345,
1394 "domain": "example.com",
1395 "ip": "2606:2800:220:1:248:1893:25c8:1946",
1396 "ttl": 300
1397 }))
1398 .with_log_template(
1399 LogTemplate::new()
1400 .with_info("DNS {query_type} {domain} from {client_ip}")
1401 .with_debug("DNS query ID={query_id}, type={query_type}, domain={domain}")
1402 .with_trace("DNS query: {json_pretty(.)}"),
1403 )
1404});
1405
1406pub static DNS_ZONE_TRANSFER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
1408 EventType::new(
1409 "dns_zone_transfer",
1410 "A client asked to transfer a zone: the whole zone (AXFR) or the changes since its \
1411 serial (IXFR). Transfer only a zone your instructions give this server, and only to a \
1412 client_ip they allow; otherwise refuse with send_dns_refused. Over UDP the client's \
1413 address can be forged, so send_dns_zone there is answered with the SOA alone and the \
1414 client must ask again over TCP.",
1415 serde_json::json!({
1416 "type": "send_dns_zone",
1417 "query_id": 12345,
1418 "zone": "example.com.",
1419 "soa": {"mname": "ns1.example.com.", "rname": "hostmaster.example.com.",
1420 "serial": 2026101101},
1421 "records": [{"name": "www", "type": "A", "ip": "192.0.2.80"}]
1422 }),
1423 )
1424 .with_parameters(vec![
1425 Parameter {
1426 name: "query_id".to_string(),
1427 type_hint: "number".to_string(),
1428 description: "DNS query ID, to echo".to_string(),
1429 required: true,
1430 },
1431 Parameter {
1432 name: "zone".to_string(),
1433 type_hint: "string".to_string(),
1434 description: "The zone asked for".to_string(),
1435 required: true,
1436 },
1437 Parameter {
1438 name: "transfer_type".to_string(),
1439 type_hint: "string".to_string(),
1440 description: "\"AXFR\" (whole zone) or \"IXFR\" (changes since client_serial)"
1441 .to_string(),
1442 required: true,
1443 },
1444 Parameter {
1445 name: "client_serial".to_string(),
1446 type_hint: "number".to_string(),
1447 description: "IXFR only: the SOA serial the client holds".to_string(),
1448 required: false,
1449 },
1450 Parameter {
1451 name: "transport".to_string(),
1452 type_hint: "string".to_string(),
1453 description: "\"tcp\", or \"udp\" for an IXFR. Over UDP send_dns_zone is always \
1454 answered with the SOA alone, and a send_dns_ixfr difference too large for UDP \
1455 is too; either sends the client to TCP"
1456 .to_string(),
1457 required: true,
1458 },
1459 Parameter {
1460 name: "signed".to_string(),
1461 type_hint: "boolean".to_string(),
1462 description: "The server signs this zone; answer an IXFR with send_dns_zone"
1463 .to_string(),
1464 required: true,
1465 },
1466 Parameter {
1467 name: "client_ip".to_string(),
1468 type_hint: "string".to_string(),
1469 description: "The address asking for the transfer; decide by it whether this \
1470 client may have the zone. Over UDP it is unauthenticated."
1471 .to_string(),
1472 required: true,
1473 },
1474 Parameter {
1475 name: "client_port".to_string(),
1476 type_hint: "number".to_string(),
1477 description: "The source port the transfer request came from".to_string(),
1478 required: true,
1479 },
1480 ])
1481 .with_actions(vec![
1482 send_dns_zone_action(),
1483 send_dns_ixfr_action(),
1484 send_dns_refused_action(),
1485 ignore_query_action(),
1486 ])
1487 .with_alternative_example(serde_json::json!({
1488 "type": "send_dns_refused",
1489 "query_id": 12345,
1490 "domain": "example.com.",
1491 "query_type": "AXFR"
1492 }))
1493 .with_log_template(
1494 LogTemplate::new()
1495 .with_info("DNS {transfer_type} {zone} from {client_ip}")
1496 .with_debug("DNS {transfer_type} {zone} id={query_id} via {transport}"),
1497 )
1498});
1499
1500pub fn get_dns_event_types() -> Vec<EventType> {
1502 vec![DNS_QUERY_EVENT.clone(), DNS_ZONE_TRANSFER_EVENT.clone()]
1503}