Skip to main content

netget/client/dns/
actions.rs

1//! DNS client protocol actions implementation
2
3use crate::llm::actions::{
4    client_trait::{Client, ClientActionResult},
5    protocol_trait::Protocol,
6    ActionDefinition, Parameter,
7};
8use crate::protocol::EventType;
9use crate::state::app_state::AppState;
10use anyhow::{Context, Result};
11use serde_json::json;
12use std::sync::LazyLock;
13
14/// Most records one zone transfer may bring in before it is abandoned.
15pub const MAX_TRANSFER_RECORDS: usize = 100_000;
16
17/// How many of a transfer's records the model is shown, and the only ones kept; the rest
18/// are counted.
19pub const MAX_REPORTED_RECORDS: usize = 200;
20
21/// Most bytes of DNS messages one zone transfer may bring in before it is abandoned. A record
22/// can be nearly 64 KiB, so the record count alone does not bound what a server can send.
23pub const MAX_TRANSFER_BYTES: usize = 64 * 1024 * 1024;
24
25/// How long a zone transfer may take.
26pub const TRANSFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
27
28/// A zone transfer finished.
29pub static DNS_CLIENT_ZONE_TRANSFER_EVENT: LazyLock<EventType> = LazyLock::new(|| {
30    EventType::new(
31        "dns_zone_transfer_received",
32        "A zone transfer finished. 'form' says what came back: \"full\" (the whole zone), \
33         \"incremental\" (an IXFR difference: SOA old, deleted records, SOA new, added records, \
34         per change), \"current\" (the SOA alone: the serial asked from is the latest), or \
35         \"none\" (refused or empty; see response_code).",
36        json!({"type": "placeholder", "event_id": "dns_zone_transfer_received"}),
37    )
38    .with_parameters(vec![
39        Parameter {
40            name: "zone".to_string(),
41            type_hint: "string".to_string(),
42            description: "The zone transferred".to_string(),
43            required: true,
44        },
45        Parameter {
46            name: "transfer_type".to_string(),
47            type_hint: "string".to_string(),
48            description: "\"AXFR\" when the whole zone was asked for, \"IXFR\" when the \
49                changes since a serial were; 'form' says what actually came back"
50                .to_string(),
51            required: true,
52        },
53        Parameter {
54            name: "form".to_string(),
55            type_hint: "string".to_string(),
56            description: "full, incremental, current or none".to_string(),
57            required: true,
58        },
59        Parameter {
60            name: "serial".to_string(),
61            type_hint: "number".to_string(),
62            description: "The zone's current SOA serial".to_string(),
63            required: false,
64        },
65        Parameter {
66            name: "record_count".to_string(),
67            type_hint: "number".to_string(),
68            description: "Records received, SOAs included".to_string(),
69            required: true,
70        },
71        Parameter {
72            name: "messages".to_string(),
73            type_hint: "number".to_string(),
74            description: "DNS messages the transfer took".to_string(),
75            required: true,
76        },
77        Parameter {
78            name: "records".to_string(),
79            type_hint: "array".to_string(),
80            description: "The records, in transfer order; the first 200 when 'truncated'"
81                .to_string(),
82            required: true,
83        },
84        Parameter {
85            name: "truncated".to_string(),
86            type_hint: "boolean".to_string(),
87            description: "More records arrived than are listed".to_string(),
88            required: true,
89        },
90        Parameter {
91            name: "response_code".to_string(),
92            type_hint: "string".to_string(),
93            description: "NOERROR, or the refusal (REFUSED, NOTAUTH, ...)".to_string(),
94            required: true,
95        },
96    ])
97});
98
99/// DNS client connected event
100pub static DNS_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
101    EventType::new(
102        "dns_connected",
103        "DNS client successfully connected to DNS server",
104        serde_json::json!({
105            "type": "send_dns_query",
106            "domain": "example.com",
107            "query_type": "A"
108        }),
109    )
110    .with_parameters(vec![Parameter {
111        name: "remote_addr".to_string(),
112        type_hint: "string".to_string(),
113        description: "DNS server address".to_string(),
114        required: true,
115    }])
116});
117
118/// DNS client response received event
119pub static DNS_CLIENT_RESPONSE_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
120    EventType::new(
121        "dns_response_received",
122        "DNS response received from server",
123        json!({"type": "placeholder", "event_id": "dns_response_received"}),
124    )
125    .with_parameters(vec![
126        Parameter {
127            name: "query_id".to_string(),
128            type_hint: "number".to_string(),
129            description: "DNS query transaction ID".to_string(),
130            required: true,
131        },
132        Parameter {
133            name: "domain".to_string(),
134            type_hint: "string".to_string(),
135            description: "Domain name queried".to_string(),
136            required: true,
137        },
138        Parameter {
139            name: "query_type".to_string(),
140            type_hint: "string".to_string(),
141            description: "DNS query type (A, AAAA, MX, etc.)".to_string(),
142            required: true,
143        },
144        Parameter {
145            name: "answers".to_string(),
146            type_hint: "array".to_string(),
147            description: "Array of answer records".to_string(),
148            required: true,
149        },
150        Parameter {
151            name: "response_code".to_string(),
152            type_hint: "string".to_string(),
153            description: "DNS response code (NOERROR, NXDOMAIN, SERVFAIL, etc.)".to_string(),
154            required: true,
155        },
156        Parameter {
157            name: "authority".to_string(),
158            type_hint: "array".to_string(),
159            description: "Authority records: the SOA of a negative answer, and with dnssec \
160                its NSEC proof and their signatures"
161                .to_string(),
162            required: false,
163        },
164        Parameter {
165            name: "authenticated_data".to_string(),
166            type_hint: "boolean".to_string(),
167            description: "The AD bit: a validating resolver vouches for the answer. An \
168                authoritative server never sets it; this client does not validate."
169                .to_string(),
170            required: true,
171        },
172        Parameter {
173            name: "truncated".to_string(),
174            type_hint: "boolean".to_string(),
175            description: "The TC bit: the answer did not fit UDP".to_string(),
176            required: true,
177        },
178    ])
179});
180
181/// DNS client protocol action handler
182pub struct DnsClientProtocol;
183
184impl Default for DnsClientProtocol {
185    fn default() -> Self {
186        Self::new()
187    }
188}
189
190impl DnsClientProtocol {
191    pub fn new() -> Self {
192        Self
193    }
194}
195
196fn dnssec_param() -> Parameter {
197    Parameter {
198        name: "dnssec".to_string(),
199        type_hint: "boolean".to_string(),
200        description: "Set the DO bit, asking for the RRSIG and NSEC records that sign the \
201            answer (default: false). They are reported in answers and authority."
202            .to_string(),
203        required: false,
204    }
205}
206
207fn zone_transfer_action() -> ActionDefinition {
208    ActionDefinition {
209        name: "send_dns_zone_transfer".to_string(),
210        description: "Transfer a zone from the server over TCP: the whole zone (AXFR), or with \
211            'serial' only the changes since that serial (IXFR). The result arrives as \
212            dns_zone_transfer_received."
213            .to_string(),
214        parameters: vec![
215            Parameter {
216                name: "zone".to_string(),
217                type_hint: "string".to_string(),
218                description: "The zone, e.g. \"example.com.\"".to_string(),
219                required: true,
220            },
221            Parameter {
222                name: "serial".to_string(),
223                type_hint: "number".to_string(),
224                description: "Ask for IXFR from this SOA serial; omit for AXFR".to_string(),
225                required: false,
226            },
227        ],
228        example: json!({"type": "send_dns_zone_transfer", "zone": "example.com."}),
229        log_template: Some(
230            crate::protocol::log_template::LogTemplate::new()
231                .with_info("DNS transfer {zone}")
232                .with_debug("DNS send_dns_zone_transfer {zone} serial={serial}"),
233        ),
234    }
235}
236
237// Implement Protocol trait (common functionality)
238impl Protocol for DnsClientProtocol {
239    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
240        vec![
241            ActionDefinition {
242                name: "send_dns_query".to_string(),
243                description: "Send a DNS query to the server".to_string(),
244                parameters: vec![
245                    Parameter {
246                        name: "domain".to_string(),
247                        type_hint: "string".to_string(),
248                        description: "Domain name to query (e.g., example.com)".to_string(),
249                        required: true,
250                    },
251                    Parameter {
252                        name: "query_type".to_string(),
253                        type_hint: "string".to_string(),
254                        description:
255                            "DNS query type (A, AAAA, MX, TXT, CNAME, NS, SOA, PTR, SRV, etc.)"
256                                .to_string(),
257                        required: true,
258                    },
259                    Parameter {
260                        name: "recursion_desired".to_string(),
261                        type_hint: "boolean".to_string(),
262                        description: "Request recursive resolution (default: true)".to_string(),
263                        required: false,
264                    },
265                    dnssec_param(),
266                ],
267                example: json!({
268                    "type": "send_dns_query",
269                    "domain": "example.com",
270                    "query_type": "A",
271                    "recursion_desired": true
272                }),
273                log_template: None,
274            },
275            zone_transfer_action(),
276            ActionDefinition {
277                name: "disconnect".to_string(),
278                description: "Disconnect from the DNS server".to_string(),
279                parameters: vec![],
280                example: json!({
281                    "type": "disconnect"
282                }),
283                log_template: None,
284            },
285        ]
286    }
287    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
288        vec![
289            ActionDefinition {
290                name: "send_dns_query".to_string(),
291                description: "Send another DNS query in response to received data".to_string(),
292                parameters: vec![
293                    Parameter {
294                        name: "domain".to_string(),
295                        type_hint: "string".to_string(),
296                        description: "Domain name to query".to_string(),
297                        required: true,
298                    },
299                    Parameter {
300                        name: "query_type".to_string(),
301                        type_hint: "string".to_string(),
302                        description: "DNS query type".to_string(),
303                        required: true,
304                    },
305                    Parameter {
306                        name: "recursion_desired".to_string(),
307                        type_hint: "boolean".to_string(),
308                        description: "Request recursive resolution".to_string(),
309                        required: false,
310                    },
311                    dnssec_param(),
312                ],
313                example: json!({
314                    "type": "send_dns_query",
315                    "domain": "mail.example.com",
316                    "query_type": "A"
317                }),
318                log_template: None,
319            },
320            zone_transfer_action(),
321            ActionDefinition {
322                name: "wait_for_more".to_string(),
323                description: "Wait for more data before responding".to_string(),
324                parameters: vec![],
325                example: json!({
326                    "type": "wait_for_more"
327                }),
328                log_template: None,
329            },
330        ]
331    }
332    fn protocol_name(&self) -> &'static str {
333        "DNS"
334    }
335    fn get_event_types(&self) -> Vec<EventType> {
336        vec![
337            EventType::new(
338                "dns_connected",
339                "Triggered when DNS client connects to server",
340                json!({"type": "placeholder", "event_id": "dns_connected"}),
341            ),
342            EventType::new(
343                "dns_response_received",
344                "Triggered when DNS client receives a response",
345                json!({"type": "placeholder", "event_id": "dns_response_received"}),
346            ),
347            DNS_CLIENT_ZONE_TRANSFER_EVENT.clone(),
348        ]
349    }
350    fn stack_name(&self) -> &'static str {
351        "ETH>IP>UDP>DNS"
352    }
353    fn keywords(&self) -> Vec<&'static str> {
354        vec!["dns", "dns client", "connect to dns", "domain name system"]
355    }
356    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
357        use crate::protocol::metadata::{DevelopmentState, ProtocolMetadataV2};
358
359        ProtocolMetadataV2::builder()
360            // BETA, October 2026, on all four conditions of the client bar: (1) the peer is
361            // ISC BIND's named, a third-party authoritative server, not NetGet's DNS server;
362            // (2) the test fails, naming bind9, when named or dig is absent; (3) a real
363            // session: a DO query answered with named's RRSIG, an AXFR of the signed zone, and
364            // after an edit and a reload an IXFR difference; (4) the client acts on its
365            // handler's answers, asserted from the server's side — dig reads the same zone and
366            // named logs the AXFR and IXFR it served. Removing the DO bit fails the test.
367            .state(DevelopmentState::Beta)
368            .implementation(
369                "hickory-client over UDP for queries (each message built by hand, so RD and the \
370                 EDNS DO bit reach the wire); a TCP hickory-client per zone transfer",
371            )
372            .llm_control(
373                "Queries (name, type, recursion, DNSSEC) and zone transfers (AXFR, or IXFR from a \
374                 serial)",
375            )
376            .e2e_testing(
377                "tests/client/dns/real_server_test.rs against ISC BIND named (bind9), \
378                 unprivileged on loopback with a zone it signs inline: a DO query reports \
379                 named's RRSIG (type covered, algorithm 13, signer); an AXFR reports the same \
380                 record count dig reads, DNSKEY, NSEC and RRSIG included; after the zone is \
381                 edited and named reloaded, an injected IXFR from the old serial reports the \
382                 incremental form with the deleted record before the added one; named's log \
383                 shows both transfers served. No skip: a missing named or dig fails the test.",
384            )
385            .notes(
386                "The client reports DNSSEC records and the AD bit but does not validate \
387                 signatures itself. A transfer stops past 100,000 records or 60 s, and the \
388                 model is shown the first 200 records with the count. No TSIG.",
389            )
390            .build()
391    }
392    fn description(&self) -> &'static str {
393        "DNS client for domain name resolution"
394    }
395    fn example_prompt(&self) -> &'static str {
396        "Connect to DNS at 8.8.8.8:53 and query A records for example.com"
397    }
398    fn group_name(&self) -> &'static str {
399        "DNS"
400    }
401    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
402        use crate::llm::actions::StartupExamples;
403        use serde_json::json;
404
405        StartupExamples::new(
406            // LLM mode: LLM controls DNS client interactions
407            json!({
408                "type": "open_client",
409                "remote_addr": "8.8.8.8:53",
410                "base_stack": "dns",
411                "instruction": "Query A record for example.com and report the IP addresses"
412            }),
413            // Script mode: Code-based deterministic responses
414            json!({
415                "type": "open_client",
416                "remote_addr": "8.8.8.8:53",
417                "base_stack": "dns",
418                "event_handlers": [{
419                    "event_pattern": "dns_response_received",
420                    "handler": {
421                        "type": "script",
422                        "language": "python",
423                        "code": "<dns_client_handler>"
424                    }
425                }]
426            }),
427            // Static mode: Fixed responses
428            json!({
429                "type": "open_client",
430                "remote_addr": "8.8.8.8:53",
431                "base_stack": "dns",
432                "event_handlers": [
433                    {
434                        "event_pattern": "dns_connected",
435                        "handler": {
436                            "type": "static",
437                            "actions": [{
438                                "type": "send_dns_query",
439                                "domain": "example.com",
440                                "query_type": "A"
441                            }]
442                        }
443                    },
444                    {
445                        "event_pattern": "dns_response_received",
446                        "handler": {
447                            "type": "static",
448                            "actions": [{
449                                "type": "disconnect"
450                            }]
451                        }
452                    }
453                ]
454            }),
455        )
456    }
457}
458
459// Implement Client trait (client-specific functionality)
460impl Client for DnsClientProtocol {
461    fn connect(
462        &self,
463        ctx: crate::protocol::ConnectContext,
464    ) -> std::pin::Pin<
465        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
466    > {
467        Box::pin(async move {
468            use crate::client::dns::DnsClient;
469            DnsClient::connect_with_llm_actions(
470                ctx.remote_addr,
471                ctx.llm_client,
472                ctx.state,
473                ctx.status_tx,
474                ctx.client_id,
475            )
476            .await
477        })
478    }
479    fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
480        let action_type = action
481            .get("type")
482            .and_then(|v| v.as_str())
483            .context("Missing 'type' field in action")?;
484
485        match action_type {
486            "send_dns_query" => {
487                let domain = action
488                    .get("domain")
489                    .and_then(|v| v.as_str())
490                    .context("Missing 'domain' field")?
491                    .to_string();
492
493                let query_type = action
494                    .get("query_type")
495                    .and_then(|v| v.as_str())
496                    .context("Missing 'query_type' field")?
497                    .to_string();
498
499                let recursion_desired = action
500                    .get("recursion_desired")
501                    .and_then(|v| v.as_bool())
502                    .unwrap_or(true);
503                let dnssec = match action.get("dnssec") {
504                    None | Some(serde_json::Value::Null) => false,
505                    Some(v) => v.as_bool().context("'dnssec' must be true or false")?,
506                };
507
508                // Return custom result with query data
509                Ok(ClientActionResult::Custom {
510                    name: "dns_query".to_string(),
511                    data: json!({
512                        "domain": domain,
513                        "query_type": query_type,
514                        "recursion_desired": recursion_desired,
515                        "dnssec": dnssec,
516                    }),
517                })
518            }
519            "send_dns_zone_transfer" => {
520                let zone = action
521                    .get("zone")
522                    .and_then(|v| v.as_str())
523                    .context("Missing 'zone' field, e.g. \"example.com.\"")?
524                    .to_string();
525                let serial = match action.get("serial") {
526                    None | Some(serde_json::Value::Null) => None,
527                    Some(v) => Some(
528                        v.as_u64()
529                            .filter(|s| *s <= u64::from(u32::MAX))
530                            .context("'serial' must be an SOA serial, 0 to 4294967295")?,
531                    ),
532                };
533                Ok(ClientActionResult::Custom {
534                    name: "dns_zone_transfer".to_string(),
535                    data: json!({"zone": zone, "serial": serial}),
536                })
537            }
538            "disconnect" => Ok(ClientActionResult::Disconnect),
539            "wait_for_more" => Ok(ClientActionResult::WaitForMore),
540            _ => Err(anyhow::anyhow!(
541                "Unknown DNS client action: {}",
542                action_type
543            )),
544        }
545    }
546}