Skip to main content

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}