Skip to main content

netget/server/http/
mod.rs

1//! HTTP server implementation using hyper
2pub mod actions;
3
4use std::convert::Infallible;
5use std::net::SocketAddr;
6use std::sync::Arc;
7
8use bytes::Bytes;
9use http_body_util::Full;
10use hyper::body::Incoming;
11use hyper::server::conn::http1;
12use hyper::service::service_fn;
13use hyper::{Request, Response};
14use hyper_util::rt::TokioIo;
15use tokio::sync::mpsc;
16use tracing::{debug, error, info};
17
18use crate::llm::action_helper::call_llm;
19use crate::llm::ollama_client::OllamaClient;
20use crate::logging::emit::Log;
21use crate::protocol::Event;
22use crate::server::connection::ConnectionId;
23use crate::server::HttpProtocol;
24use crate::state::app_state::AppState;
25use actions::HTTP_REQUEST_EVENT;
26
27/// How long a peer that has connected and produced nothing may hold a slot.
28///
29/// HTTP is client-speaks-first: the server says nothing until a request line arrives, so a peer
30/// that has completed the TCP handshake and sent no byte has asked nothing and negotiated
31/// nothing. That state carries no protocol yet, which is why this number is the same across
32/// netget's HTTP-shaped servers while the idle bound below is not. Apache's `mod_reqtimeout`
33/// gives the request header 20s and nginx's `client_header_timeout` 60s; 30s sits between the
34/// two deployed norms.
35///
36/// hyper's own `header_read_timeout` is **not** this bound: its 30-second default is inert
37/// unless `http1::Builder::timer` is also set, which nothing here does — hyper downgrades a
38/// defaulted duration to `None` when no timer is present and applies no deadline at all.
39const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
40
41/// How long a connection with nothing outstanding may sit idle between requests.
42///
43/// nginx's `keepalive_timeout` default, and this is the one server in the family where copying
44/// it needs no further argument: a netget HTTP server's clients are whatever the operator points
45/// at it — a browser, `curl`, a generated SDK — so the number the deployed web is already tuned
46/// against is the right one. Apache's `KeepAliveTimeout` of 5s is the other end of the range and
47/// is tuned for a front-end serving far more connections than this one admits.
48///
49/// A request still being answered is not silence: the watchdog reads
50/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), which reports a
51/// connection with work in flight — a model round-trip, or an event a `manual` rule parked for a
52/// human at the dashboard (`src/state/intercepts.rs`, 300s by default) — as not idle at all. So
53/// an answer that takes longer than this bound can never close the connection it is an answer
54/// for, which is the `.connectionless()`/TFTP lesson read in reverse.
55const IDLE_BETWEEN_REQUESTS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(75);
56
57/// Concurrent connections this server admits.
58///
59/// Below the shared `DEFAULT_MAX_CONNECTIONS` of 256 on purpose: each admitted connection may
60/// buffer one body of up to `http_common::MAX_REQUEST_BODY_BYTES` (8 MiB), which is the largest
61/// per-connection cost in netget's HTTP family, and the cap is what turns that per-connection
62/// bound into a total one. 128 holds the worst case to the same ~1 GiB ceiling the 4 MiB and
63/// 64 KiB servers reach at 256.
64///
65/// It is not a throughput knob, and it is deliberately not configurable: a bound decided by
66/// configuration is a bound an attacker can ask you to raise.
67const MAX_CONNECTIONS: usize = 128;
68
69/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
70///
71/// `503 Service Unavailable` with a `Retry-After`, written directly onto the socket because the
72/// peer has not sent a request line for hyper to answer. Fixed bytes: nothing derived from an
73/// error reaches the wire (see `crate::utils::wire_failure`).
74const CONNECTION_CAP_REFUSAL: &[u8] = b"HTTP/1.1 503 Service Unavailable\r\n\
75    Content-Length: 0\r\nRetry-After: 5\r\nConnection: close\r\n\r\n";
76
77/// HTTP server that delegates request handling to LLM
78pub struct HttpServer;
79
80impl HttpServer {
81    /// Spawn the HTTP server with integrated LLM actions
82    pub async fn spawn_with_llm_actions(
83        listen_addr: SocketAddr,
84        llm_client: OllamaClient,
85        app_state: Arc<AppState>,
86        status_tx: mpsc::UnboundedSender<String>,
87        server_id: crate::state::ServerId,
88        tls_config: Option<Arc<rustls::ServerConfig>>,
89    ) -> anyhow::Result<SocketAddr> {
90        let listener =
91            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
92        let local_addr = listener.local_addr()?;
93
94        let protocol_name = if tls_config.is_some() {
95            "HTTPS"
96        } else {
97            "HTTP"
98        };
99        Log::new(Some(&status_tx)).info(format!(
100            "{} server listening on {}",
101            protocol_name, local_addr
102        ));
103
104        let protocol = Arc::new(HttpProtocol::new());
105
106        // Build the per-server request filter once, at startup: path regexes
107        // compile once for the life of the server, and a bad rule is reported
108        // while the caller is still watching the start_server result rather than
109        // on the first connection.
110        let filter = Arc::new(
111            crate::server::http_common::handler::RequestFilter::from_startup_params(
112                app_state
113                    .get_server(server_id)
114                    .await
115                    .and_then(|s| s.startup_params)
116                    .as_ref(),
117            ),
118        );
119        // Filter parsing is fail-open: a bad rule is dropped, not fatal, so every
120        // request would silently reach the LLM. Make that visible in the TUI/MCP
121        // status stream, not just in netget.log.
122        let filter_log = Log::new(Some(&status_tx));
123        for warning in filter.warnings() {
124            filter_log.error(format!("HTTP request_filter: {}", warning));
125        }
126
127        // Create TLS acceptor if TLS is enabled
128        let tls_acceptor = tls_config.map(|config| tokio_rustls::TlsAcceptor::from(config));
129
130        // Spawn server loop
131        let task_registrar = app_state.clone();
132        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
133        let accept_handle = tokio::spawn(async move {
134            loop {
135                match crate::server::accept_bounded::accept_bounded(
136                    &listener,
137                    &limiter,
138                    CONNECTION_CAP_REFUSAL,
139                    "HTTP",
140                    Some(&status_tx),
141                )
142                .await
143                {
144                    Ok((stream, remote_addr, permit)) => {
145                        let connection_id =
146                            ConnectionId::new(app_state.get_next_unified_id().await);
147                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
148                        info!(
149                            "Accepted {} connection {} from {}",
150                            protocol_name, connection_id, remote_addr
151                        );
152
153                        // Add connection to ServerInstance
154                        use crate::state::server::{
155                            ConnectionState as ServerConnectionState, ConnectionStatus,
156                            ProtocolConnectionInfo,
157                        };
158                        let now = crate::utils::clock::Instant::now();
159                        let conn_state = ServerConnectionState {
160                            id: connection_id,
161                            remote_addr,
162                            local_addr: local_addr_conn,
163                            bytes_sent: 0,
164                            bytes_received: 0,
165                            packets_sent: 0,
166                            packets_received: 0,
167                            last_activity: now,
168                            status: ConnectionStatus::Active,
169                            status_changed_at: now,
170                            protocol_info: ProtocolConnectionInfo::new(serde_json::json!({
171                                "recent_requests": []
172                            })),
173                        };
174                        app_state
175                            .add_connection_to_server(server_id, conn_state)
176                            .await;
177                        let _ = status_tx.send("__UPDATE_UI__".to_string());
178
179                        let llm_client_clone = llm_client.clone();
180                        let app_state_clone = app_state.clone();
181                        let status_tx_clone = status_tx.clone();
182                        let protocol_clone = protocol.clone();
183                        let tls_acceptor_clone = tls_acceptor.clone();
184                        let filter_clone = filter.clone();
185
186                        // Spawn a task to handle this connection
187                        // Tracked, not detached: stop_server must abort this task too.
188                        let task_owner = app_state.clone();
189                        task_owner
190                            .spawn_server_task(server_id, async move {
191                                // First-byte bound, before hyper sees the socket.
192                                //
193                                // hyper owns every read once `serve_connection` starts, and a
194                                // deadline on those reads would be wrong here rather than merely
195                                // awkward: hyper keeps polling the connection for more input
196                                // while a request is being answered, so such a deadline would
197                                // fire in the middle of a model round-trip. `peek` waits for data
198                                // without consuming it, so the request line is still there for
199                                // hyper afterwards, and it bounds exactly the case that needs
200                                // bounding - a peer that has connected and sent nothing at all.
201                                let spoke = matches!(
202                                    tokio::time::timeout(
203                                        FIRST_BYTE_READ_TIMEOUT,
204                                        stream.peek(&mut [0u8; 1]),
205                                    )
206                                    .await,
207                                    Ok(Ok(n)) if n > 0
208                                );
209
210                                if !spoke {
211                                    Log::new(Some(&status_tx_clone)).debug(format!(
212                                        "{} peer {} sent nothing for {}s; closing before any \
213                                         request",
214                                        protocol_name,
215                                        remote_addr,
216                                        FIRST_BYTE_READ_TIMEOUT.as_secs()
217                                    ));
218                                    app_state_clone
219                                        .close_connection_on_server(server_id, connection_id)
220                                        .await;
221                                    let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
222                                    return;
223                                }
224
225                                // Survives this task: an h2c upgrade moves the connection to a
226                                // separate task, and the slot must stay taken while it runs.
227                                let permit = std::sync::Arc::new(permit);
228
229                                // Tracks whether this connection is answering anything, so the
230                                // idle watchdog in `serve_connection` cannot close a connection
231                                // whose answer is still being composed.
232                                let activity = std::sync::Arc::new(
233                                    crate::server::accept_bounded::ConnectionActivity::new(),
234                                );
235
236                                // Perform TLS handshake if TLS is enabled
237                                if let Some(acceptor) = tls_acceptor_clone {
238                                    match acceptor.accept(stream).await {
239                                        Ok(tls_stream) => {
240                                            Log::new(Some(&status_tx_clone)).debug(format!(
241                                                "{} TLS handshake complete with {}",
242                                                protocol_name, remote_addr
243                                            ));
244                                            let io = TokioIo::new(tls_stream);
245                                            Self::serve_connection(
246                                                io,
247                                                connection_id,
248                                                server_id,
249                                                llm_client_clone,
250                                                app_state_clone.clone(),
251                                                status_tx_clone.clone(),
252                                                protocol_clone,
253                                                filter_clone,
254                                                activity,
255                                                permit,
256                                            )
257                                            .await;
258                                        }
259                                        Err(e) => {
260                                            Log::new(Some(&status_tx_clone)).warn(format!(
261                                                "{} TLS handshake failed: {}",
262                                                protocol_name, e
263                                            ));
264                                        }
265                                    }
266                                } else {
267                                    // No TLS, use plain TCP
268                                    let io = TokioIo::new(stream);
269                                    Self::serve_connection(
270                                        io,
271                                        connection_id,
272                                        server_id,
273                                        llm_client_clone,
274                                        app_state_clone.clone(),
275                                        status_tx_clone.clone(),
276                                        protocol_clone,
277                                        filter_clone,
278                                        activity,
279                                        permit,
280                                    )
281                                    .await;
282                                }
283
284                                // Mark connection as closed
285                                app_state_clone
286                                    .close_connection_on_server(server_id, connection_id)
287                                    .await;
288                                Log::new(Some(&status_tx_clone)).info(format!(
289                                    "{} connection {connection_id} closed",
290                                    protocol_name
291                                ));
292                                let _ = status_tx_clone.send("__UPDATE_UI__".to_string());
293                            })
294                            .await;
295                    }
296                    Err(e) => {
297                        Log::new(Some(&status_tx))
298                            .error(format!("Failed to accept HTTP connection: {}", e));
299                        break;
300                    }
301                }
302            }
303        });
304
305        task_registrar
306            .register_server_task(server_id, accept_handle)
307            .await;
308
309        Ok(local_addr)
310    }
311
312    /// Serve an HTTP connection (helper function to avoid code duplication)
313    #[allow(clippy::too_many_arguments)]
314    async fn serve_connection<T>(
315        io: TokioIo<T>,
316        connection_id: ConnectionId,
317        server_id: crate::state::ServerId,
318        llm_client: OllamaClient,
319        app_state: Arc<AppState>,
320        status_tx: mpsc::UnboundedSender<String>,
321        protocol: Arc<HttpProtocol>,
322        filter: Arc<crate::server::http_common::handler::RequestFilter>,
323        activity: Arc<crate::server::accept_bounded::ConnectionActivity>,
324        permit: Arc<crate::server::accept_bounded::ConnectionPermit>,
325    ) where
326        T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
327    {
328        // Clone for service closure
329        let status_for_service = status_tx.clone();
330        let app_state_for_service = app_state.clone();
331        let activity_for_service = Arc::clone(&activity);
332
333        // The request filter is built once per server in spawn_with_llm_actions.
334        // Create a service that handles requests with LLM
335        let service = service_fn(move |req: Request<Incoming>| {
336            let llm_clone = llm_client.clone();
337            let state_clone = app_state_for_service.clone();
338            let status_clone = status_for_service.clone();
339            let protocol_clone = protocol.clone();
340            let filter_clone = filter.clone();
341            let activity = Arc::clone(&activity_for_service);
342            // Kept alive for the whole request, and cloned into an h2c upgrade if one happens,
343            // so the connection cap counts this peer for as long as it is really here.
344            let permit_clone = Arc::clone(&permit);
345            async move {
346                // Held for the whole request, so an answer waiting on the model — or parked for
347                // a human by a `manual` rule — reads as work in flight rather than as silence.
348                let _busy = activity.busy();
349                handle_http_request_with_llm_actions(
350                    req,
351                    connection_id,
352                    server_id,
353                    llm_clone,
354                    state_clone,
355                    status_clone,
356                    protocol_clone,
357                    filter_clone,
358                    permit_clone,
359                )
360                .await
361            }
362        });
363
364        // Serve HTTP/1 on this connection with upgrade support, bounded on idle time.
365        let conn = http1::Builder::new()
366            .serve_connection(io, service)
367            .with_upgrades();
368        tokio::pin!(conn);
369        tokio::select! {
370            result = &mut conn => {
371                if let Err(err) = result {
372                    error!("Error serving HTTP connection: {:?}", err);
373                }
374            }
375            _ = crate::server::accept_bounded::watch_idle(
376                Arc::clone(&activity),
377                IDLE_BETWEEN_REQUESTS_TIMEOUT,
378            ) => {
379                debug!(
380                    "HTTP connection {} idle for {}s; closing",
381                    connection_id,
382                    IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
383                );
384            }
385        }
386    }
387}
388
389/// Handle a single HTTP request, recording per-connection statistics around it.
390///
391/// This wrapper exists because the counters have to be maintained on *every*
392/// exit path, including the ones that never reach the model (h2c upgrade,
393/// request filter rejection). Two consumers depend on them:
394///
395/// - `ServerInstance::cleanup_old_connections` (`src/state/server.rs`) drops any
396///   connection whose `last_activity` is older than 10s, and both the TUI and
397///   the MCP loop call it on a timer. Without a refresh per request, a keep-alive
398///   connection is evicted from the state map 10s after it opens while it is
399///   still serving traffic, and every later stat update and the eventual
400///   `close_connection_on_server` silently target a connection that is gone.
401/// - Connection-scoped scheduled tasks put these counters and the idle time
402///   straight into the model's prompt (`src/llm/prompt.rs`), which is what an
403///   idle-timeout or rate-limiting instruction is supposed to reason about.
404///
405/// Semantics, matching the other hyper-based servers (see `oauth2`): a "packet"
406/// is one HTTP message, and the byte counts are **message bodies only** —
407/// request/status line and headers are not counted, since hyper has already
408/// parsed them away by the time we see the request.
409#[allow(clippy::too_many_arguments)]
410async fn handle_http_request_with_llm_actions(
411    req: Request<Incoming>,
412    connection_id: ConnectionId,
413    server_id: crate::state::ServerId,
414    llm_client: OllamaClient,
415    app_state: Arc<AppState>,
416    status_tx: mpsc::UnboundedSender<String>,
417    protocol: Arc<HttpProtocol>,
418    filter: Arc<crate::server::http_common::handler::RequestFilter>,
419    connection_permit: Arc<crate::server::accept_bounded::ConnectionPermit>,
420) -> Result<Response<Full<Bytes>>, Infallible> {
421    // Count the inbound message before doing anything else, so a request that is
422    // filtered out or upgraded still refreshes last_activity.
423    app_state
424        .update_connection_stats(server_id, connection_id, None, None, Some(1), None)
425        .await;
426
427    let response = handle_http_request_inner(
428        req,
429        connection_id,
430        server_id,
431        llm_client,
432        app_state.clone(),
433        status_tx,
434        protocol,
435        filter,
436        connection_permit,
437    )
438    .await;
439
440    let bytes_sent = response
441        .as_ref()
442        .ok()
443        .and_then(|resp| {
444            use hyper::body::Body;
445            resp.body().size_hint().exact()
446        })
447        .unwrap_or(0);
448    app_state
449        .update_connection_stats(
450            server_id,
451            connection_id,
452            None,
453            Some(bytes_sent),
454            None,
455            Some(1),
456        )
457        .await;
458
459    response
460}
461
462#[allow(clippy::too_many_arguments)]
463async fn handle_http_request_inner(
464    req: Request<Incoming>,
465    connection_id: ConnectionId,
466    server_id: crate::state::ServerId,
467    llm_client: OllamaClient,
468    app_state: Arc<AppState>,
469    status_tx: mpsc::UnboundedSender<String>,
470    protocol: Arc<HttpProtocol>,
471    filter: Arc<crate::server::http_common::handler::RequestFilter>,
472    // Only the h2c upgrade below needs this: the
473    // upgraded connection outlives the HTTP/1 task that accepted it, so without carrying the
474    // permit across, an upgrade would quietly hand the slot back while the peer was still on it.
475    connection_permit: Arc<crate::server::accept_bounded::ConnectionPermit>,
476) -> Result<Response<Full<Bytes>>, Infallible> {
477    // Check for an HTTP/2 upgrade request (h2c)
478    {
479        if let Some(upgrade_header) = req.headers().get(hyper::header::UPGRADE) {
480            if let Ok(upgrade_value) = upgrade_header.to_str() {
481                if upgrade_value.contains("h2c") {
482                    Log::new(Some(&status_tx)).info(format!(
483                        "HTTP/2 upgrade (h2c) requested on connection {}",
484                        connection_id
485                    ));
486
487                    // Check for HTTP2-Settings header (required for h2c upgrade)
488                    if req.headers().get("HTTP2-Settings").is_none() {
489                        // Refused by the protocol before any model call.
490                        Log::new(Some(&status_tx)).warn(format!(
491                            "HTTP h2c upgrade on connection {} decision=protocol_error: no \
492                             HTTP2-Settings header (no LLM call)",
493                            connection_id
494                        ));
495                        let response = Response::builder()
496                            .status(400) // Bad Request
497                            .body(Full::new(Bytes::from(
498                                "HTTP/2 upgrade requires HTTP2-Settings header",
499                            )))
500                            .unwrap();
501                        return Ok(response);
502                    }
503
504                    // Spawn task to handle upgrade after 101 response
505                    let llm_clone = llm_client.clone();
506                    let app_state_clone = app_state.clone();
507                    let status_tx_clone = status_tx.clone();
508                    let protocol_clone = protocol.clone();
509                    let filter_clone = filter.clone();
510                    let permit_clone = Arc::clone(&connection_permit);
511
512                    // Tracked, not detached: stop_server must abort this task too.
513                    let task_owner = app_state.clone();
514                    task_owner
515                        .spawn_server_task(server_id, async move {
516                            // The upgraded connection outlives the HTTP/1 task, so it carries
517                            // the connection-cap slot with it.
518                            let _permit = permit_clone;
519                            // Wait for upgrade to complete
520                            match hyper::upgrade::on(req).await {
521                                Ok(upgraded) => {
522                                    Log::new(Some(&status_tx_clone)).info(format!(
523                                        "Upgraded connection {} to HTTP/2",
524                                        connection_id
525                                    ));
526
527                                    // Perform h2 handshake on the upgraded connection
528                                    use hyper_util::rt::TokioIo;
529                                    let io = TokioIo::new(upgraded);
530
531                                    // Use h2 server to handle the upgraded connection
532                                    if let Err(e) = handle_upgraded_h2c_connection(
533                                        io,
534                                        connection_id,
535                                        server_id,
536                                        llm_clone,
537                                        app_state_clone,
538                                        status_tx_clone,
539                                        protocol_clone,
540                                        filter_clone,
541                                    )
542                                    .await
543                                    {
544                                        error!("Error handling upgraded h2c connection: {}", e);
545                                    }
546                                }
547                                Err(e) => {
548                                    Log::new(Some(&status_tx_clone))
549                                        .warn(format!("HTTP/2 upgrade failed: {}", e));
550                                }
551                            }
552                        })
553                        .await;
554
555                    // Return 101 Switching Protocols
556                    let response = Response::builder()
557                        .status(101) // 101 Switching Protocols
558                        .header(hyper::header::UPGRADE, "h2c")
559                        .header(hyper::header::CONNECTION, "Upgrade")
560                        .body(Full::new(Bytes::new()))
561                        .unwrap();
562
563                    return Ok(response);
564                }
565            }
566        }
567    }
568
569    // Use shared request extraction logic. A body over the shared cap is refused with
570    // 413 here rather than being truncated: a truncated body handed to the model looks
571    // exactly like a complete one, and the model would answer a request it never saw.
572    let request_data =
573        match crate::server::http_common::handler::extract_request_data(req, "HTTP", &status_tx)
574            .await
575        {
576            Ok(data) => data,
577            Err(too_large) => {
578                return Ok(
579                    crate::server::http_common::handler::build_payload_too_large_response(
580                        &too_large, "HTTP", &status_tx,
581                    ),
582                );
583            }
584        };
585
586    // The body is the only part of the request whose byte count survives hyper's
587    // parsing; the packet counter was already incremented by the caller.
588    if !request_data.body_bytes.is_empty() {
589        app_state
590            .update_connection_stats(
591                server_id,
592                connection_id,
593                Some(request_data.body_bytes.len() as u64),
594                None,
595                None,
596                None,
597            )
598            .await;
599    }
600
601    // Parse URI into path and query components
602    let (path, query_string) = if let Some(pos) = request_data.uri.find('?') {
603        (
604            request_data.uri[..pos].to_string(),
605            Some(request_data.uri[pos + 1..].to_string()),
606        )
607    } else {
608        (request_data.uri.clone(), None)
609    };
610
611    // Apply the per-server request filter: only forward matching requests to the
612    // LLM; everything else gets the configured auto-response (default 404) with
613    // no LLM call. With no filter configured this is a no-op (pass-through).
614    if !filter.is_pass_through() && !filter.allows(&request_data, &path) {
615        let resp = filter.rejection();
616        // The server's own request_filter refused this, not the model, and no LLM call was
617        // made. Its own token so it is never read as the model having answered.
618        Log::new(Some(&status_tx)).info(format!(
619            "HTTP {} {} decision=refused_by_filter -> {} (no LLM call)",
620            request_data.method,
621            path,
622            resp.status().as_u16()
623        ));
624        return Ok(resp);
625    }
626
627    // Parse query parameters into structured object
628    let query = if let Some(ref qs) = query_string {
629        let mut params = serde_json::Map::new();
630        for pair in qs.split('&') {
631            if let Some((key, value)) = pair.split_once('=') {
632                // URL decode the key and value
633                let decoded_key =
634                    urlencoding::decode(key).unwrap_or(std::borrow::Cow::Borrowed(key));
635                let decoded_value =
636                    urlencoding::decode(value).unwrap_or(std::borrow::Cow::Borrowed(value));
637                params.insert(
638                    decoded_key.to_string(),
639                    serde_json::Value::String(decoded_value.to_string()),
640                );
641            } else {
642                // Handle keys without values
643                let decoded_key =
644                    urlencoding::decode(pair).unwrap_or(std::borrow::Cow::Borrowed(pair));
645                params.insert(
646                    decoded_key.to_string(),
647                    serde_json::Value::String(String::new()),
648                );
649            }
650        }
651        serde_json::Value::Object(params)
652    } else {
653        serde_json::Value::Object(serde_json::Map::new())
654    };
655
656    // Create HTTP request event with path, query_string, and parsed query.
657    //
658    // Request bodies are attacker-controlled and need not be UTF-8. Action/event
659    // design rules forbid handing the model raw bytes or base64, so the body is
660    // always presented as (lossily) decoded text, and a non-UTF8 body is flagged
661    // explicitly rather than silently mangled into U+FFFD.
662    let body_is_binary = std::str::from_utf8(&request_data.body_bytes).is_err();
663    let body_text = String::from_utf8_lossy(&request_data.body_bytes);
664    let mut event_data = serde_json::json!({
665        "method": request_data.method,
666        "path": path,
667        "query": query,
668        "headers": request_data.headers,
669        "body": if body_text.is_empty() { "" } else { body_text.as_ref() },
670        "body_bytes": request_data.body_bytes.len()
671    });
672    if body_is_binary {
673        event_data["body_is_binary"] = serde_json::Value::Bool(true);
674    }
675
676    // Add query_string field if present
677    if let Some(qs) = query_string {
678        event_data["query_string"] = serde_json::Value::String(qs);
679    }
680
681    let event = Event::new(&HTTP_REQUEST_EVENT, event_data);
682
683    // Call LLM to generate HTTP response
684    match call_llm(
685        &llm_client,
686        &app_state,
687        server_id,
688        Some(connection_id),
689        &event,
690        protocol.as_ref(),
691    )
692    .await
693    {
694        Ok(execution_result) => {
695            debug!("LLM HTTP response received");
696
697            // Display messages
698            for msg in execution_result.messages {
699                let _ = status_tx.send(msg);
700            }
701
702            // `build_response` decides this protocol's terminal outcome, and it decides it
703            // silently. It lives in `src/server/http_common/handler.rs`, shared with
704            // ipp/openapi/…, so the tag has to be applied here at the call site instead.
705            //
706            // `produced_http_response` is `build_response`'s own predicate, exported so this
707            // line cannot drift from the bytes that actually go out: a tag claiming the model
708            // answered while the peer received the fallback would be worse than no tag. When
709            // it is false the model produced no usable `send_http_response`, and the server
710            // answers anyway — with the configured `default_response` if there is one, and
711            // otherwise with a blank **200**.
712            //
713            // **That last branch is a fail-open**, recorded here rather than repaired in
714            // this pass: an unreachable model, a model that answered with nothing, and a
715            // model that deliberately answered `200 OK` are three different things that all
716            // reach the peer as `200 OK` with an empty body. `decision=fail_closed_*` would
717            // be a lie on that path, so the silence is tagged as the model's and the
718            // fallback that was actually used is named in the same line.
719            let produced_response = crate::server::http_common::handler::produced_http_response(
720                &execution_result.protocol_results,
721            );
722            let failure_summary = execution_result
723                .failures
724                .iter()
725                .map(|f| format!("{}: {}", f.action, f.error))
726                .collect::<Vec<_>>()
727                .join("; ");
728            let has_default_response = filter.default_response_parts().is_some();
729            let fallback = if has_default_response {
730                "default_response"
731            } else {
732                "blank_200"
733            };
734            let subject = format!(
735                "HTTP {} {} (connection {})",
736                request_data.method, request_data.uri, connection_id
737            );
738            let log = Log::new(Some(&status_tx));
739            if produced_response {
740                log.info(format!("{} decision=model_answer", subject));
741            } else if !failure_summary.is_empty() {
742                log.error(format!(
743                    "{} decision=model_bad_action fallback={}: the model answered but its \
744                     action(s) could not be executed ({}), and the peer is still given an \
745                     affirmative response",
746                    subject, fallback, failure_summary
747                ));
748            } else if has_default_response {
749                log.warn(format!(
750                    "{} decision=model_silent fallback=default_response: the model produced no \
751                     send_http_response, so the server's configured default_response was sent",
752                    subject
753                ));
754            } else {
755                log.warn(format!(
756                    "{} decision=model_silent fallback=blank_200: the model produced no \
757                     send_http_response and no default_response is configured, so the peer is \
758                     answered 200 with an empty body (fail-open)",
759                    subject
760                ));
761            }
762
763            // Use shared response building logic
764            crate::server::http_common::handler::build_response(
765                execution_result.protocol_results,
766                "HTTP",
767                &request_data.method,
768                &request_data.uri,
769                &status_tx,
770                filter.default_response_parts(),
771            )
772        }
773        Err(e) => {
774            // Use shared error response building
775            crate::server::http_common::handler::build_error_response(
776                e,
777                "HTTP",
778                &request_data.method,
779                &request_data.uri,
780                &status_tx,
781            )
782        }
783    }
784}
785
786/// Handle an upgraded h2c connection (only available with http2 feature)
787#[allow(clippy::too_many_arguments)]
788async fn handle_upgraded_h2c_connection<T>(
789    io: T,
790    connection_id: ConnectionId,
791    server_id: crate::state::ServerId,
792    llm_client: OllamaClient,
793    app_state: Arc<AppState>,
794    status_tx: mpsc::UnboundedSender<String>,
795    _protocol: Arc<HttpProtocol>,
796    filter: Arc<crate::server::http_common::handler::RequestFilter>,
797) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
798where
799    T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
800{
801    use crate::server::Http2Protocol;
802
803    info!("Starting h2c connection for {}", connection_id);
804
805    // Perform h2 server handshake
806    // The same SETTINGS as a prior-knowledge HTTP/2 connection: stream count, windows, frame
807    // and header-list sizes, and one body budget shared by the connection's streams.
808    let mut h2_conn = crate::server::http2::h2_server::bounded_h2_builder()
809        .handshake(io)
810        .await?;
811    let body_budget = crate::server::http2::h2_server::BodyBudget::new();
812
813    let protocol = Arc::new(Http2Protocol::new());
814
815    // The upgraded connection gets the same idle bound as the HTTP/1 one it came from:
816    // reaching h2c costs one request, after which an unbounded peer could hold the stream — and
817    // the slot it carried across — indefinitely.
818    let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
819
820    // Accept requests on the h2 connection
821    loop {
822        let accepted = tokio::select! {
823            accepted = h2_conn.accept() => accepted,
824            _ = crate::server::accept_bounded::watch_idle(
825                Arc::clone(&activity),
826                IDLE_BETWEEN_REQUESTS_TIMEOUT,
827            ) => {
828                debug!(
829                    "H2C connection {} idle for {}s; closing",
830                    connection_id,
831                    IDLE_BETWEEN_REQUESTS_TIMEOUT.as_secs()
832                );
833                break;
834            }
835        };
836        match accepted {
837            Some(result) => {
838                let (request, send_response) = result?;
839
840                let llm_clone = llm_client.clone();
841                let app_state_clone = app_state.clone();
842                let status_tx_clone = status_tx.clone();
843                let protocol_clone = protocol.clone();
844                let filter_clone = filter.clone();
845                let activity_clone = Arc::clone(&activity);
846                let stream_budget = body_budget.clone();
847
848                // Spawn task to handle this HTTP/2 request
849                // Tracked, not detached: stop_server must abort this task too.
850                let task_owner = app_state.clone();
851                task_owner
852                    .spawn_server_task(server_id, async move {
853                        // In flight for the whole request, so a model round-trip or a parked
854                        // `manual` event reads as work rather than as an idle connection.
855                        let _busy = activity_clone.busy();
856                        if let Err(e) = crate::server::http2::h2_server::handle_h2_request(
857                            request,
858                            send_response,
859                            connection_id,
860                            server_id,
861                            llm_clone,
862                            app_state_clone,
863                            status_tx_clone,
864                            protocol_clone,
865                            filter_clone,
866                            stream_budget,
867                        )
868                        .await
869                        {
870                            error!("Error handling h2c request: {}", e);
871                        }
872                    })
873                    .await;
874            }
875            None => {
876                // Connection closed
877                info!("H2C connection {} closed", connection_id);
878                break;
879            }
880        }
881    }
882
883    Ok(())
884}