Skip to main content

edgeguard/
proxy.rs

1//! Request path: header-size limit -> rate limit (per-IP / per-route) -> auth -> per-key
2//! rate limit -> method allowlist -> body-size limit -> WAF input inspection -> forward to
3//! upstream.
4//! Response path: header injection (incl. CSP / CSP-report-only) -> cookie hardening ->
5//! strip leaky headers.
6//!
7//! All policy lives in [`Runtime`], held behind an [`ArcSwap`] so a config hot-reload swaps
8//! it atomically without blocking the request path or dropping in-flight connections. The
9//! upstream client and the metric registry sit *outside* the swap so the connection pool and
10//! counters survive a reload.
11
12use std::future::Future;
13use std::net::{IpAddr, SocketAddr};
14use std::pin::Pin;
15use std::sync::Arc;
16use std::task::{Context, Poll};
17use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
18
19use arc_swap::ArcSwap;
20use axum::{
21    body::{Body, Bytes},
22    extract::{ConnectInfo, State},
23    http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, Response, StatusCode},
24};
25use governor::{clock::DefaultClock, state::keyed::DefaultKeyedStateStore, RateLimiter};
26use http_body_util::{BodyExt, Full, Limited};
27use hyper::body::{Body as HttpBody, Frame, SizeHint};
28use hyper_util::client::legacy::{connect::HttpConnector, Client};
29use hyper_util::rt::TokioIo;
30use tokio::net::TcpStream;
31use tracing::{debug, info, warn};
32
33use crate::auth::{AuthEngine, Challenge, Decision};
34use crate::config::{Config, HeadersCfg};
35use crate::limiter::{Admit, DistributedLimiter};
36use crate::metrics::Metrics;
37use crate::waf::{WafEngine, WafMode};
38
39pub type KeyedLimiter = RateLimiter<IpAddr, DefaultKeyedStateStore<IpAddr>, DefaultClock>;
40/// Rate limiter keyed by the authenticated principal (per-key limiting).
41pub type StrLimiter = RateLimiter<String, DefaultKeyedStateStore<String>, DefaultClock>;
42pub type UpstreamClient = Client<HttpConnector, Full<Bytes>>;
43
44/// Shared, cheaply-cloned handle the router hands to every request. Only the hot-swappable
45/// [`Runtime`] changes on reload; the client and metrics are stable.
46#[derive(Clone)]
47pub struct AppState {
48    pub client: UpstreamClient,
49    pub metrics: Arc<Metrics>,
50    pub runtime: Arc<ArcSwap<Runtime>>,
51    /// Managed-mode control-plane client (`Some` only when `[control_plane]` is enabled). Used to
52    /// forward CSP reports; policy pull + usage reporting run as background tasks in `main`.
53    pub cp: Option<Arc<crate::cp::CpClient>>,
54    /// Shared quota verdict, updated by the managed-mode quota poller and read by the
55    /// hard-stop gate below. Lives here (not on the hot-swappable [`Runtime`]) so a policy reload
56    /// never resets enforcement. Inert unless `control_plane.enforce_quota` is set.
57    pub quota: Arc<crate::cp::QuotaState>,
58}
59
60/// A per-route rate-limit override: requests whose path starts with `prefix` use `limiter`.
61pub struct RouteLimiter {
62    pub prefix: String,
63    pub limiter: Arc<KeyedLimiter>,
64}
65
66/// All request-handling policy derived from a [`Config`]. Rebuilt from scratch on reload and
67/// swapped in atomically.
68pub struct Runtime {
69    pub cfg: Arc<Config>,
70    /// Default upstream base URL (the single `server.upstream`/`app_port`), used when no
71    /// `[[upstreams]]` prefix matches.
72    pub upstream_base: Arc<String>,
73    /// Per-path-prefix upstream overrides as `(prefix, base)`; the longest matching prefix wins.
74    /// Empty unless `[[upstreams]]` is configured.
75    pub upstream_routes: Vec<(String, Arc<String>)>,
76    pub auth: AuthEngine,
77    /// WAF-lite input screener. Inert (`evaluate` returns `None`) when `waf.mode = "off"`.
78    pub waf: WafEngine,
79    /// Compiled CORS policy; `None` when `cors.enabled = false` (the proxy then skips CORS).
80    pub cors: Option<crate::cors::CorsPolicy>,
81    /// Compiled IP allow/deny policy; `None` when both lists are empty (no IP gating).
82    pub access: Option<crate::access::AccessPolicy>,
83    /// Shared-store (distributed) limiter, `Some` when `ratelimit.store` is `memory`/`redis`.
84    /// When present it replaces the three `governor` limiters below (which are then `None`).
85    pub distributed: Option<DistributedLimiter>,
86    /// Global per-client-IP limiter (`None` when rate limiting is disabled or distributed).
87    pub ip_limiter: Option<Arc<KeyedLimiter>>,
88    /// Per-route limiters (also keyed per IP), checked instead of `ip_limiter` on a match.
89    pub route_limiters: Vec<RouteLimiter>,
90    /// Per-principal limiter (`None` when per-key limiting is disabled or distributed).
91    pub key_limiter: Option<Arc<StrLimiter>>,
92    pub max_body: usize,
93    /// Cap on the buffered upstream response body; `0` means unbounded.
94    pub max_response_body: usize,
95    /// Cap on total request header bytes; `0` means disabled.
96    pub max_header_bytes: usize,
97    /// Max time for the upstream request + body read; `None` disables the timeout.
98    pub upstream_timeout: Option<Duration>,
99    /// Forward `text/event-stream` responses unbuffered (SSE passthrough). See
100    /// [`crate::config::ValidationCfg::stream_passthrough`].
101    pub stream_passthrough: bool,
102    /// Tunnel WebSocket / `Upgrade` connections to the upstream. See
103    /// [`crate::config::ValidationCfg::websocket_passthrough`].
104    pub websocket_passthrough: bool,
105    /// Compiled LLM token-metering runtime (price book + on/off). Inert when `[llm]` is disabled.
106    pub llm: Arc<crate::llm::LlmRuntime>,
107    /// Compiled LLM hard-budget engine (gateway L1). `None` when no `[[llm.budgets]]` are configured.
108    pub budgets: Option<Arc<crate::budget::BudgetEngine>>,
109    /// Compiled BYO-key vault (gateway L2). `None` when no `[[llm.keys]]` are configured; when set,
110    /// every proxied request must present a known virtual key.
111    pub keyvault: Option<Arc<crate::keyvault::KeyVault>>,
112    /// Compiled edge-DLP engine (gateway L3). `None` when `[llm.dlp].mode = "off"`.
113    pub dlp: Option<Arc<crate::dlp::DlpEngine>>,
114    /// Compiled OTLP span emitter (gateway L4). Inert when `[llm.telemetry].enabled = false` or no
115    /// endpoint is set; emits one OpenInference span per metered LLM request, fire-and-forget.
116    pub telemetry: Arc<crate::telemetry::TelemetryRuntime>,
117    /// Compiled outbound alerter (gateway L4). Inert when `[alerts].enabled = false` or no webhook is
118    /// set; fires a Slack-compatible alert when a hard budget crosses its threshold, fire-and-forget.
119    pub alerts: Arc<crate::alert::AlertRuntime>,
120}
121
122impl Runtime {
123    /// The upstream base URL to forward `path` to: the longest matching `[[upstreams]]` prefix,
124    /// or the default [`Runtime::upstream_base`] when none match.
125    pub fn pick_upstream(&self, path: &str) -> &str {
126        self.upstream_routes
127            .iter()
128            .filter(|(prefix, _)| path_prefix_matches(path, prefix))
129            .max_by_key(|(prefix, _)| prefix.len())
130            .map(|(_, base)| base.as_str())
131            .unwrap_or_else(|| self.upstream_base.as_str())
132    }
133}
134
135/// Whether `prefix` matches `path` on a path-segment boundary. `prefix` is a validated upstream
136/// route prefix (always starts with `/`); `path` is the request path-and-query. A plain
137/// `str::starts_with` would route a sibling like `/apiary` to the `/api` upstream, so the match
138/// only succeeds when the prefix is followed by a real boundary: end of path, a `/`, or the query
139/// separator `?`. A trailing slash on the prefix is itself a boundary.
140fn path_prefix_matches(path: &str, prefix: &str) -> bool {
141    if prefix == "/" {
142        return true;
143    }
144    match path.strip_prefix(prefix) {
145        Some(rest) => {
146            rest.is_empty()
147                || prefix.ends_with('/')
148                || rest.starts_with('/')
149                || rest.starts_with('?')
150        }
151        None => false,
152    }
153}
154
155/// Hop-by-hop headers that must not be forwarded (RFC 7230 §6.1).
156const HOP_BY_HOP: &[&str] = &[
157    "connection",
158    "keep-alive",
159    "proxy-authenticate",
160    "proxy-authorization",
161    "te",
162    "trailer",
163    "transfer-encoding",
164    "upgrade",
165];
166
167pub async fn handle(
168    State(state): State<AppState>,
169    ConnectInfo(peer): ConnectInfo<SocketAddr>,
170    req: Request<Body>,
171) -> Response<Body> {
172    // One atomic load pins a consistent policy snapshot for the whole request, even if a reload
173    // swaps in a new Runtime mid-flight — routing, auth, *and* the final CORS decoration below all
174    // see the same one (loading again here could decorate with a policy the request never used).
175    let rt = state.runtime.load_full();
176    // Capture the request Origin before the body is consumed, so we can CORS-decorate *every*
177    // response — including EdgeGuard-generated 401/403/429 — not just proxied successes. Without
178    // this, an allowed browser origin sees a generic CORS failure instead of the real status.
179    let origin = req
180        .headers()
181        .get(header::ORIGIN)
182        .and_then(|v| v.to_str().ok())
183        .map(str::to_owned);
184    let mut resp = handle_inner(&state, &rt, peer, req).await;
185    if let Some(origin) = &origin {
186        if let Some(cors) = &rt.cors {
187            cors.decorate_origin(origin, &mut resp);
188        }
189    }
190    resp
191}
192
193async fn handle_inner(
194    state: &AppState,
195    rt: &Runtime,
196    peer: SocketAddr,
197    req: Request<Body>,
198) -> Response<Body> {
199    let started = Instant::now();
200    let m = &state.metrics;
201
202    let method = req.method().clone();
203    let path = req
204        .uri()
205        .path_and_query()
206        .map(|p| p.as_str().to_string())
207        .unwrap_or_else(|| req.uri().path().to_string());
208
209    let ip = client_ip(req.headers(), peer, rt.cfg.server.trust_forwarded_for);
210    // Request id for correlation: reuse a well-formed inbound one, else generate. Echoed on the
211    // response and the access log by `finish`, and forwarded upstream below.
212    let rid = resolve_request_id(req.headers());
213
214    // Reserve the internal namespace: never forward `/__edgeguard/*` upstream. Registered
215    // internal routes are matched before this fallback, so anything reaching here under that
216    // prefix is an unknown internal path — a `404` from EdgeGuard, not a request leaked to the
217    // app. This is also what keeps the ops endpoints (health/ready/metrics) unserved on the
218    // public listener in public/private split mode, rather than proxying them to the upstream.
219    if req.uri().path().starts_with("/__edgeguard/") {
220        return finish(
221            m,
222            &rid,
223            &method,
224            &path,
225            ip,
226            started,
227            "not_found",
228            text(StatusCode::NOT_FOUND, "Not Found"),
229        );
230    }
231
232    // 0) IP access control. A coarse network gate (CIDR allow/deny) evaluated before auth and
233    //    rate limiting, so a denied/non-allowlisted client is dropped with `403` before consuming
234    //    any limiter token or auth work. Keys on the same resolved client IP as rate limiting.
235    if let Some(access) = &rt.access {
236        if !access.allowed(ip) {
237            return finish(
238                m,
239                &rid,
240                &method,
241                &path,
242                ip,
243                started,
244                "ip_denied",
245                text(StatusCode::FORBIDDEN, "Forbidden"),
246            );
247        }
248    }
249
250    // 0.1) Total request-header-size limit.
251    if rt.max_header_bytes > 0 && header_bytes(req.headers()) > rt.max_header_bytes {
252        return finish(
253            m,
254            &rid,
255            &method,
256            &path,
257            ip,
258            started,
259            "header_too_large",
260            text(
261                StatusCode::REQUEST_HEADER_FIELDS_TOO_LARGE,
262                "Request Header Fields Too Large",
263            ),
264        );
265    }
266
267    // 0.5) Quota hard-stop (managed mode, opt-in). When the control plane reports the
268    //      edge over its quota, reject the edge's traffic with `429` and a
269    //      month-scale `Retry-After`, until the next successful poll clears it. Off unless
270    //      `control_plane.enforce_quota` is set; the `/__edgeguard/*` endpoints are excluded above,
271    //      so health/ready/metrics keep serving even while over quota.
272    if rt.cfg.control_plane.enforce_quota && state.quota.blocked() {
273        let mut resp = text(StatusCode::TOO_MANY_REQUESTS, "Quota Exceeded");
274        let reset = state.quota.reset_epoch();
275        if reset > 0 {
276            let now = std::time::SystemTime::now()
277                .duration_since(std::time::UNIX_EPOCH)
278                .map(|d| d.as_secs() as i64)
279                .unwrap_or(0);
280            let retry_after = reset.saturating_sub(now).max(0);
281            if let Ok(v) = HeaderValue::from_str(&retry_after.to_string()) {
282                resp.headers_mut().insert(header::RETRY_AFTER, v);
283            }
284        }
285        return finish(m, &rid, &method, &path, ip, started, "over_quota", resp);
286    }
287
288    // 1) Rate limit. A matching per-route override replaces the global per-IP limit. A shared
289    //    store (distributed) limiter, when configured, replaces the in-process limiters; on a
290    //    store error it fails closed (`503`) unless `ratelimit.fail_open` is set.
291    if rt.cfg.ratelimit.enabled {
292        if let Some(d) = &rt.distributed {
293            match d.check_ip_route(ip, &path).await {
294                Admit::Allowed => {}
295                Admit::Limited(scope) => {
296                    m.record_ratelimit_hit(scope);
297                    return finish(
298                        m,
299                        &rid,
300                        &method,
301                        &path,
302                        ip,
303                        started,
304                        "rate_limited",
305                        text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
306                    );
307                }
308                Admit::Error => {
309                    return finish(
310                        m,
311                        &rid,
312                        &method,
313                        &path,
314                        ip,
315                        started,
316                        "limiter_error",
317                        text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
318                    );
319                }
320            }
321        } else {
322            let (limiter, scope) = match longest_route(&rt.route_limiters, &path) {
323                Some(r) => (Some(r.limiter.as_ref()), "route"),
324                None => (rt.ip_limiter.as_deref(), "ip"),
325            };
326            if let Some(limiter) = limiter {
327                if limiter.check_key(&ip).is_err() {
328                    m.record_ratelimit_hit(scope);
329                    return finish(
330                        m,
331                        &rid,
332                        &method,
333                        &path,
334                        ip,
335                        started,
336                        "rate_limited",
337                        text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
338                    );
339                }
340            }
341        }
342    }
343
344    // 1.5) CORS preflight. Answer a browser preflight (`OPTIONS` + `Origin` +
345    //      `Access-Control-Request-Method`) here, *before* auth: a preflight carries no
346    //      credentials, so gating it behind the auth check would make every cross-origin call
347    //      fail. Only a real preflight is short-circuited; a plain `OPTIONS` falls through.
348    if method == Method::OPTIONS {
349        if let Some(cors) = &rt.cors {
350            if let Some(resp) = cors.preflight_response(req.headers()) {
351                return finish(m, &rid, &method, &path, ip, started, "cors_preflight", resp);
352            }
353        }
354    }
355
356    // 2) Authentication. On success we learn the principal for per-key limiting.
357    let principal = match rt.auth.authorize(&rt.cfg.auth, req.headers()).await {
358        Decision::Allow(principal) => principal,
359        Decision::Deny(challenge) => {
360            let mut resp = text(StatusCode::UNAUTHORIZED, "Unauthorized");
361            let challenge_value = match challenge {
362                Challenge::Basic(c) => Some(c),
363                Challenge::Bearer => Some("Bearer".to_string()),
364                Challenge::None => None,
365            };
366            if let Some(c) = challenge_value {
367                if let Ok(v) = HeaderValue::from_str(&c) {
368                    resp.headers_mut().insert(header::WWW_AUTHENTICATE, v);
369                }
370            }
371            return finish(m, &rid, &method, &path, ip, started, "unauthorized", resp);
372        }
373    };
374
375    // 3) Per-key rate limit (only for authenticated principals). Routed to the distributed
376    //    limiter when configured, else the in-process per-key limiter.
377    if let Some(principal) = &principal {
378        let key_admit = if let Some(d) = &rt.distributed {
379            Some(d.check_key(principal).await)
380        } else {
381            rt.key_limiter.as_ref().map(|limiter| {
382                if limiter.check_key(principal).is_err() {
383                    Admit::Limited("key")
384                } else {
385                    Admit::Allowed
386                }
387            })
388        };
389        match key_admit {
390            Some(Admit::Limited(scope)) => {
391                m.record_ratelimit_hit(scope);
392                return finish(
393                    m,
394                    &rid,
395                    &method,
396                    &path,
397                    ip,
398                    started,
399                    "rate_limited",
400                    text(StatusCode::TOO_MANY_REQUESTS, "Too Many Requests"),
401                );
402            }
403            Some(Admit::Error) => {
404                return finish(
405                    m,
406                    &rid,
407                    &method,
408                    &path,
409                    ip,
410                    started,
411                    "limiter_error",
412                    text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
413                );
414            }
415            Some(Admit::Allowed) | None => {}
416        }
417    }
418
419    // 4) Method allowlist.
420    let allow = &rt.cfg.validation.allow_methods;
421    if !allow.is_empty()
422        && !allow
423            .iter()
424            .any(|x| x.eq_ignore_ascii_case(method.as_str()))
425    {
426        return finish(
427            m,
428            &rid,
429            &method,
430            &path,
431            ip,
432            started,
433            "method_not_allowed",
434            text(StatusCode::METHOD_NOT_ALLOWED, "Method Not Allowed"),
435        );
436    }
437
438    // 4.5) WebSocket / `Upgrade` passthrough (opt-in). An upgrade request can't go through the
439    //      buffer-and-forward path below — it needs a raw bidirectional tunnel. When enabled, hand
440    //      off to `proxy_upgrade`, which forwards the request *with* its upgrade headers (the
441    //      normal path strips them) and splices the connections on a `101`. The request is already
442    //      authenticated and rate-limited at this point. When disabled (default), fall through and
443    //      the upgrade headers are stripped like any other hop-by-hop header.
444    if rt.websocket_passthrough && is_upgrade_request(req.headers()) {
445        // Vault check for upgrade connections: validate the virtual key and swap it for the
446        // provider key before tunnelling. WebSocket frames don't carry a parseable JSON body, so
447        // model egress can't be enforced; any key with a non-empty allowlist is denied
448        // (fail-closed — the tunnel could reach any model on the upstream).
449        let mut req = req;
450        if let Some(vault) = rt.keyvault.as_ref() {
451            let presented = req
452                .headers()
453                .get(header::AUTHORIZATION)
454                .and_then(|v| v.to_str().ok())
455                .and_then(|s| s.strip_prefix("Bearer "))
456                .map(str::trim);
457            match presented.and_then(|k| vault.lookup(k)) {
458                Some(entry) => {
459                    if !entry.model_allowed(None) {
460                        m.record_keyvault("denied_model");
461                        warn!(key = %entry.label(), client_ip = %ip, "WebSocket upgrade denied: key has a model allowlist (model cannot be verified on upgrade connections)");
462                        return finish(
463                            m,
464                            &rid,
465                            &method,
466                            &path,
467                            ip,
468                            started,
469                            "forbidden",
470                            text(StatusCode::FORBIDDEN, "Forbidden"),
471                        );
472                    }
473                    match HeaderValue::from_str(&format!("Bearer {}", entry.provider_key())) {
474                        Ok(v) => {
475                            m.record_keyvault("swapped");
476                            req.headers_mut().insert(header::AUTHORIZATION, v);
477                        }
478                        Err(e) => {
479                            warn!(key = %entry.label(), error = %e, "provider key is not a valid Authorization header value");
480                            return finish(
481                                m,
482                                &rid,
483                                &method,
484                                &path,
485                                ip,
486                                started,
487                                "bad_gateway",
488                                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
489                            );
490                        }
491                    }
492                }
493                None => {
494                    m.record_keyvault("denied_key");
495                    return finish(
496                        m,
497                        &rid,
498                        &method,
499                        &path,
500                        ip,
501                        started,
502                        "unauthorized",
503                        text(StatusCode::UNAUTHORIZED, "Unauthorized"),
504                    );
505                }
506            }
507        }
508        return proxy_upgrade(state, rt, req, &rid, &method, &path, ip, started).await;
509    }
510
511    // 5) Buffer the body up to the configured limit.
512    let (parts, body) = req.into_parts();
513    // Capture an inbound W3C `traceparent` (if any) so an emitted LLM span stitches under the
514    // caller's trace. Cheap header read; only used when `[llm.telemetry]` is enabled.
515    let traceparent = parts
516        .headers
517        .get("traceparent")
518        .and_then(|v| v.to_str().ok())
519        .map(str::to_string);
520    // Team/tag for per-team token/cost metrics (chargeback/showback), from `[llm].team_header`
521    // (default `x-edgeguard-team`; absent → the shared `_none` bucket). Owned so the streamed-path
522    // meter can carry it past the request borrow. Matches the per-team budget scope's keying.
523    let llm_team: Option<String> = parts
524        .headers
525        .get(rt.cfg.llm.team_header.as_str())
526        .and_then(|v| v.to_str().ok())
527        .map(str::trim)
528        .filter(|s| !s.is_empty())
529        .map(str::to_string);
530    let mut body_bytes = match axum::body::to_bytes(body, rt.max_body).await {
531        Ok(b) => b,
532        Err(_) => {
533            return finish(
534                m,
535                &rid,
536                &method,
537                &path,
538                ip,
539                started,
540                "payload_too_large",
541                text(StatusCode::PAYLOAD_TOO_LARGE, "Payload Too Large"),
542            )
543        }
544    };
545    // Request (ingress) size for managed-mode usage, captured before the body is forwarded upstream.
546    let ingress_bytes = header_bytes(&parts.headers).saturating_add(body_bytes.len());
547
548    // 6) WAF-lite input inspection. A no-op unless `waf.mode` is report/block. The body is
549    //    already buffered above, so inspecting it adds no extra read. On a match: `block` mode
550    //    returns 403; `report` mode logs + counts and forwards. Both record the hit so a
551    //    report-only rollout shows up in `edgeguard_waf_hits_total`.
552    if let Some(hit) = rt.waf.evaluate(&path, &parts.headers, &body_bytes) {
553        m.record_waf_hit(hit.class);
554        match rt.waf.mode() {
555            WafMode::Block => {
556                warn!(
557                    rule = %hit.rule_id,
558                    class = hit.class,
559                    location = hit.location,
560                    client_ip = %ip,
561                    path = %path,
562                    "WAF blocked request"
563                );
564                return finish(
565                    m,
566                    &rid,
567                    &method,
568                    &path,
569                    ip,
570                    started,
571                    "forbidden",
572                    text(StatusCode::FORBIDDEN, "Forbidden"),
573                );
574            }
575            WafMode::Report => warn!(
576                rule = %hit.rule_id,
577                class = hit.class,
578                location = hit.location,
579                client_ip = %ip,
580                path = %path,
581                "WAF rule matched (report-only)"
582            ),
583            // `evaluate` returns `None` when off, so this arm is unreachable; kept for
584            // exhaustiveness.
585            WafMode::Off => {}
586        }
587    }
588
589    // Reversible mask map (gateway L3): populated when inbound redaction runs in reversible mode, so
590    // the response can be unmasked back to the caller's own values (see the response paths below).
591    // Empty unless reversible masking actually replaces a span.
592    let mut mask_map = crate::dlp::MaskMap::default();
593
594    // LLM edge DLP (gateway L3) — inbound prompt. Scan the request body for PII/secrets and apply
595    // the configured mode before forwarding: `block` rejects 403 (the secret never leaves), `redact`
596    // rewrites the forwarded body, `report` logs + counts and passes through unchanged.
597    if let Some(dlp) = rt.dlp.as_ref() {
598        if dlp.scan_request() {
599            let body_text = String::from_utf8_lossy(&body_bytes);
600            let findings = dlp.scan(&body_text);
601            if !findings.is_empty() {
602                for f in &findings {
603                    m.record_dlp_finding(f.category);
604                }
605                match dlp.mode() {
606                    crate::dlp::DlpMode::Block => {
607                        m.record_dlp_blocked();
608                        warn!(findings = findings.len(), client_ip = %ip, "LLM request blocked by DLP (inbound PII/secret)");
609                        return finish(
610                            m,
611                            &rid,
612                            &method,
613                            &path,
614                            ip,
615                            started,
616                            "forbidden",
617                            text(StatusCode::FORBIDDEN, "Forbidden"),
618                        );
619                    }
620                    crate::dlp::DlpMode::Redact => {
621                        // Reversible mode masks to placeholders (recorded in `mask_map`) so the
622                        // response can restore them; plain redact rewrites irreversibly.
623                        let redacted = if dlp.reversible() {
624                            dlp.redact_reversible(&body_text, &findings, &mut mask_map)
625                        } else {
626                            dlp.redact(&body_text, &findings)
627                        };
628                        warn!(
629                            findings = findings.len(),
630                            reversible = dlp.reversible(),
631                            "DLP redacted inbound request"
632                        );
633                        body_bytes = Bytes::from(redacted);
634                    }
635                    crate::dlp::DlpMode::Report => {
636                        warn!(
637                            findings = findings.len(),
638                            "DLP findings in inbound request (report-only)"
639                        )
640                    }
641                    crate::dlp::DlpMode::Off => {}
642                }
643            }
644        }
645    }
646
647    // LLM token metering (gateway L0): if enabled, note the request's `model` *before* the body is
648    // forwarded (it's moved into the upstream request below). `None` for non-JSON / non-LLM bodies,
649    // in which case the request is simply not metered as LLM traffic. Metering is observe-only.
650    // Also parse when the vault is active: the model is needed for egress-allowlist enforcement and
651    // a missing model must be treated as denied for any key that has a non-empty allowlist.
652    let llm_model = if rt.llm.enabled || rt.keyvault.is_some() || rt.budgets.is_some() {
653        crate::llm::parse_request_model(&body_bytes)
654    } else {
655        None
656    };
657
658    // LLM key vault + egress governance (gateway L2): when configured, every proxied request must
659    // present a known virtual key. We resolve it to the mapped provider key (injected upstream
660    // below, so the provider secret never reaches the client) and enforce the key's model egress
661    // allowlist. Runs before the budget reserve so an unknown key / disallowed model never consumes
662    // budget. `upstream_auth`, when set, replaces the outbound `Authorization` header.
663    let mut upstream_auth: Option<HeaderValue> = None;
664    if let Some(vault) = rt.keyvault.as_ref() {
665        let presented = parts
666            .headers
667            .get(header::AUTHORIZATION)
668            .and_then(|v| v.to_str().ok())
669            .and_then(|s| s.strip_prefix("Bearer "))
670            .map(str::trim);
671        match presented.and_then(|k| vault.lookup(k)) {
672            Some(entry) => {
673                // Fail closed: a request whose model is absent or unparseable is denied when the
674                // key has a non-empty allowlist — same as an explicitly off-list model.
675                if !entry.model_allowed(llm_model.as_deref()) {
676                    let model = llm_model.as_deref().unwrap_or("<missing>");
677                    m.record_keyvault("denied_model");
678                    warn!(key = %entry.label(), model = %model, client_ip = %ip, "LLM request denied: model off the key's egress allowlist");
679                    return finish(
680                        m,
681                        &rid,
682                        &method,
683                        &path,
684                        ip,
685                        started,
686                        "forbidden",
687                        text(StatusCode::FORBIDDEN, "Forbidden"),
688                    );
689                }
690                // Convert to a HeaderValue now so a malformed provider key is caught here and
691                // fails with 502 rather than silently leaving the client's virtual key in place.
692                match HeaderValue::from_str(&format!("Bearer {}", entry.provider_key())) {
693                    Ok(v) => {
694                        m.record_keyvault("swapped");
695                        upstream_auth = Some(v);
696                    }
697                    Err(e) => {
698                        warn!(key = %entry.label(), error = %e, "provider key is not a valid Authorization header value");
699                        return finish(
700                            m,
701                            &rid,
702                            &method,
703                            &path,
704                            ip,
705                            started,
706                            "bad_gateway",
707                            text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
708                        );
709                    }
710                }
711            }
712            None => {
713                m.record_keyvault("denied_key");
714                return finish(
715                    m,
716                    &rid,
717                    &method,
718                    &path,
719                    ip,
720                    started,
721                    "unauthorized",
722                    text(StatusCode::UNAUTHORIZED, "Unauthorized"),
723                );
724            }
725        }
726    }
727
728    // LLM unpriced-model policy (gateway L0): when `on_unpriced_model = "block"` and a price book is
729    // configured, a request for a model absent from that book is rejected `402` *before* it reaches
730    // the upstream — an unpriced model is never served at a silent $0 (the LiteLLM `#24770` failure,
731    // designed out). Metering-only deployments (empty `[llm.models]`) never trip this. Runs after the
732    // vault (an unknown key is still `401` first) and before the budget reserve (no budget consumed).
733    if let Some(model) = llm_model.as_ref() {
734        if rt.llm.reject_unpriced(model) {
735            warn!(model = %model, client_ip = %ip, "LLM request denied: model not in price book (on_unpriced_model=block)");
736            return finish(
737                m,
738                &rid,
739                &method,
740                &path,
741                ip,
742                started,
743                "unpriced_model",
744                text(
745                    StatusCode::PAYMENT_REQUIRED,
746                    "Payment Required: model not in price book",
747                ),
748            );
749        }
750    }
751
752    // LLM hard budgets (gateway L1): reserve an estimate against every applicable budget *before*
753    // forwarding, so an over-budget request is denied 429 and never reaches the upstream. The
754    // returned guard reconciles to actual usage on success and auto-releases on any early return
755    // (upstream error / timeout) via its Drop. Only runs when budgets are configured and this is an
756    // LLM request with a known model.
757    let mut budget_guard: Option<ReservationGuard> = None;
758    if let (Some(engine), Some(model)) = (rt.budgets.as_ref(), llm_model.as_ref()) {
759        let est_prompt = crate::llm::estimate_prompt_tokens(body_bytes.len());
760        let est_completion = crate::llm::parse_request_max_tokens(&body_bytes)
761            .unwrap_or(rt.cfg.llm.default_max_tokens);
762        let estimate = crate::budget::Spend {
763            tokens: est_prompt.saturating_add(est_completion),
764            cost_micros: rt
765                .llm
766                .cost_micros(
767                    model,
768                    &crate::llm::Usage {
769                        prompt_tokens: est_prompt,
770                        completion_tokens: est_completion,
771                        ..Default::default()
772                    },
773                )
774                .unwrap_or(0),
775        };
776        // Team/tag for the per-team scope + chargeback, from the configured header (default
777        // `x-edgeguard-team`). Absent → the shared `_none` bucket.
778        let team = parts
779            .headers
780            .get(rt.cfg.llm.team_header.as_str())
781            .and_then(|v| v.to_str().ok())
782            .map(str::trim)
783            .filter(|s| !s.is_empty());
784        let dims = crate::budget::Dims {
785            principal: principal.as_deref(),
786            // Normalize a provider-prefixed model ("openai/gpt-4o") to the bare name for budget
787            // attribution, so a prefixed request can't silently escape a bare-named per-model budget.
788            model: crate::llm::canonical_model(model),
789            team,
790        };
791        match engine.reserve(dims, estimate).await {
792            crate::budget::Reserved::Ok(reservation) => {
793                // Feed the near-limit gauge with each admitted budget's post-reserve consumption,
794                // and fire an alert (edge-triggered, fire-and-forget) when one crosses the threshold.
795                for obs in reservation.observations() {
796                    m.record_budget_consumed(&obs.name, obs.consumed_ratio);
797                    rt.alerts.fire_budget_alert(&obs.name, obs.consumed_ratio);
798                }
799                // Only non-zero on the fail-open path: a store error rolled back an earlier partial
800                // reservation before admitting anyway. Same drift signal as a failed reconcile/release.
801                m.record_budget_reconcile_failures(reservation.rollback_failures());
802                budget_guard = Some(ReservationGuard {
803                    engine: Arc::clone(engine),
804                    reservation: Some(reservation),
805                    metrics: Arc::clone(m),
806                });
807            }
808            crate::budget::Reserved::Denied(denial) => {
809                m.record_budget_blocked(denial.scope.label());
810                m.record_budget_reconcile_failures(denial.rollback_failures);
811                warn!(budget = %denial.name, scope = %denial.scope.label(), model = %model, client_ip = %ip, "LLM request denied: budget exhausted");
812                // A cost cap answers 402 (Payment Required — the spend, not the rate, is the limit);
813                // a token cap answers 429 (Too Many Requests). Both carry the `over_budget` outcome.
814                let (status, body) = match denial.unit {
815                    crate::budget::BudgetUnit::UsdMicros => (
816                        StatusCode::PAYMENT_REQUIRED,
817                        "Payment Required: budget exhausted",
818                    ),
819                    crate::budget::BudgetUnit::Tokens => {
820                        (StatusCode::TOO_MANY_REQUESTS, "Too Many Requests")
821                    }
822                };
823                return finish(
824                    m,
825                    &rid,
826                    &method,
827                    &path,
828                    ip,
829                    started,
830                    "over_budget",
831                    text(status, body),
832                );
833            }
834            crate::budget::Reserved::Error { rollback_failures } => {
835                m.record_budget_reconcile_failures(rollback_failures);
836                return finish(
837                    m,
838                    &rid,
839                    &method,
840                    &path,
841                    ip,
842                    started,
843                    "limiter_error",
844                    text(StatusCode::SERVICE_UNAVAILABLE, "Service Unavailable"),
845                );
846            }
847        }
848    }
849
850    // 7) Build the upstream request (the per-path upstream override, or the default).
851    let uri = format!("{}{}", rt.pick_upstream(&path), path);
852    let mut up = Request::builder().method(parts.method.clone()).uri(&uri);
853    {
854        let headers = up.headers_mut().expect("builder headers");
855        // Drop hop-by-hop headers (the fixed set plus any named by `Connection`) before
856        // forwarding, so they don't leak across the proxy boundary.
857        let mut forwarded = parts.headers.clone();
858        strip_hop_by_hop(&mut forwarded);
859        // The body is re-sent from a sized `Full`, so the client's Content-Length may be stale (it
860        // is once DLP redaction rewrote the body). Drop it and let the upstream client recompute the
861        // correct length from the body, rather than forwarding a mismatched header.
862        forwarded.remove(header::CONTENT_LENGTH);
863        for (name, value) in forwarded.iter() {
864            if name == header::HOST {
865                continue; // let the client set Host for the upstream
866            }
867            headers.insert(name.clone(), value.clone());
868        }
869        // Standard forwarding headers.
870        if let Ok(v) = HeaderValue::from_str(&ip.to_string()) {
871            headers.insert(HeaderName::from_static("x-forwarded-for"), v);
872        }
873        headers.insert(
874            HeaderName::from_static("x-forwarded-proto"),
875            HeaderValue::from_static(forwarded_proto(&rt.cfg, &parts.headers)),
876        );
877        // Forward the (resolved/generated) request id so the upstream logs the same correlation id.
878        if let Ok(v) = HeaderValue::from_str(&rid) {
879            headers.insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
880        }
881        // L2 key vault: replace the client's `Authorization` (which carried the virtual key) with the
882        // mapped provider key. The provider secret only ever travels edge→upstream — never back to
883        // the client — and the client's virtual key never reaches the upstream. The value was
884        // already validated as a legal HeaderValue when upstream_auth was set above.
885        if let Some(v) = upstream_auth {
886            headers.insert(header::AUTHORIZATION, v);
887        }
888    }
889
890    // Content capture (gateway L4): grab the request body for the emitted span *before* it is
891    // forwarded and consumed. `body_bytes` is already the DLP-redacted/masked form at this point;
892    // `capture_for_span` additionally scans+redacts so capture is safe under any DLP mode. Only when
893    // telemetry + content capture are both on; `None` otherwise (no cost when off).
894    let telem_input: Option<String> = (rt.telemetry.enabled && rt.telemetry.capture_content)
895        .then(|| capture_for_span(rt.dlp.as_ref(), &body_bytes, rt.telemetry.max_content_bytes));
896
897    let upstream_req = match up.body(Full::new(body_bytes)) {
898        Ok(r) => r,
899        Err(e) => {
900            warn!(error = %e, "failed to build upstream request");
901            return finish(
902                m,
903                &rid,
904                &method,
905                &path,
906                ip,
907                started,
908                "bad_gateway",
909                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
910            );
911        }
912    };
913
914    // 8) Forward and collect the response under a single deadline, so a stalled upstream
915    //    can't pin this task. `None` => no timeout (validation.upstream_timeout = "0").
916    let deadline = rt.upstream_timeout.map(|d| tokio::time::Instant::now() + d);
917    let timed_out = || {
918        warn!(upstream = %uri, "upstream timed out");
919        text(StatusCode::GATEWAY_TIMEOUT, "Gateway Timeout")
920    };
921
922    let upstream_resp = match within(deadline, state.client.request(upstream_req)).await {
923        Ok(Ok(r)) => r,
924        Ok(Err(e)) => {
925            warn!(error = %e, upstream = %uri, "upstream unreachable");
926            return finish(
927                m,
928                &rid,
929                &method,
930                &path,
931                ip,
932                started,
933                "upstream_error",
934                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
935            );
936        }
937        Err(_) => {
938            return finish(
939                m,
940                &rid,
941                &method,
942                &path,
943                ip,
944                started,
945                "upstream_timeout",
946                timed_out(),
947            )
948        }
949    };
950
951    let (mut resp_parts, resp_body) = upstream_resp.into_parts();
952
953    // 8a) SSE passthrough: forward a `text/event-stream` response frame-by-frame instead of
954    //     buffering the whole body, so the client sees events as they arrive (time-to-first-byte
955    //     is preserved). The buffering path below would hold the entire stream until the upstream
956    //     finished, which defeats SSE. On a streamed body the `max_response_body` cap and the
957    //     body-read deadline don't apply — the connect/first-byte `upstream_timeout` already
958    //     bounded time-to-headers — and egress bytes are tallied by `CountingBody` as frames flow.
959    //     Response hardening is headers-only, so it stays correct on a streaming body.
960    //
961    //     Carve-out: when outbound DLP is in `block` mode, streaming can't fail closed — frames would
962    //     reach the client before the body could be judged, and a stream can't be un-sent. So skip
963    //     passthrough and fall through to the buffered path (bounded by `max_response_body`), which
964    //     applies the same block enforcement to `text/event-stream` bodies as to any other response.
965    //     Block-mode operators trade incremental delivery for the fail-closed contract they configured;
966    //     `report`/`redact` still stream (redaction rewrites frames inline as they flow).
967    let dlp_blocks_response = rt
968        .dlp
969        .as_ref()
970        .is_some_and(|d| d.scan_response() && matches!(d.mode(), crate::dlp::DlpMode::Block));
971    if rt.stream_passthrough && is_event_stream(&resp_parts.headers) && !dlp_blocks_response {
972        strip_hop_by_hop(&mut resp_parts.headers);
973        resp_parts.headers.remove(header::CONTENT_LENGTH);
974        let header_egress = header_bytes(&resp_parts.headers);
975        // LLM metering on the streamed path: capture the stream tail so the terminal `usage` frame
976        // can be parsed when the body finishes (see `CountingBody`'s `Drop`). The L1 budget
977        // reservation rides along — moved out of the guard so the guard's Drop won't release it; the
978        // body's Drop reconciles it to the streamed usage (or releases on no usage) instead.
979        let llm_meter = llm_model.as_ref().map(|model| {
980            let (engine, reservation) = match budget_guard.take() {
981                Some(mut g) => (Some(g.engine.clone()), g.reservation.take()),
982                None => (None, None),
983            };
984            // Telemetry span context: built only when emission is on (avoids a per-request UUID
985            // otherwise). Carries an inbound `traceparent` so the gateway span stitches under the app.
986            let (telemetry, ctx) = if rt.telemetry.enabled {
987                (
988                    Some(Arc::clone(&rt.telemetry)),
989                    crate::telemetry::TraceContext::from_traceparent(traceparent.as_deref()),
990                )
991            } else {
992                (None, crate::telemetry::TraceContext::from_traceparent(None))
993            };
994            LlmStreamMeter {
995                model: model.clone(),
996                llm: Arc::clone(&rt.llm),
997                tail: Vec::new(),
998                engine,
999                reservation,
1000                started,
1001                first_at: None,
1002                last_at: None,
1003                telemetry,
1004                ctx,
1005                input: telem_input.clone(),
1006                team: llm_team.clone(),
1007                key: principal.clone(),
1008            }
1009        });
1010        // Reversible unmasking (gateway L3): when active, the stream is *unmasked* back to the caller's
1011        // own values from the inbound mask map — the provider only ever saw placeholders. This
1012        // replaces the outbound DLP scan on the streamed path (restore, not re-detect).
1013        let reversible_stream = rt.dlp.as_ref().is_some_and(|d| d.reversible());
1014        // Edge-DLP scan over the streamed response. Counts findings (report); additionally rewrites
1015        // frames when `redact` + `stream_redact` are on (deterministic spans only, NER stays off the
1016        // stream). Built when DLP is on and response scanning is enabled — but not in reversible mode,
1017        // where the unmasker below takes over the stream.
1018        let dlp_scanner = if reversible_stream {
1019            None
1020        } else {
1021            rt.dlp
1022                .as_ref()
1023                .filter(|d| d.scan_response())
1024                .map(|d| DlpStreamScanner {
1025                    engine: Arc::clone(d),
1026                    metrics: Arc::clone(m),
1027                    redact: d.stream_redact(),
1028                    carry: Vec::new(),
1029                })
1030        };
1031        let unmasker = reversible_stream.then(|| UnmaskStreamState {
1032            map: std::mem::take(&mut mask_map),
1033            carry: Vec::new(),
1034        });
1035        let body = Body::new(CountingBody::new(
1036            resp_body,
1037            Arc::clone(m),
1038            ingress_bytes,
1039            header_egress,
1040            llm_meter,
1041            dlp_scanner,
1042            unmasker,
1043        ));
1044        let mut response = Response::from_parts(resp_parts, body);
1045        harden_response(&rt.cfg, &mut response);
1046        // CORS decoration happens centrally in `handle` (covers this and every error path).
1047        return finish(m, &rid, &method, &path, ip, started, "ok", response);
1048    }
1049
1050    // Buffer the upstream body, optionally capped so a huge response can't OOM the proxy.
1051    let mut resp_bytes = if rt.max_response_body > 0 {
1052        match within(
1053            deadline,
1054            Limited::new(resp_body, rt.max_response_body).collect(),
1055        )
1056        .await
1057        {
1058            Ok(Ok(c)) => c.to_bytes(),
1059            Ok(Err(_)) => {
1060                warn!(
1061                    limit = rt.max_response_body,
1062                    "upstream response exceeded max_response_body"
1063                );
1064                return finish(
1065                    m,
1066                    &rid,
1067                    &method,
1068                    &path,
1069                    ip,
1070                    started,
1071                    "upstream_body_too_large",
1072                    text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1073                );
1074            }
1075            Err(_) => {
1076                return finish(
1077                    m,
1078                    &rid,
1079                    &method,
1080                    &path,
1081                    ip,
1082                    started,
1083                    "upstream_timeout",
1084                    timed_out(),
1085                )
1086            }
1087        }
1088    } else {
1089        match within(deadline, resp_body.collect()).await {
1090            Ok(Ok(c)) => c.to_bytes(),
1091            Ok(Err(e)) => {
1092                warn!(error = %e, "failed reading upstream body");
1093                return finish(
1094                    m,
1095                    &rid,
1096                    &method,
1097                    &path,
1098                    ip,
1099                    started,
1100                    "upstream_body_error",
1101                    text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1102                );
1103            }
1104            Err(_) => {
1105                return finish(
1106                    m,
1107                    &rid,
1108                    &method,
1109                    &path,
1110                    ip,
1111                    started,
1112                    "upstream_timeout",
1113                    timed_out(),
1114                )
1115            }
1116        }
1117    };
1118
1119    // The body was rebuffered, so let the server recompute framing; strip hop-by-hop headers
1120    // (incl. any named by `Connection`) so they don't leak downstream.
1121    strip_hop_by_hop(&mut resp_parts.headers);
1122    resp_parts.headers.remove(header::CONTENT_LENGTH);
1123
1124    // Managed-mode usage: this is the proxied path, where both bodies are buffered, so the byte
1125    // counts are exact. (`add_usage_request` is recorded for every request in `finish`.)
1126    m.add_usage_bytes(
1127        ingress_bytes,
1128        header_bytes(&resp_parts.headers).saturating_add(resp_bytes.len()),
1129    );
1130
1131    // LLM token metering on the buffered (non-streaming) path: read the upstream's own `usage`
1132    // object. Priced model -> tokens + cost; unmapped model -> tokens only; no usage -> just count
1133    // the request. Best-effort and observe-only — never affects the response.
1134    if let Some(model) = &llm_model {
1135        let usage = if is_event_stream(&resp_parts.headers) {
1136            crate::llm::parse_sse_usage(&resp_bytes)
1137        } else {
1138            crate::llm::parse_response_usage(&resp_bytes)
1139        };
1140        let actual = match usage {
1141            Some(usage) => {
1142                let cost = rt.llm.cost_micros(model, &usage);
1143                let sample = crate::metrics::LlmSample {
1144                    tokens_in: usage.prompt_tokens,
1145                    tokens_out: usage.completion_tokens,
1146                    cached_tokens: usage.cached_tokens,
1147                    reasoning_tokens: usage.reasoning_tokens,
1148                    cost_micros: cost,
1149                };
1150                m.record_llm_usage(model, sample);
1151                m.record_llm_team_usage(llm_team.as_deref().unwrap_or("_none"), &sample);
1152                m.record_llm_key_usage(principal.as_deref().unwrap_or("_anon"), &sample);
1153                // Emit an OpenInference span for this (buffered) request — gateway L4,
1154                // fire-and-forget. No TTFT/TPOT on the non-streaming path. When content capture is on,
1155                // attach the redacted request (captured pre-forward) + redacted response body. At this
1156                // point `resp_bytes` is pre-unmask/pre-outbound-redaction, so `capture_for_span` does
1157                // the redaction so no PII/secret is stored regardless of DLP mode.
1158                if rt.telemetry.enabled {
1159                    let (start_nanos, end_nanos) = wall_clock_span(started);
1160                    let output = rt.telemetry.capture_content.then(|| {
1161                        capture_for_span(
1162                            rt.dlp.as_ref(),
1163                            &resp_bytes,
1164                            rt.telemetry.max_content_bytes,
1165                        )
1166                    });
1167                    rt.telemetry.emit(crate::telemetry::SpanRecord {
1168                        ctx: crate::telemetry::TraceContext::from_traceparent(
1169                            traceparent.as_deref(),
1170                        ),
1171                        name: "llm.chat".into(),
1172                        model: model.clone(),
1173                        provider: None,
1174                        prompt_tokens: usage.prompt_tokens,
1175                        completion_tokens: usage.completion_tokens,
1176                        cached_tokens: usage.cached_tokens,
1177                        reasoning_tokens: usage.reasoning_tokens,
1178                        cost_micros: cost,
1179                        start_unix_nano: start_nanos,
1180                        end_unix_nano: end_nanos,
1181                        ttft: None,
1182                        tpot: None,
1183                        status_ok: resp_parts.status.is_success(),
1184                        input: telem_input.clone(),
1185                        output,
1186                        session_id: None,
1187                    });
1188                }
1189                crate::budget::Spend {
1190                    tokens: usage.total_tokens(),
1191                    cost_micros: cost.unwrap_or(0),
1192                }
1193            }
1194            None => {
1195                m.record_llm_no_usage();
1196                crate::budget::Spend::default()
1197            }
1198        };
1199        // Reconcile the L1 budget reservation to actual spend (releases the over-estimate, or charges
1200        // a low one). `commit` consumes the guard so its Drop won't also release.
1201        if let Some(guard) = budget_guard.take() {
1202            guard.commit(actual).await;
1203        }
1204    }
1205
1206    // Reversible unmasking (gateway L3): when reversible masking is active, the response is *restored*
1207    // to the caller's own values from the inbound mask map — the provider only ever saw placeholders.
1208    // This replaces the outbound scan/redact (the goal is restoration, not re-detection), so it runs
1209    // instead of the block below.
1210    let reversible_active = rt.dlp.as_ref().is_some_and(|d| d.reversible());
1211    if reversible_active {
1212        if !mask_map.is_empty() {
1213            let restored = mask_map.unmask(&String::from_utf8_lossy(&resp_bytes));
1214            resp_bytes = Bytes::from(restored);
1215        }
1216    } else if let Some(dlp) = rt.dlp.as_ref() {
1217        // LLM edge DLP (gateway L3) — outbound completion (buffered path only; the streamed path scans
1218        // frame-by-frame in `CountingBody`). `block` withholds the body, `redact` rewrites it, `report`
1219        // logs + counts. Runs after usage metering so token accounting reads the original `usage`.
1220        if dlp.scan_response() {
1221            let body_text = String::from_utf8_lossy(&resp_bytes);
1222            let findings = dlp.scan(&body_text);
1223            if !findings.is_empty() {
1224                for f in &findings {
1225                    m.record_dlp_finding(f.category);
1226                }
1227                match dlp.mode() {
1228                    crate::dlp::DlpMode::Block => {
1229                        m.record_dlp_blocked();
1230                        warn!(
1231                            findings = findings.len(),
1232                            "DLP withheld response body (outbound PII/secret)"
1233                        );
1234                        resp_parts.status = StatusCode::FORBIDDEN;
1235                        resp_bytes =
1236                            Bytes::from_static(b"{\"error\":\"response withheld by DLP policy\"}");
1237                        resp_parts.headers.remove(header::CONTENT_TYPE);
1238                        resp_parts.headers.insert(
1239                            header::CONTENT_TYPE,
1240                            HeaderValue::from_static("application/json"),
1241                        );
1242                    }
1243                    crate::dlp::DlpMode::Redact => {
1244                        warn!(findings = findings.len(), "DLP redacted response body");
1245                        resp_bytes = Bytes::from(dlp.redact(&body_text, &findings));
1246                    }
1247                    crate::dlp::DlpMode::Report => {
1248                        warn!(
1249                            findings = findings.len(),
1250                            "DLP findings in response (report-only)"
1251                        )
1252                    }
1253                    crate::dlp::DlpMode::Off => {}
1254                }
1255            }
1256        }
1257    }
1258
1259    let mut response = Response::from_parts(resp_parts, Body::from(resp_bytes));
1260    harden_response(&rt.cfg, &mut response);
1261    // CORS decoration happens centrally in `handle` (covers this and every error path).
1262
1263    finish(m, &rid, &method, &path, ip, started, "ok", response)
1264}
1265
1266/// Readiness probe. Returns `200` only if the upstream accepts a TCP connection, so a
1267/// platform's readiness check reflects whether EdgeGuard can actually serve traffic — not
1268/// merely that the process booted. `503` while the upstream is unreachable. (Liveness, i.e.
1269/// "is EdgeGuard itself up", is the separate unconditional `/__edgeguard/health`.)
1270pub async fn ready(State(state): State<AppState>) -> StatusCode {
1271    let rt = state.runtime.load();
1272    let Some((host, port)) = rt.cfg.upstream_probe_addr() else {
1273        return StatusCode::SERVICE_UNAVAILABLE;
1274    };
1275    match tokio::time::timeout(
1276        Duration::from_secs(2),
1277        TcpStream::connect((host.as_str(), port)),
1278    )
1279    .await
1280    {
1281        Ok(Ok(_)) => StatusCode::OK,
1282        _ => StatusCode::SERVICE_UNAVAILABLE,
1283    }
1284}
1285
1286/// Prometheus scrape endpoint (`GET /__edgeguard/metrics`). Like health/ready, it is a
1287/// dedicated route outside the proxy fallback, so it is not subject to auth or rate limits —
1288/// restrict access to `/__edgeguard/*` at the network layer if that matters in your setup.
1289pub async fn metrics_handler(State(state): State<AppState>) -> Response<Body> {
1290    let body = state.metrics.render();
1291    let mut resp = Response::new(Body::from(body));
1292    resp.headers_mut().insert(
1293        header::CONTENT_TYPE,
1294        HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
1295    );
1296    resp
1297}
1298
1299/// CSP violation report sink (`POST /__edgeguard/csp-report`). Browsers POST a JSON report
1300/// here when `headers.csp_report_uri` points at it; we count and log it, then `204`.
1301pub async fn csp_report(State(state): State<AppState>, body: Bytes) -> StatusCode {
1302    state.metrics.record_csp_report();
1303    // Managed mode: forward the raw report to the control plane (fire-and-forget, so the browser's
1304    // 204 is never delayed by an outbound call). Only when a control plane is configured and
1305    // `forward_csp` is on.
1306    if let Some(cp) = &state.cp {
1307        if state.runtime.load().cfg.control_plane.forward_csp {
1308            let cp = cp.clone();
1309            let raw = body.clone();
1310            tokio::spawn(async move { cp.forward_csp(&raw).await });
1311        }
1312    }
1313    // This endpoint is unauthenticated and a report can carry the full document URL,
1314    // referrer, and query strings — logging the whole blob at `info` is both a privacy leak
1315    // and a log-flood vector. Record only the directive that fired, at `debug`.
1316    match serde_json::from_slice::<serde_json::Value>(&body) {
1317        Ok(report) => {
1318            let directive = report
1319                .get("csp-report")
1320                .and_then(|r| {
1321                    r.get("violated-directive")
1322                        .or_else(|| r.get("effective-directive"))
1323                })
1324                .and_then(|v| v.as_str())
1325                .unwrap_or("unknown");
1326            debug!(target: "edgeguard::csp", directive, "CSP violation report");
1327        }
1328        Err(_) => warn!(
1329            bytes = body.len(),
1330            "CSP violation report with an unparseable body"
1331        ),
1332    }
1333    StatusCode::NO_CONTENT
1334}
1335
1336/// Header EdgeGuard reads an inbound request id from and echoes on every response. A
1337/// `&'static str` (rather than a `HeaderName` const, which isn't a const fn) — `HeaderMap`'s
1338/// `get`/`insert` accept it directly.
1339const REQUEST_ID_HEADER: &str = "x-request-id";
1340
1341/// Resolve the request id for log correlation: reuse a well-formed inbound `X-Request-Id` (one a
1342/// CDN/LB already set), else mint a UUID v4. The inbound value is trusted only when it's a short,
1343/// printable-ASCII token, so a hostile client can't inject newlines/control characters into the
1344/// access log or the echoed response header.
1345fn resolve_request_id(headers: &HeaderMap) -> String {
1346    if let Some(v) = headers.get(REQUEST_ID_HEADER).and_then(|v| v.to_str().ok()) {
1347        let v = v.trim();
1348        if !v.is_empty() && v.len() <= 128 && v.bytes().all(|b| b.is_ascii_graphic()) {
1349            return v.to_string();
1350        }
1351    }
1352    uuid::Uuid::new_v4().to_string()
1353}
1354
1355/// Resolve the client IP. The peer socket address is authoritative; `X-Forwarded-For`
1356/// (first hop) is honored only when `trust_forwarded` is set, because a directly
1357/// reachable client can otherwise spoof it to forge their identity.
1358fn client_ip(headers: &HeaderMap, peer: SocketAddr, trust_forwarded: bool) -> IpAddr {
1359    if trust_forwarded {
1360        if let Some(xff) = headers.get("x-forwarded-for") {
1361            if let Ok(s) = xff.to_str() {
1362                if let Some(first) = s.split(',').next() {
1363                    if let Ok(ip) = first.trim().parse::<IpAddr>() {
1364                        return ip;
1365                    }
1366                }
1367            }
1368        }
1369    }
1370    peer.ip()
1371}
1372
1373/// Total size of the request headers (sum of name + value bytes), used for the header-size
1374/// policy limit. This is an application-layer approximation of the on-wire header size.
1375fn header_bytes(headers: &HeaderMap) -> usize {
1376    headers
1377        .iter()
1378        .map(|(name, value)| name.as_str().len() + value.as_bytes().len())
1379        .sum()
1380}
1381
1382/// True if the response is a Server-Sent Events stream (`Content-Type: text/event-stream`,
1383/// ignoring any `; charset=…` parameter and leading whitespace). The signal we use to forward a
1384/// response unbuffered when `validation.stream_passthrough` is on.
1385fn is_event_stream(headers: &HeaderMap) -> bool {
1386    headers
1387        .get(header::CONTENT_TYPE)
1388        .and_then(|v| v.to_str().ok())
1389        .map(|v| {
1390            v.split(';')
1391                .next()
1392                .map(str::trim)
1393                .map(|ct| ct.eq_ignore_ascii_case("text/event-stream"))
1394                .unwrap_or(false)
1395        })
1396        .unwrap_or(false)
1397}
1398
1399/// True when the request asks to upgrade the protocol — a `Connection: upgrade` token plus an
1400/// `Upgrade` header (e.g. a WebSocket handshake). The signal for [`proxy_upgrade`].
1401fn is_upgrade_request(headers: &HeaderMap) -> bool {
1402    let conn_has_upgrade = headers
1403        .get_all(header::CONNECTION)
1404        .iter()
1405        .filter_map(|v| v.to_str().ok())
1406        .flat_map(|v| v.split(','))
1407        .any(|t| t.trim().eq_ignore_ascii_case("upgrade"));
1408    conn_has_upgrade && headers.contains_key(header::UPGRADE)
1409}
1410
1411/// Tunnel a WebSocket / `Upgrade` request to the upstream. Unlike the normal path (which strips
1412/// the hop-by-hop `Upgrade`/`Connection` headers), this forwards the handshake intact; on the
1413/// upstream's `101 Switching Protocols` it splices the client and upstream connections into a raw
1414/// bidirectional byte tunnel for the lifetime of the socket. Any other upstream status is passed
1415/// back to the client unchanged, so a rejected handshake surfaces normally.
1416// Mirrors the `handle` forward path's parameters (state/runtime/request + the access-log tuple);
1417// see the note on `finish`.
1418#[allow(clippy::too_many_arguments)]
1419async fn proxy_upgrade(
1420    state: &AppState,
1421    rt: &Runtime,
1422    mut req: Request<Body>,
1423    request_id: &str,
1424    method: &Method,
1425    path: &str,
1426    ip: IpAddr,
1427    started: Instant,
1428) -> Response<Body> {
1429    let m = &state.metrics;
1430
1431    // The client-side upgrade future: once we return a `101`, the server completes it and yields
1432    // the raw client connection. Take it (removing the extension from `req`) before forwarding.
1433    let client_upgrade = hyper::upgrade::on(&mut req);
1434
1435    // Build the upstream request: copy end-to-end headers AND the upgrade/connection headers
1436    // (the handshake needs them), add the forwarding headers, send an empty body.
1437    let uri = format!("{}{}", rt.pick_upstream(path), path);
1438    let mut up = Request::builder().method(req.method().clone()).uri(&uri);
1439    {
1440        let headers = up.headers_mut().expect("builder headers");
1441        // Strip hop-by-hop headers (the fixed set + any named by `Connection`) before forwarding,
1442        // so a client can't smuggle connection-scoped headers upstream — then re-add the handshake
1443        // headers the upgrade itself needs (`Connection: upgrade` + the requested `Upgrade`).
1444        let upgrade = req.headers().get(header::UPGRADE).cloned();
1445        let mut forwarded = req.headers().clone();
1446        strip_hop_by_hop(&mut forwarded);
1447        for (name, value) in forwarded.iter() {
1448            if name == header::HOST {
1449                continue;
1450            }
1451            headers.insert(name.clone(), value.clone());
1452        }
1453        headers.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
1454        if let Some(v) = upgrade {
1455            headers.insert(header::UPGRADE, v);
1456        }
1457        if let Ok(v) = HeaderValue::from_str(&ip.to_string()) {
1458            headers.insert(HeaderName::from_static("x-forwarded-for"), v);
1459        }
1460        headers.insert(
1461            HeaderName::from_static("x-forwarded-proto"),
1462            HeaderValue::from_static(forwarded_proto(&rt.cfg, req.headers())),
1463        );
1464        if let Ok(v) = HeaderValue::from_str(request_id) {
1465            headers.insert(HeaderName::from_static(REQUEST_ID_HEADER), v);
1466        }
1467    }
1468    let upstream_req = match up.body(Full::new(Bytes::new())) {
1469        Ok(r) => r,
1470        Err(e) => {
1471            warn!(error = %e, "failed to build upstream upgrade request");
1472            return finish(
1473                m,
1474                request_id,
1475                method,
1476                path,
1477                ip,
1478                started,
1479                "bad_gateway",
1480                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1481            );
1482        }
1483    };
1484
1485    // Bound the handshake by the same `upstream_timeout` as the buffered path, so a stalled
1486    // upstream can't pin this task (a `None` deadline means no timeout).
1487    let deadline = rt.upstream_timeout.map(|d| tokio::time::Instant::now() + d);
1488    let timed_out = || {
1489        warn!(upstream = %uri, "upstream timed out (upgrade)");
1490        finish(
1491            m,
1492            request_id,
1493            method,
1494            path,
1495            ip,
1496            started,
1497            "upstream_timeout",
1498            text(StatusCode::GATEWAY_TIMEOUT, "Gateway Timeout"),
1499        )
1500    };
1501
1502    let mut up_resp = match within(deadline, state.client.request(upstream_req)).await {
1503        Ok(Ok(r)) => r,
1504        Ok(Err(e)) => {
1505            warn!(error = %e, upstream = %uri, "upstream unreachable (upgrade)");
1506            return finish(
1507                m,
1508                request_id,
1509                method,
1510                path,
1511                ip,
1512                started,
1513                "upstream_error",
1514                text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1515            );
1516        }
1517        Err(_) => return timed_out(),
1518    };
1519
1520    // Upstream declined to upgrade: forward its response as-is (the client sees the rejection),
1521    // but under the same deadline and `max_response_body` cap as the normal buffered path so a
1522    // rejected handshake can't hang or buffer an unbounded body.
1523    if up_resp.status() != StatusCode::SWITCHING_PROTOCOLS {
1524        let (mut parts, body) = up_resp.into_parts();
1525        // Collect the rejection body, capped by `max_response_body` when set. Both arms normalize
1526        // any read/limit error to `()` — the distinction doesn't change the `502` we return.
1527        let body_fut = async {
1528            if rt.max_response_body > 0 {
1529                Limited::new(body, rt.max_response_body)
1530                    .collect()
1531                    .await
1532                    .map(|c| c.to_bytes())
1533                    .map_err(|_| ())
1534            } else {
1535                body.collect().await.map(|c| c.to_bytes()).map_err(|_| ())
1536            }
1537        };
1538        let bytes = match within(deadline, body_fut).await {
1539            Ok(Ok(b)) => b,
1540            Ok(Err(())) => {
1541                warn!("upstream upgrade-rejection body failed or exceeded max_response_body");
1542                return finish(
1543                    m,
1544                    request_id,
1545                    method,
1546                    path,
1547                    ip,
1548                    started,
1549                    "bad_gateway",
1550                    text(StatusCode::BAD_GATEWAY, "Bad Gateway"),
1551                );
1552            }
1553            Err(_) => return timed_out(),
1554        };
1555        strip_hop_by_hop(&mut parts.headers);
1556        parts.headers.remove(header::CONTENT_LENGTH);
1557        let mut response = Response::from_parts(parts, Body::from(bytes));
1558        harden_response(&rt.cfg, &mut response);
1559        return finish(m, request_id, method, path, ip, started, "ok", response);
1560    }
1561
1562    // `101`: wire up the upstream-side upgrade and splice the two connections once both complete.
1563    let upstream_upgrade = hyper::upgrade::on(&mut up_resp);
1564    tokio::spawn(async move {
1565        match tokio::join!(client_upgrade, upstream_upgrade) {
1566            (Ok(client_io), Ok(up_io)) => {
1567                let mut client_io = TokioIo::new(client_io);
1568                let mut up_io = TokioIo::new(up_io);
1569                if let Err(e) = tokio::io::copy_bidirectional(&mut client_io, &mut up_io).await {
1570                    debug!(error = %e, "websocket tunnel closed");
1571                }
1572            }
1573            (c, u) => warn!(
1574                client_ok = c.is_ok(),
1575                upstream_ok = u.is_ok(),
1576                "websocket upgrade did not complete"
1577            ),
1578        }
1579    });
1580
1581    // Return the upstream's `101` — its headers carry `Sec-WebSocket-Accept` etc., and returning a
1582    // `101` is what makes the server upgrade the client side (completing `client_upgrade` above).
1583    // Strip hop-by-hop headers (the fixed set + any named by `Connection`) so the upstream can't
1584    // leak connection-scoped headers downstream, then re-add the handshake headers the upgrade
1585    // itself needs (`Connection: upgrade` + the negotiated `Upgrade`).
1586    let (mut parts, _body) = up_resp.into_parts();
1587    let upgrade = parts.headers.get(header::UPGRADE).cloned();
1588    strip_hop_by_hop(&mut parts.headers);
1589    parts.headers.remove(header::CONTENT_LENGTH);
1590    parts
1591        .headers
1592        .insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
1593    if let Some(v) = upgrade {
1594        parts.headers.insert(header::UPGRADE, v);
1595    }
1596    let response = Response::from_parts(parts, Body::empty());
1597    finish(
1598        m,
1599        request_id,
1600        method,
1601        path,
1602        ip,
1603        started,
1604        "ws_upgrade",
1605        response,
1606    )
1607}
1608
1609/// Wraps a streaming upstream body to tally egress bytes (response headers + each data frame)
1610/// and report them to managed-mode usage when the body is dropped — i.e. after the final frame
1611/// is sent, or earlier if the client disconnects mid-stream (we count what actually went out).
1612/// Used for SSE passthrough: the body isn't buffered, so the exact byte count the buffered path
1613/// takes up front can only be accumulated as frames flow.
1614struct CountingBody<B> {
1615    inner: B,
1616    metrics: Arc<Metrics>,
1617    ingress: usize,
1618    /// Running egress total: response header bytes, then each data frame as it passes.
1619    egress: usize,
1620    /// LLM token metering for a streamed response, when `[llm]` is on and this is an LLM request.
1621    llm: Option<LlmStreamMeter>,
1622    /// Edge-DLP scanner for the streamed response (gateway L3): counts findings, and in
1623    /// `redact` + `stream_redact` mode rewrites the emitted bytes (deterministic spans only).
1624    dlp: Option<DlpStreamScanner>,
1625    /// Reversible unmask over the streamed response (gateway L3): restores placeholders to the
1626    /// caller's own values, carrying a boundary tail so a placeholder split across frames unmasks
1627    /// whole. Present only when reversible masking is active; mutually exclusive with `dlp` redaction.
1628    unmask: Option<UnmaskStreamState>,
1629    /// A non-data (trailers) frame held back so the redaction flush is emitted *before* it, then
1630    /// returned on the next poll. Keeps trailers last even when a buffered redaction tail remains.
1631    pending: Option<Frame<Bytes>>,
1632}
1633
1634/// Streaming reversible-unmask state: the per-request mask map + the held-back boundary tail.
1635struct UnmaskStreamState {
1636    map: crate::dlp::MaskMap,
1637    carry: Vec<u8>,
1638}
1639
1640/// Carry-buffer size for streaming DLP: the last bytes of each frame are kept and prepended to the
1641/// next, so a secret/PII token split across two SSE frames is still detected. Sized above the
1642/// longest signature (private-key header, provider keys).
1643const DLP_STREAM_CARRY: usize = 256;
1644
1645/// Scans a streamed response frame-by-frame for DLP findings, carrying a tail across frame
1646/// boundaries so a split token is still caught. Uses the engine's **deterministic** scan only
1647/// (`scan_stream`): the ML NER family never runs on the stream. In `report` mode it counts findings;
1648/// in `redact` mode (when `[llm.dlp].stream_redact` is on) it rewrites the emitted bytes, holding back
1649/// the boundary tail so a span straddling a frame is redacted whole on the next frame / final flush.
1650struct DlpStreamScanner {
1651    engine: Arc<crate::dlp::DlpEngine>,
1652    metrics: Arc<Metrics>,
1653    /// True when the stream should be rewritten (redact mode + stream_redact), not merely counted.
1654    redact: bool,
1655    /// Report mode: trailing bytes of the previous frame, prepended to the next scan (split-token
1656    /// detection). Redact mode: the un-emitted tail held back so a boundary-straddling span waits.
1657    carry: Vec<u8>,
1658}
1659
1660impl DlpStreamScanner {
1661    /// Report mode: scan one frame (prepended with the carry), counting only findings that touch the
1662    /// new data (so a span already counted from the carry isn't double-counted), then refresh the carry.
1663    fn record_only(&mut self, data: &[u8]) {
1664        let carry_len = self.carry.len();
1665        let mut buf = std::mem::take(&mut self.carry);
1666        buf.extend_from_slice(data);
1667        let text = String::from_utf8_lossy(&buf);
1668        for f in self.engine.scan_stream(&text) {
1669            // Count a finding once, when its span reaches into the newly-arrived bytes.
1670            if f.end > carry_len {
1671                self.metrics.record_dlp_finding(f.category);
1672            }
1673        }
1674        // Keep the last DLP_STREAM_CARRY bytes for the next frame's boundary check.
1675        let keep = buf.len().min(DLP_STREAM_CARRY);
1676        self.carry = buf.split_off(buf.len() - keep);
1677    }
1678
1679    /// Redact mode: append `data` to the held-back carry, redact every deterministic span that ends
1680    /// before the boundary tail, and return the bytes to emit now (the rest waits in `carry`). A span
1681    /// straddling the boundary pulls the emit point back to its start so it is never split.
1682    fn redact_frame(&mut self, data: &[u8]) -> Vec<u8> {
1683        let mut buf = std::mem::take(&mut self.carry);
1684        buf.extend_from_slice(data);
1685        let text = String::from_utf8_lossy(&buf).into_owned();
1686        let findings = self.engine.scan_stream(&text);
1687        // Hold back the last DLP_STREAM_CARRY bytes; never emit past a span that crosses the boundary.
1688        let mut emit_to = text.len().saturating_sub(DLP_STREAM_CARRY);
1689        for f in &findings {
1690            if f.start < emit_to && f.end > emit_to {
1691                emit_to = f.start;
1692            }
1693        }
1694        while emit_to > 0 && !text.is_char_boundary(emit_to) {
1695            emit_to -= 1;
1696        }
1697        let emit: Vec<crate::dlp::Finding> =
1698            findings.into_iter().filter(|f| f.end <= emit_to).collect();
1699        for f in &emit {
1700            self.metrics.record_dlp_finding(f.category);
1701        }
1702        let out = self.engine.redact(&text[..emit_to], &emit).into_bytes();
1703        self.carry = text.as_bytes()[emit_to..].to_vec();
1704        out
1705    }
1706
1707    /// Redact mode: at end-of-stream, redact and return whatever remains in the held-back tail.
1708    fn flush(&mut self) -> Vec<u8> {
1709        if self.carry.is_empty() {
1710            return Vec::new();
1711        }
1712        let buf = std::mem::take(&mut self.carry);
1713        let text = String::from_utf8_lossy(&buf).into_owned();
1714        let findings = self.engine.scan_stream(&text);
1715        for f in &findings {
1716            self.metrics.record_dlp_finding(f.category);
1717        }
1718        self.engine.redact(&text, &findings).into_bytes()
1719    }
1720}
1721
1722/// Cap on the rolling tail buffer kept for SSE token metering. The OpenAI terminal `usage` frame is
1723/// small and arrives just before `[DONE]`, so the last 16 KiB always contains it; bounding the
1724/// buffer keeps streaming memory flat regardless of stream length.
1725const LLM_SSE_TAIL_CAP: usize = 16 * 1024;
1726
1727/// Accumulates the tail of an SSE stream so the terminal `usage` frame can be parsed when the body
1728/// finishes. Holds the model + price book; records to metrics and reconciles the L1 budget on drop.
1729struct LlmStreamMeter {
1730    model: String,
1731    llm: Arc<crate::llm::LlmRuntime>,
1732    tail: Vec<u8>,
1733    /// L1 budget engine + the held reservation, when budgets are configured. Reconciled to the
1734    /// streamed usage on drop (or released on no usage).
1735    engine: Option<Arc<crate::budget::BudgetEngine>>,
1736    reservation: Option<crate::budget::Reservation>,
1737    /// Request-receipt instant, the TTFT clock's zero. TTFT = first streamed frame − `started`.
1738    started: Instant,
1739    /// When the first / most-recent data frame was emitted to the client. TPOT is derived from the
1740    /// span between them and the terminal `usage` output-token count. `None` until the first frame.
1741    first_at: Option<Instant>,
1742    last_at: Option<Instant>,
1743    /// OTLP span emission for the streamed request (gateway L4), when `[llm.telemetry]` is on. On
1744    /// drop, the finalized usage + TTFT/TPOT are emitted as one OpenInference span. `None` when off.
1745    telemetry: Option<Arc<crate::telemetry::TelemetryRuntime>>,
1746    /// The trace context for the emitted span (carries an inbound `traceparent` when present).
1747    ctx: crate::telemetry::TraceContext,
1748    /// Captured (DLP-redacted) request body for the span's `input.value`, when content capture is on.
1749    /// The streamed *output* isn't buffered (SSE is forwarded frame-by-frame), so only input is set.
1750    input: Option<String>,
1751    /// Team/tag for the per-team token/cost metric on drop (absent → `_none`).
1752    team: Option<String>,
1753    /// Authenticated principal for the per-key token/cost metric on drop (absent → `_anon`).
1754    key: Option<String>,
1755}
1756
1757/// Holds an LLM budget reservation for the buffered/non-streaming path. `commit` reconciles it to
1758/// the actual spend; if the guard is dropped without committing (any early return on an upstream
1759/// error / timeout), its `Drop` releases the reservation in full, so a failed request never
1760/// permanently consumes budget.
1761struct ReservationGuard {
1762    engine: Arc<crate::budget::BudgetEngine>,
1763    reservation: Option<crate::budget::Reservation>,
1764    /// For recording reconcile/release failures (counter drift) to Prometheus.
1765    metrics: Arc<Metrics>,
1766}
1767
1768impl ReservationGuard {
1769    /// Reconcile the held reservation to `actual` spend (consuming the guard so `Drop` is a no-op).
1770    async fn commit(mut self, actual: crate::budget::Spend) {
1771        if let Some(reservation) = self.reservation.take() {
1772            let failed = self.engine.reconcile(&reservation, actual).await;
1773            self.metrics.record_budget_reconcile_failures(failed);
1774        }
1775    }
1776}
1777
1778impl Drop for ReservationGuard {
1779    fn drop(&mut self) {
1780        // Not committed (an error path bailed before reconcile): release the whole hold. `release`
1781        // is async, so spawn it onto the current runtime (we're always inside the request task).
1782        if let Some(reservation) = self.reservation.take() {
1783            let engine = Arc::clone(&self.engine);
1784            let metrics = Arc::clone(&self.metrics);
1785            tokio::spawn(async move {
1786                let failed = engine.release(&reservation).await;
1787                metrics.record_budget_reconcile_failures(failed);
1788            });
1789        }
1790    }
1791}
1792
1793impl<B> CountingBody<B> {
1794    fn new(
1795        inner: B,
1796        metrics: Arc<Metrics>,
1797        ingress: usize,
1798        header_egress: usize,
1799        llm: Option<LlmStreamMeter>,
1800        dlp: Option<DlpStreamScanner>,
1801        unmask: Option<UnmaskStreamState>,
1802    ) -> Self {
1803        Self {
1804            inner,
1805            metrics,
1806            ingress,
1807            egress: header_egress,
1808            llm,
1809            dlp,
1810            unmask,
1811            pending: None,
1812        }
1813    }
1814
1815    /// Append the bytes the client will actually receive to the bounded LLM tail buffer (keeping only
1816    /// the last [`LLM_SSE_TAIL_CAP`] bytes), so the terminal `usage` frame is available to parse on drop.
1817    fn push_meter_tail(&mut self, data: &[u8]) {
1818        if let Some(meter) = self.llm.as_mut() {
1819            // Stamp first/last emitted-frame time for TTFT/TPOT (this runs on the bytes the client
1820            // actually receives, so it measures server-side time-to-first-token with no client clock).
1821            if !data.is_empty() {
1822                let now = Instant::now();
1823                meter.first_at.get_or_insert(now);
1824                meter.last_at = Some(now);
1825            }
1826            meter.tail.extend_from_slice(data);
1827            if meter.tail.len() > LLM_SSE_TAIL_CAP {
1828                let drop_n = meter.tail.len() - LLM_SSE_TAIL_CAP;
1829                meter.tail.drain(..drop_n);
1830            }
1831        }
1832    }
1833}
1834
1835impl<B> HttpBody for CountingBody<B>
1836where
1837    B: HttpBody<Data = Bytes> + Unpin,
1838{
1839    type Data = Bytes;
1840    type Error = B::Error;
1841
1842    fn poll_frame(
1843        mut self: Pin<&mut Self>,
1844        cx: &mut Context<'_>,
1845    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
1846        let this = self.as_mut().get_mut();
1847        // A trailers frame held back during a redaction flush is emitted now, before anything else.
1848        if let Some(frame) = this.pending.take() {
1849            return Poll::Ready(Some(Ok(frame)));
1850        }
1851        match Pin::new(&mut this.inner).poll_frame(cx) {
1852            Poll::Ready(Some(Ok(frame))) => {
1853                // Only data frames are scanned/redacted; trailers and the like pass through — but in
1854                // redact mode any buffered tail must be flushed *before* the trailers go out.
1855                let data = match frame.into_data() {
1856                    Ok(data) => data,
1857                    Err(non_data) => {
1858                        if let Some(scanner) = this.dlp.as_mut() {
1859                            if scanner.redact {
1860                                let out = scanner.flush();
1861                                if !out.is_empty() {
1862                                    let out = Bytes::from(out);
1863                                    this.egress = this.egress.saturating_add(out.len());
1864                                    this.push_meter_tail(&out);
1865                                    this.pending = Some(non_data); // emit trailers on the next poll
1866                                    return Poll::Ready(Some(Ok(Frame::data(out))));
1867                                }
1868                            }
1869                        }
1870                        // Reversible unmask: flush the held-back tail before the trailers go out.
1871                        if let Some(u) = this.unmask.as_mut() {
1872                            let out = u.map.flush_unmask(&mut u.carry);
1873                            if !out.is_empty() {
1874                                let out = Bytes::from(out);
1875                                this.egress = this.egress.saturating_add(out.len());
1876                                this.push_meter_tail(&out);
1877                                this.pending = Some(non_data);
1878                                return Poll::Ready(Some(Ok(Frame::data(out))));
1879                            }
1880                        }
1881                        return Poll::Ready(Some(Ok(non_data)));
1882                    }
1883                };
1884                // Reversible unmask (gateway L3): restore placeholders to the caller's own values,
1885                // holding a boundary tail so a placeholder split across frames unmasks whole.
1886                if let Some(u) = this.unmask.as_mut() {
1887                    let out = Bytes::from(u.map.unmask_stream(&mut u.carry, &data));
1888                    this.egress = this.egress.saturating_add(out.len());
1889                    this.push_meter_tail(&out);
1890                    return Poll::Ready(Some(Ok(Frame::data(out))));
1891                }
1892                if this.dlp.as_ref().is_some_and(|s| s.redact) {
1893                    // Redact mode: rewrite the emitted bytes (deterministic spans only). The emitted
1894                    // length may differ from the input frame; account for the bytes the client gets.
1895                    let out = Bytes::from(this.dlp.as_mut().unwrap().redact_frame(&data));
1896                    this.egress = this.egress.saturating_add(out.len());
1897                    this.push_meter_tail(&out);
1898                    Poll::Ready(Some(Ok(Frame::data(out))))
1899                } else {
1900                    this.egress = this.egress.saturating_add(data.len());
1901                    this.push_meter_tail(&data);
1902                    // Report mode (or no redaction): count findings, pass the frame through unchanged.
1903                    if let Some(scanner) = this.dlp.as_mut() {
1904                        scanner.record_only(&data);
1905                    }
1906                    Poll::Ready(Some(Ok(Frame::data(data))))
1907                }
1908            }
1909            Poll::Ready(None) => {
1910                // Upstream ended. In redact mode, flush the held-back tail as one final data frame
1911                // (the next poll sees inner-end again and returns None).
1912                if let Some(scanner) = this.dlp.as_mut() {
1913                    if scanner.redact {
1914                        let out = scanner.flush();
1915                        if !out.is_empty() {
1916                            let out = Bytes::from(out);
1917                            this.egress = this.egress.saturating_add(out.len());
1918                            this.push_meter_tail(&out);
1919                            return Poll::Ready(Some(Ok(Frame::data(out))));
1920                        }
1921                    }
1922                }
1923                // Reversible unmask: flush the held-back tail as one final data frame.
1924                if let Some(u) = this.unmask.as_mut() {
1925                    let out = u.map.flush_unmask(&mut u.carry);
1926                    if !out.is_empty() {
1927                        let out = Bytes::from(out);
1928                        this.egress = this.egress.saturating_add(out.len());
1929                        this.push_meter_tail(&out);
1930                        return Poll::Ready(Some(Ok(Frame::data(out))));
1931                    }
1932                }
1933                Poll::Ready(None)
1934            }
1935            Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))),
1936            Poll::Pending => Poll::Pending,
1937        }
1938    }
1939
1940    fn is_end_stream(&self) -> bool {
1941        // Not done while a held-back trailers frame, or a buffered redaction tail, still has to flow.
1942        if self.pending.is_some() {
1943            return false;
1944        }
1945        if let Some(scanner) = self.dlp.as_ref() {
1946            if scanner.redact && !scanner.carry.is_empty() {
1947                return false;
1948            }
1949        }
1950        if let Some(u) = self.unmask.as_ref() {
1951            if !u.carry.is_empty() {
1952                return false;
1953            }
1954        }
1955        self.inner.is_end_stream()
1956    }
1957
1958    fn size_hint(&self) -> SizeHint {
1959        self.inner.size_hint()
1960    }
1961}
1962
1963impl<B> Drop for CountingBody<B> {
1964    fn drop(&mut self) {
1965        self.metrics.add_usage_bytes(self.ingress, self.egress);
1966        // LLM metering for the streamed body: parse the terminal `usage` frame from the tail. The
1967        // client gets usage only if it sent `stream_options.include_usage`; otherwise `no_usage`.
1968        if let Some(meter) = self.llm.as_mut() {
1969            let actual = match crate::llm::parse_sse_usage(&meter.tail) {
1970                Some(usage) => {
1971                    let cost = meter.llm.cost_micros(&meter.model, &usage);
1972                    let sample = crate::metrics::LlmSample {
1973                        tokens_in: usage.prompt_tokens,
1974                        tokens_out: usage.completion_tokens,
1975                        cached_tokens: usage.cached_tokens,
1976                        reasoning_tokens: usage.reasoning_tokens,
1977                        cost_micros: cost,
1978                    };
1979                    self.metrics.record_llm_usage(&meter.model, sample);
1980                    self.metrics
1981                        .record_llm_team_usage(meter.team.as_deref().unwrap_or("_none"), &sample);
1982                    self.metrics
1983                        .record_llm_key_usage(meter.key.as_deref().unwrap_or("_anon"), &sample);
1984                    // Server-side TTFT/TPOT from the emitted-frame timestamps. TPOT (inter-token
1985                    // latency) is only defined for >1 output token; a single-token response records
1986                    // TTFT alone; an empty stream records neither.
1987                    let (ttft, tpot) = match meter.first_at {
1988                        Some(first) => {
1989                            let ttft = first.saturating_duration_since(meter.started);
1990                            let tpot = match meter.last_at {
1991                                Some(last) if usage.completion_tokens > 1 => {
1992                                    let denom =
1993                                        (usage.completion_tokens - 1).min(u32::MAX as u64) as u32;
1994                                    Some(last.saturating_duration_since(first) / denom)
1995                                }
1996                                _ => None,
1997                            };
1998                            (Some(ttft), tpot)
1999                        }
2000                        None => (None, None),
2001                    };
2002                    if let Some(ttft) = ttft {
2003                        self.metrics.record_llm_latency(ttft, tpot);
2004                    }
2005                    // Emit an OpenInference span for the streamed request (gateway L4, fire-and-forget).
2006                    if let Some(telemetry) = meter.telemetry.as_ref() {
2007                        let (start_nanos, end_nanos) = wall_clock_span(meter.started);
2008                        telemetry.emit(crate::telemetry::SpanRecord {
2009                            ctx: meter.ctx,
2010                            name: "llm.chat".into(),
2011                            model: meter.model.clone(),
2012                            provider: None,
2013                            prompt_tokens: usage.prompt_tokens,
2014                            completion_tokens: usage.completion_tokens,
2015                            cached_tokens: usage.cached_tokens,
2016                            reasoning_tokens: usage.reasoning_tokens,
2017                            cost_micros: cost,
2018                            start_unix_nano: start_nanos,
2019                            end_unix_nano: end_nanos,
2020                            ttft,
2021                            tpot,
2022                            status_ok: true, // a streamed body means the upstream 2xx already began
2023                            input: meter.input.take(),
2024                            output: None, // streamed output isn't buffered (forwarded frame-by-frame)
2025                            session_id: None,
2026                        });
2027                    }
2028                    crate::budget::Spend {
2029                        tokens: usage.total_tokens(),
2030                        cost_micros: cost.unwrap_or(0),
2031                    }
2032                }
2033                None => {
2034                    self.metrics.record_llm_no_usage();
2035                    crate::budget::Spend::default()
2036                }
2037            };
2038            // Reconcile the L1 budget reservation to the streamed actual spend. Async, so spawn it
2039            // (we're inside the request task's runtime when the body is dropped). Record any settle
2040            // failure as counter drift.
2041            if let (Some(engine), Some(reservation)) =
2042                (meter.engine.take(), meter.reservation.take())
2043            {
2044                let metrics = Arc::clone(&self.metrics);
2045                tokio::spawn(async move {
2046                    let failed = engine.reconcile(&reservation, actual).await;
2047                    metrics.record_budget_reconcile_failures(failed);
2048                });
2049            }
2050        }
2051    }
2052}
2053
2054/// Prepare captured LLM content (`input.value` / `output.value`) for a telemetry span: truncate to
2055/// the cap, and when a DLP engine is configured, scan+redact so PII/secrets never leave the box —
2056/// **regardless of the DLP `mode`**, so content capture is safe even under `report`/`block` (which
2057/// don't rewrite the body). Without a DLP engine the content is captured as-is (an explicit
2058/// `capture_content` opt-in). Returns `None` only when capture is off (handled by the caller).
2059fn capture_for_span(dlp: Option<&Arc<crate::dlp::DlpEngine>>, bytes: &[u8], max: usize) -> String {
2060    let text = crate::telemetry::prepare_content(bytes, max);
2061    match dlp {
2062        Some(dlp) => {
2063            let findings = dlp.scan(&text);
2064            if findings.is_empty() {
2065                text
2066            } else {
2067                dlp.redact(&text, &findings)
2068            }
2069        }
2070        None => text,
2071    }
2072}
2073
2074/// Derive wall-clock (unix-nanos) span bounds from a monotonic request-start `Instant`. An `Instant`
2075/// can't be converted to a unix time directly, so we anchor the end at `SystemTime::now()` and
2076/// subtract the measured elapsed duration for the start. Used to timestamp emitted OTLP spans.
2077fn wall_clock_span(started: Instant) -> (u64, u64) {
2078    let end = SystemTime::now()
2079        .duration_since(UNIX_EPOCH)
2080        .unwrap_or_default()
2081        .as_nanos() as u64;
2082    let start = end.saturating_sub(started.elapsed().as_nanos() as u64);
2083    (start, end)
2084}
2085
2086/// Remove hop-by-hop headers so they don't leak across the proxy boundary (RFC 7230 §6.1):
2087/// the fixed [`HOP_BY_HOP`] set plus any header *named* in a `Connection` header. Applied in
2088/// both directions (request to upstream, response to client).
2089fn strip_hop_by_hop(headers: &mut HeaderMap) {
2090    // Header names listed in any `Connection` header are connection-specific; collect them
2091    // before mutating (the borrow of `headers` must end before we remove).
2092    let connection_named: Vec<HeaderName> = headers
2093        .get_all(header::CONNECTION)
2094        .iter()
2095        .filter_map(|v| v.to_str().ok())
2096        .flat_map(|v| v.split(','))
2097        .filter_map(|token| HeaderName::from_bytes(token.trim().as_bytes()).ok())
2098        .collect();
2099    for name in HOP_BY_HOP {
2100        headers.remove(*name);
2101    }
2102    for name in connection_named {
2103        headers.remove(name);
2104    }
2105}
2106
2107/// Decide the `X-Forwarded-Proto` to send upstream. If EdgeGuard terminates TLS, the client
2108/// hop is HTTPS. Otherwise, behind a trusted edge (`trust_forwarded_for`) we preserve the
2109/// proto the edge reported (falling back to `http`); an untrusted client's `X-Forwarded-Proto`
2110/// is never honored, mirroring the client-IP trust model. Returns a `'static` token so the
2111/// caller can build a `HeaderValue` without fallible parsing.
2112fn forwarded_proto(cfg: &Config, headers: &HeaderMap) -> &'static str {
2113    if cfg.tls.enabled {
2114        return "https";
2115    }
2116    if cfg.server.trust_forwarded_for {
2117        if let Some(value) = headers
2118            .get("x-forwarded-proto")
2119            .and_then(|v| v.to_str().ok())
2120        {
2121            match value.split(',').next().map(str::trim) {
2122                Some(p) if p.eq_ignore_ascii_case("https") => return "https",
2123                Some(p) if p.eq_ignore_ascii_case("http") => return "http",
2124                _ => {}
2125            }
2126        }
2127    }
2128    "http"
2129}
2130
2131/// Pick the most specific (longest-prefix) per-route limiter matching `path`, if any.
2132fn longest_route<'a>(routes: &'a [RouteLimiter], path: &str) -> Option<&'a RouteLimiter> {
2133    routes
2134        .iter()
2135        .filter(|r| path.starts_with(&r.prefix))
2136        .max_by_key(|r| r.prefix.len())
2137}
2138
2139/// The HSTS header value EdgeGuard emits when `headers.hsts` is on: a two-year `max-age`
2140/// including subdomains. A named constant so the live proxy and the static-host config
2141/// generator ([`crate::generate`]) can't drift on it.
2142pub const HSTS_VALUE: &str = "max-age=63072000; includeSubDomains";
2143
2144/// The constant security response headers EdgeGuard injects, derived from the `[headers]`
2145/// policy. This is the **single source of truth** shared by the live response-hardening path
2146/// ([`harden_response`]) and the static-host config generator ([`crate::generate`]), so a
2147/// generated `_headers` file / edge-middleware snippet matches exactly what the proxy would add
2148/// at runtime. Returns `(name, value)` pairs with canonically-cased names (for readable
2149/// generated output); the proxy normalizes the case when it inserts them.
2150///
2151/// Cookie hardening and leaky-header *stripping* are deliberately **not** here: both rewrite the
2152/// upstream's actual response (`Set-Cookie`, `Server`/`X-Powered-By`), which a static file that
2153/// can only "always add this header" cannot express. The generator documents that gap; the
2154/// WASM worker, which sees the real response, applies them too.
2155pub fn security_headers(cfg: &HeadersCfg) -> Vec<(&'static str, String)> {
2156    let mut out: Vec<(&'static str, String)> = Vec::with_capacity(6);
2157    out.push(("X-Content-Type-Options", "nosniff".to_string()));
2158    if !cfg.frame_options.is_empty() {
2159        out.push(("X-Frame-Options", cfg.frame_options.clone()));
2160    }
2161    if !cfg.referrer_policy.is_empty() {
2162        out.push(("Referrer-Policy", cfg.referrer_policy.clone()));
2163    }
2164    if !cfg.permissions_policy.is_empty() {
2165        out.push(("Permissions-Policy", cfg.permissions_policy.clone()));
2166    }
2167    if !cfg.csp.is_empty() {
2168        // Append a report-uri directive if configured, and choose enforce vs. report-only.
2169        let mut value = cfg.csp.clone();
2170        if !cfg.csp_report_uri.is_empty() {
2171            value.push_str("; report-uri ");
2172            value.push_str(&cfg.csp_report_uri);
2173        }
2174        let name = if cfg.csp_report_only {
2175            "Content-Security-Policy-Report-Only"
2176        } else {
2177            "Content-Security-Policy"
2178        };
2179        out.push((name, value));
2180    }
2181    if cfg.hsts {
2182        out.push(("Strict-Transport-Security", HSTS_VALUE.to_string()));
2183    }
2184    out
2185}
2186
2187/// Inject security headers, harden Set-Cookie, and strip leaky headers.
2188fn harden_response(cfg: &Config, resp: &mut Response<Body>) {
2189    let h = resp.headers_mut();
2190
2191    // Inject the constant security headers (shared with the static-host generator via
2192    // `security_headers`, so the two never diverge). `from_bytes` normalizes the canonical
2193    // casing to lowercase; these names/values are all valid, so the inserts don't fail.
2194    for (name, value) in security_headers(&cfg.headers) {
2195        if let (Ok(n), Ok(v)) = (
2196            HeaderName::from_bytes(name.as_bytes()),
2197            HeaderValue::from_str(&value),
2198        ) {
2199            h.insert(n, v);
2200        }
2201    }
2202
2203    // Strip leaky headers.
2204    for name in &cfg.headers.strip {
2205        if let Ok(hn) = HeaderName::from_bytes(name.as_bytes()) {
2206            h.remove(hn);
2207        }
2208    }
2209
2210    // Harden cookies: ensure Secure, HttpOnly, and a SameSite default.
2211    if cfg.headers.force_secure_cookies {
2212        let cookies: Vec<HeaderValue> = h.get_all(header::SET_COOKIE).iter().cloned().collect();
2213        if !cookies.is_empty() {
2214            h.remove(header::SET_COOKIE);
2215            for c in cookies {
2216                if let Ok(s) = c.to_str() {
2217                    // HttpOnly is added unless globally disabled or this cookie's name is
2218                    // exempt — the latter keeps a double-submit CSRF cookie JS-readable.
2219                    let add_httponly = cfg.headers.httponly_cookies
2220                        && !cookie_name_exempt(s, &cfg.headers.httponly_cookie_exempt);
2221                    let hardened = harden_cookie(s, add_httponly);
2222                    if let Ok(v) = HeaderValue::from_str(&hardened) {
2223                        h.append(header::SET_COOKIE, v);
2224                    }
2225                } else {
2226                    h.append(header::SET_COOKIE, c);
2227                }
2228            }
2229        }
2230    }
2231}
2232
2233/// The cookie's NAME — the token before the first `=` of the `name=value` pair. Cookies are
2234/// case-sensitive, so this is returned as-is (trimmed) for an exact exemption match.
2235fn cookie_name(cookie: &str) -> &str {
2236    cookie
2237        .split(';')
2238        .next()
2239        .unwrap_or("")
2240        .split('=')
2241        .next()
2242        .unwrap_or("")
2243        .trim()
2244}
2245
2246/// True when this cookie's name is on the `httponly_cookie_exempt` allowlist.
2247fn cookie_name_exempt(cookie: &str, exempt: &[String]) -> bool {
2248    let name = cookie_name(cookie);
2249    exempt.iter().any(|e| e == name)
2250}
2251
2252/// Harden one `Set-Cookie` value: ensure `Secure` and a `SameSite` default, and add
2253/// `HttpOnly` when `add_httponly` is set (the caller clears it for exempt cookies).
2254fn harden_cookie(cookie: &str, add_httponly: bool) -> String {
2255    // Inspect attribute *names* (the tokens after the first `name=value` pair), not the
2256    // whole string — otherwise a value like `session=securetoken` would look like it
2257    // already carries `Secure` and we'd skip hardening it.
2258    let attrs: std::collections::HashSet<String> = cookie
2259        .split(';')
2260        .skip(1)
2261        .filter_map(|p| p.trim().split('=').next())
2262        .map(|k| k.trim().to_ascii_lowercase())
2263        .collect();
2264
2265    let mut out = cookie.trim_end_matches(';').to_string();
2266    if !attrs.contains("secure") {
2267        out.push_str("; Secure");
2268    }
2269    if add_httponly && !attrs.contains("httponly") {
2270        out.push_str("; HttpOnly");
2271    }
2272    if !attrs.contains("samesite") {
2273        out.push_str("; SameSite=Lax");
2274    }
2275    out
2276}
2277
2278/// Run `fut` bounded by an optional deadline. `None` means no timeout. On success returns
2279/// the future's own output; `Err(Elapsed)` if the deadline passed first.
2280async fn within<F: Future>(
2281    deadline: Option<tokio::time::Instant>,
2282    fut: F,
2283) -> Result<F::Output, tokio::time::error::Elapsed> {
2284    match deadline {
2285        Some(dl) => tokio::time::timeout_at(dl, fut).await,
2286        None => Ok(fut.await),
2287    }
2288}
2289
2290fn text(status: StatusCode, msg: &str) -> Response<Body> {
2291    let mut resp = Response::new(Body::from(msg.to_string()));
2292    *resp.status_mut() = status;
2293    resp.headers_mut().insert(
2294        header::CONTENT_TYPE,
2295        HeaderValue::from_static("text/plain; charset=utf-8"),
2296    );
2297    resp
2298}
2299
2300/// Emit a structured access-log line, record metrics, stamp the response with `X-Request-Id`,
2301/// and return it.
2302// All args are part of the access-log/identity tuple for one request; bundling them in a struct
2303// would just move the same fields behind another name at every (already terse) call site.
2304#[allow(clippy::too_many_arguments)]
2305fn finish(
2306    metrics: &Metrics,
2307    request_id: &str,
2308    method: &Method,
2309    path: &str,
2310    ip: IpAddr,
2311    started: Instant,
2312    outcome: &str,
2313    mut resp: Response<Body>,
2314) -> Response<Body> {
2315    // Echo the request id on every response (including error responses) so a client / upstream /
2316    // log can be correlated. `resolve_request_id` guarantees it's a valid header value.
2317    if let Ok(v) = HeaderValue::from_str(request_id) {
2318        resp.headers_mut().insert(REQUEST_ID_HEADER, v);
2319    }
2320    let elapsed = started.elapsed();
2321    info!(
2322        request_id,
2323        %method,
2324        path = %path,
2325        client_ip = %ip,
2326        status = resp.status().as_u16(),
2327        outcome,
2328        latency_ms = elapsed.as_millis() as u64,
2329        "request"
2330    );
2331    metrics.record_request(outcome);
2332    metrics.observe_latency(elapsed);
2333    // Managed mode: count every finished request (proxied or rejected) toward the usage delta, and
2334    // — when the edge denied it — the drainable `blocked` figure. Cheap (relaxed atomic adds) and
2335    // inert unless a control plane drains it for reporting.
2336    metrics.add_usage_request(outcome);
2337    resp
2338}
2339
2340#[cfg(test)]
2341mod tests {
2342    use super::*;
2343
2344    fn headers_with(name: &'static str, value: &str) -> HeaderMap {
2345        let mut h = HeaderMap::new();
2346        h.insert(name, HeaderValue::from_str(value).unwrap());
2347        h
2348    }
2349
2350    #[test]
2351    fn capture_for_span_redacts_content_when_dlp_is_configured() {
2352        // With a DLP engine, captured content is redacted before it can be emitted — even in
2353        // `report` mode, which does not rewrite the forwarded body. This is the safety guarantee for
2354        // gateway content capture (top-20 #14): PII/secrets never leave the box via a span.
2355        let dlp = crate::dlp::DlpEngine::build(&crate::config::DlpCfg {
2356            mode: "report".into(),
2357            detect_email: true,
2358            ..Default::default()
2359        })
2360        .unwrap()
2361        .map(Arc::new);
2362        assert!(dlp.is_some(), "report mode should build a DLP engine");
2363        let body = b"please email alice@example.com about the invoice";
2364
2365        let redacted = capture_for_span(dlp.as_ref(), body, 4096);
2366        assert!(
2367            !redacted.contains("alice@example.com"),
2368            "email must be redacted before capture: {redacted}"
2369        );
2370
2371        // Without a DLP engine, capture is verbatim (an explicit `capture_content` opt-in).
2372        let raw = capture_for_span(None, body, 4096);
2373        assert!(raw.contains("alice@example.com"));
2374
2375        // The size cap still applies to the captured content.
2376        assert!(capture_for_span(None, body, 8).len() < body.len());
2377    }
2378
2379    /// Drive a sequence of byte frames through a redact-mode `DlpStreamScanner` and return the
2380    /// concatenated emitted output (frames + final flush) as a string.
2381    fn run_stream_redact(frames: &[&[u8]]) -> String {
2382        let engine = crate::dlp::DlpEngine::build(&crate::config::DlpCfg {
2383            mode: "redact".into(),
2384            stream_redact: true,
2385            ..Default::default()
2386        })
2387        .unwrap()
2388        .unwrap();
2389        let mut scanner = DlpStreamScanner {
2390            engine: Arc::new(engine),
2391            metrics: Arc::new(Metrics::new()),
2392            redact: true,
2393            carry: Vec::new(),
2394        };
2395        let mut out = Vec::new();
2396        for f in frames {
2397            out.extend_from_slice(&scanner.redact_frame(f));
2398        }
2399        out.extend_from_slice(&scanner.flush());
2400        String::from_utf8(out).unwrap()
2401    }
2402
2403    #[test]
2404    fn stream_redaction_redacts_pii_split_across_frames() {
2405        // An email split across two SSE frames is still redacted whole (carry holds the boundary).
2406        let out = run_stream_redact(&[b"hello jane.d", b"oe@example.com bye"]);
2407        assert_eq!(out, "hello [REDACTED:email] bye");
2408    }
2409
2410    #[test]
2411    fn stream_redaction_passes_clean_text_unchanged() {
2412        let out = run_stream_redact(&[b"the quick brown ", b"fox jumps over the lazy dog"]);
2413        assert_eq!(out, "the quick brown fox jumps over the lazy dog");
2414    }
2415
2416    #[test]
2417    fn stream_redaction_handles_pii_at_end_via_flush() {
2418        // PII entirely within the final held-back tail is redacted by the end-of-stream flush.
2419        let out = run_stream_redact(&[b"ssn 123-45-6789"]);
2420        assert_eq!(out, "ssn [REDACTED:ssn]");
2421    }
2422
2423    #[test]
2424    fn path_prefix_matches_on_segment_boundary_only() {
2425        // Exact, sub-path, and query-boundary matches.
2426        assert!(path_prefix_matches("/api", "/api"));
2427        assert!(path_prefix_matches("/api/users", "/api"));
2428        assert!(path_prefix_matches("/api?x=1", "/api"));
2429        // A trailing-slash prefix matches its sub-paths.
2430        assert!(path_prefix_matches("/api/users", "/api/"));
2431        // Sibling paths sharing a textual prefix must NOT match.
2432        assert!(!path_prefix_matches("/apiary", "/api"));
2433        assert!(!path_prefix_matches("/apiary/honey", "/api"));
2434        // `/` matches everything.
2435        assert!(path_prefix_matches("/anything", "/"));
2436    }
2437
2438    #[test]
2439    fn client_ip_ignores_xff_when_untrusted() {
2440        let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
2441        let h = headers_with("x-forwarded-for", "1.2.3.4");
2442        // Untrusted: a directly reachable client must not be able to spoof its IP.
2443        assert_eq!(client_ip(&h, peer, false), peer.ip());
2444    }
2445
2446    #[test]
2447    fn client_ip_uses_first_xff_hop_when_trusted() {
2448        let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
2449        let h = headers_with("x-forwarded-for", "1.2.3.4, 5.6.7.8");
2450        assert_eq!(client_ip(&h, peer, true).to_string(), "1.2.3.4");
2451    }
2452
2453    #[test]
2454    fn client_ip_falls_back_to_peer_on_missing_or_garbage_xff() {
2455        let peer: SocketAddr = "203.0.113.9:55000".parse().unwrap();
2456        assert_eq!(client_ip(&HeaderMap::new(), peer, true), peer.ip());
2457        let garbage = headers_with("x-forwarded-for", "not-an-ip");
2458        assert_eq!(client_ip(&garbage, peer, true), peer.ip());
2459    }
2460
2461    #[test]
2462    fn header_bytes_sums_names_and_values() {
2463        let mut h = HeaderMap::new();
2464        h.insert("a", HeaderValue::from_static("bb")); // 1 + 2
2465        h.insert("ccc", HeaderValue::from_static("dddd")); // 3 + 4
2466        assert_eq!(header_bytes(&h), 1 + 2 + 3 + 4);
2467    }
2468
2469    #[test]
2470    fn strip_hop_by_hop_removes_fixed_and_connection_named() {
2471        let mut h = HeaderMap::new();
2472        h.insert(
2473            "connection",
2474            HeaderValue::from_static("keep-alive, X-Custom-Hop"),
2475        );
2476        h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
2477        h.insert("x-custom-hop", HeaderValue::from_static("secret"));
2478        h.insert("content-type", HeaderValue::from_static("text/plain"));
2479        strip_hop_by_hop(&mut h);
2480        assert!(!h.contains_key("connection"));
2481        assert!(!h.contains_key("keep-alive"));
2482        // A header named by Connection is connection-specific and must be dropped.
2483        assert!(!h.contains_key("x-custom-hop"));
2484        // An end-to-end header is preserved.
2485        assert!(h.contains_key("content-type"));
2486    }
2487
2488    #[test]
2489    fn forwarded_proto_reflects_tls_and_trust() {
2490        let mut cfg = Config::default();
2491
2492        // We terminate TLS -> always https, regardless of any incoming header.
2493        cfg.tls.enabled = true;
2494        assert_eq!(
2495            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "http")),
2496            "https"
2497        );
2498
2499        // Plain HTTP, untrusted: http, and an incoming XFP is NOT trusted.
2500        cfg.tls.enabled = false;
2501        cfg.server.trust_forwarded_for = false;
2502        assert_eq!(
2503            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "https")),
2504            "http"
2505        );
2506
2507        // Plain HTTP behind a trusted edge: preserve the edge's reported proto.
2508        cfg.server.trust_forwarded_for = true;
2509        assert_eq!(
2510            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "https")),
2511            "https"
2512        );
2513        assert_eq!(
2514            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "http, https")),
2515            "http"
2516        );
2517        // Missing or unrecognized -> http.
2518        assert_eq!(forwarded_proto(&cfg, &HeaderMap::new()), "http");
2519        assert_eq!(
2520            forwarded_proto(&cfg, &headers_with("x-forwarded-proto", "garbage")),
2521            "http"
2522        );
2523    }
2524
2525    #[test]
2526    fn longest_route_picks_most_specific_prefix() {
2527        let mk = |p: &str| RouteLimiter {
2528            prefix: p.to_string(),
2529            limiter: Arc::new(RateLimiter::keyed(governor::Quota::per_second(
2530                std::num::NonZeroU32::new(1).unwrap(),
2531            ))),
2532        };
2533        let routes = vec![mk("/api/"), mk("/api/admin/")];
2534        assert_eq!(
2535            longest_route(&routes, "/api/admin/users").map(|r| r.prefix.as_str()),
2536            Some("/api/admin/")
2537        );
2538        assert_eq!(
2539            longest_route(&routes, "/api/things").map(|r| r.prefix.as_str()),
2540            Some("/api/")
2541        );
2542        assert!(longest_route(&routes, "/public").is_none());
2543    }
2544
2545    #[test]
2546    fn path_prefix_matches_on_segment_boundaries() {
2547        // A prefix without a trailing slash must not match a sibling path.
2548        assert!(path_prefix_matches("/api", "/api")); // exact
2549        assert!(path_prefix_matches("/api/users", "/api")); // segment boundary
2550        assert!(path_prefix_matches("/api?q=1", "/api")); // query boundary
2551        assert!(!path_prefix_matches("/apiary", "/api")); // sibling — must NOT match
2552                                                          // A trailing-slash prefix is a clean boundary by construction.
2553        assert!(path_prefix_matches("/api/users", "/api/"));
2554        assert!(!path_prefix_matches("/apiary", "/api/"));
2555        // "/" matches everything.
2556        assert!(path_prefix_matches("/anything", "/"));
2557    }
2558
2559    #[test]
2560    fn harden_cookie_adds_missing_flags() {
2561        let out = harden_cookie("sid=abc", true);
2562        assert!(out.contains("; Secure"), "{out}");
2563        assert!(out.contains("; HttpOnly"), "{out}");
2564        assert!(out.contains("; SameSite=Lax"), "{out}");
2565    }
2566
2567    #[test]
2568    fn harden_cookie_preserves_existing_attributes() {
2569        let out = harden_cookie("sid=abc; HttpOnly; SameSite=Strict", true);
2570        assert!(out.contains("; Secure"), "{out}");
2571        assert!(out.contains("SameSite=Strict"), "{out}");
2572        // existing SameSite isn't overridden, HttpOnly isn't duplicated
2573        assert!(!out.contains("SameSite=Lax"), "{out}");
2574        assert_eq!(out.matches("HttpOnly").count(), 1, "{out}");
2575    }
2576
2577    #[test]
2578    fn harden_cookie_value_resembling_an_attr_is_not_skipped() {
2579        // The value contains the substring "secure" but there is no Secure *attribute*;
2580        // it must still be added (regression guard for the token-vs-substring fix).
2581        let out = harden_cookie("session=securetoken", true);
2582        assert!(out.contains("; Secure"), "{out}");
2583    }
2584
2585    #[test]
2586    fn harden_cookie_skips_httponly_when_disabled() {
2587        // add_httponly=false → Secure + SameSite still added, but NOT HttpOnly. This is the
2588        // path for a JS-readable double-submit CSRF cookie (e.g. doneyet_csrf).
2589        let out = harden_cookie("doneyet_csrf=tok", false);
2590        assert!(out.contains("; Secure"), "{out}");
2591        assert!(out.contains("; SameSite=Lax"), "{out}");
2592        assert!(!out.to_ascii_lowercase().contains("httponly"), "{out}");
2593    }
2594
2595    #[test]
2596    fn cookie_name_exempt_matches_by_name_only() {
2597        let exempt = vec!["doneyet_csrf".to_string()];
2598        assert!(cookie_name_exempt(
2599            "doneyet_csrf=abc; Path=/; Secure",
2600            &exempt
2601        ));
2602        // a different cookie is not exempt; the value never triggers a match
2603        assert!(!cookie_name_exempt(
2604            "doneyet_auth=doneyet_csrf; Path=/",
2605            &exempt
2606        ));
2607        assert!(!cookie_name_exempt("sid=x", &exempt));
2608    }
2609
2610    #[test]
2611    fn security_headers_reflects_config_toggles() {
2612        // Defaults: every header present, CSP enforced (not report-only).
2613        let cfg = HeadersCfg::default();
2614        let got = security_headers(&cfg);
2615        let names: Vec<&str> = got.iter().map(|(n, _)| *n).collect();
2616        assert!(names.contains(&"X-Content-Type-Options"));
2617        assert!(names.contains(&"X-Frame-Options"));
2618        assert!(names.contains(&"Referrer-Policy"));
2619        assert!(names.contains(&"Permissions-Policy"));
2620        assert!(names.contains(&"Content-Security-Policy"));
2621        assert!(names.contains(&"Strict-Transport-Security"));
2622        assert!(!names.contains(&"Content-Security-Policy-Report-Only"));
2623
2624        // Disabling HSTS and clearing frame_options drops exactly those; report-only flips the
2625        // CSP header name and report_uri is appended to the value.
2626        let cfg = HeadersCfg {
2627            hsts: false,
2628            frame_options: String::new(),
2629            csp: "default-src 'self'".into(),
2630            csp_report_only: true,
2631            csp_report_uri: "/__edgeguard/csp-report".into(),
2632            ..HeadersCfg::default()
2633        };
2634        let got = security_headers(&cfg);
2635        let map: std::collections::HashMap<&str, String> =
2636            got.iter().map(|(n, v)| (*n, v.clone())).collect();
2637        assert!(!map.contains_key("Strict-Transport-Security"));
2638        assert!(!map.contains_key("X-Frame-Options"));
2639        assert!(!map.contains_key("Content-Security-Policy"));
2640        assert_eq!(
2641            map.get("Content-Security-Policy-Report-Only")
2642                .map(|s| s.as_str()),
2643            Some("default-src 'self'; report-uri /__edgeguard/csp-report")
2644        );
2645    }
2646}