Skip to main content

netget/server/dns/
mod.rs

1//! DNS server: UDP and TCP on one port, EDNS0, zone transfers and DNSSEC for one zone.
2//!
3//! The model answers queries and transfers with structured actions (`actions.rs`); everything
4//! the protocol decides on its own happens here, after the model has answered: echoing EDNS,
5//! signing a `dnssec_zone`, proving non-existence, truncating what does not fit UDP, and
6//! cutting a zone into transfer messages. See `src/server/dns/AGENTS.md`.
7pub mod actions;
8pub mod dnssec;
9pub mod records;
10pub mod transfer;
11
12use crate::llm::action_helper::call_llm;
13use crate::llm::ollama_client::OllamaClient;
14use crate::llm::ActionResult;
15use crate::logging::emit::Log;
16use crate::protocol::Event;
17use crate::server::connection::ConnectionId;
18use crate::server::DnsProtocol;
19use crate::state::app_state::AppState;
20use actions::{DNS_QUERY_EVENT, DNS_ZONE_TRANSFER_EVENT, RESULT_IXFR, RESULT_ZONE};
21use anyhow::Result;
22use dnssec::ZoneSigner;
23use hickory_proto::op::{Edns, Message as DnsMessage, Query, ResponseCode};
24use hickory_proto::rr::{Name, Record, RecordType};
25use std::collections::HashMap;
26use std::net::{IpAddr, Ipv6Addr, SocketAddr};
27use std::sync::Arc;
28use std::time::Duration;
29use tokio::io::{AsyncReadExt, AsyncWriteExt};
30use tokio::net::{TcpListener, TcpStream, UdpSocket};
31use tokio::sync::mpsc;
32
33use crate::server::accept_bounded::{accept_bounded, ConnectionLimiter};
34
35/// How long a TCP connection may sit idle between queries (RFC 7766 §6.2.3 suggests seconds,
36/// not minutes). The `tcp_idle_timeout_secs` startup parameter overrides it.
37pub const DEFAULT_TCP_IDLE_TIMEOUT: Duration = Duration::from_secs(10);
38
39/// Open TCP connections at once. One past it is closed on accept, unread.
40pub const MAX_TCP_CONNECTIONS: usize = 64;
41
42/// Open TCP connections one client address may hold at once; past it a new one from that
43/// address is closed unread (`decision=fail_closed_per_source_cap`). Without it one host could
44/// hold all [`MAX_TCP_CONNECTIONS`] slots — cheaply, since some messages are answered without
45/// the model. IPv6 is counted per /64, which is what one host is usually given. The
46/// `tcp_connections_per_ip` startup parameter overrides it, within 1..=`MAX_TCP_CONNECTIONS`.
47pub const MAX_TCP_CONNECTIONS_PER_IP: usize = 8;
48
49/// What a connection past the cap is told: nothing. DNS has no message a server sends
50/// before a query, so it is closed unread and the client retries or moves on.
51pub const CONNECTION_CAP_REFUSAL: &[u8] = b"";
52
53/// The UDP payload size this server advertises in its OPT record: the 2020 DNS flag day's
54/// 1232, which fits an IPv6 minimum MTU without fragmenting.
55pub const EDNS_UDP_SIZE: u16 = 1232;
56
57/// The largest UDP response to a query without EDNS (RFC 1035 §4.2.1).
58pub const PLAIN_UDP_LIMIT: usize = 512;
59
60/// The most a client's EDNS buffer size is believed; past it the response is truncated
61/// anyway, so one query cannot ask for a 64 KiB datagram.
62pub const MAX_UDP_RESPONSE: usize = 4096;
63
64/// What `spawn` reads from the startup parameters.
65pub struct DnsSettings {
66    pub dnssec_zone: Option<String>,
67    pub tcp_idle_timeout: Duration,
68    /// See [`MAX_TCP_CONNECTIONS_PER_IP`].
69    pub tcp_connections_per_ip: usize,
70}
71
72/// Open TCP connections per source address (IPv6 per /64), bounded at `max` each.
73struct PerSourceLimiter {
74    max: usize,
75    open: Arc<std::sync::Mutex<HashMap<IpAddr, usize>>>,
76}
77
78/// One admitted connection's place in its source's count; released when dropped, so it lives
79/// in the connection task.
80struct SourceSlot {
81    key: IpAddr,
82    open: Arc<std::sync::Mutex<HashMap<IpAddr, usize>>>,
83}
84
85impl PerSourceLimiter {
86    fn new(max: usize) -> Self {
87        Self {
88            max: max.max(1),
89            open: Arc::default(),
90        }
91    }
92
93    /// The source's slot, or `None` when it already holds `max` connections.
94    fn try_acquire(&self, ip: IpAddr) -> Option<SourceSlot> {
95        let key = source_key(ip);
96        let mut open = self.open.lock().unwrap_or_else(|e| e.into_inner());
97        let count = open.entry(key).or_insert(0);
98        if *count >= self.max {
99            return None;
100        }
101        *count += 1;
102        Some(SourceSlot {
103            key,
104            open: self.open.clone(),
105        })
106    }
107}
108
109impl Drop for SourceSlot {
110    fn drop(&mut self) {
111        let mut open = self.open.lock().unwrap_or_else(|e| e.into_inner());
112        if let Some(count) = open.get_mut(&self.key) {
113            *count = count.saturating_sub(1);
114            if *count == 0 {
115                open.remove(&self.key);
116            }
117        }
118    }
119}
120
121/// What a source is counted as: an IPv4 address (an IPv4-mapped IPv6 address is its IPv4
122/// address), or an IPv6 address's /64.
123fn source_key(ip: IpAddr) -> IpAddr {
124    match ip {
125        IpAddr::V4(_) => ip,
126        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
127            Some(v4) => IpAddr::V4(v4),
128            None => {
129                let s = v6.segments();
130                IpAddr::V6(Ipv6Addr::new(s[0], s[1], s[2], s[3], 0, 0, 0, 0))
131            }
132        },
133    }
134}
135
136/// DNS server that integrates with LLM for query handling
137pub struct DnsServer;
138
139#[derive(Clone, Copy, PartialEq, Eq, Debug)]
140enum Transport {
141    Udp,
142    Tcp,
143}
144
145impl Transport {
146    fn as_str(self) -> &'static str {
147        match self {
148            Transport::Udp => "udp",
149            Transport::Tcp => "tcp",
150        }
151    }
152}
153
154/// Everything a query is answered with.
155struct Shared {
156    llm_client: OllamaClient,
157    app_state: Arc<AppState>,
158    status_tx: mpsc::UnboundedSender<String>,
159    server_id: crate::state::ServerId,
160    protocol: Arc<DnsProtocol>,
161    signer: Option<Arc<ZoneSigner>>,
162}
163
164fn now_secs() -> u32 {
165    crate::utils::clock::SystemTime::now()
166        .duration_since(crate::utils::clock::UNIX_EPOCH)
167        .map(|d| d.as_secs() as u32)
168        .unwrap_or_default()
169}
170
171impl DnsServer {
172    /// Bind UDP and TCP on the same port and start both loops.
173    pub async fn spawn_with_llm_actions(
174        listen_addr: SocketAddr,
175        llm_client: OllamaClient,
176        app_state: Arc<AppState>,
177        status_tx: mpsc::UnboundedSender<String>,
178        server_id: crate::state::ServerId,
179        settings: DnsSettings,
180    ) -> Result<SocketAddr> {
181        // The same port for TCP (RFC 7766 §5: every server MUST serve TCP). When the OS picks the
182        // port, the number it gave UDP can already be taken for TCP by another process, so a
183        // fresh pair is tried; an explicitly requested port that TCP cannot have fails rather
184        // than serving half the protocol.
185        const PORT_PAIR_ATTEMPTS: usize = 16;
186        let mut attempt = 0;
187        let (socket, local_addr, listener) = loop {
188            attempt += 1;
189            let socket = Arc::new(UdpSocket::bind(listen_addr).await?);
190            let local_addr = socket.local_addr()?;
191            match TcpListener::bind(local_addr).await {
192                Ok(listener) => break (socket, local_addr, listener),
193                Err(e)
194                    if listen_addr.port() == 0
195                        && e.kind() == std::io::ErrorKind::AddrInUse
196                        && attempt < PORT_PAIR_ATTEMPTS =>
197                {
198                    continue
199                }
200                Err(e) => {
201                    return Err(anyhow::Error::new(e)
202                        .context(format!("DNS over TCP cannot bind {local_addr}")))
203                }
204            }
205        };
206        let log = Log::new(Some(&status_tx));
207        log.info(format!(
208            "DNS server listening on {local_addr} (UDP and TCP)"
209        ));
210
211        let signer = match settings.dnssec_zone.as_deref() {
212            None => None,
213            Some(zone) => {
214                let name = records::parse_name(zone, None)?;
215                let signer = ZoneSigner::generate(name, now_secs())?;
216                log.info(format!(
217                    "DNS signing {} with DNSSEC (ECDSA P-256, key tag {})",
218                    signer.zone(),
219                    signer.key_tag()
220                ));
221                Some(Arc::new(signer))
222            }
223        };
224
225        let shared = Arc::new(Shared {
226            llm_client,
227            app_state: app_state.clone(),
228            status_tx: status_tx.clone(),
229            server_id,
230            protocol: Arc::new(DnsProtocol::new()),
231            signer,
232        });
233
234        let udp_shared = shared.clone();
235        let udp_handle = tokio::spawn(async move {
236            Self::udp_loop(socket, local_addr, udp_shared).await;
237        });
238        app_state.register_server_task(server_id, udp_handle).await;
239
240        let tcp_shared = shared.clone();
241        let idle = settings.tcp_idle_timeout;
242        let per_source = PerSourceLimiter::new(settings.tcp_connections_per_ip);
243        let tcp_handle = tokio::spawn(async move {
244            Self::tcp_loop(listener, local_addr, tcp_shared, idle, per_source).await;
245        });
246        app_state.register_server_task(server_id, tcp_handle).await;
247
248        Ok(local_addr)
249    }
250
251    async fn register_connection(
252        shared: &Shared,
253        peer_addr: SocketAddr,
254        local_addr: SocketAddr,
255        received: u64,
256    ) -> ConnectionId {
257        use crate::state::server::{
258            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
259        };
260        let connection_id = ConnectionId::new(shared.app_state.get_next_unified_id().await);
261        let now = crate::utils::clock::Instant::now();
262        shared
263            .app_state
264            .add_connection_to_server(
265                shared.server_id,
266                ServerConnectionState {
267                    id: connection_id,
268                    remote_addr: peer_addr,
269                    local_addr,
270                    bytes_sent: 0,
271                    bytes_received: received,
272                    packets_sent: 0,
273                    packets_received: u64::from(received > 0),
274                    last_activity: now,
275                    status: ConnectionStatus::Active,
276                    status_changed_at: now,
277                    protocol_info: ProtocolConnectionInfo::empty(),
278                },
279            )
280            .await;
281        let _ = shared.status_tx.send("__UPDATE_UI__".to_string());
282        connection_id
283    }
284
285    async fn udp_loop(socket: Arc<UdpSocket>, local_addr: SocketAddr, shared: Arc<Shared>) {
286        // RFC 1035 caps plain UDP DNS at 512 bytes, but EDNS0 (RFC 6891) clients routinely
287        // advertise 1232-4096 byte buffers. A short buffer would silently truncate those
288        // datagrams, and the truncated bytes would then fail to parse as DNS.
289        let mut buffer = vec![0u8; 4096];
290        loop {
291            let (n, peer_addr) = match socket.recv_from(&mut buffer).await {
292                Ok(r) => r,
293                Err(e) => {
294                    Log::new(Some(&shared.status_tx)).error(format!("DNS receive error: {e}"));
295                    break;
296                }
297            };
298            let data = buffer[..n].to_vec();
299            let connection_id =
300                Self::register_connection(&shared, peer_addr, local_addr, n as u64).await;
301            {
302                let log = Log::new(Some(&shared.status_tx));
303                log.debug(format!("DNS received {n} bytes from {peer_addr} over UDP"));
304                log.trace(format!("DNS data (hex): {}", hex::encode(&data)));
305            }
306            let task_shared = shared.clone();
307            let task_socket = socket.clone();
308            // Tracked, not detached: stop_server must abort this task too.
309            shared
310                .app_state
311                .spawn_server_task(shared.server_id, async move {
312                    let replies = Self::answer(
313                        &task_shared,
314                        &data,
315                        peer_addr,
316                        connection_id,
317                        Transport::Udp,
318                    )
319                    .await;
320                    // UDP carries one message; a transfer never reaches here as more.
321                    if let Some(reply) = replies.first() {
322                        let _ = task_socket.send_to(reply, peer_addr).await;
323                        Self::count_sent(&task_shared, connection_id, reply.len()).await;
324                    }
325                })
326                .await;
327        }
328    }
329
330    async fn tcp_loop(
331        listener: TcpListener,
332        local_addr: SocketAddr,
333        shared: Arc<Shared>,
334        idle: Duration,
335        per_source: PerSourceLimiter,
336    ) {
337        // Two caps: past MAX_TCP_CONNECTIONS in all, or past `per_source.max` from one address,
338        // a connection is closed unread, and a client retries or moves to another server.
339        let limiter = ConnectionLimiter::new(MAX_TCP_CONNECTIONS);
340        loop {
341            let (stream, peer_addr, permit) = match accept_bounded(
342                &listener,
343                &limiter,
344                CONNECTION_CAP_REFUSAL,
345                "DNS",
346                Some(&shared.status_tx),
347            )
348            .await
349            {
350                Ok(r) => r,
351                Err(e) => {
352                    Log::new(Some(&shared.status_tx)).error(format!("DNS TCP accept error: {e}"));
353                    continue;
354                }
355            };
356            let Some(source_slot) = per_source.try_acquire(peer_addr.ip()) else {
357                Log::new(Some(&shared.status_tx)).warn(format!(
358                    "DNS connection from {peer_addr} refused decision=fail_closed_per_source_cap \
359                     (limit {} concurrent per address)",
360                    per_source.max
361                ));
362                // The global slot goes back at once; the socket is closed unread, as at the
363                // global cap.
364                drop(permit);
365                let mut stream = stream;
366                let _ = stream.shutdown().await;
367                continue;
368            };
369            let connection_id = Self::register_connection(&shared, peer_addr, local_addr, 0).await;
370            let task_shared = shared.clone();
371            shared
372                .app_state
373                .spawn_server_task(shared.server_id, async move {
374                    let _permit = permit;
375                    let _source_slot = source_slot;
376                    Self::tcp_connection(stream, peer_addr, connection_id, task_shared, idle).await;
377                })
378                .await;
379        }
380    }
381
382    /// One TCP connection: two-byte length, message, answer, repeat (RFC 1035 §4.2.2). Queries
383    /// are answered in order. The connection closes when the peer does, after `idle` with no
384    /// query, or on a query this server cannot read.
385    async fn tcp_connection(
386        mut stream: TcpStream,
387        peer_addr: SocketAddr,
388        connection_id: ConnectionId,
389        shared: Arc<Shared>,
390        idle: Duration,
391    ) {
392        let log = Log::new(Some(&shared.status_tx));
393        loop {
394            let len = match tokio::time::timeout(idle, stream.read_u16()).await {
395                Err(_) => {
396                    log.debug(format!(
397                        "DNS TCP {peer_addr} idle for {}s; closing decision=tcp_idle",
398                        idle.as_secs()
399                    ));
400                    break;
401                }
402                Ok(Err(_)) => break, // the peer closed
403                Ok(Ok(0)) => break,
404                Ok(Ok(n)) => n as usize,
405            };
406            let mut data = vec![0u8; len];
407            match tokio::time::timeout(idle, stream.read_exact(&mut data)).await {
408                Ok(Ok(_)) => {}
409                _ => {
410                    log.debug(format!(
411                        "DNS TCP {peer_addr} stopped part way through a {len}-byte message"
412                    ));
413                    break;
414                }
415            }
416            shared
417                .app_state
418                .update_connection_stats(
419                    shared.server_id,
420                    connection_id,
421                    Some(len as u64 + 2),
422                    None,
423                    Some(1),
424                    None,
425                )
426                .await;
427            log.debug(format!(
428                "DNS received {len} bytes from {peer_addr} over TCP"
429            ));
430            let replies =
431                Self::answer(&shared, &data, peer_addr, connection_id, Transport::Tcp).await;
432            if replies.is_empty() && DnsMessage::from_vec(&data).is_err() {
433                break; // unreadable: nothing on this stream can be trusted to be framed
434            }
435            for reply in &replies {
436                // Every reply is a message hickory encoded, which stops at 65535 octets; one
437                // that did not fit the two-byte length would be misframed, so it closes the
438                // connection instead.
439                let Ok(reply_len) = u16::try_from(reply.len()) else {
440                    log.error(format!(
441                        "DNS reply of {} bytes to {peer_addr} does not fit a TCP length prefix; \
442                         closing decision=fail_closed_oversize_answer",
443                        reply.len()
444                    ));
445                    let _ = stream.shutdown().await;
446                    return;
447                };
448                let mut framed = Vec::with_capacity(reply.len() + 2);
449                framed.extend_from_slice(&reply_len.to_be_bytes());
450                framed.extend_from_slice(reply);
451                if stream.write_all(&framed).await.is_err() {
452                    return;
453                }
454                Self::count_sent(&shared, connection_id, framed.len()).await;
455            }
456        }
457        let _ = stream.shutdown().await;
458    }
459
460    async fn count_sent(shared: &Shared, connection_id: ConnectionId, bytes: usize) {
461        shared
462            .app_state
463            .update_connection_stats(
464                shared.server_id,
465                connection_id,
466                None,
467                Some(bytes as u64),
468                None,
469                Some(1),
470            )
471            .await;
472    }
473
474    /// Answer one query. Returns the messages to send: none, one, or — for a zone transfer
475    /// over TCP — as many as the zone needs.
476    async fn answer(
477        shared: &Shared,
478        data: &[u8],
479        peer_addr: SocketAddr,
480        connection_id: ConnectionId,
481        transport: Transport,
482    ) -> Vec<Vec<u8>> {
483        let log = Log::new(Some(&shared.status_tx));
484        let query = match DnsMessage::from_vec(data) {
485            Ok(q) => q,
486            Err(e) => {
487                log.warn(format!("Failed to parse DNS query: {e}"));
488                log.debug(format!(
489                    "DNS malformed query from {peer_addr} ({} bytes, hex: {})",
490                    data.len(),
491                    hex::encode(data)
492                ));
493                return Vec::new();
494            }
495        };
496        let edns = query.extensions().clone();
497        let dnssec_ok = edns.as_ref().is_some_and(|e| e.dnssec_ok());
498        let udp_limit = match (&edns, transport) {
499            (_, Transport::Tcp) => u16::MAX as usize,
500            (Some(e), Transport::Udp) => (e.max_payload() as usize)
501                .max(PLAIN_UDP_LIMIT)
502                .min(MAX_UDP_RESPONSE),
503            (None, Transport::Udp) => PLAIN_UDP_LIMIT,
504        };
505        let reply = Reply {
506            query: &query,
507            edns: edns.is_some(),
508            dnssec_ok,
509            udp_limit,
510            transport,
511        };
512
513        let Some(question) = query.queries().first().cloned() else {
514            log.warn(format!(
515                "DNS query from {peer_addr} has no question; FORMERR decision=spec_reply"
516            ));
517            return reply.rcode_only(ResponseCode::FormErr);
518        };
519        let qname = question.name().clone();
520        let qtype = question.query_type();
521
522        // The signed zone's DNSKEY is the server's, not the model's.
523        if let Some(signer) = &shared.signer {
524            if qtype == RecordType::DNSKEY && qname.to_lowercase() == signer.zone().to_lowercase() {
525                log.debug(format!(
526                    "DNS DNSKEY {qname} from {peer_addr} answered by the server decision=spec_reply"
527                ));
528                let mut message = transfer::response_shell(&query, ResponseCode::NoError);
529                message.add_query(question.clone());
530                message.add_answer(signer.dnskey_record());
531                return reply.finish(message, shared.signer.as_deref(), &log);
532            }
533        }
534
535        match qtype {
536            RecordType::AXFR if transport == Transport::Udp => {
537                // RFC 5936 §4.2: AXFR is a TCP exchange. Not the model's decision.
538                log.info(format!(
539                    "DNS AXFR {qname} from {peer_addr} over UDP; NOTIMP decision=spec_reply"
540                ));
541                reply.rcode_only(ResponseCode::NotImp)
542            }
543            RecordType::AXFR | RecordType::IXFR => {
544                Self::transfer(shared, &reply, &question, peer_addr, connection_id, &log).await
545            }
546            _ => Self::query(shared, &reply, &question, peer_addr, connection_id, &log).await,
547        }
548    }
549
550    async fn query(
551        shared: &Shared,
552        reply: &Reply<'_>,
553        question: &Query,
554        peer_addr: SocketAddr,
555        connection_id: ConnectionId,
556        log: &Log<'_>,
557    ) -> Vec<Vec<u8>> {
558        let query = reply.query;
559        log.debug(format!(
560            "DNS query: {} {} {} over {}",
561            question.name(),
562            question.query_type(),
563            question.query_class(),
564            reply.transport.as_str()
565        ));
566        let event = Event::new(
567            &DNS_QUERY_EVENT,
568            serde_json::json!({
569                "query_id": query.id(),
570                "domain": question.name().to_string(),
571                "query_type": question.query_type().to_string(),
572                "transport": reply.transport.as_str(),
573                "dnssec_ok": reply.dnssec_ok,
574                "client_ip": peer_addr.ip().to_string(),
575                "client_port": peer_addr.port(),
576            }),
577        );
578        // `Some(connection_id)`, not `None`: it is what `call_llm_inner` uses to look up the
579        // peer address for the `dns_query` event's INFO template, and what scopes
580        // connection-scoped handlers and tasks.
581        let result = call_llm(
582            &shared.llm_client,
583            &shared.app_state,
584            shared.server_id,
585            Some(connection_id),
586            &event,
587            shared.protocol.as_ref(),
588        )
589        .await;
590        let execution = match result {
591            Ok(r) => r,
592            Err(e) => return reply.servfail_for_llm_error(&e, peer_addr, connection_id, log),
593        };
594        for message in &execution.messages {
595            log.info(message);
596        }
597        // Three outcomes stay distinct, and two of them look identical on the wire: an answer
598        // was written (model_answer), `ignore_query` (model_silent, a real decision), and
599        // nothing usable came back (fail_closed_no_action) — a model answering only with
600        // common actions, or with a DNS action `execute_action` refused.
601        let mut model_chose_silence = false;
602        for result in execution.protocol_results {
603            match result {
604                ActionResult::NoAction => model_chose_silence = true,
605                ActionResult::Output(bytes) => {
606                    let Ok(message) = DnsMessage::from_vec(&bytes) else {
607                        // Only `send_dns_response`'s hand-assembled bytes can fail here. Bytes
608                        // that are not a DNS message never go out: unparsed, they would skip
609                        // truncation and could be any length at all.
610                        log.error(format!(
611                            "DNS answer to the query from {peer_addr} is not a DNS message \
612                             ({} bytes); answering SERVFAIL \
613                             decision=fail_closed_unparseable_answer",
614                            bytes.len()
615                        ));
616                        return reply.servfail();
617                    };
618                    log.debug(format!("DNS query from {peer_addr} decision=model_answer"));
619                    log.info(format!("DNS response to {peer_addr}"));
620                    return reply.finish(message, shared.signer.as_deref(), log);
621                }
622                _ => {}
623            }
624        }
625        if model_chose_silence {
626            log.debug(format!(
627                "DNS deliberately sending nothing to {peer_addr} decision=model_silent"
628            ));
629            return Vec::new();
630        }
631        log.error(format!(
632            "DNS no usable action for the query from {peer_addr} ({connection_id}); answering \
633             SERVFAIL decision=fail_closed_no_action (failures: {:?})",
634            execution.failures
635        ));
636        reply.servfail()
637    }
638
639    async fn transfer(
640        shared: &Shared,
641        reply: &Reply<'_>,
642        question: &Query,
643        peer_addr: SocketAddr,
644        connection_id: ConnectionId,
645        log: &Log<'_>,
646    ) -> Vec<Vec<u8>> {
647        let query = reply.query;
648        let zone = question.name().clone();
649        let kind = question.query_type();
650        // IXFR carries the serial the client holds as an SOA in the authority section.
651        let client_serial = query
652            .name_servers()
653            .iter()
654            .find(|r| r.record_type() == RecordType::SOA)
655            .and_then(transfer::serial_of);
656        let signed = shared
657            .signer
658            .as_ref()
659            .is_some_and(|s| s.zone().to_lowercase() == zone.to_lowercase());
660        let mut data = serde_json::json!({
661            "query_id": query.id(),
662            "zone": zone.to_string(),
663            "transfer_type": kind.to_string(),
664            "transport": reply.transport.as_str(),
665            "signed": signed,
666            "client_ip": peer_addr.ip().to_string(),
667            "client_port": peer_addr.port(),
668        });
669        if let Some(serial) = client_serial {
670            data["client_serial"] = serde_json::json!(serial);
671        }
672        log.info(format!(
673            "DNS {kind} {zone} from {peer_addr} over {}",
674            reply.transport.as_str()
675        ));
676        let result = call_llm(
677            &shared.llm_client,
678            &shared.app_state,
679            shared.server_id,
680            Some(connection_id),
681            &Event::new(&DNS_ZONE_TRANSFER_EVENT, data),
682            shared.protocol.as_ref(),
683        )
684        .await;
685        let execution = match result {
686            Ok(r) => r,
687            Err(e) => return reply.servfail_for_llm_error(&e, peer_addr, connection_id, log),
688        };
689        for message in &execution.messages {
690            log.info(message);
691        }
692        for result in execution.protocol_results {
693            let built = match &result {
694                ActionResult::NoAction => {
695                    log.debug(format!(
696                        "DNS {kind} {zone} from {peer_addr}: sending nothing decision=model_silent"
697                    ));
698                    return Vec::new();
699                }
700                ActionResult::Output(bytes) => {
701                    // A refusal (or any single-message answer) to the transfer.
702                    let Ok(message) = DnsMessage::from_vec(bytes) else {
703                        log.error(format!(
704                            "DNS answer to the {kind} of {zone} from {peer_addr} is not a DNS \
705                             message ({} bytes); answering SERVFAIL \
706                             decision=fail_closed_unparseable_answer",
707                            bytes.len()
708                        ));
709                        return reply.servfail();
710                    };
711                    let decision = if message.response_code() == ResponseCode::Refused {
712                        "model_reject"
713                    } else {
714                        "model_answer"
715                    };
716                    log.info(format!(
717                        "DNS {kind} {zone} from {peer_addr} decision={decision}"
718                    ));
719                    return reply.finish(message, None, log);
720                }
721                ActionResult::Custom { name, data } if name == RESULT_ZONE => {
722                    if reply.transport == Transport::Udp {
723                        log.info(format!(
724                            "DNS {kind} {zone} from {peer_addr}: a full zone is never sent over \
725                             UDP; answering with the SOA alone decision=udp_soa_only"
726                        ));
727                    }
728                    Self::build_axfr(shared, reply, question, data)
729                }
730                ActionResult::Custom { name, data } if name == RESULT_IXFR => {
731                    let has_changes = data
732                        .get("changes")
733                        .and_then(|c| c.as_array())
734                        .is_some_and(|c| !c.is_empty());
735                    // "You are current" is the SOA alone and carries nothing unsigned; a
736                    // difference would.
737                    if signed && has_changes {
738                        Err(anyhow::anyhow!(
739                            "{zone} is signed and its IXFR must be answered with the full zone \
740                             (send_dns_zone); a diff would carry no signatures"
741                        ))
742                    } else {
743                        Self::build_ixfr(reply, question, data, client_serial.unwrap_or(0))
744                    }
745                }
746                _ => continue,
747            };
748            return match built {
749                Ok(messages) => {
750                    log.info(format!(
751                        "DNS {kind} {zone} to {peer_addr}: {} message(s) decision=model_answer",
752                        messages.len()
753                    ));
754                    messages
755                }
756                Err(e) => {
757                    log.error(format!(
758                        "DNS {kind} {zone} from {peer_addr}: cannot build the transfer ({e:#}); \
759                         answering SERVFAIL decision=fail_closed_no_action"
760                    ));
761                    reply.servfail()
762                }
763            };
764        }
765        log.error(format!(
766            "DNS no usable action for the {kind} of {zone} from {peer_addr}; answering SERVFAIL \
767             decision=fail_closed_no_action (failures: {:?})",
768            execution.failures
769        ));
770        reply.servfail()
771    }
772
773    /// The AXFR stream, signed when the zone is: SOA, records (and DNSKEY, NSEC chain and every
774    /// RRSIG for a signed zone), SOA. Over UDP — an IXFR answered with the full zone — the
775    /// answer is the SOA alone whatever the zone's size (RFC 1995 §2's "use TCP"): a UDP
776    /// source address can be forged, so a full zone never goes to one.
777    fn build_axfr(
778        shared: &Shared,
779        reply: &Reply<'_>,
780        question: &Query,
781        action: &serde_json::Value,
782    ) -> Result<Vec<Vec<u8>>> {
783        let zone = transfer::zone_from_action(action)?;
784        if zone.origin.to_lowercase() != question.name().to_lowercase() {
785            anyhow::bail!(
786                "the answer transfers {} but the client asked for {}",
787                zone.origin,
788                question.name()
789            );
790        }
791        if reply.transport == Transport::Udp {
792            return Self::soa_only(reply, question, Some(zone.soa.clone()));
793        }
794        let mut sequence = transfer::axfr_sequence(&zone);
795        if let Some(signer) = shared
796            .signer
797            .as_deref()
798            .filter(|s| s.zone().to_lowercase() == zone.origin.to_lowercase())
799        {
800            sequence = Self::sign_zone(signer, &zone)?;
801        }
802        Self::transfer_messages(reply, question, sequence)
803    }
804
805    fn sign_zone(signer: &ZoneSigner, zone: &transfer::Zone) -> Result<Vec<Record>> {
806        let now = now_secs();
807        let mut body: Vec<Record> = vec![zone.soa.clone(), signer.dnskey_record()];
808        for r in &zone.records {
809            if r.record_type() == RecordType::NS && r.name() != &zone.origin {
810                anyhow::bail!(
811                    "{} NS is a delegation; a signed zone here holds no delegations",
812                    r.name()
813                );
814            }
815            body.push(r.clone());
816        }
817        let nsec = signer.nsec_chain(&body, dnssec::negative_ttl(&zone.soa))?;
818        body.extend(nsec);
819        let sigs = signer.signatures_for(&mut body, now)?;
820        // The SOA (now with its signed TTL) opens and closes the stream; RRSIGs follow the
821        // records they cover.
822        let soa = body[0].clone();
823        let mut out = body;
824        out.extend(sigs);
825        out.push(soa);
826        Ok(out)
827    }
828
829    fn build_ixfr(
830        reply: &Reply<'_>,
831        question: &Query,
832        action: &serde_json::Value,
833        client_serial: u32,
834    ) -> Result<Vec<Vec<u8>>> {
835        let (origin, sequence) = transfer::ixfr_from_action(action, client_serial)?;
836        if origin.to_lowercase() != question.name().to_lowercase() {
837            anyhow::bail!(
838                "the answer transfers {origin} but the client asked for {}",
839                question.name()
840            );
841        }
842        Self::transfer_messages(reply, question, sequence)
843    }
844
845    /// The current SOA alone: RFC 1995 §2's answer to an IXFR over UDP that is not sent there,
846    /// which tells the client to ask again over TCP.
847    fn soa_only(reply: &Reply<'_>, question: &Query, soa: Option<Record>) -> Result<Vec<Vec<u8>>> {
848        let mut only_soa = transfer::response_shell(reply.query, ResponseCode::NoError);
849        only_soa.add_query(question.clone());
850        only_soa.add_answers(soa);
851        if let Some(e) = reply.response_edns() {
852            only_soa.set_edns(e);
853        }
854        Ok(vec![only_soa.to_vec()?])
855    }
856
857    fn transfer_messages(
858        reply: &Reply<'_>,
859        question: &Query,
860        sequence: Vec<Record>,
861    ) -> Result<Vec<Vec<u8>>> {
862        let edns = reply.response_edns();
863        let add_edns = |m: &mut DnsMessage| {
864            if let Some(e) = &edns {
865                m.set_edns(e.clone());
866            }
867        };
868        if reply.transport == Transport::Udp {
869            // RFC 1995 §2: an IXFR difference that does not fit UDP is replaced by the current
870            // SOA.
871            let mut whole = transfer::response_shell(reply.query, ResponseCode::NoError);
872            whole.add_query(question.clone());
873            let soa = sequence.first().cloned();
874            whole.add_answers(sequence);
875            add_edns(&mut whole);
876            let bytes = whole.to_vec().ok();
877            if let Some(bytes) = bytes.filter(|b| b.len() <= reply.udp_limit) {
878                return Ok(vec![bytes]);
879            }
880            return Self::soa_only(reply, question, soa);
881        }
882        transfer::split_into_messages(
883            reply.query,
884            Some(question),
885            sequence,
886            transfer::TRANSFER_MESSAGE_BYTES,
887            add_edns,
888        )
889    }
890}
891
892/// What every reply to one query shares.
893struct Reply<'a> {
894    query: &'a DnsMessage,
895    edns: bool,
896    dnssec_ok: bool,
897    udp_limit: usize,
898    transport: Transport,
899}
900
901impl Reply<'_> {
902    /// The OPT this server answers an EDNS query with (RFC 6891 §6.1.1); `None` for a query
903    /// without one, which must not get one back.
904    fn response_edns(&self) -> Option<Edns> {
905        self.edns.then(|| {
906            let mut e = Edns::new();
907            e.set_max_payload(EDNS_UDP_SIZE);
908            e.set_version(0);
909            e.set_dnssec_ok(self.dnssec_ok);
910            e
911        })
912    }
913
914    /// A reply with only a header, the question and (for EDNS) an OPT.
915    fn rcode_only(&self, rcode: ResponseCode) -> Vec<Vec<u8>> {
916        let mut message = transfer::response_shell(self.query, rcode);
917        message.set_authoritative(false);
918        for q in self.query.queries() {
919            message.add_query(q.clone());
920        }
921        if let Some(e) = self.response_edns() {
922            message.set_edns(e);
923        }
924        message.to_vec().map(|b| vec![b]).unwrap_or_default()
925    }
926
927    /// SERVFAIL, through the one builder every fail-closed path uses.
928    fn servfail(&self) -> Vec<Vec<u8>> {
929        match actions::build_servfail(self.query) {
930            Ok(packet) => vec![packet],
931            Err(_) => Vec::new(),
932        }
933    }
934
935    fn servfail_for_llm_error(
936        &self,
937        e: &anyhow::Error,
938        peer_addr: SocketAddr,
939        connection_id: ConnectionId,
940        log: &Log<'_>,
941    ) -> Vec<Vec<u8>> {
942        // SERVFAIL is the same bytes whatever went wrong, so the log is the only place the
943        // distinction survives (`src/server/radius/` is the model for this).
944        let decision = if crate::llm::is_overload_error(e) {
945            "fail_closed_llm_overload"
946        } else {
947            "fail_closed_llm_error"
948        };
949        log.warn(format!(
950            "DNS LLM call failed for query from {peer_addr} ({connection_id}) decision={decision}: {e}"
951        ));
952        let out = self.servfail();
953        if let Some(packet) = out.first() {
954            log.info(format!(
955                "DNS SERVFAIL to {peer_addr} ({} bytes)",
956                packet.len()
957            ));
958        }
959        out
960    }
961
962    /// Finish a one-message answer: DNSSEC for a signed name when the client asked, the OPT
963    /// for an EDNS query, and truncation when it does not fit the client's UDP buffer.
964    fn finish(
965        &self,
966        mut message: DnsMessage,
967        signer: Option<&ZoneSigner>,
968        log: &Log<'_>,
969    ) -> Vec<Vec<u8>> {
970        let qname = self.query.queries().first().map(|q| q.name().clone());
971        if let (Some(signer), Some(qname)) = (signer, qname.as_ref()) {
972            if self.dnssec_ok && signer.covers(qname) {
973                if let Err(e) = Self::sign_answer(&mut message, signer, qname) {
974                    log.error(format!(
975                        "DNS cannot sign the answer for {qname} ({e:#}); answering SERVFAIL \
976                         decision=fail_closed_dnssec"
977                    ));
978                    return self.servfail();
979                }
980            }
981        }
982        if let Some(e) = self.response_edns() {
983            message.set_edns(e);
984        }
985        let bytes = match message.to_vec() {
986            Ok(b) => b,
987            Err(e) => {
988                log.error(format!(
989                    "DNS cannot encode the answer ({e}); answering SERVFAIL \
990                     decision=fail_closed_no_action"
991                ));
992                return self.servfail();
993            }
994        };
995        if self.transport == Transport::Udp && bytes.len() > self.udp_limit {
996            // RFC 2181 §9 / RFC 7766: set TC and send what is certain to fit — the header and
997            // the question — so the client retries over TCP.
998            log.debug(format!(
999                "DNS answer of {} bytes is over the client's {} over UDP; TC decision=truncated",
1000                bytes.len(),
1001                self.udp_limit
1002            ));
1003            let mut truncated = transfer::response_shell(self.query, message.response_code());
1004            truncated.set_truncated(true);
1005            for q in self.query.queries() {
1006                truncated.add_query(q.clone());
1007            }
1008            if let Some(e) = self.response_edns() {
1009                truncated.set_edns(e);
1010            }
1011            return truncated.to_vec().map(|b| vec![b]).unwrap_or_default();
1012        }
1013        vec![bytes]
1014    }
1015
1016    /// Sign every in-zone RRset of an answer, and prove a negative one with compact denial.
1017    fn sign_answer(message: &mut DnsMessage, signer: &ZoneSigner, qname: &Name) -> Result<()> {
1018        let now = now_secs();
1019        let negative = message.response_code() == ResponseCode::NXDomain
1020            || (message.response_code() == ResponseCode::NoError && message.answers().is_empty());
1021        let mut answers = message.take_answers();
1022        let mut authority = message.take_name_servers();
1023        let mut additional = message.take_additionals();
1024        if negative {
1025            let soa = match authority
1026                .iter()
1027                .find(|r| r.record_type() == RecordType::SOA && signer.covers(r.name()))
1028            {
1029                Some(soa) => soa.clone(),
1030                None => {
1031                    let soa = signer.soa_record();
1032                    authority.push(soa.clone());
1033                    soa
1034                }
1035            };
1036            authority.push(signer.compact_denial(qname, dnssec::negative_ttl(&soa))?);
1037            // Compact denial answers NOERROR: the NSEC proves the name has no types at all.
1038            message.set_response_code(ResponseCode::NoError);
1039        }
1040        let answer_sigs = signer.signatures_for(&mut answers, now)?;
1041        let authority_sigs = signer.signatures_for(&mut authority, now)?;
1042        let additional_sigs = signer.signatures_for(&mut additional, now)?;
1043        answers.extend(answer_sigs);
1044        authority.extend(authority_sigs);
1045        additional.extend(additional_sigs);
1046        message.insert_answers(answers);
1047        message.insert_name_servers(authority);
1048        message.add_additionals(additional);
1049        message.set_authoritative(true);
1050        Ok(())
1051    }
1052}