Skip to main content

netget/server/dot/
mod.rs

1//! DNS-over-TLS (DoT) server implementation
2//!
3//! Implements RFC 7858 DNS-over-TLS protocol using hickory-dns and rustls.
4//! The LLM controls DNS responses while NetGet handles the TLS transport layer.
5
6pub mod actions;
7
8use crate::llm::action_helper::call_llm;
9use crate::llm::ollama_client::OllamaClient;
10use crate::logging::emit::Log;
11use crate::protocol::Event;
12use crate::server::connection::ConnectionId;
13use crate::server::DotProtocol;
14use crate::state::app_state::AppState;
15use crate::state::ServerId;
16use actions::DOT_QUERY_EVENT;
17use anyhow::{Context, Result};
18use hickory_proto::op::Message as DnsMessage;
19use serde_json::json;
20use std::net::SocketAddr;
21use std::sync::Arc;
22use tokio::io::{AsyncReadExt, AsyncWriteExt};
23use tokio::net::{TcpListener, TcpStream};
24use tokio::sync::mpsc;
25use tokio::time::{timeout, Duration};
26use tokio_rustls::TlsAcceptor;
27use tracing::error;
28
29/// Bound on the TLS handshake for one accepted connection.
30///
31/// Without it, a peer that completes the TCP handshake and then sends nothing holds
32/// `acceptor.accept()` — and the task around it — open forever. That is reachable from the
33/// wire by anyone who can connect, and costs a task and a socket per attempt.
34const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
35
36/// Bound on how long an established DoT connection may sit without sending a query.
37///
38/// RFC 7858 §3.4 has the client manage an idle timeout and the server free to close an idle
39/// connection; without any bound here a connection that never sends a second query is a
40/// permanently parked task. This is deliberately generous — resolvers legitimately pin one
41/// TLS connection and reuse it — but finite.
42const IDLE_READ_TIMEOUT: Duration = Duration::from_secs(300);
43
44/// Once a length prefix arrives, the peer must finish that DNS message promptly. The
45/// between-query idle timeout cannot bound a read that is already waiting for its body.
46const MESSAGE_READ_TIMEOUT: Duration = Duration::from_secs(10);
47
48/// Pause after a failed `accept()` before trying again.
49///
50/// `accept` failing is usually transient (the peer went away between the SYN and the accept)
51/// and retrying immediately is right. It is not always transient: at the file-descriptor
52/// limit, `accept` returns `EMFILE` instantly and keeps doing so, and a bare `continue` then
53/// spins the accept loop at full speed writing a warning per iteration onto an **unbounded**
54/// status channel — turning "out of descriptors" into "out of memory". A short pause costs
55/// nothing in the transient case and bounds the pathological one.
56const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(100);
57
58/// Concurrent connections this server admits before it starts refusing.
59///
60/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`TLS_HANDSHAKE_TIMEOUT`] and
61/// [`IDLE_READ_TIMEOUT`] bound how long *one* peer holds a slot; only a cap bounds how many of
62/// them there can be at once, and `IDLE_READ_TIMEOUT` is deliberately five minutes, so without
63/// a cap a peer that connects once a second holds three hundred connections before the first
64/// one is even eligible to be closed. DoT is the transport a resolver pins and reuses for the
65/// life of its process (RFC 7858 §3.4 is written around exactly that), so a real deployment's
66/// live-connection count is its resolver count — 256 is far above any of those and far below
67/// the descriptor and TLS-session state an unbounded accept loop would let a stranger pin.
68const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
69
70/// A peer over [`MAX_CONNECTIONS`] gets a **plain close**, with nothing written.
71///
72/// **DoT's first byte is a TLS record, so there is nothing to say it in.** A DNS response is
73/// not merely inapplicable here — it is unreachable: RFC 7858 puts the whole DNS exchange
74/// *inside* the TLS session, so a length-prefixed DNS message written to this socket is not a
75/// SERVFAIL, it is a malformed TLS record, and the client reports a handshake failure rather
76/// than a busy server. (A DNS refusal would be wrong twice over: a response echoes the query's
77/// ID and question section, and a peer refused at the accept has sent no query.)
78///
79/// Refusing *inside* TLS would mean completing a handshake in order to say no, which hands a
80/// stranger a certificate signature per refused connection — a cap that costs more to enforce
81/// than to ignore. TLS's own record layer has no alert for this: RFC 8446 §6.2 defines nothing
82/// meaning "at capacity", and `internal_error` claims this server is broken when it is merely
83/// full.
84///
85/// So the refusal is EOF before the handshake. This is `smtp`'s implicit-TLS reasoning, and
86/// every DoT client already has a path for it: RFC 7858 §3.1 has the client fall back or
87/// retry when a TLS connection cannot be established. The refusal is logged with
88/// `decision=fail_closed_connection_cap`, which is where the real reason lives.
89const CONNECTION_CAP_REFUSAL: &[u8] = b"";
90
91/// DNS-over-TLS server
92pub struct DotServer;
93
94impl DotServer {
95    /// Spawn the DoT server.
96    ///
97    /// The listener is bound here, *before* the accept loop is spawned, so that
98    /// a bind failure (port in use, permission denied) is returned to the
99    /// caller instead of being swallowed by the background task - otherwise the
100    /// server would be reported as `Running` while nothing is listening.
101    /// Binding here also means the returned address carries the real port when
102    /// the caller asked for port 0.
103    pub async fn spawn(
104        bind_addr: SocketAddr,
105        llm_client: OllamaClient,
106        app_state: Arc<AppState>,
107        server_id: ServerId,
108        status_tx: mpsc::UnboundedSender<String>,
109    ) -> Result<SocketAddr> {
110        // Generate TLS configuration (use default self-signed cert)
111        let tls_config = crate::server::tls_cert_manager::generate_default_tls_config()
112            .context("Failed to generate TLS configuration")?;
113
114        Log::new(Some(&status_tx)).info(format!("Starting DoT server on {}", bind_addr));
115
116        let listener = TcpListener::bind(bind_addr)
117            .await
118            .context("Failed to bind DoT TCP listener")?;
119
120        // Actual bound address (important for port 0 dynamic allocation)
121        let local_addr = listener
122            .local_addr()
123            .context("Failed to get DoT listener local address")?;
124
125        Log::new(Some(&status_tx)).info(format!("DoT server listening on {}", local_addr));
126
127        let task_registrar = app_state.clone();
128        let handle = tokio::spawn(async move {
129            if let Err(e) = Self::run(
130                listener, tls_config, llm_client, app_state, server_id, status_tx,
131            )
132            .await
133            {
134                error!("DoT server error: {}", e);
135            }
136        });
137
138        // Register the accept loop so stop_server can abort it and release the port.
139        task_registrar.register_server_task(server_id, handle).await;
140
141        Ok(local_addr)
142    }
143
144    /// Run the DoT accept loop on an already-bound listener
145    async fn run(
146        listener: TcpListener,
147        tls_config: Arc<rustls::ServerConfig>,
148        llm_client: OllamaClient,
149        app_state: Arc<AppState>,
150        server_id: ServerId,
151        status_tx: mpsc::UnboundedSender<String>,
152    ) -> Result<()> {
153        let acceptor = TlsAcceptor::from(tls_config);
154        let local_addr = listener
155            .local_addr()
156            .context("Failed to get DoT listener local address")?;
157
158        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
159
160        loop {
161            match crate::server::accept_bounded::accept_bounded(
162                &listener,
163                &limiter,
164                CONNECTION_CAP_REFUSAL,
165                "DOT",
166                Some(&status_tx),
167            )
168            .await
169            {
170                Ok((stream, peer_addr, permit)) => {
171                    Log::new(Some(&status_tx))
172                        .debug(format!("DoT TCP connection from {}", peer_addr));
173
174                    // Register the peer in server state before the handshake, so the
175                    // dashboard rail shows the connection while TLS is still being
176                    // negotiated rather than only once a query arrives. Nothing tracked
177                    // DoT connections at all before this: a DoT server showed an empty
178                    // peer list however many resolvers were talking to it, and the
179                    // byte/packet counters the rail draws stayed at zero.
180                    let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
181                    {
182                        use crate::state::server::{
183                            ConnectionState as ServerConnectionState, ConnectionStatus,
184                            ProtocolConnectionInfo,
185                        };
186                        let now = crate::utils::clock::Instant::now();
187                        app_state
188                            .add_connection_to_server(
189                                server_id,
190                                ServerConnectionState {
191                                    id: connection_id,
192                                    remote_addr: peer_addr,
193                                    local_addr,
194                                    bytes_sent: 0,
195                                    bytes_received: 0,
196                                    packets_sent: 0,
197                                    packets_received: 0,
198                                    last_activity: now,
199                                    status: ConnectionStatus::Active,
200                                    status_changed_at: now,
201                                    protocol_info: ProtocolConnectionInfo::empty(),
202                                },
203                            )
204                            .await;
205                    }
206                    let _ = status_tx.send("__UPDATE_UI__".to_string());
207
208                    let acceptor = acceptor.clone();
209                    let llm_client = llm_client.clone();
210                    let conn_state = app_state.clone();
211                    let app_state = app_state.clone();
212                    let status_tx = status_tx.clone();
213
214                    // Registered, not detached. `stop_server` aborts the tasks a server
215                    // registered; an unregistered per-connection task survives it, so a
216                    // stopped DoT server went on serving every connection it had already
217                    // accepted.
218                    let handle = tokio::spawn(async move {
219                        // Released when this task ends, and this task is the whole of the
220                        // connection — DoT spawns nothing else per peer — so
221                        // `MAX_CONNECTIONS` caps live connections rather than accepts.
222                        let _permit = permit;
223                        if let Err(e) = Self::handle_connection(
224                            stream,
225                            peer_addr,
226                            connection_id,
227                            acceptor,
228                            llm_client,
229                            app_state,
230                            server_id,
231                            status_tx,
232                        )
233                        .await
234                        {
235                            error!("DoT connection error from {}: {}", peer_addr, e);
236                        }
237                    });
238                    conn_state.register_server_task(server_id, handle).await;
239                }
240                Err(e) => {
241                    Log::new(Some(&status_tx))
242                        .warn(format!("Failed to accept DoT TCP connection: {}", e));
243                    tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await;
244                }
245            }
246        }
247    }
248
249    /// Handle a single DoT connection
250    #[allow(clippy::too_many_arguments)]
251    async fn handle_connection(
252        stream: TcpStream,
253        peer_addr: SocketAddr,
254        connection_id: ConnectionId,
255        acceptor: TlsAcceptor,
256        llm_client: OllamaClient,
257        app_state: Arc<AppState>,
258        server_id: ServerId,
259        status_tx: mpsc::UnboundedSender<String>,
260    ) -> Result<()> {
261        let outcome = Self::serve_connection(
262            stream,
263            peer_addr,
264            connection_id,
265            acceptor,
266            llm_client,
267            &app_state,
268            server_id,
269            &status_tx,
270        )
271        .await;
272
273        // Mark the peer closed however the session ended — handshake failure, read
274        // error, idle timeout or a clean close — so the rail stops drawing it as live
275        // and any connection-scoped tasks are cleaned up.
276        app_state
277            .close_connection_on_server(server_id, connection_id)
278            .await;
279        let _ = status_tx.send("__UPDATE_UI__".to_string());
280        outcome
281    }
282
283    #[allow(clippy::too_many_arguments)]
284    async fn serve_connection(
285        stream: TcpStream,
286        peer_addr: SocketAddr,
287        connection_id: ConnectionId,
288        acceptor: TlsAcceptor,
289        llm_client: OllamaClient,
290        app_state: &Arc<AppState>,
291        server_id: ServerId,
292        status_tx: &mpsc::UnboundedSender<String>,
293    ) -> Result<()> {
294        let status_tx = status_tx.clone();
295        let app_state = app_state.clone();
296        // Perform TLS handshake, bounded. An unbounded `accept` is a task a peer can
297        // park forever by connecting and saying nothing.
298        let mut tls_stream = timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream))
299            .await
300            .map_err(|_| {
301                anyhow::anyhow!(
302                    "TLS handshake with {peer_addr} did not complete within {:?}",
303                    TLS_HANDSHAKE_TIMEOUT
304                )
305            })?
306            .context("TLS handshake failed")?;
307
308        Log::new(Some(&status_tx)).debug(format!("DoT TLS handshake complete with {}", peer_addr));
309
310        Log::new(Some(&status_tx)).info(format!("DoT connection from {}", peer_addr));
311
312        // Handle DNS queries over TLS
313        loop {
314            // Read length-prefixed DNS message (2-byte big-endian length)
315            let mut len_buf = [0u8; 2];
316            let read_len =
317                match timeout(IDLE_READ_TIMEOUT, tls_stream.read_exact(&mut len_buf)).await {
318                    Ok(r) => r,
319                    Err(_) => {
320                        Log::new(Some(&status_tx)).debug(format!(
321                            "DoT connection from {} idle for {:?}, closing",
322                            peer_addr, IDLE_READ_TIMEOUT
323                        ));
324                        break;
325                    }
326                };
327            match read_len {
328                Ok(_) => {}
329                Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
330                    Log::new(Some(&status_tx))
331                        .debug(format!("DoT connection from {} closed", peer_addr));
332                    break;
333                }
334                Err(e) => {
335                    Log::new(Some(&status_tx))
336                        .error(format!("Failed to read DoT length prefix: {}", e));
337                    break;
338                }
339            }
340
341            // A `u16` length cannot exceed 65535, so the old `dns_len > 65535` arm was
342            // unreachable and read as a bound that was not there.
343            let dns_len = u16::from_be_bytes(len_buf) as usize;
344
345            if dns_len == 0 {
346                Log::new(Some(&status_tx))
347                    .warn(format!("Invalid DoT DNS message length: {}", dns_len));
348                break;
349            }
350
351            // Read DNS message
352            let mut dns_buf = vec![0u8; dns_len];
353            match timeout(MESSAGE_READ_TIMEOUT, tls_stream.read_exact(&mut dns_buf)).await {
354                Ok(Ok(_)) => {}
355                Ok(Err(e)) => {
356                    Log::new(Some(&status_tx))
357                        .error(format!("Failed to read DoT DNS message: {}", e));
358                    break;
359                }
360                Err(_) => {
361                    Log::new(Some(&status_tx)).warn(format!(
362                        "DoT message from {peer_addr} incomplete after {MESSAGE_READ_TIMEOUT:?} \
363                         decision=fail_closed_read_timeout"
364                    ));
365                    break;
366                }
367            }
368
369            app_state
370                .update_connection_stats(
371                    server_id,
372                    connection_id,
373                    Some((dns_len + 2) as u64),
374                    None,
375                    Some(1),
376                    None,
377                )
378                .await;
379
380            Log::new(Some(&status_tx))
381                .debug(format!("DoT received {} bytes from {}", dns_len, peer_addr));
382
383            // Parse DNS query
384            let dns_message = match DnsMessage::from_vec(&dns_buf) {
385                Ok(msg) => msg,
386                Err(e) => {
387                    Log::new(Some(&status_tx))
388                        .warn(format!("Failed to parse DoT DNS message: {}", e));
389                    continue;
390                }
391            };
392
393            // Extract query information
394            let queries = dns_message.queries();
395            if queries.is_empty() {
396                Log::new(Some(&status_tx)).warn("DoT DNS message has no queries");
397                continue;
398            }
399
400            let query = &queries[0];
401            let domain = query.name().to_utf8();
402            // Display, not Debug: the model reads this string and echoes it back into
403            // `send_dns_nxdomain`'s `query_type`, which parses it with
404            // `RecordType::from_str`. Debug and Display agree for the common types and
405            // do not for all of them, so `{:?}` was a round-trip waiting to break.
406            let query_type = query.query_type().to_string();
407            let query_id = dns_message.id();
408
409            Log::new(Some(&status_tx)).info(format!(
410                "DoT query: {} {} (ID: {})",
411                domain, query_type, query_id
412            ));
413
414            Log::new(Some(&status_tx))
415                .trace(format!("DoT DNS query hex: {}", hex::encode(&dns_buf)));
416
417            // Create event for LLM
418            let event = Event::new(
419                &DOT_QUERY_EVENT,
420                json!({
421                    "query_id": query_id,
422                    "domain": domain,
423                    "query_type": query_type,
424                    "peer_addr": peer_addr.to_string(),
425                }),
426            );
427
428            // Get protocol actions
429            let protocol = Arc::new(DotProtocol::new());
430
431            Log::new(Some(&status_tx))
432                .debug(format!("DoT calling LLM for query from {}", peer_addr));
433
434            // Call LLM
435            match call_llm(
436                &llm_client,
437                &app_state,
438                server_id,
439                Some(connection_id),
440                &event,
441                protocol.as_ref(),
442            )
443            .await
444            {
445                Ok(execution_result) => {
446                    // Display messages from LLM
447                    for message in &execution_result.messages {
448                        Log::new(Some(&status_tx)).info(format!("{}", message));
449                    }
450
451                    Log::new(Some(&status_tx)).debug(format!(
452                        "DoT got {} protocol results",
453                        execution_result.protocol_results.len()
454                    ));
455
456                    // Execute actions from LLM response
457                    for protocol_result in &execution_result.protocol_results {
458                        use crate::llm::actions::protocol_trait::ActionResult;
459                        match protocol_result {
460                            ActionResult::Output(bytes) => {
461                                Self::send_framed(
462                                    &mut tls_stream,
463                                    bytes,
464                                    peer_addr,
465                                    connection_id,
466                                    server_id,
467                                    &app_state,
468                                    &status_tx,
469                                )
470                                .await;
471                            }
472                            ActionResult::Custom { data, .. } => {
473                                if let Some(output_data) =
474                                    data.get("output_data").and_then(|v| v.as_str())
475                                {
476                                    // Decode hex DNS response
477                                    if let Ok(response_bytes) = hex::decode(output_data) {
478                                        Self::send_framed(
479                                            &mut tls_stream,
480                                            &response_bytes,
481                                            peer_addr,
482                                            connection_id,
483                                            server_id,
484                                            &app_state,
485                                            &status_tx,
486                                        )
487                                        .await;
488                                    }
489                                }
490                            }
491                            ActionResult::CloseConnection => {
492                                Log::new(Some(&status_tx)).info(format!(
493                                    "DoT connection from {} closed by LLM",
494                                    peer_addr
495                                ));
496                                return Ok(());
497                            }
498                            ActionResult::NoAction => {
499                                // Ignore query - don't send response
500                                Log::new(Some(&status_tx)).debug("DoT query ignored by LLM");
501                            }
502                            _ => {}
503                        }
504                    }
505                }
506                Err(e) => {
507                    // DoT is DNS: the client is a stub resolver blocked on an answer, and
508                    // `continue` gave it nothing until its own timeout expired - the same
509                    // defect plain DNS had, and worse here, because a TLS connection is
510                    // expensive enough that resolvers pin one and serialise queries over it.
511                    //
512                    // SERVFAIL makes the resolver move on to another server at once. The
513                    // query id and question section must be echoed or a stub resolver
514                    // discards the packet as unsolicited and we are back to silence, so this
515                    // reuses the DNS server's own builder rather than synthesising a header.
516                    // `decision=` tag, as `src/server/radius/` does it: SERVFAIL is the
517                    // same byte sequence whatever went wrong, so the log is the only place
518                    // the distinction can survive.
519                    let decision = if crate::llm::is_overload_error(&e) {
520                        "fail_closed_llm_overload"
521                    } else {
522                        "fail_closed_llm_error"
523                    };
524                    Log::new(Some(&status_tx)).warn(format!(
525                        "DoT answering SERVFAIL to {} decision={}: {}",
526                        peer_addr, decision, e
527                    ));
528                    match crate::server::dns::actions::build_servfail(&dns_message) {
529                        Ok(packet) => {
530                            // RFC 7858 uses the DNS-over-TCP framing: a two-byte length
531                            // prefix in front of every message.
532                            Self::send_framed(
533                                &mut tls_stream,
534                                &packet,
535                                peer_addr,
536                                connection_id,
537                                server_id,
538                                &app_state,
539                                &status_tx,
540                            )
541                            .await;
542                        }
543                        Err(build_err) => {
544                            Log::new(Some(&status_tx)).error(format!(
545                                "DoT failed to build SERVFAIL for {}: {}",
546                                peer_addr, build_err
547                            ));
548                        }
549                    }
550                    continue;
551                }
552            }
553        }
554
555        // Connection closed
556        Log::new(Some(&status_tx)).info(format!("DoT connection from {} closed", peer_addr));
557
558        Ok(())
559    }
560
561    /// Write one DNS message to the peer in RFC 7858 framing: a two-byte big-endian
562    /// length followed by the message.
563    ///
564    /// The length is checked rather than cast. Every call site used
565    /// `bytes.len() as u16`, which **wraps** silently: a 65540-byte message — reachable
566    /// through `send_dns_response`, whose whole point is that the model hands over
567    /// arbitrary hex — announces a length of 4 and then writes 65540 bytes. The peer
568    /// reads 4 of them as a message and the remaining 65536 as the next length prefix
569    /// and the next message, so the stream is desynchronised for good and every
570    /// subsequent query on that connection is answered with garbage. Refusing to send
571    /// is the only safe outcome; a DNS message cannot exceed 65535 bytes by definition,
572    /// so nothing legitimate is being turned away.
573    #[allow(clippy::too_many_arguments)]
574    async fn send_framed(
575        tls_stream: &mut tokio_rustls::server::TlsStream<TcpStream>,
576        message: &[u8],
577        peer_addr: SocketAddr,
578        connection_id: ConnectionId,
579        server_id: ServerId,
580        app_state: &Arc<AppState>,
581        status_tx: &mpsc::UnboundedSender<String>,
582    ) {
583        let Ok(len) = u16::try_from(message.len()) else {
584            Log::new(Some(status_tx)).error(format!(
585                "DoT refusing to send a {}-byte message to {}: RFC 7858 framing carries a \
586                 16-bit length, so this cannot be put on the wire without desynchronising \
587                 the connection",
588                message.len(),
589                peer_addr
590            ));
591            return;
592        };
593
594        let mut framed = Vec::with_capacity(message.len() + 2);
595        framed.extend_from_slice(&len.to_be_bytes());
596        framed.extend_from_slice(message);
597
598        if let Err(e) = tls_stream.write_all(&framed).await {
599            Log::new(Some(status_tx)).error(format!(
600                "Failed to send DoT response to {}: {}",
601                peer_addr, e
602            ));
603            return;
604        }
605
606        app_state
607            .update_connection_stats(
608                server_id,
609                connection_id,
610                None,
611                Some(framed.len() as u64),
612                None,
613                Some(1),
614            )
615            .await;
616
617        let log = Log::new(Some(status_tx));
618        log.debug(format!("DoT sent {} bytes to {}", message.len(), peer_addr));
619        log.trace(format!("DoT response hex: {}", hex::encode(message)));
620    }
621}