1pub mod config;
5pub mod metrics;
6pub mod tls;
7
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::path::Path;
11use std::pin::Pin;
12use std::sync::atomic::{AtomicU64, Ordering};
13use std::sync::{Arc, RwLock};
14use std::task::{Context, Poll};
15use std::time::{Instant, SystemTime};
16
17use http_body_util::combinators::BoxBody;
18use http_body_util::{BodyExt, Full};
19use hyper::body::{Body, Bytes, Frame, Incoming};
20use hyper::service::service_fn;
21use hyper::{HeaderMap, Request, Response, Uri};
22use hyper_util::client::legacy::connect::HttpConnector;
23use hyper_util::client::legacy::Client;
24use hyper_util::rt::{TokioExecutor, TokioIo};
25use hyper_util::server::conn::auto;
26use tokio::net::TcpListener;
27use tokio_rustls::TlsAcceptor;
28
29use crate::metrics::{Metrics, Outcome};
30use tls::TlsCertSource;
31use tracing::{debug, error, info, warn};
32
33use waf_core::{
34 ClientIpResolver, Config, FailMode, IpSource, Normalized, RateLimitState, RequestContext,
35 ResilienceConfig, StateStore, WafModule,
36};
37use waf_detection::{
38 crs::CrsModule,
39 graphql::GraphqlModule, grpc::GrpcModule, header_injection::HeaderInjectionModule, ldap::LdapModule,
40 lfi_rfi::LfiRfiModule,
41 mail::MailModule, nosql::NosqlModule, path_traversal::PathTraversalModule,
42 rate_limit::RateLimitModule,
43 rce::RceModule, request_smuggling::RequestSmugglingModule, scanner::ScannerModule,
44 sqli::SqliModule, ssi::SsiModule, ssrf::SsrfModule, ssti::SstiModule, xss::XssModule,
45 xxe::XxeModule, ContentPrefilter,
46};
47use waf_normalizer::Normalizer;
48use waf_pipeline::{NoopLogger, Pipeline, PipelineVerdict};
49use waf_wasm::{WasmModule, WasmOptions};
50
51pub type HyperBoxBody = BoxBody<Bytes, hyper::Error>;
52
53pub type ModuleFactory =
60 dyn Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
61 + Send
62 + Sync;
63
64const HOP_BY_HOP: &[&str] = &[
66 "connection",
67 "host", "keep-alive",
69 "proxy-authenticate",
70 "proxy-authorization",
71 "te",
72 "trailers",
73 "transfer-encoding",
74 "upgrade",
75];
76
77static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(0);
78
79fn next_request_id() -> String {
80 let n = REQUEST_COUNTER.fetch_add(1, Ordering::Relaxed);
81 format!("req-{n:016x}")
82}
83
84pub fn full_body(data: impl Into<Bytes>) -> HyperBoxBody {
85 Full::new(data.into())
86 .map_err(|never| match never {})
87 .boxed()
88}
89
90struct FramedBody {
95 data: Option<Bytes>,
96 trailers: Option<HeaderMap>,
97}
98
99impl Body for FramedBody {
100 type Data = Bytes;
101 type Error = Infallible;
102
103 fn poll_frame(
104 mut self: Pin<&mut Self>,
105 _cx: &mut Context<'_>,
106 ) -> Poll<Option<Result<Frame<Bytes>, Infallible>>> {
107 if let Some(d) = self.data.take() {
108 return Poll::Ready(Some(Ok(Frame::data(d))));
109 }
110 if let Some(t) = self.trailers.take() {
111 return Poll::Ready(Some(Ok(Frame::trailers(t))));
112 }
113 Poll::Ready(None)
114 }
115}
116
117fn body_with_trailers(data: Bytes, trailers: Option<HeaderMap>) -> HyperBoxBody {
120 match trailers {
121 None => full_body(data),
122 Some(t) => FramedBody { data: Some(data), trailers: Some(t) }
123 .map_err(|never| match never {})
124 .boxed(),
125 }
126}
127
128async fn collect_with_trailers<B>(body: B) -> Result<(Bytes, Option<HeaderMap>), B::Error>
132where
133 B: Body<Data = Bytes>,
134{
135 let collected = body.collect().await?;
136 let trailers = collected.trailers().cloned();
137 Ok((collected.to_bytes(), trailers))
138}
139
140fn is_grpc_request(parts: &hyper::http::request::Parts) -> bool {
144 parts
145 .headers
146 .get(hyper::header::CONTENT_TYPE)
147 .and_then(|v| v.to_str().ok())
148 .map(|ct| ct.trim_start().starts_with("application/grpc"))
149 .unwrap_or(false)
150}
151
152fn parse_cookies(headers: &[(String, String)]) -> Vec<(String, String)> {
153 headers
154 .iter()
155 .filter(|(name, _)| name.eq_ignore_ascii_case("cookie"))
156 .flat_map(|(_, value)| {
157 value.split(';').filter_map(|pair| {
158 let mut parts = pair.splitn(2, '=');
159 let key = parts.next()?.trim().to_string();
160 let val = parts.next().unwrap_or("").trim().to_string();
161 Some((key, val))
162 })
163 })
164 .collect()
165}
166
167fn build_context(
168 parts: &hyper::http::request::Parts,
169 body: &Bytes,
170 client_addr: SocketAddr,
171 ip_resolver: &ClientIpResolver,
172) -> RequestContext {
173 let path = parts.uri.path().to_string();
174 let query = parts.uri.query().map(str::to_string);
175 let method = parts.method.to_string();
176 let http_version = format!("{:?}", parts.version);
177
178 let headers: Vec<(String, String)> = parts
179 .headers
180 .iter()
181 .filter_map(|(name, value)| {
182 value.to_str().ok().map(|v| (name.to_string(), v.to_string()))
183 })
184 .collect();
185
186 let cookies = parse_cookies(&headers);
187
188 let normalized = Normalized::default();
189
190 let request_id = next_request_id();
195 let resolved = ip_resolver.resolve(client_addr.ip(), &headers);
196 match resolved.source {
197 IpSource::FallbackMissingHeader | IpSource::FallbackMalformed => warn!(
198 request_id = %request_id,
199 peer = %client_addr.ip(),
200 source = ?resolved.source,
201 "client-IP resolution fell back to peer address"
202 ),
203 IpSource::DirectPeer | IpSource::TrustedHeader => {}
204 }
205
206 RequestContext {
207 client_ip: resolved.ip,
208 request_id,
209 timestamp: SystemTime::now(),
210 method,
211 path: path.clone(),
212 raw_path: path,
213 query,
214 http_version,
215 headers,
216 cookies,
217 body: body.clone(),
218 normalized,
219 score: 0,
220 score_contributions: vec![],
221 }
222}
223
224struct Reloadable {
228 backend: String,
229 normalizer: Normalizer,
230 pipeline: Pipeline,
231 prefilter: ContentPrefilter,
235 ip_resolver: ClientIpResolver,
236 resilience: ResilienceConfig,
237}
238
239struct StaticState {
246 client: Client<HttpConnector, HyperBoxBody>,
247 grpc_client: Client<HttpConnector, HyperBoxBody>,
251 listen_addr: SocketAddr,
252 rl_state: RateLimitState,
253 current: RwLock<Arc<Reloadable>>,
254 mode: HandlerMode,
255 tls_acceptor: Option<TlsAcceptor>,
259 metrics: Arc<Metrics>,
262 module_factory: Option<Arc<ModuleFactory>>,
267}
268
269#[derive(Clone, Copy)]
276enum HandlerMode {
277 Inspect,
278 Passthrough,
279}
280
281impl StaticState {
282 fn current(&self) -> Arc<Reloadable> {
287 self.current
288 .read()
289 .unwrap_or_else(|poisoned| poisoned.into_inner())
290 .clone()
291 }
292}
293
294#[derive(Clone)]
298pub struct Reloader(Arc<StaticState>);
299
300impl Reloader {
301 pub fn reload_from(&self, path: &Path) -> Result<(), config::LoadError> {
305 let new_cfg = match config::load(path) {
306 Ok(c) => c,
307 Err(e) => {
308 error!(error = %e, "config reload failed; keeping current configuration");
309 return Err(e);
310 }
311 };
312
313 if new_cfg.proxy.listen != self.0.listen_addr {
315 warn!(
316 current = %self.0.listen_addr,
317 requested = %new_cfg.proxy.listen,
318 "proxy.listen change requires a restart; keeping the current bind address"
319 );
320 }
321
322 let extra = match &self.0.module_factory {
329 Some(factory) => match factory() {
330 Ok(modules) => modules,
331 Err(e) => {
332 error!(error = %e, "module factory failed on reload; keeping current configuration");
333 return Err(config::LoadError::ModuleFactory(e.to_string()));
334 }
335 },
336 None => Vec::new(),
337 };
338
339 let new_reloadable = build_reloadable(&new_cfg, self.0.rl_state.clone(), extra);
342
343 *self
347 .0
348 .current
349 .write()
350 .unwrap_or_else(|poisoned| poisoned.into_inner()) = Arc::new(new_reloadable);
351 info!("configuration reloaded");
352 Ok(())
353 }
354}
355
356fn upstream_error_response(
361 ctx: &RequestContext,
362 resilience: &ResilienceConfig,
363 detail: &str,
364) -> Response<HyperBoxBody> {
365 let (status, body) = match resilience.on_upstream_error {
366 FailMode::FailClosed => (502, "Bad Gateway"),
367 FailMode::FailOpen => (503, "Service Unavailable"),
368 };
369 warn!(
370 request_id = %ctx.request_id,
371 client_ip = %ctx.client_ip,
372 status = status,
373 policy = ?resilience.on_upstream_error,
374 detail = detail,
375 "upstream error: applying on_upstream_error policy"
376 );
377 Response::builder().status(status).body(full_body(body)).unwrap()
378}
379
380fn contributions_json(ctx: &RequestContext) -> String {
386 serde_json::to_string(&ctx.score_contributions).unwrap_or_else(|_| "[]".to_string())
387}
388
389fn deny_response(
392 ctx: &RequestContext,
393 verdict: PipelineVerdict,
394) -> Option<(Response<HyperBoxBody>, Outcome)> {
395 match verdict {
396 PipelineVerdict::Allow => None,
397 PipelineVerdict::Block { rule_id, reason } => {
398 warn!(
399 request_id = %ctx.request_id,
400 rule_id = %rule_id,
401 reason = %reason,
402 score = ctx.score,
403 score_contributions = %contributions_json(ctx),
404 "request blocked"
405 );
406 Some((
407 Response::builder()
408 .status(403)
409 .body(full_body("Forbidden"))
410 .unwrap(),
411 Outcome::Blocked,
412 ))
413 }
414 PipelineVerdict::Reject { rule_id, reason, status, retry_after } => {
415 warn!(
416 request_id = %ctx.request_id,
417 rule_id = %rule_id,
418 reason = %reason,
419 status = status,
420 score_contributions = %contributions_json(ctx),
421 "request rejected"
422 );
423 let (body, outcome) = match status {
426 429 => ("Too Many Requests", Outcome::RateLimited),
427 400 => ("Bad Request", Outcome::BadRequest),
428 _ => ("Rejected", Outcome::BadRequest),
429 };
430 let mut builder = Response::builder().status(status);
431 if let Some(secs) = retry_after {
432 builder = builder.header("retry-after", secs.to_string());
433 }
434 Some((builder.body(full_body(body)).unwrap(), outcome))
435 }
436 }
437}
438
439async fn try_forward(
440 req: Request<Incoming>,
441 state: &StaticState,
442 client_addr: SocketAddr,
443) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
444 let rel = state.current();
447
448 let (parts, body) = req.into_parts();
449 let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
452
453 let mut ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
454
455 let connection_verdict = rel.pipeline.run_connection(&mut ctx);
458 if let Some(denied) = deny_response(&ctx, connection_verdict) {
459 return Ok(denied);
460 }
461
462 let normalized_ok = match rel.normalizer.normalize(&mut ctx) {
466 Ok(()) => true,
467 Err(e) => match rel.resilience.on_parser_limit {
468 FailMode::FailClosed => {
469 warn!(
470 request_id = %ctx.request_id,
471 error = %e,
472 policy = ?FailMode::FailClosed,
473 "normalization failed: rejecting (on_parser_limit)"
474 );
475 return Ok((
476 Response::builder()
477 .status(400)
478 .body(full_body("Bad Request"))
479 .unwrap(),
480 Outcome::BadRequest,
481 ));
482 }
483 FailMode::FailOpen => {
484 warn!(
485 request_id = %ctx.request_id,
486 error = %e,
487 policy = ?FailMode::FailOpen,
488 "normalization failed: forwarding UNINSPECTED (on_parser_limit)"
489 );
490 false
491 }
492 },
493 };
494
495 let path_and_query = parts
496 .uri
497 .path_and_query()
498 .map(|pq| pq.as_str())
499 .unwrap_or("/")
500 .to_string();
501
502 info!(
503 request_id = %ctx.request_id,
504 method = %ctx.method,
505 path = %path_and_query,
506 client_ip = %ctx.client_ip,
507 "→ request"
508 );
509
510 if normalized_ok {
513 let inspect = rel.prefilter.is_candidate(&ctx);
519 let inspection_verdict = rel.pipeline.run_inspection_gated(&mut ctx, inspect);
520 if let Some(denied) = deny_response(&ctx, inspection_verdict) {
521 return Ok(denied);
522 }
523 }
524
525 forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
526}
527
528#[allow(clippy::too_many_arguments)]
537async fn forward_to_backend(
538 state: &StaticState,
539 rel: &Reloadable,
540 parts: &hyper::http::request::Parts,
541 path_and_query: &str,
542 body_bytes: Bytes,
543 req_trailers: Option<HeaderMap>,
544 client_addr: SocketAddr,
545 ctx: &RequestContext,
546) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
547 let backend_uri: Uri = format!("{}{}", rel.backend, path_and_query).parse()?;
548 let is_grpc = is_grpc_request(parts);
549
550 let mut builder = Request::builder()
551 .method(parts.method.clone())
552 .uri(backend_uri);
553
554 for (name, value) in &parts.headers {
555 if !HOP_BY_HOP.contains(&name.as_str()) {
556 builder = builder.header(name, value);
557 }
558 }
559 builder = builder.header("x-forwarded-for", client_addr.ip().to_string());
562 builder = builder.header("x-request-id", ctx.request_id.as_str());
563 if is_grpc {
566 builder = builder.header("te", "trailers");
567 }
568
569 let (client, fwd_body) = if is_grpc {
572 (&state.grpc_client, body_with_trailers(body_bytes, req_trailers))
573 } else {
574 (&state.client, full_body(body_bytes))
575 };
576 let fwd_req = builder.body(fwd_body)?;
577
578 let upstream = tokio::time::timeout(rel.resilience.upstream_timeout(), async {
582 let resp = client.request(fwd_req).await?;
583 let (resp_parts, resp_body) = resp.into_parts();
584 let (resp_bytes, resp_trailers) = collect_with_trailers(resp_body).await?;
587 Ok::<_, Box<dyn std::error::Error + Send + Sync>>((resp_parts, resp_bytes, resp_trailers))
588 })
589 .await;
590
591 let (resp_parts, resp_bytes, resp_trailers) = match upstream {
592 Ok(Ok(triple)) => triple,
593 Ok(Err(e)) => {
594 return Ok((
595 upstream_error_response(ctx, &rel.resilience, &e.to_string()),
596 Outcome::UpstreamError,
597 ))
598 }
599 Err(_elapsed) => {
600 return Ok((
601 upstream_error_response(ctx, &rel.resilience, "upstream timeout"),
602 Outcome::UpstreamError,
603 ))
604 }
605 };
606
607 info!(
608 request_id = %ctx.request_id,
609 status = %resp_parts.status,
610 score = ctx.score,
611 "← response"
612 );
613
614 Ok((
615 Response::from_parts(resp_parts, body_with_trailers(resp_bytes, resp_trailers)),
616 Outcome::Allowed,
617 ))
618}
619
620async fn try_passthrough(
627 req: Request<Incoming>,
628 state: &StaticState,
629 client_addr: SocketAddr,
630) -> Result<(Response<HyperBoxBody>, Outcome), Box<dyn std::error::Error + Send + Sync>> {
631 let rel = state.current();
632 let (parts, body) = req.into_parts();
633 let (body_bytes, req_trailers) = collect_with_trailers(body).await?;
634 let ctx = build_context(&parts, &body_bytes, client_addr, &rel.ip_resolver);
635 let path_and_query = parts
636 .uri
637 .path_and_query()
638 .map(|pq| pq.as_str())
639 .unwrap_or("/")
640 .to_string();
641 forward_to_backend(state, &rel, &parts, &path_and_query, body_bytes, req_trailers, client_addr, &ctx).await
642}
643
644async fn handle(
645 req: Request<Incoming>,
646 state: Arc<StaticState>,
647 client_addr: SocketAddr,
648) -> Result<Response<HyperBoxBody>, Infallible> {
649 let start = Instant::now();
652 let result = match state.mode {
653 HandlerMode::Inspect => try_forward(req, &state, client_addr).await,
654 HandlerMode::Passthrough => try_passthrough(req, &state, client_addr).await,
655 };
656 let (resp, outcome) = match result {
660 Ok((resp, outcome)) => (resp, outcome),
661 Err(e) => {
662 error!(error = %e, client_ip = %client_addr.ip(), "forwarding error");
663 let resp = Response::builder()
664 .status(502)
665 .body(full_body("Bad Gateway"))
666 .unwrap();
667 (resp, Outcome::InternalError)
668 }
669 };
670 state.metrics.record(outcome, start.elapsed());
671 Ok(resp)
672}
673
674pub struct Proxy {
675 listener: TcpListener,
676 state: Arc<StaticState>,
677 metrics_listener: Option<TcpListener>,
681}
682
683fn build_modules(config: &Config, rl_state: &RateLimitState) -> Vec<Box<dyn WafModule>> {
686 let mut modules: Vec<Box<dyn WafModule>> = vec![Box::new(NoopLogger)];
687 if config.modules.request_smuggling.enabled {
690 modules.push(Box::new(RequestSmugglingModule::new()));
691 }
692 if config.rate_limit.enabled {
693 modules.push(Box::new(RateLimitModule::with_state(rl_state.clone())));
694 }
695 if config.modules.sqli.enabled {
696 modules.push(Box::new(SqliModule::new()));
697 }
698 if config.modules.xss.enabled {
699 modules.push(Box::new(XssModule::new()));
700 }
701 if config.modules.path_traversal.enabled {
702 modules.push(Box::new(PathTraversalModule::new()));
703 }
704 if config.modules.rce.enabled {
705 modules.push(Box::new(RceModule::new()));
706 }
707 if config.modules.lfi_rfi.enabled {
708 modules.push(Box::new(LfiRfiModule::new()));
709 }
710 if config.modules.ssrf.enabled {
711 modules.push(Box::new(SsrfModule::new()));
712 }
713 if config.modules.ldap.enabled {
714 modules.push(Box::new(LdapModule::new()));
715 }
716 if config.modules.nosql.enabled {
717 modules.push(Box::new(NosqlModule::new()));
718 }
719 if config.modules.mail.enabled {
720 modules.push(Box::new(MailModule::new()));
721 }
722 if config.modules.ssti.enabled {
723 modules.push(Box::new(SstiModule::new()));
724 }
725 if config.modules.scanner.enabled {
726 modules.push(Box::new(ScannerModule::new()));
727 }
728 if config.modules.ssi.enabled {
729 modules.push(Box::new(SsiModule::new()));
730 }
731 if config.modules.xxe.enabled {
732 modules.push(Box::new(XxeModule::new()));
733 }
734 if config.modules.header_injection.enabled {
735 modules.push(Box::new(HeaderInjectionModule::new()));
736 }
737 if config.modules.graphql.enabled {
738 modules.push(Box::new(GraphqlModule::new()));
739 }
740 if config.modules.grpc.enabled {
741 modules.push(Box::new(GrpcModule::new()));
742 }
743 if config.modules.crs.enabled {
744 modules.push(Box::new(load_crs_module(&config.modules.crs.files)));
745 }
746 if config.modules.wasm.enabled {
747 for plugin in &config.modules.wasm.plugins {
748 if let Some(m) = load_wasm_plugin(plugin, &config.modules.wasm) {
749 modules.push(Box::new(m));
750 }
751 }
752 }
753 modules
754}
755
756fn load_wasm_plugin(
762 plugin: &waf_core::WasmPluginConfig,
763 cfg: &waf_core::WasmConfig,
764) -> Option<WasmModule> {
765 let bytes = match std::fs::read(&plugin.path) {
766 Ok(b) => b,
767 Err(e) => {
768 error!(file = %plugin.path, error = %e, "WASM: cannot read plugin (skipped)");
769 return None;
770 }
771 };
772 let name = plugin_name(&plugin.path);
773 let opts = WasmOptions {
774 pool_size: cfg.pool_size,
775 fuel_per_request: cfg.fuel_per_request,
776 max_memory_bytes: cfg.max_memory_bytes,
777 checkout_timeout: std::time::Duration::from_millis(cfg.checkout_timeout_ms),
778 };
779 let config_bytes = plugin.config.as_deref().unwrap_or("").as_bytes();
780 match WasmModule::from_bytes(&name, &bytes, config_bytes, &opts) {
781 Ok((module, report)) => {
782 info!(plugin = %name, "{}", report.summary());
785 Some(module)
786 }
787 Err(e) => {
788 error!(file = %plugin.path, error = %e, "WASM: plugin failed to load (skipped)");
789 None
790 }
791 }
792}
793
794fn plugin_name(path: &str) -> String {
796 std::path::Path::new(path)
797 .file_stem()
798 .and_then(|s| s.to_str())
799 .unwrap_or("plugin")
800 .to_string()
801}
802
803fn load_crs_module(files: &[String]) -> CrsModule {
810 let mut combined = String::new();
811 for path in files {
812 match std::fs::read_to_string(path) {
813 Ok(text) => {
814 combined.push_str(&text);
815 combined.push('\n');
816 }
817 Err(e) => error!(file = %path, error = %e, "CRS import: cannot read file (skipped)"),
818 }
819 }
820 let module = CrsModule::from_source(&combined);
821 info!(files = files.len(), "{}", module.report());
822 if !module.skipped().is_empty() {
823 warn!(
824 skipped = module.skipped().len(),
825 "CRS import: some rules fall outside the supported subset (see debug logs for reasons)"
826 );
827 for s in module.skipped() {
828 debug!(id = ?s.id, line = s.line_no, reason = %s.reason, "CRS import: rule skipped");
829 }
830 }
831 module
832}
833
834fn build_reloadable(
839 config: &Config,
840 rl_state: RateLimitState,
841 extra: Vec<Box<dyn WafModule>>,
842) -> Reloadable {
843 let mut modules = build_modules(config, &rl_state);
844 modules.extend(extra);
845 let pipeline = Pipeline::new(config, modules);
846
847 if config.waf.paranoia_level > waf_detection::HIGHEST_RULE_PARANOIA {
850 warn!(
851 paranoia_level = config.waf.paranoia_level,
852 highest_rule_paranoia = waf_detection::HIGHEST_RULE_PARANOIA,
853 "paranoia_level exceeds the highest existing rule paranoia: no additional rules are activated"
854 );
855 }
856 let ip_resolver = ClientIpResolver::from_config(&config.network);
857 if ip_resolver.trusted_count() < config.network.trusted_proxies.len() {
858 warn!(
859 configured = config.network.trusted_proxies.len(),
860 valid = ip_resolver.trusted_count(),
861 "some trusted_proxies CIDR entries were invalid and skipped"
862 );
863 }
864
865 Reloadable {
866 backend: config.proxy.backend.trim_end_matches('/').to_string(),
867 normalizer: Normalizer::new(&config.limits),
868 pipeline,
869 prefilter: ContentPrefilter::new(config.waf.paranoia_level),
871 ip_resolver,
872 resilience: config.resilience,
873 }
874}
875
876impl Proxy {
877 pub async fn bind(config: &Config) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
881 Self::builder(config).build().await
882 }
883
884 pub fn builder(config: &Config) -> ProxyBuilder<'_> {
889 ProxyBuilder {
890 config,
891 modules: Vec::new(),
892 state_store: None,
893 cert_source: None,
894 module_factory: None,
895 mode: HandlerMode::Inspect,
896 }
897 }
898
899 #[doc(hidden)]
905 pub async fn bind_with_modules(
906 config: &Config,
907 extra: Vec<Box<dyn WafModule>>,
908 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
909 Self::bind_inner(config, extra, HandlerMode::Inspect, None, None, None).await
910 }
911
912 #[doc(hidden)]
917 pub async fn bind_passthrough(
918 config: &Config,
919 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
920 Self::bind_inner(config, Vec::new(), HandlerMode::Passthrough, None, None, None).await
921 }
922
923 async fn bind_inner(
924 config: &Config,
925 extra: Vec<Box<dyn WafModule>>,
926 mode: HandlerMode,
927 state_store: Option<RateLimitState>,
928 cert_source: Option<Arc<dyn TlsCertSource>>,
929 module_factory: Option<Arc<ModuleFactory>>,
930 ) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
931 let listener = TcpListener::bind(config.proxy.listen).await?;
932 let listen_addr = listener.local_addr()?;
933 let client: Client<HttpConnector, HyperBoxBody> =
934 Client::builder(TokioExecutor::new()).build(HttpConnector::new());
935 let grpc_client: Client<HttpConnector, HyperBoxBody> =
937 Client::builder(TokioExecutor::new()).http2_only(true).build(HttpConnector::new());
938
939 let tls_acceptor = tls::acceptor_from_source(&config.tls, cert_source)
943 .map_err(|e| Box::new(e) as Box<dyn std::error::Error + Send + Sync>)?;
944 if tls_acceptor.is_some() {
945 info!(listen = %listen_addr, alpn = ?config.tls.alpn, "TLS termination enabled");
946 }
947
948 let rl_state = state_store
953 .unwrap_or_else(|| RateLimitState::in_memory(config.rate_limit.max_tracked_keys));
954
955 let mut extra_total = extra;
960 if let Some(factory) = &module_factory {
961 extra_total.extend(factory()?);
962 }
963 let reloadable = build_reloadable(config, rl_state.clone(), extra_total);
964
965 let metrics = Arc::new(Metrics::new());
969 let metrics_listener = if config.metrics.enabled {
970 let l = TcpListener::bind(config.metrics.listen).await?;
971 info!(listen = %l.local_addr()?, "metrics endpoint enabled (/metrics)");
972 Some(l)
973 } else {
974 None
975 };
976
977 Ok(Self {
978 listener,
979 state: Arc::new(StaticState {
980 client,
981 grpc_client,
982 listen_addr,
983 rl_state,
984 current: RwLock::new(Arc::new(reloadable)),
985 mode,
986 tls_acceptor,
987 metrics,
988 module_factory,
989 }),
990 metrics_listener,
991 })
992 }
993
994 pub fn reloader(&self) -> Reloader {
998 Reloader(Arc::clone(&self.state))
999 }
1000
1001 pub fn local_addr(&self) -> std::io::Result<SocketAddr> {
1002 self.listener.local_addr()
1003 }
1004
1005 pub fn metrics_addr(&self) -> Option<SocketAddr> {
1007 self.metrics_listener.as_ref().and_then(|l| l.local_addr().ok())
1008 }
1009
1010 pub async fn run(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
1011 if let Some(metrics_listener) = self.metrics_listener {
1014 let metrics = Arc::clone(&self.state.metrics);
1015 tokio::spawn(serve_metrics(metrics_listener, metrics));
1016 }
1017 loop {
1018 let (stream, client_addr) = self.listener.accept().await?;
1019 let state = Arc::clone(&self.state);
1020
1021 tokio::spawn(async move {
1022 match state.tls_acceptor.clone() {
1027 Some(acceptor) => match acceptor.accept(stream).await {
1028 Ok(tls_stream) => {
1029 serve_connection(TokioIo::new(tls_stream), state, client_addr).await;
1030 }
1031 Err(e) => {
1032 warn!(error = %e, client_ip = %client_addr.ip(), "TLS handshake error");
1033 }
1034 },
1035 None => {
1036 serve_connection(TokioIo::new(stream), state, client_addr).await;
1037 }
1038 }
1039 });
1040 }
1041 }
1042}
1043
1044pub struct ProxyBuilder<'a> {
1050 config: &'a Config,
1051 modules: Vec<Box<dyn WafModule>>,
1052 state_store: Option<RateLimitState>,
1053 cert_source: Option<Arc<dyn TlsCertSource>>,
1054 module_factory: Option<Arc<ModuleFactory>>,
1055 mode: HandlerMode,
1056}
1057
1058impl<'a> ProxyBuilder<'a> {
1059 pub fn modules(mut self, modules: Vec<Box<dyn WafModule>>) -> Self {
1062 self.modules = modules;
1063 self
1064 }
1065
1066 pub fn add_module(mut self, module: Box<dyn WafModule>) -> Self {
1068 self.modules.push(module);
1069 self
1070 }
1071
1072 pub fn state_store(mut self, store: Arc<dyn StateStore>) -> Self {
1076 self.state_store = Some(RateLimitState::with_store(store));
1077 self
1078 }
1079
1080 pub fn cert_source(mut self, source: Arc<dyn TlsCertSource>) -> Self {
1085 self.cert_source = Some(source);
1086 self
1087 }
1088
1089 pub fn module_factory<F>(mut self, factory: F) -> Self
1097 where
1098 F: Fn() -> Result<Vec<Box<dyn WafModule>>, Box<dyn std::error::Error + Send + Sync>>
1099 + Send
1100 + Sync
1101 + 'static,
1102 {
1103 self.module_factory = Some(Arc::new(factory));
1104 self
1105 }
1106
1107 pub async fn build(self) -> Result<Proxy, Box<dyn std::error::Error + Send + Sync>> {
1109 Proxy::bind_inner(
1110 self.config,
1111 self.modules,
1112 self.mode,
1113 self.state_store,
1114 self.cert_source,
1115 self.module_factory,
1116 )
1117 .await
1118 }
1119}
1120
1121async fn serve_connection<I>(io: I, state: Arc<StaticState>, client_addr: SocketAddr)
1125where
1126 I: hyper::rt::Read + hyper::rt::Write + Unpin + Send + 'static,
1127{
1128 let svc = service_fn(move |req| {
1129 let state = Arc::clone(&state);
1130 handle(req, state, client_addr)
1131 });
1132 if let Err(e) = auto::Builder::new(TokioExecutor::new())
1133 .serve_connection(io, svc)
1134 .await
1135 {
1136 warn!(error = %e, client_ip = %client_addr.ip(), "connection error");
1137 }
1138}
1139
1140async fn serve_metrics(listener: TcpListener, metrics: Arc<Metrics>) {
1144 loop {
1145 let Ok((stream, _)) = listener.accept().await else { continue };
1146 let metrics = Arc::clone(&metrics);
1147 tokio::spawn(async move {
1148 let svc = service_fn(move |req: Request<Incoming>| {
1149 let metrics = Arc::clone(&metrics);
1150 async move { Ok::<_, Infallible>(metrics_response(&req, &metrics)) }
1151 });
1152 let _ = hyper::server::conn::http1::Builder::new()
1153 .serve_connection(TokioIo::new(stream), svc)
1154 .await;
1155 });
1156 }
1157}
1158
1159fn metrics_response(req: &Request<Incoming>, metrics: &Metrics) -> Response<HyperBoxBody> {
1161 if req.method() == hyper::Method::GET && req.uri().path() == "/metrics" {
1162 Response::builder()
1163 .status(200)
1164 .header("content-type", "text/plain; version=0.0.4; charset=utf-8")
1165 .body(full_body(metrics.render()))
1166 .unwrap()
1167 } else {
1168 Response::builder().status(404).body(full_body("Not Found")).unwrap()
1169 }
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175 use waf_core::WafMode;
1176
1177 #[test]
1178 fn hop_by_hop_includes_connection_and_host() {
1179 assert!(HOP_BY_HOP.contains(&"connection"));
1180 assert!(HOP_BY_HOP.contains(&"host"));
1181 assert!(HOP_BY_HOP.contains(&"transfer-encoding"));
1182 }
1183
1184 #[test]
1185 fn hop_by_hop_excludes_regular_headers() {
1186 assert!(!HOP_BY_HOP.contains(&"content-type"));
1187 assert!(!HOP_BY_HOP.contains(&"authorization"));
1188 assert!(!HOP_BY_HOP.contains(&"x-custom-header"));
1189 }
1190
1191 #[test]
1192 fn config_parses_from_toml() {
1193 let raw = r#"
1194[proxy]
1195listen = "127.0.0.1:8080"
1196backend = "http://localhost:3000"
1197
1198[waf]
1199mode = "detection-only"
1200block_threshold = 10
1201"#;
1202 let config: Config = toml::from_str(raw).unwrap();
1203 assert_eq!(config.proxy.backend, "http://localhost:3000");
1204 assert_eq!(config.waf.mode, WafMode::DetectionOnly);
1205 assert_eq!(config.waf.block_threshold, 10);
1206 }
1207
1208 #[test]
1209 fn config_uses_default_block_threshold_when_omitted() {
1210 let raw = r#"
1211[proxy]
1212listen = "127.0.0.1:8080"
1213backend = "http://localhost:3000"
1214
1215[waf]
1216mode = "detection-only"
1217"#;
1218 let config: Config = toml::from_str(raw).unwrap();
1219 assert_eq!(config.waf.block_threshold, 5);
1220 }
1221
1222 #[test]
1223 fn config_parses_network_section() {
1224 let raw = r#"
1225[proxy]
1226listen = "127.0.0.1:8080"
1227backend = "http://localhost:3000"
1228
1229[waf]
1230mode = "blocking"
1231
1232[network]
1233trusted_proxies = ["10.0.0.0/8", "::1"]
1234client_ip_header = "X-Forwarded-For"
1235trusted_hops = 2
1236"#;
1237 let config: Config = toml::from_str(raw).unwrap();
1238 assert_eq!(config.network.trusted_proxies, vec!["10.0.0.0/8", "::1"]);
1239 assert_eq!(config.network.client_ip_header, "X-Forwarded-For");
1240 assert_eq!(config.network.trusted_hops, 2);
1241 }
1242
1243 #[test]
1244 fn config_network_defaults_to_failsafe_when_absent() {
1245 let raw = r#"
1246[proxy]
1247listen = "127.0.0.1:8080"
1248backend = "http://localhost:3000"
1249
1250[waf]
1251mode = "detection-only"
1252"#;
1253 let config: Config = toml::from_str(raw).unwrap();
1254 assert!(config.network.trusted_proxies.is_empty());
1255 assert_eq!(config.network.trusted_hops, 1);
1256 assert_eq!(config.network.client_ip_header, "x-forwarded-for".to_string());
1257 }
1258
1259 #[test]
1260 fn config_rejects_unknown_mode() {
1261 let raw = r#"
1262[proxy]
1263listen = "127.0.0.1:8080"
1264backend = "http://localhost:3000"
1265
1266[waf]
1267mode = "unknown-mode"
1268"#;
1269 assert!(toml::from_str::<Config>(raw).is_err());
1270 }
1271
1272 #[test]
1273 fn parse_cookies_splits_on_semicolon() {
1274 let headers = vec![("cookie".to_string(), "session=abc; user=123".to_string())];
1275 let cookies = parse_cookies(&headers);
1276 assert_eq!(cookies.len(), 2);
1277 assert!(cookies.contains(&("session".to_string(), "abc".to_string())));
1278 assert!(cookies.contains(&("user".to_string(), "123".to_string())));
1279 }
1280
1281 #[test]
1282 fn parse_cookies_handles_missing_value() {
1283 let headers = vec![("cookie".to_string(), "flag=; token=xyz".to_string())];
1284 let cookies = parse_cookies(&headers);
1285 assert!(cookies.contains(&("flag".to_string(), "".to_string())));
1286 assert!(cookies.contains(&("token".to_string(), "xyz".to_string())));
1287 }
1288
1289 #[test]
1290 fn parse_cookies_handles_empty_header_list() {
1291 assert!(parse_cookies(&[]).is_empty());
1292 }
1293
1294 #[test]
1295 fn request_id_is_unique_per_call() {
1296 let id1 = next_request_id();
1297 let id2 = next_request_id();
1298 assert_ne!(id1, id2);
1299 assert!(id1.starts_with("req-"));
1300 }
1301}