netget/server/doh/mod.rs
1//! DNS-over-HTTPS (DoH) server implementation
2//!
3//! Implements RFC 8484 DNS-over-HTTPS protocol using hickory-dns, hyper, and rustls.
4//! The LLM controls DNS responses while NetGet handles the HTTPS 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::DohProtocol;
14use crate::state::app_state::AppState;
15use crate::state::ServerId;
16use actions::DOH_QUERY_EVENT;
17use anyhow::{Context, Result};
18use hickory_proto::op::Message as DnsMessage;
19use http_body_util::{BodyExt, Full};
20use hyper::body::Bytes;
21use hyper::server::conn::http2;
22use hyper::service::service_fn;
23use hyper::{Method, Request, Response, StatusCode};
24use hyper_util::rt::TokioIo;
25use serde_json::json;
26use std::net::SocketAddr;
27use std::sync::Arc;
28use tokio::net::TcpListener;
29use tokio::sync::mpsc;
30use tokio::time::{timeout, Duration};
31use tokio_rustls::TlsAcceptor;
32use tracing::{debug, error};
33
34/// Bound on the TLS handshake for one accepted connection.
35///
36/// Without it, a peer that completes the TCP handshake and then sends nothing holds
37/// `acceptor.accept()` — and the task around it — open forever, at no cost to itself.
38const TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
39
40/// How long an established connection may carry no query at all before it is closed.
41///
42/// [`TLS_HANDSHAKE_TIMEOUT`] ends at the handshake, and after it hyper owns the HTTP/2 session
43/// and applies no idle bound of its own — so a peer that completed TLS and then went quiet, or
44/// never sent the HTTP/2 preface, held its slot forever. 300 seconds, chosen to stay above the
45/// 90 seconds `reqwest` keeps an idle pooled connection: NetGet's own DoH client is a pooled
46/// `reqwest` client, and a bound below the pool's races it and loses a query. RFC 8484 sets no
47/// number; resolvers multiplex onto few long-lived connections, and [`MAX_CONNECTIONS`] bounds
48/// how many of those can sit idle at once.
49///
50/// **A query still being answered is not silence.** Each request's service future holds the
51/// connection's `ConnectionActivity` busy for the whole of its answer — a model round-trip, or a
52/// `manual` rule parked for a human — and the watchdog never fires while anything is in flight.
53/// HTTP/2 PING frames are answered by hyper and are not activity. On expiry the connection gets
54/// a GOAWAY (`graceful_shutdown`) rather than a bare close, which a client reads as "reconnect
55/// for the next query". Declared as the `idle_timeout_secs` startup parameter.
56pub const IDLE_BETWEEN_QUERIES_TIMEOUT: Duration = Duration::from_secs(300);
57
58/// How long the GOAWAY sent at the idle bound is given to reach the peer. The connection is idle
59/// by definition, so there is nothing to drain; this only stops a peer that has stopped reading
60/// from holding the close open.
61const GOAWAY_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
62
63/// Largest DoH request body accepted, in bytes.
64///
65/// A DNS message is at most 65535 bytes by construction (its length is carried in 16 bits
66/// wherever DNS is framed over a stream), so anything larger cannot be a DNS query. The body
67/// was read with `req.collect()`, which is unbounded: over an established HTTP/2 connection a
68/// peer could POST gigabytes to `/dns-query` and NetGet would buffer all of it in memory
69/// before discovering it was not a DNS message. This is a hard cap with headroom, not a
70/// tuning knob.
71pub const MAX_DOH_BODY_BYTES: u64 = 65_535;
72
73/// Pause after a failed `accept()` before trying again.
74///
75/// `accept` failing is usually transient (the peer went away between the SYN and the accept)
76/// and retrying immediately is right. It is not always transient: at the file-descriptor
77/// limit, `accept` returns `EMFILE` instantly and keeps doing so, and a bare `continue` then
78/// spins the accept loop at full speed writing a warning per iteration onto an **unbounded**
79/// status channel — turning "out of descriptors" into "out of memory". A short pause costs
80/// nothing in the transient case and bounds the pathological one.
81const ACCEPT_ERROR_BACKOFF: Duration = Duration::from_millis(100);
82
83/// Concurrent connections this server admits before it starts refusing.
84///
85/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`TLS_HANDSHAKE_TIMEOUT`]
86/// bounds how long one peer can hold a slot without completing a handshake; only a cap bounds
87/// how many of them there can be at once, and ten seconds times an unbounded arrival rate is
88/// still unbounded. DoH resolvers multiplex every query of a whole client population onto a
89/// handful of long-lived HTTP/2 connections — that is what RFC 8484 chose HTTP/2 for — so a
90/// real deployment's live-connection count is its *resolver* count, not its query rate, and
91/// 256 is far above anything legitimate while still bounding the TLS state an attacker can
92/// make this process hold.
93const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
94
95/// A peer over [`MAX_CONNECTIONS`] gets a **plain close**, with nothing written.
96///
97/// **This server's first byte is a TLS record, and there is no TLS alert that means "busy".**
98/// The three HTTP-carried servers that were capped alongside this one — `hls`, `ipp`, `proxy`
99/// — answer 503 with `Retry-After`, and that is right for them because their listeners are
100/// plaintext: the refusal is the first thing on the wire and the client parses it as HTTP.
101/// Here the peer is mid-`ClientHello` and everything it reads is fed to a TLS record parser,
102/// so `HTTP/1.1 503` is not a refusal at all — it is a malformed record, and the client
103/// records a TLS protocol failure (a handshake it cannot complete, which several stacks treat
104/// as evidence of interception) instead of backing off. `smtp` faced exactly this split and
105/// resolved it the same way, speaking 421 on its plain listener and nothing on its
106/// implicit-TLS one.
107///
108/// Answering inside TLS would mean completing a handshake in order to refuse, which hands an
109/// attacker a signature verification per refused connection — a cap that costs more to enforce
110/// than to ignore. The record layer's own vocabulary does not help either: RFC 8446 §6.2 has
111/// no resource-exhaustion alert, and the nearest, `internal_error`, tells the client this
112/// server is broken rather than full.
113///
114/// So the honest answer is EOF before the handshake, which every TLS client already has a
115/// path for and treats as a transient connection failure. The refusal is logged with
116/// `decision=fail_closed_connection_cap`, and that log line is the diagnosis.
117const CONNECTION_CAP_REFUSAL: &[u8] = b"";
118
119/// DNS-over-HTTPS server
120pub struct DohServer;
121
122impl DohServer {
123 /// Spawn the DoH server.
124 ///
125 /// The listener is bound here, *before* the accept loop is spawned, so that
126 /// a bind failure (port in use, permission denied) is returned to the
127 /// caller instead of being swallowed by the background task - otherwise the
128 /// server would be reported as `Running` while nothing is listening.
129 /// Binding here also means the returned address carries the real port when
130 /// the caller asked for port 0.
131 pub async fn spawn(
132 bind_addr: SocketAddr,
133 llm_client: OllamaClient,
134 app_state: Arc<AppState>,
135 server_id: ServerId,
136 status_tx: mpsc::UnboundedSender<String>,
137 idle_timeout: Duration,
138 ) -> Result<SocketAddr> {
139 // Generate TLS configuration (default self-signed cert), advertising `h2`.
140 //
141 // RFC 8484 DoH runs over HTTP/2, and this server speaks nothing else. Without ALPN in
142 // the handshake a real client has no way to learn that: it either negotiates nothing
143 // and falls back to HTTP/1.1, which this server cannot answer, or refuses outright.
144 // The E2E test papered over it by connecting with `http2_prior_knowledge()`, which
145 // skips ALPN entirely — so the gap could not show up there.
146 let tls_config =
147 crate::server::tls_cert_manager::generate_default_tls_config_with_alpn(&["h2"])
148 .context("Failed to generate TLS configuration")?;
149
150 Log::new(Some(&status_tx)).info(format!("Starting DoH server on {}", bind_addr));
151
152 let listener = TcpListener::bind(bind_addr)
153 .await
154 .context("Failed to bind DoH TCP listener")?;
155
156 // Actual bound address (important for port 0 dynamic allocation)
157 let local_addr = listener
158 .local_addr()
159 .context("Failed to get DoH listener local address")?;
160
161 Log::new(Some(&status_tx)).info(format!("DoH server listening on {}", local_addr));
162
163 let task_registrar = app_state.clone();
164 let handle = tokio::spawn(async move {
165 if let Err(e) = Self::run(
166 listener,
167 tls_config,
168 llm_client,
169 app_state,
170 server_id,
171 status_tx,
172 idle_timeout,
173 )
174 .await
175 {
176 error!("DoH server error: {}", e);
177 }
178 });
179
180 // Register the accept loop so stop_server can abort it and release the port.
181 task_registrar.register_server_task(server_id, handle).await;
182
183 Ok(local_addr)
184 }
185
186 /// Run the DoH accept loop on an already-bound listener
187 async fn run(
188 listener: TcpListener,
189 tls_config: Arc<rustls::ServerConfig>,
190 llm_client: OllamaClient,
191 app_state: Arc<AppState>,
192 server_id: ServerId,
193 status_tx: mpsc::UnboundedSender<String>,
194 idle_timeout: Duration,
195 ) -> Result<()> {
196 let acceptor = TlsAcceptor::from(tls_config);
197 let local_addr = listener
198 .local_addr()
199 .context("Failed to get DoH listener local address")?;
200
201 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
202
203 loop {
204 match crate::server::accept_bounded::accept_bounded(
205 &listener,
206 &limiter,
207 CONNECTION_CAP_REFUSAL,
208 "DOH",
209 Some(&status_tx),
210 )
211 .await
212 {
213 Ok((stream, peer_addr, permit)) => {
214 Log::new(Some(&status_tx))
215 .debug(format!("DoH TCP connection from {}", peer_addr));
216
217 // Register the peer before the handshake. Nothing tracked DoH
218 // connections at all before this, so a DoH server drew an empty peer
219 // list however many resolvers were talking to it, `{client_ip}` in the
220 // `doh_query` log template rendered empty, and the rail's byte counters
221 // stayed at zero.
222 let connection_id = ConnectionId::new(app_state.get_next_unified_id().await);
223 {
224 use crate::state::server::{
225 ConnectionState as ServerConnectionState, ConnectionStatus,
226 ProtocolConnectionInfo,
227 };
228 let now = crate::utils::clock::Instant::now();
229 app_state
230 .add_connection_to_server(
231 server_id,
232 ServerConnectionState {
233 id: connection_id,
234 remote_addr: peer_addr,
235 local_addr,
236 bytes_sent: 0,
237 bytes_received: 0,
238 packets_sent: 0,
239 packets_received: 0,
240 last_activity: now,
241 status: ConnectionStatus::Active,
242 status_changed_at: now,
243 protocol_info: ProtocolConnectionInfo::empty(),
244 },
245 )
246 .await;
247 }
248 let _ = status_tx.send("__UPDATE_UI__".to_string());
249
250 let acceptor = acceptor.clone();
251 let llm_client = llm_client.clone();
252 let conn_state = app_state.clone();
253 let app_state = app_state.clone();
254 let status_tx = status_tx.clone();
255
256 // Registered, not detached: `stop_server` aborts the tasks a server
257 // registered, and an unregistered per-connection task outlives it, so a
258 // stopped DoH server went on answering every connection it had already
259 // accepted.
260 let handle = tokio::spawn(async move {
261 // Released when this task ends, and this task is the whole of the
262 // connection — DoH spawns nothing else per peer; hyper's HTTP/2
263 // service runs inside `handle_connection` — so `MAX_CONNECTIONS`
264 // caps live connections rather than accepts.
265 let _permit = permit;
266 if let Err(e) = Self::handle_connection(
267 stream,
268 peer_addr,
269 connection_id,
270 acceptor,
271 llm_client,
272 app_state,
273 server_id,
274 status_tx,
275 idle_timeout,
276 )
277 .await
278 {
279 // `{:#}`: the cause chain is where rustls says what actually
280 // went wrong. `{}` prints only "TLS handshake failed", which
281 // names the step and not the reason.
282 error!("DoH connection error from {}: {:#}", peer_addr, e);
283 }
284 });
285 conn_state.register_server_task(server_id, handle).await;
286 }
287 Err(e) => {
288 Log::new(Some(&status_tx))
289 .warn(format!("Failed to accept DoH TCP connection: {}", e));
290 tokio::time::sleep(ACCEPT_ERROR_BACKOFF).await;
291 }
292 }
293 }
294 }
295
296 /// Handle a single DoH connection
297 #[allow(clippy::too_many_arguments)]
298 async fn handle_connection(
299 stream: tokio::net::TcpStream,
300 peer_addr: SocketAddr,
301 connection_id: ConnectionId,
302 acceptor: TlsAcceptor,
303 llm_client: OllamaClient,
304 app_state: Arc<AppState>,
305 server_id: ServerId,
306 status_tx: mpsc::UnboundedSender<String>,
307 idle_timeout: Duration,
308 ) -> Result<()> {
309 let closer = app_state.clone();
310 let close_tx = status_tx.clone();
311 let outcome = Self::serve_connection(
312 stream,
313 peer_addr,
314 connection_id,
315 acceptor,
316 llm_client,
317 app_state,
318 server_id,
319 status_tx,
320 idle_timeout,
321 )
322 .await;
323
324 // Mark the peer closed however the session ended — a failed handshake included, so
325 // a connection that never got past TLS does not sit in the rail as live.
326 closer
327 .close_connection_on_server(server_id, connection_id)
328 .await;
329 let _ = close_tx.send("__UPDATE_UI__".to_string());
330 outcome
331 }
332
333 #[allow(clippy::too_many_arguments)]
334 async fn serve_connection(
335 stream: tokio::net::TcpStream,
336 peer_addr: SocketAddr,
337 connection_id: ConnectionId,
338 acceptor: TlsAcceptor,
339 llm_client: OllamaClient,
340 app_state: Arc<AppState>,
341 server_id: ServerId,
342 status_tx: mpsc::UnboundedSender<String>,
343 idle_timeout: Duration,
344 ) -> Result<()> {
345 // Perform TLS handshake, bounded. An unbounded `accept` is a task a peer can park
346 // forever by connecting and saying nothing.
347 let tls_stream = timeout(TLS_HANDSHAKE_TIMEOUT, acceptor.accept(stream))
348 .await
349 .map_err(|_| {
350 anyhow::anyhow!(
351 "TLS handshake with {peer_addr} did not complete within {:?}",
352 TLS_HANDSHAKE_TIMEOUT
353 )
354 })?
355 .context("TLS handshake failed")?;
356
357 Log::new(Some(&status_tx)).debug(format!("DoH TLS handshake complete with {}", peer_addr));
358
359 // Wrap in TokioIo for hyper compatibility
360 let io = TokioIo::new(tls_stream);
361
362 // Whether this connection is answering anything. Every request holds it busy for the
363 // whole of its answer, so the idle watchdog below cannot close a connection whose query
364 // is waiting on the model or parked for a human. Created after the handshake, so a peer
365 // that handshakes and then never sends the HTTP/2 preface is on this clock too.
366 let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
367 let activity_for_service = Arc::clone(&activity);
368 let idle_log_tx = status_tx.clone();
369
370 // Create service closure
371 let service = service_fn(move |req: Request<hyper::body::Incoming>| {
372 let llm_client = llm_client.clone();
373 let app_state = app_state.clone();
374 let status_tx = status_tx.clone();
375 let busy = activity_for_service.busy();
376
377 async move {
378 let _busy = busy;
379 Self::handle_request(
380 req,
381 peer_addr,
382 connection_id,
383 server_id,
384 llm_client,
385 app_state,
386 status_tx,
387 )
388 .await
389 }
390 });
391
392 // Serve HTTP/2, raced against the idle watchdog. hyper keeps polling the connection
393 // while a request is answered, so a deadline on its reads would fire mid-answer; the
394 // watchdog reads ConnectionActivity instead, which is busy for exactly that time.
395 let conn =
396 http2::Builder::new(hyper_util::rt::TokioExecutor::new()).serve_connection(io, service);
397 tokio::pin!(conn);
398 let result = tokio::select! {
399 result = conn.as_mut() => result,
400 _ = crate::server::accept_bounded::watch_idle(Arc::clone(&activity), idle_timeout) => {
401 Log::new(Some(&idle_log_tx)).debug(format!(
402 "DoH connection {} from {} carried no query for {}s; sending GOAWAY",
403 connection_id,
404 peer_addr,
405 idle_timeout.as_secs()
406 ));
407 conn.as_mut().graceful_shutdown();
408 timeout(GOAWAY_DRAIN_TIMEOUT, conn.as_mut())
409 .await
410 .unwrap_or(Ok(()))
411 }
412 };
413
414 if let Err(e) = result {
415 debug!("DoH HTTP/2 connection error: {}", e);
416 }
417
418 Ok(())
419 }
420
421 /// Handle a single DoH HTTP request
422 #[allow(clippy::too_many_arguments)]
423 async fn handle_request(
424 req: Request<hyper::body::Incoming>,
425 peer_addr: SocketAddr,
426 connection_id: ConnectionId,
427 server_id: ServerId,
428 llm_client: OllamaClient,
429 app_state: Arc<AppState>,
430 status_tx: mpsc::UnboundedSender<String>,
431 ) -> Result<Response<Full<Bytes>>, hyper::Error> {
432 let method = req.method().clone();
433 let uri = req.uri().clone();
434
435 Log::new(Some(&status_tx)).debug(format!("DoH request: {} {}", method, uri));
436
437 // Extract DNS query based on method
438 let dns_bytes = match method {
439 Method::GET => {
440 // Extract DNS query from ?dns= parameter (base64url encoded)
441 let query = uri.query().unwrap_or("");
442 let mut dns_param = None;
443
444 for param in query.split('&') {
445 if let Some(value) = param.strip_prefix("dns=") {
446 dns_param = Some(value);
447 break;
448 }
449 }
450
451 match dns_param {
452 Some(encoded) => {
453 // Decode base64url
454 match base64_url_decode(encoded) {
455 Ok(bytes) => bytes,
456 Err(e) => {
457 Log::new(Some(&status_tx))
458 .warn(format!("Invalid base64url DNS query: {}", e));
459 return Ok(error_response(
460 StatusCode::BAD_REQUEST,
461 "Invalid DNS query encoding",
462 ));
463 }
464 }
465 }
466 None => {
467 Log::new(Some(&status_tx))
468 .warn("Missing dns= parameter in DoH GET request");
469 return Ok(error_response(
470 StatusCode::BAD_REQUEST,
471 "Missing dns parameter",
472 ));
473 }
474 }
475 }
476 Method::POST => {
477 // Check Content-Type
478 if let Some(content_type) = req.headers().get("content-type") {
479 if !is_dns_message_content_type(content_type) {
480 Log::new(Some(&status_tx))
481 .warn(format!("Invalid DoH Content-Type: {:?}", content_type));
482 return Ok(error_response(
483 StatusCode::BAD_REQUEST,
484 "Invalid Content-Type",
485 ));
486 }
487 } else {
488 Log::new(Some(&status_tx)).warn("Missing Content-Type in DoH POST");
489 return Ok(error_response(
490 StatusCode::BAD_REQUEST,
491 "Missing Content-Type",
492 ));
493 }
494
495 // Read the body under a hard cap.
496 //
497 // `req.collect()` alone is unbounded — a peer that has completed TLS and the
498 // HTTP/2 preface can stream a body of any size and NetGet buffers all of it
499 // before deciding it is not DNS. `Limited` stops reading at the cap and
500 // errors, so the memory a single request can claim is bounded by a constant.
501 use http_body_util::Limited;
502 let limited = Limited::new(req.into_body(), MAX_DOH_BODY_BYTES as usize);
503 match limited.collect().await {
504 Ok(collected) => collected.to_bytes().to_vec(),
505 Err(e) => {
506 Log::new(Some(&status_tx)).warn(format!(
507 "DoH POST body from {} exceeded {} bytes or could not be read: {}",
508 peer_addr, MAX_DOH_BODY_BYTES, e
509 ));
510 return Ok(error_response(
511 StatusCode::PAYLOAD_TOO_LARGE,
512 "DNS message too large",
513 ));
514 }
515 }
516 }
517 _ => {
518 Log::new(Some(&status_tx)).warn(format!("Unsupported DoH method: {}", method));
519 return Ok(error_response(
520 StatusCode::METHOD_NOT_ALLOWED,
521 "Only GET and POST are supported",
522 ));
523 }
524 };
525
526 let log = Log::new(Some(&status_tx));
527 log.debug(format!("DoH received {} bytes", dns_bytes.len()));
528 log.trace(format!("DoH DNS query hex: {}", hex::encode(&dns_bytes)));
529
530 app_state
531 .update_connection_stats(
532 server_id,
533 connection_id,
534 Some(dns_bytes.len() as u64),
535 None,
536 Some(1),
537 None,
538 )
539 .await;
540
541 // Parse DNS query
542 let dns_message = match DnsMessage::from_vec(&dns_bytes) {
543 Ok(msg) => msg,
544 Err(e) => {
545 Log::new(Some(&status_tx)).warn(format!("Failed to parse DoH DNS message: {}", e));
546 return Ok(error_response(
547 StatusCode::BAD_REQUEST,
548 "Invalid DNS message",
549 ));
550 }
551 };
552
553 // Extract query information
554 let queries = dns_message.queries();
555 if queries.is_empty() {
556 Log::new(Some(&status_tx)).warn("DoH DNS message has no queries");
557 return Ok(error_response(StatusCode::BAD_REQUEST, "No DNS queries"));
558 }
559
560 let query = &queries[0];
561 let domain = query.name().to_utf8();
562 // Display, not Debug: the model reads this string and echoes it back into
563 // `send_dns_nxdomain`'s `query_type`, which parses it with `RecordType::from_str`.
564 let query_type = query.query_type().to_string();
565 let query_id = dns_message.id();
566
567 Log::new(Some(&status_tx)).info(format!(
568 "DoH query: {} {} (ID: {})",
569 domain, query_type, query_id
570 ));
571
572 // Create event for LLM
573 let event = Event::new(
574 &DOH_QUERY_EVENT,
575 json!({
576 "query_id": query_id,
577 "domain": domain,
578 "query_type": query_type,
579 "peer_addr": peer_addr.to_string(),
580 "method": method.to_string(),
581 }),
582 );
583
584 // Get protocol actions
585 let protocol = Arc::new(DohProtocol::new());
586
587 Log::new(Some(&status_tx)).debug(format!("DoH calling LLM for query from {}", peer_addr));
588
589 // Call LLM
590 let execution_result = match call_llm(
591 &llm_client,
592 &app_state,
593 server_id,
594 Some(connection_id),
595 &event,
596 protocol.as_ref(),
597 )
598 .await
599 {
600 Ok(result) => result,
601 Err(e) => {
602 // Answer SERVFAIL in a 200, the way DNS and DoT answer SERVFAIL on the wire.
603 //
604 // RFC 8484 §4.2.1 makes 200 the status for "the HTTP transaction carried a
605 // DNS message"; whether that message is an answer or a failure is DNS's
606 // business, not HTTP's. A 5xx instead tells the client the *resolver
607 // endpoint* is broken, which makes real DoH clients mark the server down and
608 // fail over — a heavier reaction than the transient backend hiccup that
609 // caused it. The transaction id and question are echoed by `build_servfail`,
610 // without which the client discards the message.
611 //
612 // `decision=`, as `src/server/radius/` does it: SERVFAIL is the same bytes
613 // whatever went wrong, so the log is the only place the distinction lives.
614 let decision = if crate::llm::is_overload_error(&e) {
615 "fail_closed_llm_overload"
616 } else {
617 "fail_closed_llm_error"
618 };
619 Log::new(Some(&status_tx)).warn(format!(
620 "DoH answering SERVFAIL to {} decision={}: {}",
621 peer_addr, decision, e
622 ));
623
624 return Ok(
625 match crate::server::dns::actions::build_servfail(&dns_message) {
626 Ok(packet) => dns_message_response(packet),
627 Err(build_err) => {
628 // Nothing DNS-shaped can be produced, so the failure has to be
629 // expressed in HTTP. The peer gets a category, never the error text.
630 Log::new(Some(&status_tx)).error(format!(
631 "DoH failed to build SERVFAIL for {}: {}",
632 peer_addr, build_err
633 ));
634 let failure = crate::utils::WireFailure::classify(&e);
635 match failure {
636 crate::utils::WireFailure::Overloaded => retry_later_response(),
637 crate::utils::WireFailure::Unavailable => error_response(
638 StatusCode::INTERNAL_SERVER_ERROR,
639 failure.prefixed_text(),
640 ),
641 }
642 }
643 },
644 );
645 }
646 };
647
648 // Display messages from LLM
649 for message in &execution_result.messages {
650 Log::new(Some(&status_tx)).info(format!("{}", message));
651 }
652
653 Log::new(Some(&status_tx)).debug(format!(
654 "DoH got {} protocol results",
655 execution_result.protocol_results.len()
656 ));
657
658 // Execute actions from LLM response
659 for protocol_result in &execution_result.protocol_results {
660 use crate::llm::actions::protocol_trait::ActionResult;
661 match protocol_result {
662 ActionResult::Output(bytes) => {
663 // DNS action returned binary response directly
664 let log = Log::new(Some(&status_tx));
665 log.debug(format!("DoH sending {} bytes", bytes.len()));
666 log.trace(format!("DoH response hex: {}", hex::encode(bytes)));
667
668 app_state
669 .update_connection_stats(
670 server_id,
671 connection_id,
672 None,
673 Some(bytes.len() as u64),
674 None,
675 Some(1),
676 )
677 .await;
678
679 // Return DNS response with correct Content-Type.
680 // Content-Length is left to hyper, which derives it from the
681 // Full<Bytes> body - keeping every builder input constant so
682 // this cannot panic on model-supplied data.
683 return Ok(dns_message_response(bytes.clone()));
684 }
685 ActionResult::Custom { data, .. } => {
686 if let Some(output_data) = data.get("output_data").and_then(|v| v.as_str()) {
687 // Decode hex DNS response
688 if let Ok(response_bytes) = hex::decode(output_data) {
689 let log = Log::new(Some(&status_tx));
690 log.debug(format!("DoH sending {} bytes", response_bytes.len()));
691 log.trace(format!("DoH response hex: {}", output_data));
692
693 app_state
694 .update_connection_stats(
695 server_id,
696 connection_id,
697 None,
698 Some(response_bytes.len() as u64),
699 None,
700 Some(1),
701 )
702 .await;
703
704 // Return DNS response with correct Content-Type
705 return Ok(dns_message_response(response_bytes));
706 }
707 }
708 }
709 ActionResult::NoAction => {
710 // Ignore query - return empty response
711 Log::new(Some(&status_tx)).debug("DoH query ignored by LLM");
712 return Ok(error_response(StatusCode::NOT_FOUND, "Query ignored"));
713 }
714 _ => {}
715 }
716 }
717
718 // Default: no response sent
719 Ok(error_response(
720 StatusCode::INTERNAL_SERVER_ERROR,
721 "No response generated",
722 ))
723 }
724}
725
726/// Decode base64url (URL-safe base64 without padding)
727fn base64_url_decode(encoded: &str) -> Result<Vec<u8>> {
728 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
729 use base64::Engine;
730
731 URL_SAFE_NO_PAD
732 .decode(encoded)
733 .context("Failed to decode base64url")
734}
735
736/// Build a `200 application/dns-message` response carrying a DNS wire-format body.
737///
738/// Every header passed to the builder is a constant, so the `expect` below is
739/// unreachable regardless of what the model or the network produced.
740fn dns_message_response(body: Vec<u8>) -> Response<Full<Bytes>> {
741 Response::builder()
742 .status(StatusCode::OK)
743 .header("Content-Type", "application/dns-message")
744 .body(Full::new(Bytes::from(body)))
745 .expect("constant status and headers always build a valid response")
746}
747
748/// Is this `Content-Type` `application/dns-message`?
749///
750/// RFC 9110 §8.3 makes the media type case-insensitive and allows parameters after a `;`, so
751/// `Application/DNS-Message` and `application/dns-message; charset=utf-8` are both the same
752/// type. The check was a byte-for-byte `!=` against the canonical spelling, which rejected
753/// both as "Invalid Content-Type" — a conformant client turned away for being conformant.
754fn is_dns_message_content_type(value: &hyper::header::HeaderValue) -> bool {
755 let Ok(text) = value.to_str() else {
756 return false;
757 };
758 let media_type = text.split(';').next().unwrap_or("").trim();
759 media_type.eq_ignore_ascii_case("application/dns-message")
760}
761
762/// A `503` telling the client to come back, for a transient backend saturation.
763///
764/// Distinct from the 500 path on purpose: a client that sees 503 + `Retry-After` backs off,
765/// where a 500 is recorded as a permanent fault. This mirrors what `src/server/http` does with
766/// the same two `WireFailure` categories.
767fn retry_later_response() -> Response<Full<Bytes>> {
768 Response::builder()
769 .status(StatusCode::SERVICE_UNAVAILABLE)
770 .header("Content-Type", "text/plain")
771 .header("Retry-After", "1")
772 .body(Full::new(Bytes::from(
773 crate::utils::WireFailure::Overloaded.prefixed_text(),
774 )))
775 .expect("constant status and headers always build a valid response")
776}
777
778/// Create an error response
779fn error_response(status: StatusCode, message: &str) -> Response<Full<Bytes>> {
780 Response::builder()
781 .status(status)
782 .header("Content-Type", "text/plain")
783 .body(Full::new(Bytes::from(message.to_string())))
784 .expect("constant status and headers always build a valid response")
785}