Skip to main content

netget/server/http_common/
handler.rs

1//! Shared HTTP request/response handling logic
2
3use crate::console_trace;
4use crate::llm::ActionResult;
5use bytes::Bytes;
6use http_body_util::{BodyExt, Full};
7use hyper::body::Incoming;
8use hyper::{Request, Response};
9use std::collections::HashMap;
10use std::convert::Infallible;
11use tokio::sync::mpsc;
12use tracing::{debug, error, trace, warn};
13
14/// Extracted request data common to HTTP and HTTP/2
15#[derive(Debug)]
16pub struct RequestData {
17    pub method: String,
18    pub uri: String,
19    pub version: String,
20    pub headers: HashMap<String, String>,
21    pub body_bytes: Bytes,
22}
23
24/// How much of a request body is read before the request is refused with 413.
25///
26/// The body is buffered whole and then embedded in an LLM prompt, so there is no
27/// legitimate use for a large one: a model cannot read 10 MB, and every byte past a
28/// few kilobytes is cost without benefit. Without a cap the buffer is whatever the
29/// peer chooses to send — `Incoming` has no default limit — so a single unauthenticated
30/// `POST` could exhaust the process's memory. Refusing early is both the safe and the
31/// honest answer, and 413 is the code that says exactly that.
32pub const MAX_REQUEST_BODY_BYTES: usize = 8 * 1024 * 1024;
33
34/// The request body exceeded [`MAX_REQUEST_BODY_BYTES`] (or could not be read).
35///
36/// Deliberately a distinct type rather than an empty body: a truncated body handed to
37/// the model looks like a complete one, and the model would answer a request it never
38/// actually saw.
39#[derive(Debug)]
40pub struct BodyTooLarge {
41    pub method: String,
42    pub uri: String,
43}
44
45/// Extract request data from hyper Request.
46///
47/// The body is bounded by [`MAX_REQUEST_BODY_BYTES`]; a larger one yields
48/// [`BodyTooLarge`] and the caller answers 413 without spending an LLM call.
49pub async fn extract_request_data(
50    req: Request<Incoming>,
51    protocol_label: &str,
52    status_tx: &mpsc::UnboundedSender<String>,
53) -> Result<RequestData, BodyTooLarge> {
54    // Extract request details first for logging
55    let method = req.method().to_string();
56    // Only use path+query portion (not scheme/host) for event data
57    let uri = req
58        .uri()
59        .path_and_query()
60        .map(|pq| pq.as_str())
61        .unwrap_or(req.uri().path())
62        .to_string();
63    let version = format!("{:?}", req.version());
64
65    // Extract headers
66    let mut headers = HashMap::new();
67    for (name, value) in req.headers() {
68        if let Ok(value_str) = value.to_str() {
69            headers.insert(name.to_string(), value_str.to_string());
70        }
71    }
72
73    // Read body, bounded. `Limited` errors as soon as the cap is passed rather than
74    // after buffering the whole thing, so an oversized upload costs at most the cap.
75    let limited = http_body_util::Limited::new(req.into_body(), MAX_REQUEST_BODY_BYTES);
76    let body_bytes = match limited.collect().await {
77        Ok(collected) => collected.to_bytes(),
78        Err(e) => {
79            error!(
80                "{} {} {}: refusing request body ({}); limit is {} bytes",
81                protocol_label, method, uri, e, MAX_REQUEST_BODY_BYTES
82            );
83            let _ = status_tx.send(format!(
84                "✗ {} {} {} → 413 (request body over {} bytes)",
85                protocol_label, method, uri, MAX_REQUEST_BODY_BYTES
86            ));
87            return Err(BodyTooLarge { method, uri });
88        }
89    };
90
91    // DEBUG: Log request summary to both file and TUI
92    debug!(
93        "{} request (action-based): {} {} {} ({} bytes)",
94        protocol_label,
95        method,
96        uri,
97        version,
98        body_bytes.len(),
99    );
100    let _ = status_tx.send(format!(
101        "[DEBUG] {} request: {} {} {} ({} bytes)",
102        protocol_label,
103        method,
104        uri,
105        version,
106        body_bytes.len()
107    ));
108
109    // TRACE: Log full request details
110    trace!("{} request headers:", protocol_label);
111    for (name, value) in &headers {
112        trace!("  {}: {}", name, value);
113        let _ = status_tx.send(format!(
114            "[TRACE] {} header: {}: {}",
115            protocol_label, name, value
116        ));
117    }
118    if !body_bytes.is_empty() {
119        if let Ok(body_str) = std::str::from_utf8(&body_bytes) {
120            // Try to pretty-print if it's JSON
121            if let Ok(json) = serde_json::from_str::<serde_json::Value>(body_str) {
122                let pretty = serde_json::to_string_pretty(&json).unwrap_or(body_str.to_string());
123                trace!("{} request body (JSON):\n{}", protocol_label, pretty);
124                let _ = status_tx.send(format!(
125                    "[TRACE] {} request body (JSON):\r\n{}",
126                    protocol_label,
127                    pretty.replace('\n', "\r\n")
128                ));
129            } else {
130                trace!("{} request body:\n{}", protocol_label, body_str);
131                let _ = status_tx.send(format!(
132                    "[TRACE] {} request body:\r\n{}",
133                    protocol_label,
134                    body_str.replace('\n', "\r\n")
135                ));
136            }
137        } else {
138            console_trace!(
139                status_tx,
140                "{} request body (binary): {} bytes",
141                protocol_label,
142                body_bytes.len()
143            );
144        }
145    }
146
147    Ok(RequestData {
148        method,
149        uri,
150        version,
151        headers,
152        body_bytes,
153    })
154}
155
156/// The 413 answer for a request whose body exceeded [`MAX_REQUEST_BODY_BYTES`].
157///
158/// Static text: nothing derived from the request or from an internal error reaches the
159/// peer, only the fact and the limit.
160pub fn build_payload_too_large_response(
161    too_large: &BodyTooLarge,
162    protocol_label: &str,
163    status_tx: &mpsc::UnboundedSender<String>,
164) -> Response<Full<Bytes>> {
165    warn!(
166        "{} {} {} decision=refused_body_too_large: over {} bytes",
167        protocol_label, too_large.method, too_large.uri, MAX_REQUEST_BODY_BYTES
168    );
169    let _ = status_tx.send(format!(
170        "→ {} {} {} → 413",
171        protocol_label, too_large.method, too_large.uri
172    ));
173    build_safe_response(
174        413,
175        [("Content-Type".to_string(), "text/plain".to_string())],
176        format!(
177            "Payload Too Large: request bodies are limited to {} bytes\n",
178            MAX_REQUEST_BODY_BYTES
179        ),
180        protocol_label,
181    )
182}
183
184/// Build a hyper `Response` from parts that came from the LLM or from server
185/// startup params, without ever panicking.
186///
187/// Everything here is attacker- or model-influenced, so nothing is trusted:
188///
189/// - an out-of-range status (`status < 100 || status > 599`) is replaced by 500,
190/// - header names/values hyper rejects (notably ones containing CR/LF, i.e.
191///   response-splitting attempts) are dropped individually,
192/// - if the builder still fails for any reason, a bare 500 is returned.
193///
194/// The previous implementation ended in `.body(..).unwrap()`, which turned any
195/// of the above into a panic inside the connection task.
196pub fn build_safe_response(
197    status: u16,
198    headers: impl IntoIterator<Item = (String, String)>,
199    body: String,
200    context: &str,
201) -> Response<Full<Bytes>> {
202    let status_code = match hyper::StatusCode::from_u16(status) {
203        Ok(code) => code,
204        Err(_) => {
205            error!(
206                "{}: invalid HTTP status {} (must be 100-599), sending 500 instead",
207                context, status
208            );
209            hyper::StatusCode::INTERNAL_SERVER_ERROR
210        }
211    };
212
213    let mut builder = Response::builder().status(status_code);
214    for (name, value) in headers {
215        match (
216            hyper::header::HeaderName::from_bytes(name.as_bytes()),
217            hyper::header::HeaderValue::from_str(&value),
218        ) {
219            (Ok(n), Ok(v)) => builder = builder.header(n, v),
220            _ => warn!(
221                "{}: dropping invalid response header {:?} (name or value is not a legal HTTP header)",
222                context, name
223            ),
224        }
225    }
226
227    match builder.body(Full::new(Bytes::from(body))) {
228        Ok(response) => response,
229        Err(e) => {
230            error!(
231                "{}: failed to build response ({}), sending bare 500",
232                context, e
233            );
234            let mut fallback =
235                Response::new(Full::new(Bytes::from_static(b"Internal Server Error")));
236            *fallback.status_mut() = hyper::StatusCode::INTERNAL_SERVER_ERROR;
237            fallback
238        }
239    }
240}
241
242/// Walk `results` in order, handing every `Output` payload to `visit`.
243///
244/// `ActionResult::Multiple` nests, and flattening it is not cosmetic: an `Output` inside a
245/// `Multiple` used to be skipped outright, so the request fell through to the fallback as
246/// though the model had said nothing. HTTP's own executor never produces `Multiple` today —
247/// which is why this went unnoticed — but nine protocols elsewhere in the tree do, and
248/// `src/server/vnc/mod.rs`, `jsonrpc`, `openvpn` and `usb/smartcard` each hand-rolled a
249/// flatten for exactly this reason.
250/// How deep a `Multiple` may nest before this stops descending.
251///
252/// Only one place in the tree constructs a `Multiple` today (`src/server/xmpp/actions.rs`) and
253/// it does not nest, so the recursion below cannot currently run away. The bound is here
254/// anyway, because the cost of being wrong about that is not a wrong answer: a Rust stack
255/// overflow is a SIGSEGV against the guard page, not a panic, so `catch_unwind` cannot see it
256/// and `tokio::spawn` cannot contain it — **the whole NetGet process dies, taking every other
257/// server in it.** This tree has had six of those.
258///
259/// 8 is far above anything a real executor produces; the deepest today is 1.
260const MAX_RESULT_NESTING: usize = 8;
261
262fn for_each_output(results: &[ActionResult], visit: &mut impl FnMut(&[u8])) {
263    for_each_output_at(results, 0, visit)
264}
265
266fn for_each_output_at(results: &[ActionResult], depth: usize, visit: &mut impl FnMut(&[u8])) {
267    if depth >= MAX_RESULT_NESTING {
268        // Refuse to descend rather than unwinding the whole reply: the outputs already
269        // visited are real and the peer should still get them.
270        tracing::warn!(
271            "http_common: result nesting deeper than {MAX_RESULT_NESTING}, not descending \
272             further. An executor is producing nested Multiple results; the outputs found so \
273             far are still used."
274        );
275        return;
276    }
277    for result in results {
278        match result {
279            ActionResult::Output(data) => visit(data),
280            ActionResult::Multiple(inner) => for_each_output_at(inner, depth + 1, visit),
281            _ => {}
282        }
283    }
284}
285
286/// Did the model produce a usable `send_http_response`?
287///
288/// True when some `Output` payload parses as JSON — the same condition [`build_response`]
289/// uses to decide whether to answer from the model or fall back to the server's
290/// `default_response` (or a blank `200`).
291///
292/// It is public because the *caller* is where that outcome has to be logged: `build_response`
293/// is shared by every HTTP-shaped protocol and cannot know what any one of them wants to say
294/// about it. Callers use this rather than re-deriving the predicate, so the log line and the
295/// bytes on the wire cannot drift apart — a tag claiming the model answered while the peer
296/// received the fallback would be worse than no tag at all.
297pub fn produced_http_response(results: &[ActionResult]) -> bool {
298    let mut produced = false;
299    for_each_output(results, &mut |data| {
300        if serde_json::from_slice::<serde_json::Value>(data).is_ok() {
301            produced = true;
302        }
303    });
304    produced
305}
306
307/// Build HTTP response from LLM execution results
308pub fn build_response(
309    protocol_results: Vec<ActionResult>,
310    protocol_label: &str,
311    method: &str,
312    uri: &str,
313    status_tx: &mpsc::UnboundedSender<String>,
314    // Fallback for when the model produced no `send_http_response` — the server's
315    // `default_response` startup param, as `(status, headers, body)`. `None` keeps
316    // the historical empty `200`.
317    default_response: Option<(u16, Vec<(String, String)>, String)>,
318) -> Result<Response<Full<Bytes>>, Infallible> {
319    // Default response in case nothing was produced
320    let mut status_code = 200;
321    let mut response_headers = HashMap::new();
322    let mut response_body = String::new();
323    let mut produced_response = false;
324
325    // Nested `Multiple` results are walked too — see `for_each_output`. An `Output` buried in
326    // one used to be dropped, which read to the caller as a silent model.
327    for_each_output(&protocol_results, &mut |output_data| {
328        // Parse the output as JSON containing HTTP response fields
329        if let Ok(json_value) = serde_json::from_slice::<serde_json::Value>(output_data) {
330            produced_response = true;
331            if let Some(status) = json_value.get("status").and_then(|v| v.as_u64()) {
332                // `status as u16` truncates, and `65736 as u16` is `200` — a nonsense status
333                // silently becoming a success. `build_safe_response` below rejects anything
334                // outside 100-599, so keep the value out of range rather than wrapping it
335                // into a plausible one.
336                status_code = u16::try_from(status).unwrap_or(500);
337            }
338            if let Some(headers_obj) = json_value.get("headers").and_then(|v| v.as_object()) {
339                for (k, v) in headers_obj {
340                    if let Some(v_str) = v.as_str() {
341                        response_headers.insert(k.clone(), v_str.to_string());
342                    }
343                }
344            }
345            if let Some(body) = json_value.get("body").and_then(|v| v.as_str()) {
346                response_body = body.to_string();
347            }
348        }
349    });
350
351    // The model was consulted but returned no send_http_response. Use the server's
352    // configured default_response if it set one, instead of a bare (blank) 200.
353    if !produced_response {
354        if let Some((status, headers, body)) = default_response {
355            status_code = status;
356            response_headers = headers.into_iter().collect();
357            response_body = body;
358        }
359    }
360
361    let _ = status_tx.send(format!(
362        "→ {} {} {} → {} ({} bytes)",
363        protocol_label,
364        method,
365        uri,
366        status_code,
367        response_body.len()
368    ));
369
370    // Build the HTTP response. Status/headers/body all originate from model
371    // output, so this must not be able to panic (see build_safe_response).
372    Ok(build_safe_response(
373        status_code,
374        response_headers,
375        response_body,
376        protocol_label,
377    ))
378}
379
380/// Build error response for LLM failures.
381///
382/// The peer gets a *category*, never the error: `error` is classified by
383/// [`crate::utils::WireFailure`] and then only logged. Overload is transient and
384/// retryable, which 500 does not say, so it becomes 503 + `Retry-After` and a client
385/// backs off instead of recording a permanent server fault — that is the path a second
386/// concurrent request takes when `--llm-max-concurrent` is saturated and the queue is
387/// full or the wait timed out. Everything else is a 500.
388///
389/// Both branches carry a `decision=` tag, so an operator reading `netget.log` can tell a
390/// saturated backend from a broken one without parsing the error text
391/// (`src/server/radius/` is the reference for this).
392pub fn build_error_response(
393    error: anyhow::Error,
394    protocol_label: &str,
395    method: &str,
396    uri: &str,
397    status_tx: &mpsc::UnboundedSender<String>,
398) -> Result<Response<Full<Bytes>>, Infallible> {
399    let failure = crate::utils::WireFailure::classify(&error);
400    let (status, decision) = match failure {
401        crate::utils::WireFailure::Overloaded => (503, "fail_closed_llm_overloaded"),
402        crate::utils::WireFailure::Unavailable => (500, "fail_closed_llm_error"),
403    };
404    error!(
405        "{} {} {} decision={} status={}: {}",
406        protocol_label, method, uri, decision, status, error
407    );
408    // The token goes on both sinks. It used to be on the `error!` alone, so `netget.log`
409    // could tell a saturated backend from a broken one and the TUI/MCP status stream — which
410    // is what an operator is actually watching, and what the e2e harness reads — could not.
411    let _ = status_tx.send(format!(
412        "[ERROR] ✗ LLM error for {} {} → {} decision={}: {}",
413        method, uri, status, decision, error
414    ));
415
416    if status == 503 {
417        return Ok(Response::builder()
418            .status(503)
419            .header(hyper::header::RETRY_AFTER, "1")
420            .body(Full::new(Bytes::from(failure.prefixed_text())))
421            .unwrap());
422    }
423
424    Ok(Response::builder()
425        .status(500)
426        .body(Full::new(Bytes::from(failure.prefixed_text())))
427        .unwrap())
428}
429
430// ===== Request filtering =====
431//
432// A per-server allowlist that decides which requests are worth an LLM call.
433// The server declares a `request_filter` in its startup params; a request
434// reaches the LLM only if it matches at least one rule. Everything else gets a
435// cheap, configurable auto-response (default 404) and never hits the LLM. This
436// keeps browser/scanner noise (favicon probes, OPTIONS preflights, asset/XHR
437// requests, vuln scans) from wasting slow, billable LLM round-trips.
438
439/// How a single header condition is matched against the request.
440#[derive(Debug, Clone)]
441enum HeaderExpect {
442    /// The header just needs to be present (config value `true`).
443    Present,
444    /// The header value must contain this substring, case-insensitively
445    /// (config value is a string, e.g. `"text/html"`).
446    Contains(String),
447}
448
449/// A single header match condition. `name` is stored lowercased; request header
450/// names (hyper-canonicalised to lowercase) are compared case-insensitively.
451#[derive(Debug, Clone)]
452struct HeaderMatch {
453    name: String,
454    expect: HeaderExpect,
455}
456
457/// One rule in a `request_filter`. All present conditions must hold (AND); an
458/// omitted condition is a wildcard.
459#[derive(Debug)]
460struct FilterRule {
461    /// Allowed request methods (uppercased). `None` = any method.
462    methods: Option<Vec<String>>,
463    /// Compiled path regex matched against the path (portion before `?`).
464    /// `None` = any path.
465    path: Option<regex::Regex>,
466    /// Header conditions (all must hold).
467    headers: Vec<HeaderMatch>,
468}
469
470impl FilterRule {
471    /// Does this request satisfy every condition in the rule?
472    fn matches(&self, req: &RequestData, path: &str) -> bool {
473        if let Some(methods) = &self.methods {
474            let m = req.method.to_ascii_uppercase();
475            if !methods.iter().any(|allowed| allowed == &m) {
476                return false;
477            }
478        }
479        if let Some(re) = &self.path {
480            if !re.is_match(path) {
481                return false;
482            }
483        }
484        for hm in &self.headers {
485            // Case-insensitive header-name lookup (request keys are already
486            // lowercase, but don't rely on it).
487            let value = req
488                .headers
489                .iter()
490                .find(|(k, _)| k.eq_ignore_ascii_case(&hm.name))
491                .map(|(_, v)| v);
492            match (&hm.expect, value) {
493                (HeaderExpect::Present, Some(_)) => {}
494                (HeaderExpect::Contains(needle), Some(v))
495                    if v.to_ascii_lowercase()
496                        .contains(&needle.to_ascii_lowercase()) => {}
497                _ => return false,
498            }
499        }
500        true
501    }
502}
503
504/// The response returned for requests that match no rule (no LLM call).
505#[derive(Debug, Clone)]
506struct FilteredResponse {
507    status: u16,
508    body: String,
509    headers: Vec<(String, String)>,
510}
511
512impl Default for FilteredResponse {
513    fn default() -> Self {
514        Self {
515            status: 404,
516            body: String::new(),
517            headers: Vec::new(),
518        }
519    }
520}
521
522/// A per-server request allowlist parsed from `startup_params`.
523///
524/// With no rules the filter is "pass-through": every request is forwarded to
525/// the LLM (unchanged default behavior).
526#[derive(Debug)]
527pub struct RequestFilter {
528    rules: Vec<FilterRule>,
529    filtered_response: FilteredResponse,
530    /// Canned response used when the model is consulted but produces no
531    /// `send_http_response` (an empty action list). `None` keeps the historical
532    /// behavior of an empty `200`. Set via the `default_response` startup param so
533    /// a declined request looks intentional (e.g. a 404 page) rather than blank.
534    default_response: Option<FilteredResponse>,
535    /// Human-readable problems found while parsing the config. Callers surface
536    /// these on the status channel so a typo is visible in the TUI/MCP stream,
537    /// not just in `netget.log`.
538    warnings: Vec<String>,
539}
540
541impl RequestFilter {
542    /// Parse a filter from a server's `startup_params`.
543    ///
544    /// **Fail-open by design.** A missing `request_filter` yields a pass-through
545    /// filter, and any individual malformed rule (or invalid path regex) is
546    /// skipped rather than failing the whole server. The consequence is worth
547    /// stating plainly: if every rule you wrote is malformed, the filter ends up
548    /// empty and *every* request reaches the LLM — slow and billable — instead
549    /// of being rejected. Server startup is never failed on a bad filter, so the
550    /// only signal is loud logging plus [`RequestFilter::warnings`]; callers are
551    /// expected to forward those to the status channel.
552    pub fn from_startup_params(params: Option<&serde_json::Value>) -> Self {
553        let mut rules = Vec::new();
554        let mut warnings = Vec::new();
555
556        match params.and_then(|p| p.get("request_filter")) {
557            None | Some(serde_json::Value::Null) => {}
558            Some(serde_json::Value::Array(arr)) => {
559                for (i, raw) in arr.iter().enumerate() {
560                    match Self::parse_rule(raw) {
561                        Ok(rule) => rules.push(rule),
562                        Err(e) => {
563                            let msg =
564                                format!("invalid request_filter rule #{}: {} — rule ignored", i, e);
565                            error!("{}", msg);
566                            warnings.push(msg);
567                        }
568                    }
569                }
570                if !arr.is_empty() && rules.is_empty() {
571                    let msg = "request_filter was configured but EVERY rule was invalid — \
572                               the filter is disabled and every request will reach the LLM \
573                               (fail-open)"
574                        .to_string();
575                    error!("{}", msg);
576                    warnings.push(msg);
577                }
578            }
579            Some(other) => {
580                let msg = format!(
581                    "request_filter must be an array of rule objects, got {} — \
582                     filter ignored, every request will reach the LLM (fail-open)",
583                    kind_of(other)
584                );
585                error!("{}", msg);
586                warnings.push(msg);
587            }
588        }
589
590        let filtered_response = params
591            .and_then(|p| p.get("filtered_response"))
592            .map(|raw| Self::parse_filtered_response(raw, &mut warnings))
593            .unwrap_or_default();
594
595        if rules.is_empty() && params.and_then(|p| p.get("filtered_response")).is_some() {
596            let msg = "filtered_response is set but no valid request_filter rules exist — \
597                       it will never be used"
598                .to_string();
599            warn!("{}", msg);
600            warnings.push(msg);
601        }
602
603        // Independent of the filter: what to send when the model is asked and
604        // answers with no response action. Absent => the historical empty 200.
605        let default_response = params
606            .and_then(|p| p.get("default_response"))
607            .map(|raw| Self::parse_filtered_response(raw, &mut warnings));
608
609        Self {
610            rules,
611            filtered_response,
612            default_response,
613            warnings,
614        }
615    }
616
617    /// The configured `default_response` as `(status, headers, body)`, or `None`
618    /// to keep the empty-200 default. Used by `build_response` when the model
619    /// produced no `send_http_response`.
620    pub fn default_response_parts(&self) -> Option<(u16, Vec<(String, String)>, String)> {
621        self.default_response
622            .as_ref()
623            .map(|r| (r.status, r.headers.clone(), r.body.clone()))
624    }
625
626    /// Problems found while parsing the filter config (empty when it is clean).
627    /// Every one of these means the filter is doing less than the caller asked.
628    pub fn warnings(&self) -> &[String] {
629        &self.warnings
630    }
631
632    fn parse_rule(raw: &serde_json::Value) -> Result<FilterRule, String> {
633        let obj = raw.as_object().ok_or("rule must be an object")?;
634
635        let methods = match obj.get("methods") {
636            None | Some(serde_json::Value::Null) => None,
637            Some(serde_json::Value::Array(arr)) => Some(
638                arr.iter()
639                    .filter_map(|m| m.as_str().map(|s| s.to_ascii_uppercase()))
640                    .collect::<Vec<_>>(),
641            ),
642            // Also accept a bare string for convenience: "methods": "GET"
643            Some(serde_json::Value::String(s)) => Some(vec![s.to_ascii_uppercase()]),
644            Some(_) => return Err("`methods` must be a string or array of strings".into()),
645        };
646
647        let path = match obj.get("path") {
648            None | Some(serde_json::Value::Null) => None,
649            Some(serde_json::Value::String(pattern)) => Some(
650                regex::Regex::new(pattern)
651                    .map_err(|e| format!("invalid `path` regex {:?}: {}", pattern, e))?,
652            ),
653            Some(_) => return Err("`path` must be a regex string".into()),
654        };
655
656        let mut headers = Vec::new();
657        if let Some(hdrs) = obj.get("headers") {
658            let map = hdrs.as_object().ok_or("`headers` must be an object")?;
659            for (name, matcher) in map {
660                let expect = match matcher {
661                    serde_json::Value::Bool(true) => HeaderExpect::Present,
662                    serde_json::Value::Bool(false) => continue, // explicitly no constraint
663                    serde_json::Value::String(s) => HeaderExpect::Contains(s.clone()),
664                    _ => {
665                        return Err(format!(
666                            "header `{}` matcher must be true or a string",
667                            name
668                        ))
669                    }
670                };
671                headers.push(HeaderMatch {
672                    name: name.to_ascii_lowercase(),
673                    expect,
674                });
675            }
676        }
677
678        if methods.is_none() && path.is_none() && headers.is_empty() {
679            // A rule with no conditions matches everything; allow it (explicit
680            // "handle all" escape hatch) rather than treating it as an error.
681            debug!("request_filter rule has no conditions — matches all requests");
682        }
683
684        Ok(FilterRule {
685            methods,
686            path,
687            headers,
688        })
689    }
690
691    fn parse_filtered_response(
692        raw: &serde_json::Value,
693        warnings: &mut Vec<String>,
694    ) -> FilteredResponse {
695        let mut resp = FilteredResponse::default();
696        if let Some(status) = raw.get("status") {
697            match status.as_u64() {
698                // Reject out-of-range values here rather than truncating with
699                // `as u16` and blowing up later while building the response.
700                Some(s) if (100..=599).contains(&s) => resp.status = s as u16,
701                _ => {
702                    let msg = format!(
703                        "filtered_response.status {} is not a valid HTTP status (100-599) — using {}",
704                        status, resp.status
705                    );
706                    error!("{}", msg);
707                    warnings.push(msg);
708                }
709            }
710        }
711        if let Some(body) = raw.get("body").and_then(|v| v.as_str()) {
712            resp.body = body.to_string();
713        }
714        if let Some(headers) = raw.get("headers").and_then(|v| v.as_object()) {
715            resp.headers = headers
716                .iter()
717                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
718                .collect();
719        }
720        resp
721    }
722
723    /// True when no rules are configured — every request goes to the LLM.
724    pub fn is_pass_through(&self) -> bool {
725        self.rules.is_empty()
726    }
727
728    /// Should this request be forwarded to the LLM?
729    pub fn allows(&self, req: &RequestData, path: &str) -> bool {
730        self.is_pass_through() || self.rules.iter().any(|r| r.matches(req, path))
731    }
732
733    /// The configured auto-response as raw parts, for transports that build
734    /// their own response type (HTTP/2 through the `h2` crate).
735    pub fn rejection_parts(&self) -> (u16, Vec<(String, String)>, String) {
736        (
737            self.filtered_response.status,
738            self.filtered_response.headers.clone(),
739            self.filtered_response.body.clone(),
740        )
741    }
742
743    /// Build the auto-response for a request that matched no rule.
744    ///
745    /// `filtered_response` comes straight from caller-supplied startup params,
746    /// so this goes through the non-panicking builder.
747    pub fn rejection(&self) -> Response<Full<Bytes>> {
748        build_safe_response(
749            self.filtered_response.status,
750            self.filtered_response.headers.iter().cloned(),
751            self.filtered_response.body.clone(),
752            "filtered_response",
753        )
754    }
755}
756
757/// Name of a JSON value's type, for error messages.
758fn kind_of(v: &serde_json::Value) -> &'static str {
759    match v {
760        serde_json::Value::Null => "null",
761        serde_json::Value::Bool(_) => "a boolean",
762        serde_json::Value::Number(_) => "a number",
763        serde_json::Value::String(_) => "a string",
764        serde_json::Value::Array(_) => "an array",
765        serde_json::Value::Object(_) => "an object",
766    }
767}
768
769/// Startup parameters describing the request filter, for `get_startup_parameters()`.
770/// Shared by HTTP/1.1 and HTTP/2 so the LLM sees the same schema and examples.
771pub fn request_handling_startup_parameters() -> Vec<crate::llm::actions::ParameterDefinition> {
772    use crate::llm::actions::ParameterDefinition;
773    vec![
774        ParameterDefinition {
775            name: "request_filter".to_string(),
776            type_hint: "array".to_string(),
777            description: "RECOMMENDED for any HTTP server a browser or the public internet will \
778                reach: set this so you do not spend a slow LLM call on favicon.ico, CORS \
779                preflights, and scanner noise. A good default that only reasons about real page \
780                loads is [{\"methods\":[\"GET\"],\"headers\":{\"accept\":\"text/html\"}}] — favicon \
781                and image probes carry `Accept: image/*`, and preflights are not GET, so they are \
782                auto-answered without a model call. \
783                Allowlist of request-match rules deciding which requests reach the LLM. \
784                A request is handled by the LLM only if it matches at least one rule; requests \
785                matching no rule get `filtered_response` (default 404) with NO LLM call. Omit this \
786                to send every request to the LLM. Each rule is an object; all present conditions \
787                must hold (AND), and rules are OR'd together. Conditions: `methods` (array of HTTP \
788                methods, case-insensitive; omit for any), `path` (a regular expression matched \
789                against the URL path, e.g. \"^/$\" or \"^/api/\"; omit for any), `headers` (object \
790                mapping header name to either true = must be present, or a string = value must \
791                contain that substring case-insensitively, e.g. {\"accept\": \"text/html\"}). \
792                Example filter that only sends real browser page loads to the LLM (favicon/OPTIONS/\
793                XHR are auto-404'd): [{\"methods\":[\"GET\"],\"headers\":{\"accept\":\"text/html\"}}]. \
794                Fail-open: a malformed rule (e.g. an invalid `path` regex) is dropped with a loud \
795                error instead of failing startup, so a typo means MORE requests reach the LLM, not \
796                fewer. Check the server status output after starting."
797                .to_string(),
798            required: false,
799            example: serde_json::json!([
800                { "methods": ["GET"], "path": "^/$", "headers": { "accept": "text/html" } },
801                { "methods": ["POST"], "path": "^/api/" }
802            ]),
803            default: None,
804        },
805        ParameterDefinition {
806            name: "filtered_response".to_string(),
807            type_hint: "object".to_string(),
808            description: "Response returned (without any LLM call) for requests that match no \
809                `request_filter` rule. Fields: `status` (number, default 404), `body` (string, \
810                default empty), `headers` (object of name→value). Ignored when no request_filter \
811                is set."
812                .to_string(),
813            required: false,
814            example: serde_json::json!({ "status": 404, "body": "Not Found" }),
815            default: None,
816        },
817        ParameterDefinition {
818            name: "default_response".to_string(),
819            type_hint: "object".to_string(),
820            description: "Response used when the model IS consulted for a request but returns no \
821                send_http_response (an empty answer). Without this, such a request gets a blank \
822                200, which looks broken. Set a sensible fallback — commonly a 404 page — so a \
823                declined request looks intentional. Same shape as filtered_response: `status` \
824                (number), `body` (string), `headers` (object of name→value)."
825                .to_string(),
826            required: false,
827            example: serde_json::json!({
828                "status": 404,
829                "headers": { "Content-Type": "text/html" },
830                "body": "<!doctype html><title>Not Found</title><h1>404</h1>"
831            }),
832            default: None,
833        },
834    ]
835}