1use 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
14pub const MAX_TRANSFER_RECORDS: usize = 100_000;
16
17pub const MAX_REPORTED_RECORDS: usize = 200;
20
21pub const MAX_TRANSFER_BYTES: usize = 64 * 1024 * 1024;
24
25pub const TRANSFER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
27
28pub 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
99pub 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
118pub 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
181pub 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
237impl 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 .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 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 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 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
459impl 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 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}