Skip to main content

waf_proxy/
lib.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4pub mod config;
5pub mod metrics;
6pub mod tls;
7
8use std::convert::Infallible;
9use std::future::Future;
10use std::net::SocketAddr;
11use std::path::Path;
12use std::pin::Pin;
13use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
14use std::sync::{Arc, RwLock};
15use std::task::{Context, Poll};
16use std::time::{Instant, SystemTime};
17
18use tokio::sync::{watch, Notify};
19
20use http_body_util::combinators::BoxBody;
21use http_body_util::{BodyExt, Full};
22use hyper::body::{Body, Bytes, Frame, Incoming};
23use hyper::service::service_fn;
24use hyper::{HeaderMap, Request, Response, Uri};
25use hyper_util::client::legacy::connect::HttpConnector;
26use hyper_util::client::legacy::Client;
27use hyper_util::rt::{TokioExecutor, TokioIo};
28use hyper_util::server::conn::auto;
29use tokio::net::TcpListener;
30use tokio_rustls::TlsAcceptor;
31
32use crate::metrics::{Metrics, Outcome};
33use tls::TlsCertSource;
34use tracing::{debug, error, info, warn};
35
36use waf_core::{
37    ClientIpResolver, Config, FailMode, IpSource, Normalized, RateLimitState, RequestContext,
38    ResilienceConfig, StateStore, WafModule,
39};
40use waf_detection::{
41    crs::CrsModule,
42    evasion::EvasionModule,
43    graphql::GraphqlModule, grpc::GrpcModule, header_injection::HeaderInjectionModule, ldap::LdapModule,
44    lfi_rfi::LfiRfiModule,
45    mail::MailModule, nosql::NosqlModule, path_traversal::PathTraversalModule,
46    rate_limit::RateLimitModule,
47    rce::RceModule, request_smuggling::RequestSmugglingModule, scanner::ScannerModule,
48    sqli::SqliModule, ssi::SsiModule, ssrf::SsrfModule, ssti::SstiModule, xss::XssModule,
49    xxe::XxeModule, ContentPrefilter,
50};
51use waf_normalizer::Normalizer;
52use waf_pipeline::{NoopLogger, Pipeline, PipelineVerdict};
53use waf_wasm::{WasmModule, WasmOptions};
54
55pub type HyperBoxBody = BoxBody<Bytes, hyper::Error>;
56
57/// A factory that (re)builds the injected detection modules. Called ONCE at bind and again
58/// on every config reload — so modules injected by an embedder (BOUNDARY §4) SURVIVE a
59/// SIGHUP and are re-`init`'d, instead of being dropped (the pre-0.3 behaviour). It returns
60/// a `Result` as a UNIT: on error the whole reload is aborted and the last-good `Reloadable`
61/// (which still holds the working modules) is kept — the modules are never dropped on a
62/// failed rebuild. A boxed closure so an embedder can capture its own (enterprise) config.
63pub type ModuleFactory =
64    dyn Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
65        + Send
66        + Sync;
67
68/// Headers that must not be forwarded verbatim to the backend (RFC 7230).
69const HOP_BY_HOP: &[&str] = &[
70    "connection",
71    "host", // re-set by hyper from the target URI
72    "keep-alive",
73    "proxy-authenticate",
74    "proxy-authorization",
75    "te",
76    "trailers",
77    "transfer-encoding",
78    "upgrade",
79];
80
81static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
82
83fn next_request_id() -> String {
84    let n = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
85    format!("req-{n:016x}")
86}
87
88pub fn full_body(data: impl Into<Bytes>) -> HyperBoxBody {
89    Full::new(data.into())
90        .map_err(|never| match never {})
91        .boxed()
92}
93
94/// A buffered body that emits one DATA frame, then one TRAILERS frame. A plain `Full`
95/// cannot carry trailers; gRPC puts its status in HTTP/2 trailers (`grpc-status`/
96/// `grpc-message`), so relaying them requires this. Used only when trailers are present —
97/// the non-gRPC path keeps using `full_body` (byte-identical to before).
98struct FramedBody {
99    data: Option<Bytes>,
100    trailers: Option<HeaderMap>,
101}
102
103impl Body for FramedBody {
104    type Data = Bytes;
105    type Error = Infallible;
106
107    fn poll_frame(
108        mut self: Pin<&mut Self>,
109        _cx: &mut Context<'_>,
110    ) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
111        if let Some(d) = self.data.take() {
112            return Poll::Ready(Some(Ok(Frame::data(d))));
113        }
114        if let Some(t) = self.trailers.take() {
115            return Poll::Ready(Some(Ok(Frame::trailers(t))));
116        }
117        Poll::Ready(None)
118    }
119}
120
121/// Box a buffered body, attaching `trailers` when present. With no trailers this is exactly
122/// `full_body` (so the non-gRPC datapath is unchanged); with trailers it is a `FramedBody`.
123fn body_with_trailers(data: Bytes, trailers: Option<HeaderMap>) -> HyperBoxBody {
124    match trailers {
125        None => full_body(data),
126        Some(t) => FramedBody { data: Some(data), trailers: Some(t) }
127            .map_err(|never| match never {})
128            .boxed(),
129    }
130}
131
132/// Collect a body into `(bytes, trailers)` — the trailer-preserving alternative to
133/// `collect().to_bytes()`. Keeps the buffered model (so the body is still inspectable)
134/// while not discarding the trailers that follow it (Step-0 invariant).
135async fn collect_with_trailers<B>(body: B) -> Result<(Bytes, Option<HeaderMap>), B::Error>
136where
137    B: Body<Data = Bytes>,
138{
139    let collected = body.collect().await?;
140    let trailers = collected.trailers().cloned();
141    Ok((collected.to_bytes(), trailers))
142}
143
144/// A gRPC request, by Content-Type (`application/grpc`, `+proto`, `-web`, …). Such requests
145/// are forwarded over h2c with their trailers relayed; everything else takes the unchanged
146/// h1 path.
147fn is_grpc_request(parts: &hyper::http::request::Parts) -> bool {
148    parts
149        .headers
150        .get(hyper::header::CONTENT_TYPE)
151        .and_then(|v| v.to_str().ok())
152        .map(|ct| ct.trim_start().starts_with("application/grpc"))
153        .unwrap_or(false)
154}
155
156fn parse_cookies(headers: &[(String, String)]) -> Vec<(String, String)> {
157    headers
158        .iter()
159        .filter(|(name, _)| name.eq_ignore_ascii_case("cookie"))
160        .flat_map(|(_, value)| {
161            value.split(';').filter_map(|pair| {
162                let mut parts = pair.splitn(2, '=');
163                let key = parts.next()?.trim().to_string();
164                let val = parts.next().unwrap_or("").trim().to_string();
165                Some((key, val))
166            })
167        })
168        .collect()
169}
170
171fn build_context(
172    parts: &hyper::http::request::Parts,
173    body: &Bytes,
174    client_addr: SocketAddr,
175    ip_resolver: &ClientIpResolver,
176) -> RequestContext {
177    let path = parts.uri.path().to_string();
178    let query = parts.uri.query().map(str::to_string);
179    let method = parts.method.to_string();
180    let http_version = format!("{:?}", parts.version);
181
182    let headers: Vec<(String, String)> = parts
183        .headers
184        .iter()
185        .filter_map(|(name, value)| {
186            value.to_str().ok().map(|v| (name.to_string(), v.to_string()))
187        })
188        .collect();
189
190    let cookies = parse_cookies(&headers);
191
192    let normalized = Normalized::default();
193
194    // Resolve the real client IP ONCE here: rate limiting, logging and future
195    // Geo/IP-reputation all read it back from `ctx.client_ip` (single source of
196    // truth). A fallback behind a trusted proxy means a spoofing attempt or a
197    // misconfigured upstream — log it.
198    let request_id = next_request_id();
199    let resolved = ip_resolver.resolve(client_addr.ip(), &headers);
200    match resolved.source {
201        IpSource::FallbackMissingHeader | IpSource::FallbackMalformed => warn!(
202            request_id = %request_id,
203            peer = %client_addr.ip(),
204            source = ?resolved.source,
205            "client-IP resolution fell back to peer address"
206        ),
207        IpSource::DirectPeer | IpSource::TrustedHeader => {}
208    }
209
210    RequestContext {
211        client_ip: resolved.ip,
212        request_id,
213        timestamp: SystemTime::now(),
214        method,
215        path: path.clone(),
216        raw_path: path,
217        query,
218        http_version,
219        headers,
220        cookies,
221        body: body.clone(),
222        normalized,
223        score: 0,
224        score_contributions: vec![],
225    }
226}
227
228/// Config-derived state, rebuilt as a unit on every hot reload and swapped
229/// atomically. A request loads either the entire old or the entire new value —
230/// never a mix of recompiled rules and stale thresholds.
231struct Reloadable {
232    backend: String,
233    normalizer: Normalizer,
234    pipeline: Pipeline,
235    /// Fast-path skip prefilter (Fase 7 / Pillar 3). Built here, in the SAME unit as
236    /// `pipeline`, from the same rule sources and the same `paranoia_level` snapshot,
237    /// so a reload regenerates both together — they can never drift apart.
238    prefilter: ContentPrefilter,
239    ip_resolver: ClientIpResolver,
240    resilience: ResilienceConfig,
241}
242
243/// Process-lifetime state that survives reloads:
244/// - `client`: the hyper connection pool (kept warm);
245/// - `listen_addr`: the bound address (restart-required if it changes);
246/// - `rl_state`: the rate-limiter token buckets (NOT reset by a reload, so a
247///   reload cannot be used to clear an attacker's throttle);
248/// - `current`: the atomically-swappable `Reloadable`.
249struct StaticState {
250    client: Client<HttpConnector, HyperBoxBody>,
251    /// A SEPARATE h2c (HTTP/2 prior-knowledge) client used ONLY for gRPC targets. Kept
252    /// distinct from `client` on purpose: flipping the general client to `http2_only` would
253    /// break all existing h1 forwarding — gRPC needs end-to-end h2, the rest stays h1.
254    grpc_client: Client<HttpConnector, HyperBoxBody>,
255    listen_addr: SocketAddr,
256    rl_state: RateLimitState,
257    current: RwLock<Arc<Reloadable>>,
258    mode: HandlerMode,
259    /// Inbound TLS terminator (Phase 12). `Some` ⇒ the listener serves ONLY TLS (h1/h2
260    /// by ALPN); `None` ⇒ cleartext (h1 + h2c). Built once at bind; a required-but-broken
261    /// cert fails the bind, so there is no runtime path that downgrades to cleartext.
262    tls_acceptor: Option<TlsAcceptor>,
263    /// Process-lifetime metrics (B1). Survives reloads like the rate-limit store. Recorded
264    /// once per request in `handle`; served by the metrics task (`Proxy::metrics_listener`).
265    metrics: Arc<Metrics>,
266    /// Factory that rebuilds the injected (embedder) modules on every reload (core 0.3). Process
267    /// lifetime, so `Reloader::reload_from` can re-run it in place of the pre-0.3 `Vec::new()` —
268    /// this is what makes `.add_module`-style injected modules survive a SIGHUP. `None` ⇒ no
269    /// injected modules to carry across a reload (the default OPEN build).
270    module_factory: Option<Arc<ModuleFactory>>,
271}
272
273/// Which request handler the accept loop dispatches to. `Inspect` is the ONLY mode a
274/// configured WAF ever uses (every public `bind*` sets it). `Passthrough` is a
275/// `#[doc(hidden)]` bench seam set ONLY by `bind_passthrough` — no `config.toml` field
276/// reaches it (that is the line separating a bench seam from a production bypass flag).
277/// It exists so the Fase 9 (c) load-test can measure the WAF-overhead delta against the
278/// SAME `forward_to_backend` the inspecting path uses.
279#[derive(Clone, Copy)]
280enum HandlerMode {
281    Inspect,
282    Passthrough,
283}
284
285impl StaticState {
286    /// Load the current config snapshot: take the read lock just long enough to
287    /// clone the `Arc`, then release it (never held across `.await`). Poisoning is
288    /// recovered (`into_inner`) because the only writer holds the lock solely for a
289    /// pointer assignment that cannot panic — so the data is never left invalid.
290    fn current(&self) -> Arc<Reloadable> {
291        self.current
292            .read()
293            .unwrap_or_else(|poisoned| poisoned.into_inner())
294            .clone()
295    }
296}
297
298/// Handle that can hot-reload a running proxy's configuration. Obtained via
299/// `Proxy::reloader()`; cheap to clone (an `Arc`). Used by the SIGHUP task in the
300/// binary and directly by tests.
301#[derive(Clone)]
302pub struct Reloader(Arc<StaticState>);
303
304impl Reloader {
305    /// Re-read, validate (reusing Pillar-1 `config::load`) and atomically swap.
306    /// On any error the current configuration is KEPT and the error is logged —
307    /// a failed reload never degrades a working WAF.
308    pub fn reload_from(&self, path: &Path) -> Result<(), config::LoadError> {
309        let new_cfg = match config::load(path) {
310            Ok(c) => c,
311            Err(e) => {
312                error!(error = %e, "config reload failed; keeping current configuration");
313                return Err(e);
314            }
315        };
316
317        // Restart-required field: the socket is already bound.
318        if new_cfg.proxy.listen != self.0.listen_addr {
319            warn!(
320                current = %self.0.listen_addr,
321                requested = %new_cfg.proxy.listen,
322                "proxy.listen change requires a restart; keeping the current bind address"
323            );
324        }
325
326        // Rebuild the injected (embedder) modules via the factory (core 0.3). Pre-0.3 this
327        // passed `Vec::new()`, silently dropping every `.add_module` module on a reload. The
328        // factory is fallible as a UNIT: if it errors (e.g. an enterprise schema file became
329        // invalid on disk), the whole reload is ABORTED and the current `Reloadable` — which
330        // still holds the working modules — is kept, exactly like a rejected config. No
331        // partial rebuild, no unprotected window, and the modules are never dropped on error.
332        let extra = match &self.0.module_factory {
333            Some(factory) => match factory() {
334                Ok(modules) => modules,
335                Err(e) => {
336                    error!(error = %e, "module factory failed on reload; keeping current configuration");
337                    return Err(config::LoadError::ModuleFactory(e.to_string()));
338                }
339            },
340            None => Vec::new(),
341        };
342
343        // Rebuild ALL config-derived state (rules recompiled, CIDR re-parsed),
344        // reusing the shared rate-limit buckets so the throttle state survives.
345        let new_reloadable = build_reloadable(&new_cfg, self.0.rl_state.clone(), extra);
346
347        // Atomic swap. The write section is a single pointer assignment that
348        // cannot panic, so the lock is never poisoned by this path; recover
349        // defensively anyway so a foreign poison can't wedge reloads.
350        *self
351            .0
352            .current
353            .write()
354            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::new(new_reloadable);
355        info!("configuration reloaded");
356        Ok(())
357    }
358}
359
360/// Build an upstream-error response per `on_upstream_error`: 502 (fail_closed,
361/// definitive gateway failure) or 503 (fail_open, retryable). Note: "fail_open"
362/// here does NOT pass traffic through — there is no origin to reach — it only
363/// softens the status to a retryable one. Always logged (critical operational event).
364fn upstream_error_response(
365    ctx: &RequestContext,
366    resilience: &ResilienceConfig,
367    detail: &str,
368) -> Response<HyperBoxBody> {
369    let (status, body) = match resilience.on_upstream_error {
370        FailMode::FailClosed => (502, "Bad Gateway"),
371        FailMode::FailOpen => (503, "Service Unavailable"),
372    };
373    warn!(
374        request_id = %ctx.request_id,
375        client_ip = %ctx.client_ip,
376        status = status,
377        policy = ?resilience.on_upstream_error,
378        detail = detail,
379        "upstream error: applying on_upstream_error policy"
380    );
381    Response::builder().status(status).body(full_body(body)).unwrap()
382}
383
384/// Serialize the per-rule score breakdown to a compact JSON array for the decision-log
385/// (`score_contributions` field). This is the data the enterprise control-plane drill-down
386/// (§7) reconstructs a blocked verdict from — emitted only on the already-logged denied path,
387/// never per benign request. Serialization of this plain data cannot realistically fail; an
388/// empty array on the theoretical error keeps the log line well-formed.
389fn contributions_json(ctx: &RequestContext) -> String {
390    serde_json::to_string(&ctx.score_contributions).unwrap_or_else(|_| "[]".to_string())
391}
392
393/// Map a denying pipeline verdict to an HTTP response (403 for Block, the
394/// carried status — e.g. 429 + `Retry-After` — for Reject). `Allow` → `None`.
395fn deny_response(
396    ctx: &RequestContext,
397    verdict: PipelineVerdict,
398) -> Option<(Response<HyperBoxBody>, Outcome)> {
399    match verdict {
400        PipelineVerdict::Allow => None,
401        PipelineVerdict::Block { rule_id, reason } => {
402            warn!(
403                request_id = %ctx.request_id,
404                rule_id = %rule_id,
405                reason = %reason,
406                score = ctx.score,
407                score_contributions = %contributions_json(ctx),
408                "request blocked"
409            );
410            Some((
411                Response::builder()
412                    .status(403)
413                    .body(full_body("Forbidden"))
414                    .unwrap(),
415                Outcome::Blocked,
416            ))
417        }
418        PipelineVerdict::Reject { rule_id, reason, status, retry_after } => {
419            warn!(
420                request_id = %ctx.request_id,
421                rule_id = %rule_id,
422                reason = %reason,
423                status = status,
424                score_contributions = %contributions_json(ctx),
425                "request rejected"
426            );
427            // Reason phrase + metric outcome by status: 429 rate-limit, 400 illegal framing
428            // (request smuggling). Block (403 detection) is a separate arm above.
429            let (body, outcome) = match status {
430                429 => ("Too Many Requests", Outcome::RateLimited),
431                400 => ("Bad Request", Outcome::BadRequest),
432                _ => ("Rejected", Outcome::BadRequest),
433            };
434            let mut builder = Response::builder().status(status);
435            if let Some(secs) = retry_after {
436                builder = builder.header("retry-after", secs.to_string());
437            }
438            Some((builder.body(full_body(body)).unwrap(), outcome))
439        }
440    }
441}
442
443async fn try_forward(
444    req: Request<Incoming>,
445    state: &StaticState,
446    client_addr: SocketAddr,
447) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
448    // Load the current config snapshot ONCE per request (atomic): the whole
449    // request runs against this `Reloadable`, immune to a concurrent reload.
450    let rel = state.current();
451
452    let (parts, body) = req.into_parts();
453    // Collect the body for inspection AND keep any trailers (gRPC carries `grpc-status` in
454    // HTTP/2 trailers); they are relayed to the backend, never inspected.
455    let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
456
457    let mut ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
458
459    // Connection-phase modules (rate limiting) run BEFORE normalization, so
460    // flood traffic is rejected without paying for Fase 2 parsing.
461    let connection_verdict = rel.pipeline.run_connection(&mut ctx);
462    if let Some(denied) = deny_response(&ctx, connection_verdict) {
463        return Ok(denied);
464    }
465
466    // Parser-limit policy (Fase 6 / Pillar 2): on a normalization failure
467    // (limits exceeded / malformed input) `fail_closed` → 400; `fail_open` →
468    // forward UNINSPECTED (logged loudly), trading inspection for availability.
469    let normalized_ok = match rel.normalizer.normalize(&mut ctx) {
470        Ok(()) => true,
471        Err(e) => match rel.resilience.on_parser_limit {
472            FailMode::FailClosed => {
473                warn!(
474                    request_id = %ctx.request_id,
475                    error = %e,
476                    policy = ?FailMode::FailClosed,
477                    "normalization failed: rejecting (on_parser_limit)"
478                );
479                return Ok((
480                    Response::builder()
481                        .status(400)
482                        .body(full_body("Bad Request"))
483                        .unwrap(),
484                    Outcome::BadRequest,
485                ));
486            }
487            FailMode::FailOpen => {
488                warn!(
489                    request_id = %ctx.request_id,
490                    error = %e,
491                    policy = ?FailMode::FailOpen,
492                    "normalization failed: forwarding UNINSPECTED (on_parser_limit)"
493                );
494                false
495            }
496        },
497    };
498
499    let path_and_query = parts
500        .uri
501        .path_and_query()
502        .map(|pq| pq.as_str())
503        .unwrap_or("/")
504        .to_string();
505
506    info!(
507        request_id = %ctx.request_id,
508        method = %ctx.method,
509        path = %path_and_query,
510        client_ip = %ctx.client_ip,
511        "→ request"
512    );
513
514    // Skip inspection when normalization failed under fail_open (no canonical
515    // data to inspect); the request is forwarded uninspected.
516    if normalized_ok {
517        // Fast-path (Fase 7 / Pillar 3): the prefilter decides whether any content
518        // rule *could* match the canonical surface. If not, `run_inspection_gated`
519        // skips inspection and returns Allow with an identical decision log. Sound
520        // by construction (the scope-aware union is the OR of every active rule);
521        // equivalence is proven on the corpus oracle through this same gate.
522        let inspect = rel.prefilter.is_candidate(&ctx);
523        let inspection_verdict = rel.pipeline.run_inspection_gated(&mut ctx, inspect);
524        if let Some(denied) = deny_response(&ctx, inspection_verdict) {
525            return Ok(denied);
526        }
527    }
528
529    forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
530}
531
532/// The SINGLE forwarding path. Both the inspecting handler (`try_forward`) and the
533/// `#[doc(hidden)]` passthrough seam (`try_passthrough`) call it, so the (c) load-test's
534/// no-WAF leg cannot drift from production forwarding — the §13 duplicate-path risk is
535/// removed at the root, not mitigated. Behaviour is unchanged vs the inlined version
536/// (proven by the `passthrough_*` integration tests, green before and after the extract).
537// Forwarding intrinsically threads many request facets (config snapshot, parts, payload +
538// trailers, peer, context); bundling them into a struct would only move the list, not
539// shorten the data this single forwarding path needs.
540#[allow(clippy::too_many_arguments)]
541async fn forward_to_backend(
542    state: &StaticState,
543    rel: &Reloadable,
544    parts: &hyper::http::request::Parts,
545    path_and_query: &str,
546    body_bytes: Bytes,
547    req_trailers: Option<HeaderMap>,
548    client_addr: SocketAddr,
549    ctx: &RequestContext,
550) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
551    let backend_uri: Uri = format!("{}{}", rel.backend, path_and_query).parse()?;
552    let is_grpc = is_grpc_request(parts);
553
554    let mut builder = Request::builder()
555        .method(parts.method.clone())
556        .uri(backend_uri);
557
558    for (name, value) in &parts.headers {
559        if !HOP_BY_HOP.contains(&name.as_str()) {
560            builder = builder.header(name, value);
561        }
562    }
563    // XFF hop record: append the address THIS proxy actually saw (the peer), not
564    // the resolved client IP — that would corrupt the forwarded chain semantics.
565    builder = builder.header("x-forwarded-for", client_addr.ip().to_string());
566    builder = builder.header("x-request-id", ctx.request_id.as_str());
567    // gRPC requires `TE: trailers` on the request (stripped above as hop-by-hop) so the
568    // backend negotiates trailer delivery — re-add it for gRPC targets only.
569    if is_grpc {
570        builder = builder.header("te", "trailers");
571    }
572
573    // gRPC: relay the request trailers and forward over the dedicated h2c client. Non-gRPC:
574    // a plain `Full` body over the existing h1 client — byte-identical to before.
575    let (client, fwd_body) = if is_grpc {
576        (&state.grpc_client, body_with_trailers(body_bytes, req_trailers))
577    } else {
578        (&state.client, full_body(body_bytes))
579    };
580    let fwd_req = builder.body(fwd_body)?;
581
582    // Upstream round-trip under a hard timeout so a stalled origin cannot pin the
583    // worker. Connection/timeout failures apply on_upstream_error (502/503),
584    // returned here rather than bubbling to the generic 502 in `handle`.
585    let upstream = tokio::time::timeout(rel.resilience.upstream_timeout(), async {
586        let resp = client.request(fwd_req).await?;
587        let (resp_parts, resp_body) = resp.into_parts();
588        // Keep the response trailers (gRPC `grpc-status`/`grpc-message`); they are relayed,
589        // not inspected. A non-gRPC h1 response has none → `None` → a plain body downstream.
590        let (resp_bytes, resp_trailers) = collect_with_trailers(resp_body).await?;
591        Ok::<_, Box<dyn std::error::Error + Send + Sync>>((resp_parts, resp_bytes, resp_trailers))
592    })
593    .await;
594
595    let (resp_parts, resp_bytes, resp_trailers) = match upstream {
596        Ok(Ok(triple)) => triple,
597        Ok(Err(e)) => {
598            return Ok((
599                upstream_error_response(ctx, &rel.resilience, &e.to_string()),
600                Outcome::UpstreamError,
601            ))
602        }
603        Err(_elapsed) => {
604            return Ok((
605                upstream_error_response(ctx, &rel.resilience, "upstream timeout"),
606                Outcome::UpstreamError,
607            ))
608        }
609    };
610
611    info!(
612        request_id = %ctx.request_id,
613        status = %resp_parts.status,
614        score = ctx.score,
615        "← response"
616    );
617
618    Ok((
619        Response::from_parts(resp_parts, body_with_trailers(resp_bytes, resp_trailers)),
620        Outcome::Allowed,
621    ))
622}
623
624/// `#[doc(hidden)]` passthrough seam: build the context and forward, SKIPPING the
625/// connection phase, normalization and inspection. The WAF-overhead delta the (c)
626/// load-test publishes = (inspecting leg) − (this leg) = normalize + detect, measured
627/// against the identical `forward_to_backend`. `build_context` runs in BOTH legs (shared
628/// proxy machinery) so it cancels in the delta. Reached only via `bind_passthrough`; no
629/// `config.toml` field selects it.
630async fn try_passthrough(
631    req: Request<Incoming>,
632    state: &StaticState,
633    client_addr: SocketAddr,
634) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
635    let rel = state.current();
636    let (parts, body) = req.into_parts();
637    let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
638    let ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
639    let path_and_query = parts
640        .uri
641        .path_and_query()
642        .map(|pq| pq.as_str())
643        .unwrap_or("/")
644        .to_string();
645    forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
646}
647
648async fn handle(
649    req: Request<Incoming>,
650    state: Arc<StaticState>,
651    client_addr: SocketAddr,
652) -> Result<Response<HyperBoxBody>, Infallible> {
653    // Dispatch on the (config-unreachable) handler mode. `Inspect` is production; the
654    // `try_forward` decision path is unchanged. `Passthrough` is the bench seam.
655    let start = Instant::now();
656    let result = match state.mode {
657        HandlerMode::Inspect => try_forward(req, &state, client_addr).await,
658        HandlerMode::Passthrough => try_passthrough(req, &state, client_addr).await,
659    };
660    // Single recording point (pure side effect): the inner path classifies the Outcome;
661    // an unexpected error here is the WAF's OWN failure → `internal_error`, distinct from the
662    // structured upstream 502/503 already classified inside `forward_to_backend`.
663    let (resp, outcome) = match result {
664        Ok((resp, outcome)) => (resp, outcome),
665        Err(e) => {
666            error!(error = %e, client_ip = %client_addr.ip(), "forwarding error");
667            let resp = Response::builder()
668                .status(502)
669                .body(full_body("Bad Gateway"))
670                .unwrap();
671            (resp, Outcome::InternalError)
672        }
673    };
674    state.metrics.record(outcome, start.elapsed());
675    Ok(resp)
676}
677
678pub struct Proxy {
679    listener: TcpListener,
680    state: Arc<StaticState>,
681    /// Dedicated `/metrics` listener (`Some` ⇒ `[metrics].enabled`). Bound at `bind` for
682    /// fail-fast; the server task is spawned by `run`. NEVER the data port (serving internal
683    /// posture there would be an info leak and would be inspected by the WAF itself).
684    metrics_listener: Option<TcpListener>,
685}
686
687/// Build the enabled built-in modules from config. The rate limiter is given the
688/// SHARED bucket store so its throttle state survives a reload.
689fn build_modules(config: &Config, rl_state: &RateLimitState) -> Vec<Box<dyn WafModule>> {
690    let mut modules: Vec<Box<dyn WafModule>> = vec![Box::new(NoopLogger)];
691    // Framing validation runs first among Connection-phase modules: illegal
692    // framing is refused before it is even counted against the rate limit.
693    if config.modules.request_smuggling.enabled {
694        modules.push(Box::new(RequestSmugglingModule::new()));
695    }
696    if config.rate_limit.enabled {
697        modules.push(Box::new(RateLimitModule::with_state(rl_state.clone())));
698    }
699    if config.modules.sqli.enabled {
700        modules.push(Box::new(SqliModule::new()));
701    }
702    if config.modules.xss.enabled {
703        modules.push(Box::new(XssModule::new()));
704    }
705    if config.modules.path_traversal.enabled {
706        modules.push(Box::new(PathTraversalModule::new()));
707    }
708    if config.modules.rce.enabled {
709        modules.push(Box::new(RceModule::new()));
710    }
711    if config.modules.lfi_rfi.enabled {
712        modules.push(Box::new(LfiRfiModule::new()));
713    }
714    if config.modules.ssrf.enabled {
715        modules.push(Box::new(SsrfModule::new()));
716    }
717    if config.modules.ldap.enabled {
718        modules.push(Box::new(LdapModule::new()));
719    }
720    if config.modules.nosql.enabled {
721        modules.push(Box::new(NosqlModule::new()));
722    }
723    if config.modules.mail.enabled {
724        modules.push(Box::new(MailModule::new()));
725    }
726    if config.modules.ssti.enabled {
727        modules.push(Box::new(SstiModule::new()));
728    }
729    if config.modules.scanner.enabled {
730        modules.push(Box::new(ScannerModule::new()));
731    }
732    if config.modules.ssi.enabled {
733        modules.push(Box::new(SsiModule::new()));
734    }
735    if config.modules.xxe.enabled {
736        modules.push(Box::new(XxeModule::new()));
737    }
738    if config.modules.header_injection.enabled {
739        modules.push(Box::new(HeaderInjectionModule::new()));
740    }
741    if config.modules.evasion.enabled {
742        modules.push(Box::new(EvasionModule::new()));
743    }
744    if config.modules.graphql.enabled {
745        modules.push(Box::new(GraphqlModule::new()));
746    }
747    if config.modules.grpc.enabled {
748        modules.push(Box::new(GrpcModule::new()));
749    }
750    if config.modules.crs.enabled {
751        modules.push(Box::new(load_crs_module(&config.modules.crs.files)));
752    }
753    if config.modules.wasm.enabled {
754        for plugin in &config.modules.wasm.plugins {
755            if let Some(m) = load_wasm_plugin(plugin, &config.modules.wasm) {
756                modules.push(Box::new(m));
757            }
758        }
759    }
760    modules
761}
762
763/// Load one Proxy-Wasm plugin. A plugin whose file is unreadable or whose `.wasm` cannot be
764/// compiled/instantiated is logged loudly and skipped (fail-open at LOAD, like CRS — the
765/// runtime posture is fail-closed per request). The import report is logged so the operator
766/// sees the coverage, and a plugin relying on stubbed (semantic) host calls is flagged
767/// **DEGRADED** but still loaded — the operator decides (policy D3=A, paletto #4).
768fn load_wasm_plugin(
769    plugin: &waf_core::WasmPluginConfig,
770    cfg: &waf_core::WasmConfig,
771) -> Option<WasmModule> {
772    let bytes = match std::fs::read(&plugin.path) {
773        Ok(b) => b,
774        Err(e) => {
775            error!(file = %plugin.path, error = %e, "WASM: cannot read plugin (skipped)");
776            return None;
777        }
778    };
779    let name = plugin_name(&plugin.path);
780    let opts = WasmOptions {
781        pool_size: cfg.pool_size,
782        fuel_per_request: cfg.fuel_per_request,
783        max_memory_bytes: cfg.max_memory_bytes,
784        checkout_timeout: std::time::Duration::from_millis(cfg.checkout_timeout_ms),
785    };
786    let config_bytes = plugin.config.as_deref().unwrap_or("").as_bytes();
787    match WasmModule::from_bytes(&name, &bytes, config_bytes, &opts) {
788        Ok((module, report)) => {
789            // Informational at boot; the loud "degraded" signal is emitted at runtime the
790            // first time the plugin actually invokes a stubbed semantic host call.
791            info!(plugin = %name, "{}", report.summary());
792            Some(module)
793        }
794        Err(e) => {
795            error!(file = %plugin.path, error = %e, "WASM: plugin failed to load (skipped)");
796            None
797        }
798    }
799}
800
801/// Derive a short plugin name from its path (file stem), for log correlation and `rule_id`.
802fn plugin_name(path: &str) -> String {
803    std::path::Path::new(path)
804        .file_stem()
805        .and_then(|s| s.to_str())
806        .unwrap_or("plugin")
807        .to_string()
808}
809
810/// Read the configured CRS `seclang` files (in order), concatenate them and build the
811/// [`CrsModule`]. An unreadable file is logged loudly and skipped — CRS is an additive
812/// detection layer (default off), so a missing import file fails open (consistent with
813/// `resilience.on_config_error` = fail-open) rather than taking down the proxy; the boot
814/// log makes the gap explicit. The loaded/skipped report and the skipped-rule reasons are
815/// logged so the operator sees exactly what coverage they got (policy D3=A).
816fn load_crs_module(files: &[String]) -> CrsModule {
817    let mut combined = String::new();
818    for path in files {
819        match std::fs::read_to_string(path) {
820            Ok(text) => {
821                combined.push_str(&text);
822                combined.push('\n');
823            }
824            Err(e) => error!(file = %path, error = %e, "CRS import: cannot read file (skipped)"),
825        }
826    }
827    let module = CrsModule::from_source(&combined);
828    info!(files = files.len(), "{}", module.report());
829    if !module.skipped().is_empty() {
830        warn!(
831            skipped = module.skipped().len(),
832            "CRS import: some rules fall outside the supported subset (see debug logs for reasons)"
833        );
834        for s in module.skipped() {
835            debug!(id = ?s.id, line = s.line_no, reason = %s.reason, "CRS import: rule skipped");
836        }
837    }
838    module
839}
840
841/// Build the full config-derived state as a unit (rules recompiled, CIDR
842/// re-parsed). Used at startup AND on every reload, so reload gets exactly the
843/// same construction path — no mixed state. `extra` modules are appended after the
844/// built-ins (test seam; they are NOT carried across a reload).
845fn build_reloadable(
846    config: &Config,
847    rl_state: RateLimitState,
848    extra: Vec<Box<dyn WafModule>>,
849) -> Reloadable {
850    let mut modules = build_modules(config, &rl_state);
851    modules.extend(extra);
852    let pipeline = Pipeline::new(config, modules);
853
854    // PL4 is "empty but legal": warn that a paranoia_level above the highest
855    // shipped rule activates no extra rules (forward-compatible).
856    if config.waf.paranoia_level > waf_detection::HIGHEST_RULE_PARANOIA {
857        warn!(
858            paranoia_level = config.waf.paranoia_level,
859            highest_rule_paranoia = waf_detection::HIGHEST_RULE_PARANOIA,
860            "paranoia_level exceeds the highest existing rule paranoia: no additional rules are activated"
861        );
862    }
863    let ip_resolver = ClientIpResolver::from_config(&config.network);
864    if ip_resolver.trusted_count() < config.network.trusted_proxies.len() {
865        warn!(
866            configured = config.network.trusted_proxies.len(),
867            valid = ip_resolver.trusted_count(),
868            "some trusted_proxies CIDR entries were invalid and skipped"
869        );
870    }
871
872    Reloadable {
873        backend: config.proxy.backend.trim_end_matches('/').to_string(),
874        normalizer: Normalizer::new(&config.limits),
875        pipeline,
876        // Same construction point + config snapshot as the pipeline above.
877        prefilter: ContentPrefilter::new(config.waf.paranoia_level),
878        ip_resolver,
879        resilience: config.resilience,
880    }
881}
882
883impl Proxy {
884    /// Bind a proxy from config with the default extension surface (built-in
885    /// modules, in-memory rate-limit store, file-based TLS cert). For embedding —
886    /// injecting a custom store or extra modules — use [`Proxy::builder`].
887    pub async fn bind(config: &Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
888        Self::builder(config).build().await
889    }
890
891    /// Start configuring a proxy with injectable extension points (the stable
892    /// embedding API): extra detection modules and the rate-limit [`StateStore`].
893    /// Every seam has a default, so `Proxy::builder(cfg).build()` equals
894    /// [`Proxy::bind`].
895    pub fn builder(config: &Config) -> ProxyBuilder<'_> {
896        ProxyBuilder {
897            config,
898            modules: Vec::new(),
899            state_store: None,
900            cert_source: None,
901            module_factory: None,
902            mode: HandlerMode::Inspect,
903        }
904    }
905
906    /// Bind with extra detection modules appended after the built-in set.
907    ///
908    /// Internal seam kept for integration tests (inject a panicking module to verify
909    /// Pillar-2 isolation). The stable public equivalent is
910    /// `Proxy::builder(cfg).modules(..).build()`.
911    #[doc(hidden)]
912    pub async fn bind_with_modules(
913        config: &Config,
914        extra: Vec<Box<dyn WafModule>>,
915    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
916        Self::bind_inner(config, extra, HandlerMode::Inspect, None, None, None).await
917    }
918
919    /// `#[doc(hidden)]` bench seam: bind a proxy that FORWARDS WITHOUT inspecting (no
920    /// connection phase, no normalization, no detection) — the no-WAF leg of the Fase 9
921    /// (c) load-test, sharing `forward_to_backend` with the real path. Not a production
922    /// surface: no `config.toml` field selects it, only this constructor does.
923    #[doc(hidden)]
924    pub async fn bind_passthrough(
925        config: &Config,
926    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
927        Self::bind_inner(config, Vec::new(), HandlerMode::Passthrough, None, None, None).await
928    }
929
930    async fn bind_inner(
931        config: &Config,
932        extra: Vec<Box<dyn WafModule>>,
933        mode: HandlerMode,
934        state_store: Option<RateLimitState>,
935        cert_source: Option<Arc<dyn TlsCertSource>>,
936        module_factory: Option<Arc<ModuleFactory>>,
937    ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
938        let listener = TcpListener::bind(config.proxy.listen).await?;
939        let listen_addr = listener.local_addr()?;
940        let client: Client<HttpConnector, HyperBoxBody> =
941            Client::builder(TokioExecutor::new()).build(HttpConnector::new());
942        // Dedicated h2c client for gRPC backends (prior-knowledge HTTP/2 over cleartext).
943        let grpc_client: Client<HttpConnector, HyperBoxBody> =
944            Client::builder(TokioExecutor::new()).http2_only(true).build(HttpConnector::new());
945
946        // Build the TLS terminator BEFORE serving: a required cert that cannot be loaded
947        // is a fatal boot error (fail-closed), never a silent downgrade to cleartext. An
948        // injected cert source (e.g. enterprise ACME/mTLS) replaces the default file source.
949        let tls_acceptor = tls::acceptor_from_source(&config.tls, cert_source)
950            .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
951        if tls_acceptor.is_some() {
952            info!(listen = %listen_addr, alpn = ?config.tls.alpn, "TLS termination enabled");
953        }
954
955        // The rate-limiter bucket store lives here (process lifetime), shared into
956        // every (re)built pipeline so reloads never reset the throttle. The
957        // tracked-key cap is fixed at boot (the store outlives reloads). An injected
958        // store (e.g. enterprise Redis) replaces the default in-memory one.
959        let rl_state = state_store
960            .unwrap_or_else(|| RateLimitState::in_memory(config.rate_limit.max_tracked_keys));
961
962        // Injected modules (core 0.3): the static `.add_module` extras first, then the factory's
963        // output. The factory is the SINGLE source of reload-surviving modules, so it also runs
964        // at boot here — a boot-time error is fatal (fail-closed), the same posture an embedder
965        // had when it built these modules inline before passing them in.
966        let mut extra_total = extra;
967        if let Some(factory) = &module_factory {
968            extra_total.extend(factory()?);
969        }
970        let reloadable = build_reloadable(config, rl_state.clone(), extra_total);
971
972        // Metrics (B1): a dedicated `/metrics` listener bound here for fail-fast (a busy
973        // port is a boot error, never a silent miss). Loopback by default; NEVER the data
974        // port. Counters live process-wide and survive reloads.
975        let metrics = Arc::new(Metrics::new());
976        let metrics_listener = if config.metrics.enabled {
977            let l = TcpListener::bind(config.metrics.listen).await?;
978            info!(listen = %l.local_addr()?, "metrics endpoint enabled (/metrics)");
979            Some(l)
980        } else {
981            None
982        };
983
984        Ok(Self {
985            listener,
986            state: Arc::new(StaticState {
987                client,
988                grpc_client,
989                listen_addr,
990                rl_state,
991                current: RwLock::new(Arc::new(reloadable)),
992                mode,
993                tls_acceptor,
994                metrics,
995                module_factory,
996            }),
997            metrics_listener,
998        })
999    }
1000
1001    /// A cheap, cloneable handle to hot-reload this proxy's configuration.
1002    /// Obtain it before `run()` (which consumes `self`); the binary wires it to
1003    /// SIGHUP, tests call `reload_from` directly.
1004    pub fn reloader(&self) -> Reloader {
1005        Reloader(Arc::clone(&self.state))
1006    }
1007
1008    pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
1009        self.listener.local_addr()
1010    }
1011
1012    /// Address of the metrics endpoint, when `[metrics].enabled` (tests/operability).
1013    pub fn metrics_addr(&self) -> Option<SocketAddr> {
1014        self.metrics_listener.as_ref().and_then(|l| l.local_addr().ok())
1015    }
1016
1017    /// Serve forever (until an accept error). Equivalent to [`Self::run_with_shutdown`] with a
1018    /// signal that never fires — the historical behaviour.
1019    pub async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1020        self.run_with_shutdown(std::future::pending::<()>()).await
1021    }
1022
1023    /// Serve until `shutdown` resolves, then **gracefully drain** (core 0.5.4 seam).
1024    ///
1025    /// On the signal the listener stops accepting NEW connections and each in-flight connection
1026    /// is told to finish its current exchange (keep-alive disabled) and close; the method returns
1027    /// once every connection has drained. An embedder wires this to `SIGTERM` so a Kubernetes
1028    /// rolling update loses no in-flight request (the caller bounds total drain time — e.g.
1029    /// `terminationGracePeriodSeconds`). Additive: `run()` delegates here with a never-firing
1030    /// signal, so existing behaviour is unchanged.
1031    pub async fn run_with_shutdown(
1032        self,
1033        shutdown: impl Future<Output = ()> + Send,
1034    ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1035        // Spawn the metrics server (B1) on its dedicated listener, if enabled. It shares the
1036        // process-wide `Metrics` with the datapath and is wholly separate from data serving.
1037        if let Some(metrics_listener) = self.metrics_listener {
1038            let metrics = Arc::clone(&self.state.metrics);
1039            tokio::spawn(serve_metrics(metrics_listener, metrics));
1040        }
1041
1042        // `drain` broadcasts the shutdown to every live connection; `conns` counts in-flight
1043        // connections so we can wait for a real quiescence (not just "stopped accepting").
1044        let (drain_tx, drain_rx) = watch::channel(false);
1045        let conns = Arc::new(ConnTracker::default());
1046        let mut shutdown = std::pin::pin!(shutdown);
1047
1048        loop {
1049            let (stream, client_addr) = tokio::select! {
1050                accepted = self.listener.accept() => accepted?,
1051                _ = &mut shutdown => break,
1052            };
1053            let state = Arc::clone(&self.state);
1054            let drain_rx = drain_rx.clone();
1055            let conns = Arc::clone(&conns);
1056            conns.enter();
1057
1058            tokio::spawn(async move {
1059                // When TLS is enabled, complete the handshake first; a handshake error is
1060                // logged and the connection dropped (non-fatal — the listener stays up).
1061                // Then serve h1/h2 (TLS by ALPN, cleartext by preface) via the auto Builder.
1062                // The acceptor is Arc-backed → cheap clone, frees `state` to move into serve.
1063                match state.tls_acceptor.clone() {
1064                    Some(acceptor) => match acceptor.accept(stream).await {
1065                        Ok(tls_stream) => {
1066                            serve_connection(TokioIo::new(tls_stream), state, client_addr, drain_rx)
1067                                .await;
1068                        }
1069                        Err(e) => {
1070                            warn!(error = %e, client_ip = %client_addr.ip(), "TLS handshake error");
1071                        }
1072                    },
1073                    None => {
1074                        serve_connection(TokioIo::new(stream), state, client_addr, drain_rx).await;
1075                    }
1076                }
1077                conns.leave();
1078            });
1079        }
1080
1081        // Drain: stop accepting (listener dropped below), signal live connections to finish, and
1082        // wait for them to close. The caller bounds the total time (k8s SIGKILLs on overrun).
1083        info!("shutdown signal received; draining in-flight connections");
1084        drop(self.listener);
1085        let _ = drain_tx.send(true);
1086        conns.wait_idle().await;
1087        info!("drain complete; all connections closed");
1088        Ok(())
1089    }
1090}
1091
1092/// Counts in-flight connections so `run_with_shutdown` can wait for real quiescence.
1093#[derive(Default)]
1094struct ConnTracker {
1095    count: AtomicUsize,
1096    idle: Notify,
1097}
1098
1099impl ConnTracker {
1100    fn enter(&self) {
1101        self.count.fetch_add(1, Ordering::AcqRel);
1102    }
1103    fn leave(&self) {
1104        if self.count.fetch_sub(1, Ordering::AcqRel) == 1 {
1105            self.idle.notify_waiters();
1106        }
1107    }
1108    async fn wait_idle(&self) {
1109        loop {
1110            // Arm the notification BEFORE re-reading the count to avoid a lost wakeup.
1111            let armed = self.idle.notified();
1112            if self.count.load(Ordering::Acquire) == 0 {
1113                return;
1114            }
1115            armed.await;
1116        }
1117    }
1118}
1119
1120/// Stable builder for embedding the proxy with custom extension points. Obtain it
1121/// via [`Proxy::builder`]. Every seam defaults to the built-in behaviour, so a
1122/// builder with no overrides is identical to [`Proxy::bind`]. The enterprise plugs
1123/// a distributed rate-limit store or premium modules here **without forking**
1124/// (BOUNDARY §4).
1125pub struct ProxyBuilder<'a> {
1126    config: &'a Config,
1127    modules: Vec<Box<dyn WafModule>>,
1128    state_store: Option<RateLimitState>,
1129    cert_source: Option<Arc<dyn TlsCertSource>>,
1130    module_factory: Option<Arc<ModuleFactory>>,
1131    mode: HandlerMode,
1132}
1133
1134impl<'a> ProxyBuilder<'a> {
1135    /// Replace the extra detection modules appended after the built-in set. These
1136    /// run after the built-ins and are NOT carried across a config reload.
1137    pub fn modules(mut self, modules: Vec<Box<dyn WafModule>>) -> Self {
1138        self.modules = modules;
1139        self
1140    }
1141
1142    /// Append a single extra detection module (additive over [`Self::modules`]).
1143    pub fn add_module(mut self, module: Box<dyn WafModule>) -> Self {
1144        self.modules.push(module);
1145        self
1146    }
1147
1148    /// Inject the rate-limit [`StateStore`] (e.g. a distributed Redis store). The
1149    /// store survives config reloads. Defaults to the in-memory token bucket sized
1150    /// from `[rate_limit].max_tracked_keys`.
1151    pub fn state_store(mut self, store: Arc<dyn StateStore>) -> Self {
1152        self.state_store = Some(RateLimitState::with_store(store));
1153        self
1154    }
1155
1156    /// Inject the [`TlsCertSource`] (e.g. enterprise ACME/managed-PKI/mTLS). `[tls].enabled`
1157    /// and `[tls].alpn` still come from config; the source only governs cert provenance, so
1158    /// the config `cert_path`/`key_path` are ignored when one is injected. Defaults to the
1159    /// OPEN `FileCertSource` reading those paths.
1160    pub fn cert_source(mut self, source: Arc<dyn TlsCertSource>) -> Self {
1161        self.cert_source = Some(source);
1162        self
1163    }
1164
1165    /// Inject a [`ModuleFactory`] that (re)builds the extra detection modules (core 0.3).
1166    /// Unlike [`Self::add_module`]/[`Self::modules`] (built once, dropped on a reload), the
1167    /// factory is re-run on every config reload, so injected modules SURVIVE a SIGHUP and are
1168    /// re-`init`'d. It runs at bind too (the single source of reload-surviving modules): a
1169    /// boot error is fatal, and a reload error aborts that reload and keeps the last-good
1170    /// modules. This is the seam an embedder uses to keep premium modules across reloads
1171    /// (BOUNDARY §4). Factory output is appended AFTER any static `.add_module` extras.
1172    pub fn module_factory<F>(mut self, factory: F) -> Self
1173    where
1174        F: Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
1175            + Send
1176            + Sync
1177            + 'static,
1178    {
1179        self.module_factory = Some(Arc::new(factory));
1180        self
1181    }
1182
1183    /// Bind the listener and construct the proxy with the chosen seams.
1184    pub async fn build(self) -> Result<Proxy, Box<dyn std::error::Error + Send + Sync>> {
1185        Proxy::bind_inner(
1186            self.config,
1187            self.modules,
1188            self.mode,
1189            self.state_store,
1190            self.cert_source,
1191            self.module_factory,
1192        )
1193        .await
1194    }
1195}
1196
1197/// Serve one connection with the auto (h1/h2) builder. Generic over the transport so the
1198/// SAME service runs over a plain `TcpStream` or a `TlsStream` — the protocol negotiation
1199/// (h1 vs h2/h2c) is entirely inside `auto::Builder`, and `handle()` stays protocol-neutral.
1200async fn serve_connection<I>(
1201    io: I,
1202    state: Arc<StaticState>,
1203    client_addr: SocketAddr,
1204    mut drain_rx: watch::Receiver<bool>,
1205) where
1206    I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
1207{
1208    let svc = service_fn(move |req| {
1209        let state = Arc::clone(&state);
1210        handle(req, state, client_addr)
1211    });
1212    let builder = auto::Builder::new(TokioExecutor::new());
1213    let conn = builder.serve_connection(io, svc);
1214    let mut conn = std::pin::pin!(conn);
1215    loop {
1216        tokio::select! {
1217            res = conn.as_mut() => {
1218                if let Err(e) = res {
1219                    warn!(error = %e, client_ip = %client_addr.ip(), "connection error");
1220                }
1221                break;
1222            }
1223            // On the drain signal, tell hyper to finish the in-flight exchange, disable
1224            // keep-alive, and close — then await the connection to completion.
1225            changed = drain_rx.changed() => {
1226                if changed.is_err() || *drain_rx.borrow() {
1227                    conn.as_mut().graceful_shutdown();
1228                    if let Err(e) = conn.as_mut().await {
1229                        warn!(error = %e, client_ip = %client_addr.ip(), "connection error during drain");
1230                    }
1231                    break;
1232                }
1233            }
1234        }
1235    }
1236}
1237
1238/// Serve the `/metrics` endpoint on its dedicated listener (B1). Plain h1; a scraper opens a
1239/// short connection, GETs `/metrics`, reads the text. Anything that is not `GET /metrics`
1240/// gets a 404 — no path reflection, no other surface.
1241async fn serve_metrics(listener: TcpListener, metrics: Arc<Metrics>) {
1242    loop {
1243        let Ok((stream, _)) = listener.accept().await else { continue };
1244        let metrics = Arc::clone(&metrics);
1245        tokio::spawn(async move {
1246            let svc = service_fn(move |req: Request<Incoming>| {
1247                let metrics = Arc::clone(&metrics);
1248                async move { Ok::<_, Infallible>(metrics_response(&req, &metrics)) }
1249            });
1250            let _ = hyper::server::conn::http1::Builder::new()
1251                .serve_connection(TokioIo::new(stream), svc)
1252                .await;
1253        });
1254    }
1255}
1256
1257/// `GET /metrics` → Prometheus text exposition; anything else → 404.
1258fn metrics_response(req: &Request<Incoming>, metrics: &Metrics) -> Response<HyperBoxBody> {
1259    if req.method() == hyper::Method::GET && req.uri().path() == "/metrics" {
1260        Response::builder()
1261            .status(200)
1262            .header("content-type", "text/plain; version=0.0.4; charset=utf-8")
1263            .body(full_body(metrics.render()))
1264            .unwrap()
1265    } else {
1266        Response::builder().status(404).body(full_body("Not Found")).unwrap()
1267    }
1268}
1269
1270#[cfg(test)]
1271mod tests {
1272    use super::*;
1273    use waf_core::WafMode;
1274
1275    #[test]
1276    fn hop_by_hop_includes_connection_and_host() {
1277        assert!(HOP_BY_HOP.contains(&"connection"));
1278        assert!(HOP_BY_HOP.contains(&"host"));
1279        assert!(HOP_BY_HOP.contains(&"transfer-encoding"));
1280    }
1281
1282    #[test]
1283    fn hop_by_hop_excludes_regular_headers() {
1284        assert!(!HOP_BY_HOP.contains(&"content-type"));
1285        assert!(!HOP_BY_HOP.contains(&"authorization"));
1286        assert!(!HOP_BY_HOP.contains(&"x-custom-header"));
1287    }
1288
1289    #[test]
1290    fn config_parses_from_toml() {
1291        let raw = r#"
1292[proxy]
1293listen = "127.0.0.1:8080"
1294backend = "http://localhost:3000"
1295
1296[waf]
1297mode = "detection-only"
1298block_threshold = 10
1299"#;
1300        let config: Config = toml::from_str(raw).unwrap();
1301        assert_eq!(config.proxy.backend, "http://localhost:3000");
1302        assert_eq!(config.waf.mode, WafMode::DetectionOnly);
1303        assert_eq!(config.waf.block_threshold, 10);
1304    }
1305
1306    #[test]
1307    fn config_uses_default_block_threshold_when_omitted() {
1308        let raw = r#"
1309[proxy]
1310listen = "127.0.0.1:8080"
1311backend = "http://localhost:3000"
1312
1313[waf]
1314mode = "detection-only"
1315"#;
1316        let config: Config = toml::from_str(raw).unwrap();
1317        assert_eq!(config.waf.block_threshold, 5);
1318    }
1319
1320    #[test]
1321    fn config_parses_network_section() {
1322        let raw = r#"
1323[proxy]
1324listen = "127.0.0.1:8080"
1325backend = "http://localhost:3000"
1326
1327[waf]
1328mode = "blocking"
1329
1330[network]
1331trusted_proxies = ["10.0.0.0/8", "::1"]
1332client_ip_header = "X-Forwarded-For"
1333trusted_hops = 2
1334"#;
1335        let config: Config = toml::from_str(raw).unwrap();
1336        assert_eq!(config.network.trusted_proxies, vec!["10.0.0.0/8", "::1"]);
1337        assert_eq!(config.network.client_ip_header, "X-Forwarded-For");
1338        assert_eq!(config.network.trusted_hops, 2);
1339    }
1340
1341    #[test]
1342    fn config_network_defaults_to_failsafe_when_absent() {
1343        let raw = r#"
1344[proxy]
1345listen = "127.0.0.1:8080"
1346backend = "http://localhost:3000"
1347
1348[waf]
1349mode = "detection-only"
1350"#;
1351        let config: Config = toml::from_str(raw).unwrap();
1352        assert!(config.network.trusted_proxies.is_empty());
1353        assert_eq!(config.network.trusted_hops, 1);
1354        assert_eq!(config.network.client_ip_header, "x-forwarded-for".to_string());
1355    }
1356
1357    #[test]
1358    fn config_rejects_unknown_mode() {
1359        let raw = r#"
1360[proxy]
1361listen = "127.0.0.1:8080"
1362backend = "http://localhost:3000"
1363
1364[waf]
1365mode = "unknown-mode"
1366"#;
1367        assert!(toml::from_str::<Config>(raw).is_err());
1368    }
1369
1370    #[test]
1371    fn parse_cookies_splits_on_semicolon() {
1372        let headers = vec![("cookie".to_string(), "session=abc; user=123".to_string())];
1373        let cookies = parse_cookies(&headers);
1374        assert_eq!(cookies.len(), 2);
1375        assert!(cookies.contains(&("session".to_string(), "abc".to_string())));
1376        assert!(cookies.contains(&("user".to_string(), "123".to_string())));
1377    }
1378
1379    #[test]
1380    fn parse_cookies_handles_missing_value() {
1381        let headers = vec![("cookie".to_string(), "flag=; token=xyz".to_string())];
1382        let cookies = parse_cookies(&headers);
1383        assert!(cookies.contains(&("flag".to_string(), "".to_string())));
1384        assert!(cookies.contains(&("token".to_string(), "xyz".to_string())));
1385    }
1386
1387    #[test]
1388    fn parse_cookies_handles_empty_header_list() {
1389        assert!(parse_cookies(&[]).is_empty());
1390    }
1391
1392    #[test]
1393    fn request_id_is_unique_per_call() {
1394        let id1 = next_request_id();
1395        let id2 = next_request_id();
1396        assert_ne!(id1, id2);
1397        assert!(id1.starts_with("req-"));
1398    }
1399}