Skip to main content

netget/server/dns/
actions.rs

1//! DNS protocol actions implementation
2
3use 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
18/// Longest DNS `<character-string>`, in octets (RFC 1035 ยง3.3).
19///
20/// A character-string is one length octet followed by that many characters, so 255 is what
21/// the format can describe โ€” not a policy this server chose. `send_dns_txt_response` emits
22/// the TXT record as a single character-string, so this is its ceiling.
23///
24/// It is enforced in `execute_send_dns_txt_response` rather than left to hickory, and the
25/// difference is not cosmetic: hickory's encoder does refuse, but it refuses inside
26/// `message.to_vec()`, which fails the whole action with "Failed to serialize DNS message".
27/// `src/server/dns/mod.rs` then takes its `Ok` branch with no `ActionResult::Output` in hand
28/// and writes **nothing at all** โ€” the peer waits out its own timeout, and the model is told
29/// a serialisation error rather than the one number it needed. Stating the bound here turns a
30/// fail-silent into an error the model can act on, which is the same reason
31/// `coap::codec::MAX_PAYLOAD_LEN` is checked in `decode_payload` instead of at `send_to`.
32pub const MAX_CHARACTER_STRING_LEN: usize = 255;
33
34/// Most records one `send_dns_records` answer may carry, across all three sections.
35pub const MAX_ANSWER_RECORDS: usize = 100;
36
37/// Names carried by the [`ActionResult::Custom`] results the zone-transfer actions produce.
38/// The action comes back validated and `mod.rs` builds the transfer from it, because a zone
39/// does not fit the one message an `ActionResult::Output` would carry.
40pub const RESULT_ZONE: &str = "dns_zone";
41pub const RESULT_IXFR: &str = "dns_ixfr";
42
43/// DNS protocol action handler
44pub 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    /// The actions that answer a query โ€” what DoH, DoT and DoQ forward onto their own query
58    /// events. The zone-transfer actions are left out: those servers have no transfer event.
59    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
75// Implement Protocol trait (common functionality)
76impl Protocol for DnsProtocol {
77    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
78        Vec::new() // DNS has no async actions
79    }
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: SERVFAIL echoing the query id, never silence.
145            .answers_on_failure()
146            // STABLE, set 16 September 2026 against the six conditions in the root CLAUDE.md,
147            // each verified in that pass rather than inherited. Read `.e2e_testing` and
148            // `.notes` before quoting it: the rating covers the surface this server
149            // implements, which is plain UDP DNS with one record per answer.
150            .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        // A fixed-resolve DNS server: everything under example.com answers with a
256        // fixed A record, everything else NXDOMAIN. Reads the event from stdin
257        // and echoes the client's random transaction id (query_id) โ€” the one
258        // thing a static handler cannot do, which is why DNS needs script mode.
259        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            // LLM mode: a different answer per subdomain โ€” genuine reasoning.
277            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            // Script mode: deterministic fixed-resolve, no LLM call. The script
284            // sees the event and can echo the client's random transaction id.
285            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            // Static mode: static handlers emit fixed action JSON with no access
299            // to the event, so they cannot echo the client's random transaction
300            // ID or the queried name - a static answer record would be silently
301            // discarded by every real resolver. Dropping queries (a DNS
302            // blackhole) is what static mode can do correctly here; use script
303            // mode for deterministic answers.
304            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
322// Implement Server trait (server-specific functionality)
323impl 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                // The serial the client holds is read from its query, not from the model, and
396                // nothing checked here depends on it, so 0 validates every field.
397                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
409/// Read the DNS transaction ID out of an action.
410///
411/// The transaction ID is how a client correlates a response with the query it
412/// sent, and clients pick it at random. Silently truncating an out-of-range
413/// value with `as u16` would produce a response the client discards without any
414/// diagnostic, so an out-of-range value is a hard error instead.
415fn 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
430/// Parse a DNS record type name (`"A"`, `"AAAA"`, `"MX"`, ...).
431fn 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
436/// Build the shell of a DNS response: header plus the echoed question section.
437///
438/// RFC 1035 ยง4.1.2 requires a response to repeat the question it answers, and
439/// real stub resolvers (glibc's, systemd-resolved, `dig`) discard responses
440/// whose question section does not match the query they sent. The question is
441/// reconstructed from the action's `domain` plus the record type implied by the
442/// action, so answering a query with the matching action produces a response
443/// those clients accept.
444fn 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    // Echo the question section (counts are recomputed by hickory at encode time).
463    message.add_query(Query::query(name.clone(), query_type));
464
465    Ok((message, name))
466}
467
468/// Build a SERVFAIL (RCODE 2) answer to `query`, for when the server cannot
469/// produce a real answer at all โ€” the LLM backend is down, overloaded, or
470/// returned nothing usable.
471///
472/// RFC 1035 ยง4.1.1 gives exactly one code for "this server failed while
473/// processing your query", and a resolver that receives it stops waiting and
474/// moves to the next nameserver immediately. Writing nothing instead leaves the
475/// resolver blocked for its own full timeout (5s in glibc, per server).
476///
477/// The transaction ID and the question section are copied from the query, for
478/// the same reason `new_response` echoes them: a stub resolver discards any
479/// response whose ID or question does not match what it sent, which would turn
480/// this into silence again.
481///
482/// `AA` is deliberately *not* set: we are not answering authoritatively, we are
483/// failing.
484pub 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
503/// Serialize a response message to DNS wire format.
504fn 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        // A CNAME may be returned for any query type; the question echoes CNAME
575        // unless the caller says which type was actually asked for.
576        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        // NXDOMAIN carries no answer, so the question section is the only way a
651        // client can tell which of its outstanding queries the response is for.
652        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        // Hex only. The previous behaviour fell back to sending the raw string
666        // bytes when hex decoding failed, which silently put non-DNS bytes on
667        // the wire and left the client timing out with no diagnostic.
668        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
687/// The RCODE names an answer may carry.
688fn 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
702/// `send_dns_records`: any number of records in any of the three sections, any RCODE.
703fn 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
737/// `send_dns_refused`: REFUSED (RCODE 5), the answer for a query or transfer this server will
738/// not serve to this client.
739fn 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        // A complete RFC 1035 response: id=0x1234, flags 0x8180 (QR|RD|RA), one
1044        // question (example.com A IN) and one answer (compressed name pointer
1045        // 0xc00c, A IN, TTL 300, rdlength 4, 93.184.216.34). The previous example
1046        // was abbreviated with an ellipsis and omitted the 2-byte id, so it was
1047        // neither valid hex nor a DNS message.
1048        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
1318// ============================================================================
1319// DNS Event Type Constants
1320// ============================================================================
1321
1322/// DNS query event - triggered when DNS client sends a query
1323pub 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
1406/// A zone transfer request: AXFR or IXFR, over TCP (or IXFR over UDP).
1407pub 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
1500/// Get DNS event types
1501pub fn get_dns_event_types() -> Vec<EventType> {
1502    vec![DNS_QUERY_EVENT.clone(), DNS_ZONE_TRANSFER_EVENT.clone()]
1503}