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}