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}