Skip to main content

rust_web_server/proxy_config/
mod.rs

1//! Config-driven proxy application.
2//!
3//! When `rws.config.toml` contains `[[route]]` or `[[upstream]]` sections,
4//! `ConfigDrivenApp` is used as the top-level `Application` instead of the
5//! hardcoded `build_app()` in `main.rs`.
6//!
7//! # Quick start
8//!
9//! ```toml
10//! # rws.config.toml
11//! [[upstream]]
12//! name = "api"
13//! backends = ["localhost:3000"]
14//!
15//! [[route]]
16//! name = "api-proxy"
17//!
18//! [route.match]
19//! path = "/api/*"
20//!
21//! [route.action]
22//! type = "proxy"
23//!
24//! [route.action.proxy]
25//! upstream = "api"
26//! ```
27
28pub mod parser;
29pub mod health;
30pub(crate) mod upstream;
31
32pub use upstream::{BreakerConfig, RetryConfig, StickyConfig};
33pub mod builder;
34pub mod validate;
35pub mod mcp_gateway;
36pub mod llm;
37
38#[cfg(test)]
39mod tests;
40
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::sync::{Arc, RwLock};
43
44use crate::app::App;
45use crate::application::Application;
46use crate::core::New;
47use crate::request::Request;
48use crate::header::Header;
49use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
50use crate::server::ConnectionInfo;
51use crate::server_config::ServerConfig;
52
53// ── Public config types ────────────────────────────────────────────────────────
54
55#[derive(Debug, Clone)]
56pub struct ProxyConfig {
57    pub upstreams: Vec<UpstreamConfig>,
58    pub routes: Vec<RouteConfig>,
59    pub tcp_proxies: Vec<TcpProxyConfig>,
60    pub udp_proxies: Vec<UdpProxyConfig>,
61    pub ws_proxies: Vec<WsProxyConfig>,
62    pub global_middleware: MiddlewareConfig,
63}
64
65#[derive(Debug, Clone)]
66pub struct UpstreamConfig {
67    pub name: String,
68    pub backends: Vec<String>,
69    pub strategy: String, // "round_robin" | "random" | "ip_hash"
70    pub health_check: Option<HealthCheckConfig>,
71    /// `true` when all backends use `https://` scheme — connections to the
72    /// upstream are made over TLS. Requires the `http-client` or `http2`
73    /// feature (which bring in `rustls` + `webpki-roots`).
74    pub tls: bool,
75    /// `tls_ca_file` — PEM file of CA certificates to trust for this
76    /// upstream's `https://` backends **instead of** the public WebPKI roots
77    /// (a private CA or a self-signed certificate). `None` = WebPKI.
78    pub tls_ca_file: Option<String>,
79    /// `tls_client_cert_file` / `tls_client_key_file` — a client certificate
80    /// (PEM chain and key) presented to TLS backends that require one.
81    /// Since 18.3.0.
82    pub tls_client_cert_file: Option<String>,
83    pub tls_client_key_file: Option<String>,
84    /// `weights` — one per backend, in order (default 1 each). Since 18.6.0.
85    pub weights: Vec<u32>,
86    /// `[upstream.sticky]` — keep a client on one backend. Since 18.6.0.
87    pub sticky: Option<StickyConfig>,
88    /// `[upstream.circuit_breaker]` — take failing backends out of rotation
89    /// (passive health checks). Since 18.6.0.
90    pub circuit_breaker: Option<BreakerConfig>,
91    /// `[upstream.retry]` — try another backend. Since 18.6.0.
92    pub retry: Option<RetryConfig>,
93}
94
95#[derive(Debug, Clone)]
96pub struct HealthCheckConfig {
97    pub path: String,
98    pub interval_secs: u64,
99    pub timeout_ms: u64,
100    pub healthy_threshold: u32,
101    pub unhealthy_threshold: u32,
102}
103
104#[derive(Debug, Clone)]
105pub struct RouteConfig {
106    pub name: String,
107    pub match_: MatchConfig,
108    pub action: ActionConfig,
109    pub middleware: MiddlewareConfig,
110}
111
112#[derive(Debug, Clone, Default)]
113pub struct MatchConfig {
114    pub host: Option<String>,
115    pub path: Option<String>,
116    pub method: Option<String>,
117    pub content_type: Option<String>,
118}
119
120#[derive(Debug, Clone)]
121pub enum ActionConfig {
122    Proxy {
123        upstream: String,
124        connect_timeout_ms: u64,
125        read_timeout_ms: u64,
126        strip_path_prefix: Option<String>,
127        add_path_prefix: Option<String>,
128    },
129    Grpc {
130        upstream: String,
131        connect_timeout_ms: u64,
132        read_timeout_ms: u64,
133    },
134    Static {
135        root: String,
136        index: Vec<String>,
137    },
138    Redirect {
139        location: String,
140        status: u16,
141    },
142    Respond {
143        status: u16,
144        body: String,
145        content_type: String,
146    },
147    /// The MCP gateway ([`mcp_gateway`]).
148    Mcp(Box<mcp_gateway::McpGatewayConfig>),
149    /// The LLM proxy ([`llm`]).
150    Llm(Box<llm::LlmConfig>),
151    Unknown(String),
152}
153
154#[derive(Debug, Clone, Default)]
155pub struct MiddlewareConfig {
156    pub rate_limit: Option<RateLimitConfig>,
157    pub cache: Option<CacheConfig>,
158    pub auth: Option<AuthConfig>,
159    pub rewrite_request: Vec<RewriteRuleConfig>,
160    pub rewrite_response: Vec<RewriteRuleConfig>,
161    pub ip_allow: Vec<String>,
162    pub ip_deny: Vec<String>,
163    /// `timeout_ms` — if the route (including all its other middleware)
164    /// doesn't produce a response within this many milliseconds, the client
165    /// gets `504 Gateway Timeout` instead of waiting further. See
166    /// `crate::timeout` for the underlying mechanism and its limitations.
167    pub timeout_ms: Option<u64>,
168    /// `max_body_size` — reject this route's request with `413 Payload Too
169    /// Large` if its body exceeds this many bytes.
170    ///
171    /// This is a *route-specific* ceiling on top of the global, process-wide
172    /// `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (see `crate::entry_point::get_max_body_size`)
173    /// — e.g. a small JSON API route can be capped stricter than a
174    /// file-upload route without lowering the global limit for everyone.
175    /// It is **not** a substitute for the global limit: by the time any
176    /// route-level middleware runs, `Request` (including its body) has
177    /// already been fully read off the socket — route matching itself
178    /// requires the complete request, so a per-route check can only run
179    /// after that memory is already spent, not before. The global limit is
180    /// what protects total server memory from an oversized upload;
181    /// `max_body_size` protects only this route's own logic/upstream from
182    /// an oversized-but-still-within-the-global-limit body.
183    pub max_body_size: Option<u64>,
184    /// `[route.middleware.wasm]`: a WebAssembly plugin (feature
185    /// `wasm-plugins`, 18.10.0).
186    pub wasm: Option<WasmConfig>,
187}
188
189/// `[route.middleware.wasm]`.
190#[derive(Debug, Clone, Default, PartialEq)]
191pub struct WasmConfig {
192    /// Path of the `.wasm` file.
193    pub module: String,
194    pub fuel: u64,
195    pub memory_mb: u64,
196    /// The string the plugin's `rws_init` gets.
197    pub config: String,
198}
199
200#[derive(Debug, Clone)]
201pub struct RateLimitConfig {
202    pub max_requests: u32,
203    pub window_secs: u64,
204}
205
206#[derive(Debug, Clone)]
207pub struct CacheConfig {
208    pub ttl_secs: u64,
209    pub vary_by: Vec<String>,
210}
211
212#[derive(Debug, Clone)]
213pub enum AuthConfig {
214    /// `auth = { type = "basic", htpasswd_file = ".htpasswd" }`. See
215    /// `crate::auth::BasicAuthLayer::from_htpasswd_file` for the supported
216    /// file format (plain text and `{SHA256}` only — not Apache's `{SHA}`,
217    /// `$apr1$`, or bcrypt).
218    Basic { htpasswd_file: String },
219    /// `auth = { type = "jwt", secret_env = "JWT_SECRET" }`. Requires the
220    /// `auth` feature; verifies HS256 JWTs via `crate::auth::JwtLayer`.
221    Jwt { secret_env: String },
222    Bearer { token_env: String },
223}
224
225#[derive(Debug, Clone, Default)]
226pub struct RewriteRuleConfig {
227    pub type_: String,
228    pub name: Option<String>,
229    pub value: Option<String>,
230    pub prefix: Option<String>,
231    pub from: Option<String>,
232    pub to: Option<String>,
233    pub code: Option<u16>,
234    pub reason: Option<String>,
235}
236
237#[derive(Debug, Clone)]
238pub struct TcpProxyConfig {
239    pub name: String,
240    pub listen: String,
241    pub backends: Vec<String>,
242    pub connect_timeout_ms: u64,
243}
244
245#[derive(Debug, Clone)]
246pub struct UdpProxyConfig {
247    pub name: String,
248    pub listen: String,
249    pub backends: Vec<String>,
250    pub reply_timeout_ms: u64,
251    pub buffer_size: usize,
252}
253
254#[derive(Debug, Clone)]
255pub struct WsProxyConfig {
256    pub name: String,
257    pub listen: String,
258    pub backends: Vec<String>,
259    pub connect_timeout_ms: u64,
260    pub read_timeout_ms: u64,
261    /// Optional `[ws_proxy.health_check]` — same shape and semantics as
262    /// `[upstream.health_check]`. The probe is a plain HTTP `GET {path}`
263    /// against the backend's `host:port` (the `ws://`/`wss://` scheme only
264    /// determines whether the probe connects over TLS); most WebSocket
265    /// backends serve a regular HTTP health endpoint alongside their upgrade
266    /// route, the same way nginx/Traefik health-check WS upstreams.
267    pub health_check: Option<HealthCheckConfig>,
268}
269
270// ── ProxyConfig loading ────────────────────────────────────────────────────────
271
272
273/// An action's settings may sit directly under `[route.action]` next to
274/// `type` (the short form README.md and the docs use), or in a nested
275/// `[route.action.<type>]` table (the long form). Copy short-form keys into
276/// the nested table so the parser reads one place; a key set in both keeps
277/// the nested value.
278fn merge_action_keys(map: &mut parser::SectionMap, action_section: &str, action_type: &str) {
279    if action_type.is_empty() {
280        return;
281    }
282    let short_form: Vec<(String, String)> = match map.get(action_section) {
283        Some(entries) => entries.iter().filter(|(k, _)| k != "type").cloned().collect(),
284        None => return,
285    };
286    let nested = map.entry(format!("{action_section}.{action_type}")).or_default();
287    for (key, value) in short_form {
288        if !nested.iter().any(|(k, _)| *k == key) {
289            nested.push((key, value));
290        }
291    }
292}
293
294impl ProxyConfig {
295    /// Returns `true` if `rws.config.toml` (or `RWS_CONFIG_FILE`) contains
296    /// `[[route]]` or `[[upstream]]` sections, meaning config-driven mode
297    /// should be used.
298    pub fn is_proxy_mode() -> bool {
299        let path = config_file_path();
300        match std::fs::read_to_string(&path) {
301            Ok(contents) => {
302                contents.contains("[[route]]") || contents.contains("[[upstream]]")
303            }
304            Err(_) => false,
305        }
306    }
307
308    /// Parse the config file and return a `ProxyConfig`.
309    pub fn load() -> Self {
310        let path = config_file_path();
311        let contents = std::fs::read_to_string(&path).unwrap_or_default();
312        Self::from_str(&contents)
313    }
314
315    /// Parse `toml` text directly into a `ProxyConfig`. Used in tests.
316    pub fn from_str(toml: &str) -> Self {
317        use parser::{get_array, get_str, get_u32, get_u64, section_exists};
318
319        let mut map = parser::parse(toml);
320
321        // ── upstreams ──────────────────────────────────────────────────────────
322        let mut upstreams = Vec::new();
323        let mut i = 0;
324        loop {
325            let sec = format!("upstream[{}]", i);
326            if !section_exists(&map, &sec) {
327                break;
328            }
329            let name = get_str(&map, &sec, "name");
330            let backends = get_array(&map, &sec, "backends");
331            let strategy = {
332                let s = get_str(&map, &sec, "strategy");
333                if s.is_empty() { "round_robin".to_string() } else { s }
334            };
335            let hc_sec = format!("{}.health_check", sec);
336            let health_check = if section_exists(&map, &hc_sec) {
337                Some(HealthCheckConfig {
338                    path: {
339                        let p = get_str(&map, &hc_sec, "path");
340                        if p.is_empty() { "/health".to_string() } else { p }
341                    },
342                    interval_secs: get_u64(&map, &hc_sec, "interval_secs", 30),
343                    timeout_ms: get_u64(&map, &hc_sec, "timeout_ms", 5000),
344                    healthy_threshold: get_u32(&map, &hc_sec, "healthy_threshold", 2),
345                    unhealthy_threshold: get_u32(&map, &hc_sec, "unhealthy_threshold", 3),
346                })
347            } else {
348                None
349            };
350            let tls = backends.iter().any(|b| ["https://", "h2s://", "grpcs://"].iter().any(|s| b.starts_with(s)));
351            let tls_ca_file = Some(get_str(&map, &sec, "tls_ca_file")).filter(|v| !v.is_empty());
352            let tls_client_cert_file = Some(get_str(&map, &sec, "tls_client_cert_file")).filter(|v| !v.is_empty());
353            let tls_client_key_file = Some(get_str(&map, &sec, "tls_client_key_file")).filter(|v| !v.is_empty());
354            let weights: Vec<u32> = get_array(&map, &sec, "weights").iter().map(|w| w.trim().parse().unwrap_or(0)).collect();
355            let sticky_sec = format!("{}.sticky", sec);
356            let sticky = section_exists(&map, &sticky_sec).then(|| StickyConfig {
357                cookie: Some(get_str(&map, &sticky_sec, "cookie")).filter(|c| !c.is_empty()).unwrap_or_else(|| "rws_backend".to_string()),
358                max_age_secs: Some(get_u64(&map, &sticky_sec, "max_age_secs", 0)).filter(|s| *s > 0),
359            });
360            let cb_sec = format!("{}.circuit_breaker", sec);
361            let circuit_breaker = section_exists(&map, &cb_sec).then(|| BreakerConfig {
362                failure_threshold: get_u32(&map, &cb_sec, "failure_threshold", 5),
363                recovery_secs: get_u64(&map, &cb_sec, "recovery_secs", 30),
364                half_open_probes: get_u32(&map, &cb_sec, "half_open_probes", 1),
365            });
366            let retry_sec = format!("{}.retry", sec);
367            let retry = section_exists(&map, &retry_sec).then(|| {
368                let status: Vec<i16> = get_array(&map, &retry_sec, "status").iter().filter_map(|s| s.trim().parse().ok()).collect();
369                RetryConfig { attempts: get_u32(&map, &retry_sec, "attempts", 1), status: if status.is_empty() { vec![502, 503, 504] } else { status } }
370            });
371            upstreams.push(UpstreamConfig {
372                name, backends, strategy, health_check, tls, tls_ca_file, tls_client_cert_file, tls_client_key_file,
373                weights, sticky, circuit_breaker, retry,
374            });
375            i += 1;
376        }
377
378        // ── routes ─────────────────────────────────────────────────────────────
379        let mut routes = Vec::new();
380        let mut i = 0;
381        loop {
382            let sec = format!("route[{}]", i);
383            if !section_exists(&map, &sec) {
384                break;
385            }
386            let name = get_str(&map, &sec, "name");
387
388            // match
389            let m_sec = format!("{}.match", sec);
390            let match_ = MatchConfig {
391                host: {
392                    let h = get_str(&map, &m_sec, "host");
393                    if h.is_empty() { None } else { Some(h) }
394                },
395                path: {
396                    let p = get_str(&map, &m_sec, "path");
397                    if p.is_empty() { None } else { Some(p) }
398                },
399                method: {
400                    let m = get_str(&map, &m_sec, "method");
401                    if m.is_empty() { None } else { Some(m.to_uppercase()) }
402                },
403                content_type: {
404                    let c = get_str(&map, &m_sec, "content_type");
405                    if c.is_empty() { None } else { Some(c) }
406                },
407            };
408
409            // action
410            let a_sec = format!("{}.action", sec);
411            let action_type = get_str(&map, &a_sec, "type");
412            merge_action_keys(&mut map, &a_sec, &action_type);
413            let action = match action_type.as_str() {
414                "proxy" => {
415                    let p_sec = format!("{}.action.proxy", sec);
416                    ActionConfig::Proxy {
417                        upstream: get_str(&map, &p_sec, "upstream"),
418                        connect_timeout_ms: get_u64(&map, &p_sec, "connect_timeout_ms", 5000),
419                        read_timeout_ms: get_u64(&map, &p_sec, "read_timeout_ms", 30000),
420                        strip_path_prefix: {
421                            let v = get_str(&map, &p_sec, "strip_path_prefix");
422                            if v.is_empty() { None } else { Some(v) }
423                        },
424                        add_path_prefix: {
425                            let v = get_str(&map, &p_sec, "add_path_prefix");
426                            if v.is_empty() { None } else { Some(v) }
427                        },
428                    }
429                }
430                "grpc" => {
431                    let p_sec = format!("{}.action.grpc", sec);
432                    ActionConfig::Grpc {
433                        upstream: get_str(&map, &p_sec, "upstream"),
434                        connect_timeout_ms: get_u64(&map, &p_sec, "connect_timeout_ms", 5000),
435                        read_timeout_ms: get_u64(&map, &p_sec, "read_timeout_ms", 30000),
436                    }
437                }
438                "static" => {
439                    let s_sec = format!("{}.action.static", sec);
440                    ActionConfig::Static {
441                        root: get_str(&map, &s_sec, "root"),
442                        index: get_array(&map, &s_sec, "index"),
443                    }
444                }
445                "redirect" => {
446                    let r_sec = format!("{}.action.redirect", sec);
447                    ActionConfig::Redirect {
448                        location: get_str(&map, &r_sec, "location"),
449                        status: get_u64(&map, &r_sec, "status", 301) as u16,
450                    }
451                }
452                "respond" => {
453                    let r_sec = format!("{}.action.respond", sec);
454                    ActionConfig::Respond {
455                        status: get_u64(&map, &r_sec, "status", 200) as u16,
456                        body: get_str(&map, &r_sec, "body"),
457                        content_type: {
458                            let ct = get_str(&map, &r_sec, "content_type");
459                            if ct.is_empty() { "text/plain".to_string() } else { ct }
460                        },
461                    }
462                }
463                "mcp" => ActionConfig::Mcp(Box::new(mcp_gateway::parse(&map, &a_sec))),
464                "llm" => ActionConfig::Llm(Box::new(llm::parse(&map, &a_sec))),
465                other => ActionConfig::Unknown(other.to_string()),
466            };
467
468            // middleware
469            let mw_sec = format!("{}.middleware", sec);
470            let middleware = parse_middleware_config(&map, &mw_sec, i);
471
472            routes.push(RouteConfig { name, match_, action, middleware });
473            i += 1;
474        }
475
476        // ── tcp_proxy ──────────────────────────────────────────────────────────
477        let mut tcp_proxies = Vec::new();
478        let mut i = 0;
479        loop {
480            let sec = format!("tcp_proxy[{}]", i);
481            if !section_exists(&map, &sec) {
482                break;
483            }
484            tcp_proxies.push(TcpProxyConfig {
485                name: get_str(&map, &sec, "name"),
486                listen: get_str(&map, &sec, "listen"),
487                backends: get_array(&map, &sec, "backends"),
488                connect_timeout_ms: get_u64(&map, &sec, "connect_timeout_ms", 5000),
489            });
490            i += 1;
491        }
492
493        // ── udp_proxy ──────────────────────────────────────────────────────────
494        let mut udp_proxies = Vec::new();
495        let mut i = 0;
496        loop {
497            let sec = format!("udp_proxy[{}]", i);
498            if !section_exists(&map, &sec) {
499                break;
500            }
501            udp_proxies.push(UdpProxyConfig {
502                name: get_str(&map, &sec, "name"),
503                listen: get_str(&map, &sec, "listen"),
504                backends: get_array(&map, &sec, "backends"),
505                reply_timeout_ms: get_u64(&map, &sec, "reply_timeout_ms", 5000),
506                buffer_size: get_u64(&map, &sec, "buffer_size", 65536) as usize,
507            });
508            i += 1;
509        }
510
511        // ── ws_proxy ───────────────────────────────────────────────────────────
512        let mut ws_proxies = Vec::new();
513        let mut i = 0;
514        loop {
515            let sec = format!("ws_proxy[{}]", i);
516            if !section_exists(&map, &sec) {
517                break;
518            }
519            let hc_sec = format!("{}.health_check", sec);
520            let health_check = if section_exists(&map, &hc_sec) {
521                Some(HealthCheckConfig {
522                    path: {
523                        let p = get_str(&map, &hc_sec, "path");
524                        if p.is_empty() { "/health".to_string() } else { p }
525                    },
526                    interval_secs: get_u64(&map, &hc_sec, "interval_secs", 30),
527                    timeout_ms: get_u64(&map, &hc_sec, "timeout_ms", 5000),
528                    healthy_threshold: get_u32(&map, &hc_sec, "healthy_threshold", 2),
529                    unhealthy_threshold: get_u32(&map, &hc_sec, "unhealthy_threshold", 3),
530                })
531            } else {
532                None
533            };
534            ws_proxies.push(WsProxyConfig {
535                name: get_str(&map, &sec, "name"),
536                listen: get_str(&map, &sec, "listen"),
537                backends: get_array(&map, &sec, "backends"),
538                connect_timeout_ms: get_u64(&map, &sec, "connect_timeout_ms", 5000),
539                read_timeout_ms: get_u64(&map, &sec, "read_timeout_ms", 30000),
540                health_check,
541            });
542            i += 1;
543        }
544
545        // ── global middleware ──────────────────────────────────────────────────
546        let global_middleware = parse_middleware_config(&map, "middleware", usize::MAX);
547
548        ProxyConfig {
549            upstreams,
550            routes,
551            tcp_proxies,
552            udp_proxies,
553            ws_proxies,
554            global_middleware,
555        }
556    }
557}
558
559/// Parse a `MiddlewareConfig` from the section map at a given base path.
560/// `route_idx` is used only to build inner-array section paths for rewrite rules.
561fn parse_middleware_config(
562    map: &parser::SectionMap,
563    mw_sec: &str,
564    route_idx: usize,
565) -> MiddlewareConfig {
566    use parser::{get_array, get_str, get_u32, get_u64, section_exists};
567
568    let rl_sec = format!("{}.rate_limit", mw_sec);
569    let rate_limit = if section_exists(map, &rl_sec) {
570        Some(RateLimitConfig {
571            max_requests: get_u32(map, &rl_sec, "max_requests", 1000),
572            window_secs: get_u64(map, &rl_sec, "window_secs", 60),
573        })
574    } else {
575        None
576    };
577
578    let c_sec = format!("{}.cache", mw_sec);
579    let cache = if section_exists(map, &c_sec) {
580        Some(CacheConfig {
581            ttl_secs: get_u64(map, &c_sec, "ttl_secs", 60),
582            vary_by: get_array(map, &c_sec, "vary_by"),
583        })
584    } else {
585        None
586    };
587
588    let a_sec = format!("{}.auth", mw_sec);
589    let auth = if section_exists(map, &a_sec) {
590        let auth_type = get_str(map, &a_sec, "type");
591        match auth_type.as_str() {
592            "bearer" => Some(AuthConfig::Bearer {
593                token_env: get_str(map, &a_sec, "token_env"),
594            }),
595            "jwt" => Some(AuthConfig::Jwt {
596                secret_env: get_str(map, &a_sec, "secret_env"),
597            }),
598            "basic" => Some(AuthConfig::Basic {
599                htpasswd_file: get_str(map, &a_sec, "htpasswd_file"),
600            }),
601            _ => None,
602        }
603    } else {
604        None
605    };
606
607    // Rewrite rules — the section paths use route_idx for route-scoped rules
608    // or a flat path for global middleware. We look for:
609    //   route[N].middleware.rewrite.request[0], [1], …
610    //   route[N].middleware.rewrite.response[0], [1], …
611    // For global: middleware.rewrite.request[0], etc.
612    let rewrite_request = collect_rewrite_rules(map, mw_sec, "request");
613    let rewrite_response = collect_rewrite_rules(map, mw_sec, "response");
614
615    let ip_sec = format!("{}.ip_filter", mw_sec);
616    let ip_allow = if section_exists(map, &ip_sec) {
617        get_array(map, &ip_sec, "allow")
618    } else {
619        vec![]
620    };
621    let ip_deny = if section_exists(map, &ip_sec) {
622        get_array(map, &ip_sec, "deny")
623    } else {
624        vec![]
625    };
626
627    let _ = route_idx; // used implicitly via mw_sec paths
628
629    // Flat scalar directly under [route.middleware] (or the global
630    // [middleware] table), not a nested sub-table like rate_limit/cache —
631    // 0/absent both mean "no timeout configured".
632    let timeout_ms = match get_u64(map, mw_sec, "timeout_ms", 0) {
633        0 => None,
634        ms => Some(ms),
635    };
636
637    // Same flat-scalar convention as timeout_ms — 0/absent means "no
638    // route-specific limit" (the global RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES
639    // still applies regardless).
640    let max_body_size = match get_u64(map, mw_sec, "max_body_size", 0) {
641        0 => None,
642        n => Some(n),
643    };
644
645    let w_sec = format!("{}.wasm", mw_sec);
646    let wasm = section_exists(map, &w_sec).then(|| WasmConfig {
647        module: get_str(map, &w_sec, "module"),
648        fuel: get_u64(map, &w_sec, "fuel", 50_000_000),
649        memory_mb: get_u64(map, &w_sec, "memory_mb", 16),
650        config: get_str(map, &w_sec, "config"),
651    });
652
653    MiddlewareConfig {
654        rate_limit, cache, auth, rewrite_request, rewrite_response, ip_allow, ip_deny,
655        timeout_ms, max_body_size, wasm,
656    }
657}
658
659/// Collect `[[{mw_sec}.rewrite.{direction}]]` entries.
660fn collect_rewrite_rules(
661    map: &parser::SectionMap,
662    mw_sec: &str,
663    direction: &str,
664) -> Vec<RewriteRuleConfig> {
665    use parser::{get_str, get_u64};
666
667    let mut rules = Vec::new();
668    let mut j = 0;
669    loop {
670        let rsec = format!("{}.rewrite.{}[{}]", mw_sec, direction, j);
671        if !parser::section_exists(map, &rsec) {
672            break;
673        }
674        let code_val = get_u64(map, &rsec, "code", 0);
675        rules.push(RewriteRuleConfig {
676            type_: get_str(map, &rsec, "type"),
677            name: {
678                let v = get_str(map, &rsec, "name");
679                if v.is_empty() { None } else { Some(v) }
680            },
681            value: {
682                let v = get_str(map, &rsec, "value");
683                if v.is_empty() { None } else { Some(v) }
684            },
685            prefix: {
686                let v = get_str(map, &rsec, "prefix");
687                if v.is_empty() { None } else { Some(v) }
688            },
689            from: {
690                let v = get_str(map, &rsec, "from");
691                if v.is_empty() { None } else { Some(v) }
692            },
693            to: {
694                let v = get_str(map, &rsec, "to");
695                if v.is_empty() { None } else { Some(v) }
696            },
697            code: if code_val == 0 { None } else { Some(code_val as u16) },
698            reason: {
699                let v = get_str(map, &rsec, "reason");
700                if v.is_empty() { None } else { Some(v) }
701            },
702        });
703        j += 1;
704    }
705    rules
706}
707
708pub(crate) fn config_file_path() -> String {
709    std::env::var("RWS_CONFIG_FILE").unwrap_or_else(|_| "rws.config.toml".to_string())
710}
711
712// ── ConfigDrivenApp ────────────────────────────────────────────────────────────
713
714/// A compiled route: a matcher paired with a handler application.
715pub(crate) struct CompiledRoute {
716    pub(crate) matcher: RouteMatcher,
717    /// Shared, type-erased handler. `Arc` makes `Clone` cheap (pointer copy).
718    pub(crate) handler: Arc<dyn Application + Send + Sync>,
719    /// The route's proxy when it can be served without blocking a thread
720    /// (plain-HTTP upstream, no route middleware) — see
721    /// [`DynamicProxy::execute_async`].
722    #[cfg_attr(not(feature = "http2"), allow(dead_code))]
723    pub(crate) async_proxy: Option<Arc<DynamicProxy>>,
724}
725
726/// Matching criteria for a single route.
727#[derive(Clone, Default)]
728pub(crate) struct RouteMatcher {
729    /// Optional SNI hostname / `Host` header match.
730    pub(crate) host: Option<String>,
731    /// Path prefix to match (derived from `path = "/v1/*"`).
732    pub(crate) path_prefix: Option<String>,
733    /// Exact path to match (derived from `path = "/v1/ping"`).
734    pub(crate) path_exact: Option<String>,
735    /// Uppercase HTTP method, or `None` for any.
736    pub(crate) method: Option<String>,
737    /// `Content-Type` prefix (e.g. `"application/grpc"`).
738    pub(crate) content_type_prefix: Option<String>,
739}
740
741impl RouteMatcher {
742    pub(crate) fn from_match_config(cfg: &MatchConfig) -> Self {
743        let (path_prefix, path_exact) = match &cfg.path {
744            Some(p) if p.ends_with('*') => {
745                // "/v1/*" → prefix "/v1/"
746                let stripped = p.trim_end_matches('*').to_string();
747                (Some(stripped), None)
748            }
749            Some(p) => (None, Some(p.clone())),
750            None => (None, None),
751        };
752        let content_type_prefix = cfg.content_type.as_ref().map(|ct| {
753            if ct.ends_with('*') {
754                ct.trim_end_matches('*').to_string()
755            } else {
756                ct.clone()
757            }
758        });
759        RouteMatcher {
760            host: cfg.host.clone(),
761            path_prefix,
762            path_exact,
763            method: cfg.method.clone(),
764            content_type_prefix,
765        }
766    }
767
768    /// Returns `true` if `request` and `conn` match all configured criteria.
769    pub(crate) fn matches(&self, request: &Request, conn: &ConnectionInfo) -> bool {
770        // Host matching: SNI first, then Host header
771        if let Some(ref expected_host) = self.host {
772            let actual_host = conn
773                .sni_hostname
774                .as_deref()
775                .or_else(|| {
776                    request
777                        .headers
778                        .iter()
779                        .find(|h| h.name.eq_ignore_ascii_case("host"))
780                        .map(|h| h.value.as_ref())
781                })
782                .unwrap_or("");
783            if !host_matches(expected_host, actual_host) {
784                return false;
785            }
786        }
787
788        // Method matching
789        if let Some(ref m) = self.method {
790            if request.method.to_uppercase() != m.as_str() {
791                return false;
792            }
793        }
794
795        // Path matching: strip query string for comparison
796        let path = request.request_uri.split('?').next().unwrap_or(&request.request_uri);
797        if let Some(ref prefix) = self.path_prefix {
798            if !path.starts_with(prefix.as_str()) {
799                return false;
800            }
801        } else if let Some(ref exact) = self.path_exact {
802            if path != exact.as_str() {
803                return false;
804            }
805        }
806
807        // Content-Type prefix matching
808        if let Some(ref ct_prefix) = self.content_type_prefix {
809            let actual_ct = request
810                .headers
811                .iter()
812                .find(|h| h.name.eq_ignore_ascii_case("content-type"))
813                .map(|h| h.value.as_ref())
814                .unwrap_or("");
815            if !actual_ct.starts_with(ct_prefix.as_str()) {
816                return false;
817            }
818        }
819
820        true
821    }
822}
823
824/// An `Application` that routes requests based on a parsed `ProxyConfig`.
825///
826/// Clones share one live configuration: a hot reload
827/// ([`ConfigDrivenApp::reload_from_str`], run on `SIGHUP` for an app built by
828/// [`build_from_file`]) swaps it for every clone at once. In-flight requests
829/// finish on the configuration they started with.
830#[derive(Clone)]
831pub struct ConfigDrivenApp {
832    current: Arc<RwLock<Arc<Generation>>>,
833    /// Serializes reloads: one SIGHUP reaches every accept loop.
834    reload_lock: Arc<std::sync::Mutex<()>>,
835}
836
837/// One loaded configuration: its compiled routes plus what a reload needs.
838struct Generation {
839    dispatch: RouteDispatch,
840    /// `dispatch` wrapped in the top-level `[middleware]` table; `None` when
841    /// that table is empty.
842    entry: Option<Arc<dyn Application + Send + Sync>>,
843    config: ProxyConfig,
844    /// The file text this generation was built from, to skip no-op reloads.
845    source: Option<String>,
846    /// Set when this generation is replaced: stops its health checkers.
847    health_stop: Arc<AtomicBool>,
848}
849
850impl Generation {
851    fn new(routes: Arc<Vec<CompiledRoute>>, fallback: App, config: ProxyConfig, health_stop: Arc<AtomicBool>, source: Option<String>) -> Self {
852        let dispatch = RouteDispatch { routes, fallback };
853        let entry = if config.global_middleware.is_empty() {
854            None
855        } else {
856            Some(builder::apply_middleware(arc_app(dispatch.clone()), &config.global_middleware))
857        };
858        Generation { dispatch, entry, config, source, health_stop }
859    }
860}
861
862/// First-match routing over the compiled routes, then the fallback.
863#[derive(Clone)]
864struct RouteDispatch {
865    routes: Arc<Vec<CompiledRoute>>,
866    /// Fallback for unmatched requests — handles /healthz, /readyz, /metrics,
867    /// static files, and the 404 controller. Reads `RWS_CONFIG_*` env vars
868    /// per request (`App::new()`'s default) unless pinned via
869    /// [`ConfigDrivenApp::with_config`].
870    fallback: App,
871}
872
873impl Application for RouteDispatch {
874    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
875        for route in self.routes.iter() {
876            if route.matcher.matches(request, conn) {
877                return route.handler.execute(request, conn);
878            }
879        }
880        self.fallback.execute(request, conn)
881    }
882
883    fn streams_body(&self, request: &Request, conn: &ConnectionInfo) -> bool {
884        match self.routes.iter().find(|r| r.matcher.matches(request, conn)) {
885            Some(route) => route.handler.streams_body(request, conn),
886            None => self.fallback.streams_body(request, conn),
887        }
888    }
889}
890
891impl MiddlewareConfig {
892    /// `true` when no layer is configured.
893    pub fn is_empty(&self) -> bool {
894        self.rate_limit.is_none() && self.cache.is_none() && self.auth.is_none()
895            && self.rewrite_request.is_empty() && self.rewrite_response.is_empty()
896            && self.ip_allow.is_empty() && self.ip_deny.is_empty()
897            && self.timeout_ms.is_none() && self.max_body_size.is_none() && self.wasm.is_none()
898    }
899}
900
901/// What a successful [`ConfigDrivenApp::reload_from_str`] changed.
902#[derive(Debug, Clone, PartialEq)]
903pub enum ReloadOutcome {
904    /// The configuration text is unchanged; nothing was rebuilt.
905    Unchanged,
906    /// New routes are live. `warnings` includes `[[tcp_proxy]]` /
907    /// `[[udp_proxy]]` / `[[ws_proxy]]` changes, which need a restart.
908    Reloaded { routes: usize, warnings: Vec<String> },
909}
910
911impl ConfigDrivenApp {
912    pub(crate) fn new(routes: Vec<CompiledRoute>, config: ProxyConfig, health_stop: Arc<AtomicBool>, source: Option<String>) -> Self {
913        use crate::core::New;
914        let generation = Generation::new(Arc::new(routes), App::new(), config, health_stop, source);
915        ConfigDrivenApp {
916            current: Arc::new(RwLock::new(Arc::new(generation))),
917            reload_lock: Arc::new(std::sync::Mutex::new(())),
918        }
919    }
920
921    fn current(&self) -> Arc<Generation> {
922        Arc::clone(&self.current.read().unwrap_or_else(|e| e.into_inner()))
923    }
924
925    /// Pin the fallback [`App`] (used for any request none of the
926    /// config-driven routes match) to an explicit [`ServerConfig`], instead
927    /// of reading `RWS_CONFIG_*` environment variables per request.
928    ///
929    /// Mirrors [`App::with_config`] / [`crate::state::AppWithState::with_config`]
930    /// — same rationale: safe for parallel tests, and lets multiple
931    /// differently-configured proxy instances coexist in one process. The
932    /// pinned fallback is kept across hot reloads.
933    ///
934    /// ```rust,no_run
935    /// use rust_web_server::proxy_config::build_from_file;
936    /// use rust_web_server::server_config::ServerConfig;
937    ///
938    /// let (app, _handles) = build_from_file();
939    /// let app = app.with_config(ServerConfig::default());
940    /// ```
941    pub fn with_config(self, config: ServerConfig) -> Self {
942        {
943            let mut slot = self.current.write().unwrap_or_else(|e| e.into_inner());
944            let old = Arc::clone(&slot);
945            *slot = Arc::new(Generation::new(
946                Arc::clone(&old.dispatch.routes),
947                App::with_config(config),
948                old.config.clone(),
949                Arc::clone(&old.health_stop),
950                old.source.clone(),
951            ));
952        }
953        self
954    }
955
956    /// Replace the routes, upstreams and `[middleware]` with those in `toml`.
957    ///
958    /// The new configuration is validated first ([`ProxyConfig::validate`]
959    /// plus unknown keys); if it has errors, the running configuration stays
960    /// in place and the errors are returned — a typo never takes the proxy
961    /// down. Otherwise the new routes go live for every clone of this app,
962    /// the previous configuration's health checkers stop, and new ones start.
963    /// `[[tcp_proxy]]` / `[[udp_proxy]]` / `[[ws_proxy]]` listeners are not
964    /// rebound; a change to them is reported as a warning.
965    pub fn reload_from_str(&self, toml: &str) -> Result<ReloadOutcome, crate::config_check::ConfigReport> {
966        let _serialized = self.reload_lock.lock().unwrap_or_else(|e| e.into_inner());
967        let old = self.current();
968        if old.source.as_deref() == Some(toml) {
969            return Ok(ReloadOutcome::Unchanged);
970        }
971        let config = ProxyConfig::from_str(toml);
972        let mut report = crate::config_check::ConfigReport::default();
973        validate::check_unknown_keys(toml, &mut report);
974        config.validate(&mut report);
975        if !report.is_ok() {
976            return Err(report);
977        }
978
979        let mut warnings = report.warnings;
980        let listeners = |c: &ProxyConfig| format!("{:?}{:?}{:?}", c.tcp_proxies, c.udp_proxies, c.ws_proxies);
981        if listeners(&config) != listeners(&old.config) {
982            warnings.push("[[tcp_proxy]] / [[udp_proxy]] / [[ws_proxy]] changes need a restart; the old listeners keep running".to_string());
983        }
984
985        let health_stop = Arc::new(AtomicBool::new(false));
986        let routes = builder::compile_routes(&config, &health_stop);
987        let route_count = routes.len();
988        let generation = Generation::new(Arc::new(routes), old.dispatch.fallback.clone(), config, health_stop, Some(toml.to_string()));
989        let replaced = {
990            let mut slot = self.current.write().unwrap_or_else(|e| e.into_inner());
991            std::mem::replace(&mut *slot, Arc::new(generation))
992        };
993        replaced.health_stop.store(true, Ordering::Relaxed);
994        Ok(ReloadOutcome::Reloaded { routes: route_count, warnings })
995    }
996
997    /// Re-read the configuration file (`RWS_CONFIG_FILE` or
998    /// `rws.config.toml`) and [`reload_from_str`](Self::reload_from_str) it,
999    /// logging the outcome. Registered as a [`crate::config_reload`] hook by
1000    /// [`build_from_file`].
1001    pub fn reload_from_file(&self) {
1002        let path = config_file_path();
1003        let toml = match std::fs::read_to_string(&path) {
1004            Ok(toml) => toml,
1005            Err(e) => {
1006                eprintln!("[proxy_config] reload: cannot read {path}: {e} — keeping the running routes");
1007                crate::config_reload::report_error(format!("cannot read {path}: {e}"));
1008                return;
1009            }
1010        };
1011        match self.reload_from_str(&toml) {
1012            Ok(ReloadOutcome::Unchanged) => {}
1013            Ok(ReloadOutcome::Reloaded { routes, warnings }) => {
1014                for w in &warnings {
1015                    eprintln!("[proxy_config] Warning: {w}");
1016                }
1017                println!("[proxy_config] reloaded {path}: {routes} route{}", if routes == 1 { "" } else { "s" });
1018            }
1019            Err(report) => {
1020                for e in &report.errors {
1021                    eprintln!("[proxy_config] reload ERROR: {e}");
1022                    crate::config_reload::report_error(e.clone());
1023                }
1024                eprintln!("[proxy_config] {path} has errors — keeping the running routes");
1025            }
1026        }
1027    }
1028}
1029
1030/// The request with its target normalized ([`normalize_request_target`]),
1031/// borrowed when it already is.
1032fn normalized(request: &Request) -> std::borrow::Cow<'_, Request> {
1033    match normalize_request_target(&request.request_uri) {
1034        std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(request),
1035        std::borrow::Cow::Owned(target) => {
1036            let mut owned = request.clone();
1037            owned.request_uri = target;
1038            std::borrow::Cow::Owned(owned)
1039        }
1040    }
1041}
1042
1043/// A request target's path normalized before routes match it, and sent
1044/// upstream that way (#50): unreserved characters percent-decoded (`%61` →
1045/// `a`; other escapes kept, hex upper-cased), repeated slashes collapsed,
1046/// `.` and `..` segments resolved (never above `/`). Otherwise `/%61dmin`,
1047/// `//admin` or `/x/../admin` missed a protected `/admin` route and reached
1048/// the backend through a catch-all route. The query is kept as is; a
1049/// target that isn't a path (`*`) is returned unchanged.
1050pub(crate) fn normalize_request_target(target: &str) -> std::borrow::Cow<'_, str> {
1051    let (path, query) = match target.find('?') {
1052        Some(i) => (&target[..i], &target[i..]),
1053        None => (target, ""),
1054    };
1055    if !path.starts_with('/') || !(path.contains('%') || path.contains("//") || path.contains("/.")) {
1056        return std::borrow::Cow::Borrowed(target);
1057    }
1058    let bytes = path.as_bytes();
1059    let mut decoded = String::with_capacity(path.len());
1060    let mut i = 0;
1061    while i < bytes.len() {
1062        let hex = |b: u8| (b as char).to_digit(16);
1063        if bytes[i] == b'%' && i + 2 < bytes.len() {
1064            if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
1065                let value = (h * 16 + l) as u8;
1066                if value.is_ascii_alphanumeric() || matches!(value, b'-' | b'.' | b'_' | b'~') {
1067                    decoded.push(value as char);
1068                } else {
1069                    decoded.push('%');
1070                    decoded.push(bytes[i + 1].to_ascii_uppercase() as char);
1071                    decoded.push(bytes[i + 2].to_ascii_uppercase() as char);
1072                }
1073                i += 3;
1074                continue;
1075            }
1076        }
1077        // A `%` that starts no escape is a literal percent sign: encoded, so
1078        // what follows can't form a new escape with it (normalizing twice
1079        // must change nothing — the fuzzer found `%%611` → `%a1`).
1080        if bytes[i] == b'%' {
1081            decoded.push_str("%25");
1082            i += 1;
1083            continue;
1084        }
1085        // `path` is a &str: copy whole UTF-8 sequences.
1086        let ch = path[i..].chars().next().unwrap_or('\u{FFFD}');
1087        decoded.push(ch);
1088        i += ch.len_utf8().max(1);
1089    }
1090    let mut segments: Vec<&str> = Vec::new();
1091    let parts: Vec<&str> = decoded.split('/').collect();
1092    let last = parts.len().saturating_sub(1);
1093    let mut trailing_slash = false;
1094    for (n, segment) in parts.iter().enumerate() {
1095        match *segment {
1096            "" => trailing_slash = n == last,
1097            "." => trailing_slash = n == last,
1098            ".." => {
1099                segments.pop();
1100                trailing_slash = n == last;
1101            }
1102            s => {
1103                segments.push(s);
1104                trailing_slash = false;
1105            }
1106        }
1107    }
1108    let mut out = String::with_capacity(decoded.len() + query.len());
1109    out.push('/');
1110    out.push_str(&segments.join("/"));
1111    if trailing_slash && !segments.is_empty() {
1112        out.push('/');
1113    }
1114    out.push_str(query);
1115    if out == target {
1116        std::borrow::Cow::Borrowed(target)
1117    } else {
1118        std::borrow::Cow::Owned(out)
1119    }
1120}
1121
1122/// A route's `host` against the request's (SNI or `Host`): ignoring ASCII
1123/// case, and ignoring a port the route doesn't name (#50).
1124fn host_matches(expected: &str, actual: &str) -> bool {
1125    let actual = if expected.contains(':') {
1126        actual
1127    } else if let Some(rest) = actual.strip_prefix('[') {
1128        // [IPv6]:port
1129        rest.split(']').next().unwrap_or(rest)
1130    } else {
1131        actual.rsplit_once(':').map_or(actual, |(host, port)| if port.bytes().all(|b| b.is_ascii_digit()) { host } else { actual })
1132    };
1133    let expected = expected.trim_start_matches('[').trim_end_matches(']');
1134    actual.eq_ignore_ascii_case(expected)
1135}
1136
1137impl Application for ConfigDrivenApp {
1138    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1139        let request = normalized(request);
1140        let generation = self.current();
1141        match &generation.entry {
1142            Some(entry) => entry.execute(&request, conn),
1143            None => generation.dispatch.execute(&request, conn),
1144        }
1145    }
1146
1147    /// The matched route decides: a `grpc` route streams request bodies
1148    /// (client-streaming and bidirectional calls), the others don't yet.
1149    fn streams_body(&self, request: &Request, conn: &ConnectionInfo) -> bool {
1150        let request = normalized(request);
1151        let generation = self.current();
1152        match &generation.entry {
1153            Some(entry) => entry.streams_body(&request, conn),
1154            None => generation.dispatch.streams_body(&request, conn),
1155        }
1156    }
1157
1158    /// Routes proxy upstream or run configured middleware: only requests
1159    /// that match no route and fall through to the static files run inline,
1160    /// and only without a global `[middleware]` table.
1161    fn runs_inline(&self, request: &Request, conn: &ConnectionInfo) -> bool {
1162        let request = normalized(request);
1163        let generation = self.current();
1164        generation.entry.is_none()
1165            && !generation.dispatch.routes.iter().any(|r| r.matcher.matches(&request, conn))
1166            && App::is_inline_request(&request)
1167    }
1168
1169    /// A request for a proxy route with a plain-HTTP upstream and no
1170    /// middleware (route or global) waits for the backend without holding
1171    /// a thread.
1172    #[cfg(feature = "http2")]
1173    fn execute_async<'a>(&'a self, request: &'a Request, conn: &'a ConnectionInfo) -> Option<crate::application::AsyncResponse<'a>> {
1174        let generation = self.current();
1175        if generation.entry.is_some() {
1176            return None;
1177        }
1178        let request = normalized(request);
1179        let route = generation.dispatch.routes.iter().find(|r| r.matcher.matches(&request, conn))?;
1180        let proxy = Arc::clone(route.async_proxy.as_ref()?);
1181        Some(Box::pin(async move { proxy.execute_async(&request, conn).await }))
1182    }
1183}
1184
1185// ── H2Route ───────────────────────────────────────────────────────────────────
1186
1187/// The `grpc` action: requests forwarded over HTTP/2 to the upstream's live
1188/// backends in turn, bodies streamed both ways, trailers kept; gRPC-web
1189/// requests translated to gRPC on the way ([`GrpcWebLayer`](crate::proxy::GrpcWebLayer)).
1190#[cfg(feature = "http2")]
1191pub(crate) struct H2Route {
1192    live: Arc<RwLock<Vec<String>>>,
1193    tls: bool,
1194    trust: Option<Arc<crate::proxy::UpstreamTls>>,
1195    connect_timeout: std::time::Duration,
1196    read_timeout: std::time::Duration,
1197    counter: std::sync::atomic::AtomicUsize,
1198}
1199
1200#[cfg(feature = "http2")]
1201impl H2Route {
1202    pub(crate) fn new(
1203        live: Arc<RwLock<Vec<String>>>,
1204        tls: bool,
1205        trust: Option<Arc<crate::proxy::UpstreamTls>>,
1206        connect_timeout_ms: u64,
1207        read_timeout_ms: u64,
1208    ) -> H2Route {
1209        H2Route {
1210            live,
1211            tls,
1212            trust,
1213            connect_timeout: std::time::Duration::from_millis(connect_timeout_ms),
1214            read_timeout: std::time::Duration::from_millis(read_timeout_ms),
1215            counter: std::sync::atomic::AtomicUsize::new(0),
1216        }
1217    }
1218}
1219
1220#[cfg(feature = "http2")]
1221impl Application for H2Route {
1222    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1223        use crate::middleware::Middleware;
1224        crate::proxy::GrpcWebLayer::new().handle(request, conn, &H2Forward(self))
1225    }
1226
1227    fn streams_body(&self, _request: &Request, _conn: &ConnectionInfo) -> bool {
1228        true
1229    }
1230}
1231
1232/// [`H2Route`] after the gRPC-web translation.
1233#[cfg(feature = "http2")]
1234struct H2Forward<'a>(&'a H2Route);
1235
1236#[cfg(feature = "http2")]
1237impl Application for H2Forward<'_> {
1238    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1239        let route = self.0;
1240        let backends = route.live.read().unwrap_or_else(|e| e.into_inner()).clone();
1241        if backends.is_empty() {
1242            return Ok(crate::proxy::bad_gateway());
1243        }
1244        // A streamed request body can be sent once: no second backend then.
1245        let attempts = if matches!(request.body, crate::request::Body::Stream(_)) { 1 } else { backends.len() };
1246        let start = route.counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1247        for attempt in 0..attempts {
1248            let url = &backends[(start + attempt) % backends.len()];
1249            let result = crate::proxy::forward_h2(
1250                request, crate::proxy::Peer::of(conn), url, route.tls, route.trust.as_deref(), route.connect_timeout, route.read_timeout,
1251            );
1252            match result {
1253                Ok(response) => return Ok(response),
1254                Err(e) => eprintln!("grpc route: {e}"),
1255            }
1256        }
1257        Ok(crate::proxy::bad_gateway())
1258    }
1259}
1260
1261// ── NullApp ────────────────────────────────────────────────────────────────────
1262
1263/// A dead-end `Application` that always returns 404.
1264/// Used as the `next` parameter when calling `Middleware::handle` directly.
1265#[derive(Clone, Copy)]
1266pub(crate) struct NullApp;
1267
1268impl Application for NullApp {
1269    fn execute(&self, _request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1270        let mut r = Response::new();
1271        r.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
1272        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
1273        Ok(r)
1274    }
1275}
1276
1277// ── DynamicProxy ──────────────────────────────────────────────────────────────
1278
1279use std::collections::HashMap;
1280use std::collections::hash_map::DefaultHasher;
1281use std::hash::{Hash, Hasher};
1282use std::sync::atomic::AtomicUsize;
1283use std::time::{Duration, SystemTime, UNIX_EPOCH};
1284
1285/// Backend-selection strategy for `DynamicProxy`, configured via the
1286/// `strategy` field on `[[upstream]]` in `rws.config.toml`. Unknown or empty
1287/// values fall back to `RoundRobin`, matching the parser's own default.
1288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1289pub(crate) enum LoadBalanceStrategy {
1290    RoundRobin,
1291    Random,
1292    IpHash,
1293    LeastConnections,
1294}
1295
1296impl LoadBalanceStrategy {
1297    fn parse(s: &str) -> Self {
1298        match s {
1299            "random" => LoadBalanceStrategy::Random,
1300            "ip_hash" => LoadBalanceStrategy::IpHash,
1301            "least_connections" => LoadBalanceStrategy::LeastConnections,
1302            _ => LoadBalanceStrategy::RoundRobin,
1303        }
1304    }
1305}
1306
1307/// A proxy adapter that reads its backend list from a shared, health-checker-
1308/// maintained live list at request time. Supports dynamic removal/restoration
1309/// of backends without restarting, and — for a declared `[[upstream]]` — its
1310/// [`upstream::UpstreamPolicy`]: weights, stickiness, the circuit breaker,
1311/// retries and metrics.
1312///
1313/// This type is `Clone + Send + Sync` and implements `Application`.
1314#[derive(Clone)]
1315pub(crate) struct DynamicProxy {
1316    live: Arc<RwLock<Vec<String>>>,
1317    counter: Arc<AtomicUsize>,
1318    connect_timeout: Duration,
1319    read_timeout: Duration,
1320    strip_prefix: Option<Arc<String>>,
1321    add_prefix: Option<Arc<String>>,
1322    tls: bool,
1323    strategy: LoadBalanceStrategy,
1324    connections: Arc<RwLock<HashMap<String, Arc<AtomicUsize>>>>,
1325    /// The upstream's `tls_ca_file` trust settings; `None` = WebPKI roots.
1326    trust: Option<Arc<crate::proxy::UpstreamTls>>,
1327    /// The upstream's name, for metrics.
1328    upstream: Arc<String>,
1329    /// The declared upstream's policy; `None` for an inline backend.
1330    policy: Option<Arc<upstream::UpstreamPolicy>>,
1331}
1332
1333impl DynamicProxy {
1334    /// Trust the given CA for TLS backends instead of the WebPKI roots.
1335    pub(crate) fn with_trust(mut self, trust: Option<Arc<crate::proxy::UpstreamTls>>) -> Self {
1336        self.trust = trust;
1337        self
1338    }
1339
1340    /// Apply the upstream's weights, stickiness, circuit breaker, retries
1341    /// and metrics.
1342    pub(crate) fn with_policy(mut self, upstream: &str, policy: Option<Arc<upstream::UpstreamPolicy>>) -> Self {
1343        self.upstream = Arc::new(upstream.to_string());
1344        self.policy = policy;
1345        self
1346    }
1347
1348    pub(crate) fn new(
1349        live: Arc<RwLock<Vec<String>>>,
1350        connect_timeout_ms: u64,
1351        read_timeout_ms: u64,
1352        strip_prefix: Option<String>,
1353        add_prefix: Option<String>,
1354        tls: bool,
1355        strategy: String,
1356    ) -> Self {
1357        DynamicProxy {
1358            live,
1359            counter: Arc::new(AtomicUsize::new(0)),
1360            connect_timeout: Duration::from_millis(connect_timeout_ms),
1361            read_timeout: Duration::from_millis(read_timeout_ms),
1362            strip_prefix: strip_prefix.map(Arc::new),
1363            add_prefix: add_prefix.map(Arc::new),
1364            tls,
1365            strategy: LoadBalanceStrategy::parse(&strategy),
1366            connections: Arc::new(RwLock::new(HashMap::new())),
1367            trust: None,
1368            upstream: Arc::new(String::new()),
1369            policy: None,
1370        }
1371    }
1372
1373    /// Pick by the strategy among `candidates` (not empty).
1374    fn pick(&self, candidates: &[String], client_ip: &str) -> String {
1375        let policy = self.policy.as_deref().filter(|p| p.weighted());
1376        let idx = match self.strategy {
1377            LoadBalanceStrategy::RoundRobin => match policy {
1378                Some(policy) => return policy.weighted_round_robin(candidates).unwrap_or_else(|| candidates[0].clone()),
1379                None => self.counter.fetch_add(1, Ordering::Relaxed) % candidates.len(),
1380            },
1381            LoadBalanceStrategy::Random => {
1382                let nanos = SystemTime::now()
1383                    .duration_since(UNIX_EPOCH)
1384                    .map(|d| d.subsec_nanos())
1385                    .unwrap_or(0) as usize;
1386                let salt = self.counter.fetch_add(1, Ordering::Relaxed);
1387                let roll = nanos.wrapping_add(salt.wrapping_mul(2_654_435_761));
1388                match policy {
1389                    Some(policy) => policy.weighted_index(candidates, roll),
1390                    None => roll % candidates.len(),
1391                }
1392            }
1393            LoadBalanceStrategy::IpHash => {
1394                let mut hasher = DefaultHasher::new();
1395                client_ip.hash(&mut hasher);
1396                (hasher.finish() as usize) % candidates.len()
1397            }
1398            LoadBalanceStrategy::LeastConnections => {
1399                let connections = self.connections.read().unwrap();
1400                let load = |backend: &String| connections.get(backend).map(|c| c.load(Ordering::Relaxed)).unwrap_or(0) as u64;
1401                let weight = |backend: &String| self.policy.as_deref().map_or(1, |p| p.weight(backend)) as u64;
1402                // Fewest in flight per unit of weight (compared crosswise).
1403                candidates
1404                    .iter()
1405                    .enumerate()
1406                    .min_by(|(_, a), (_, b)| (load(a) * weight(b)).cmp(&(load(b) * weight(a))))
1407                    .map(|(i, _)| i)
1408                    .unwrap_or(0)
1409            }
1410        };
1411        candidates[idx].clone()
1412    }
1413
1414    /// The backend for this attempt: the sticky one if it's still in
1415    /// rotation, else one by the strategy, skipping `tried` and backends
1416    /// whose circuit breaker is open. `Err(true)`: none, because the breaker
1417    /// holds the rest open; `Err(false)`: none at all.
1418    fn next_backend(&self, request: &Request, client_ip: &str, tried: &[String]) -> Result<String, bool> {
1419        let mut candidates: Vec<String> = self.live.read().unwrap().iter().filter(|b| !tried.contains(b)).cloned().collect();
1420        if !candidates.is_empty() {
1421            candidates.retain(|b| !upstream::is_drained(&self.upstream, b));
1422            if candidates.is_empty() {
1423                // Every live backend drained: 503, like an open breaker.
1424                return Err(true);
1425            }
1426        }
1427        let Some(policy) = self.policy.as_deref() else {
1428            return if candidates.is_empty() { Err(false) } else { Ok(self.pick(&candidates, client_ip)) };
1429        };
1430        if let Some(sticky) = &policy.sticky {
1431            if let Some(id) = upstream::cookie_value(request, &sticky.cookie) {
1432                if let Some(backend) = candidates.iter().find(|b| upstream::backend_id(b) == id).cloned() {
1433                    if policy.admits(&backend) {
1434                        return Ok(backend);
1435                    }
1436                    candidates.retain(|b| *b != backend);
1437                }
1438            }
1439        }
1440        let ejected = policy.any_open(&candidates);
1441        while !candidates.is_empty() {
1442            let backend = self.pick(&candidates, client_ip);
1443            if policy.admits(&backend) {
1444                return Ok(backend);
1445            }
1446            candidates.retain(|b| *b != backend);
1447        }
1448        Err(ejected)
1449    }
1450
1451    /// Returns the shared in-flight connection counter for `backend`,
1452    /// creating it on first use. Only consulted under `LeastConnections`.
1453    fn connection_counter(&self, backend: &str) -> Arc<AtomicUsize> {
1454        if let Some(counter) = self.connections.read().unwrap().get(backend) {
1455            return Arc::clone(counter);
1456        }
1457        let mut connections = self.connections.write().unwrap();
1458        Arc::clone(
1459            connections
1460                .entry(backend.to_string())
1461                .or_insert_with(|| Arc::new(AtomicUsize::new(0))),
1462        )
1463    }
1464}
1465
1466/// Decrements a backend's in-flight connection count when the request
1467/// finishes (including early returns), keeping `least_connections` accurate.
1468struct ConnectionGuard {
1469    counter: Arc<AtomicUsize>,
1470}
1471
1472impl Drop for ConnectionGuard {
1473    fn drop(&mut self) {
1474        self.counter.fetch_sub(1, Ordering::Relaxed);
1475    }
1476}
1477
1478/// A request ready to send: the chosen backend and the (possibly rewritten)
1479/// request. Holding it keeps the backend's least-connections count raised.
1480struct Prepared<'a> {
1481    backend: String,
1482    host: String,
1483    port: u16,
1484    request: std::borrow::Cow<'a, Request>,
1485    _guard: Option<ConnectionGuard>,
1486}
1487
1488/// How one attempt went.
1489enum Attempt {
1490    /// A response to pass on.
1491    Done(Response),
1492    /// A failure, retried when the policy allows: the backend's response (a
1493    /// retried status) or none (no response).
1494    Failed(Option<Response>),
1495}
1496
1497impl DynamicProxy {
1498    /// Pick a backend (not one of `tried`) and apply the path rewriting;
1499    /// `Err` is the response to send instead (no backend in rotation — `503`
1500    /// when the circuit breaker took them out, `502` otherwise — or an
1501    /// unparsable backend address).
1502    fn prepare<'a>(&self, request: &'a Request, conn: &ConnectionInfo, tried: &[String]) -> Result<Prepared<'a>, Response> {
1503        let backend = self.next_backend(request, &conn.client.ip, tried).map_err(|ejected| if ejected { service_unavailable() } else { bad_gateway() })?;
1504
1505        let guard = if self.strategy == LoadBalanceStrategy::LeastConnections {
1506            let counter = self.connection_counter(&backend);
1507            counter.fetch_add(1, Ordering::Relaxed);
1508            Some(ConnectionGuard { counter })
1509        } else {
1510            None
1511        };
1512
1513        let (host, port, _) = crate::proxy_config::health::parse_backend_url(&backend).ok_or_else(bad_gateway)?;
1514
1515        // Apply path rewriting if configured
1516        let request = if self.strip_prefix.is_some() || self.add_prefix.is_some() {
1517            let mut req_clone = request.clone();
1518            if let Some(ref sp) = self.strip_prefix {
1519                if let Some(stripped) = req_clone.request_uri.strip_prefix(sp.as_str()) {
1520                    req_clone.request_uri = if stripped.is_empty() || !stripped.starts_with('/') {
1521                        format!("/{}", stripped)
1522                    } else {
1523                        stripped.to_string()
1524                    };
1525                }
1526            }
1527            if let Some(ref ap) = self.add_prefix {
1528                req_clone.request_uri = format!("{}{}", ap, req_clone.request_uri);
1529            }
1530            std::borrow::Cow::Owned(req_clone)
1531        } else {
1532            std::borrow::Cow::Borrowed(request)
1533        };
1534
1535        Ok(Prepared { backend, host, port, request, _guard: guard })
1536    }
1537
1538    /// Record an attempt's outcome (breaker, metrics) and classify it.
1539    fn settle(&self, backend: &str, result: Result<Response, ()>, started: std::time::Instant) -> Attempt {
1540        let status = result.as_ref().ok().map(|r| r.status_code);
1541        upstream::observe(&self.upstream, backend, status, started);
1542        let failed = status.is_none_or(|s| matches!(s, 502..=504));
1543        if let Some(policy) = &self.policy {
1544            policy.record(backend, failed);
1545        }
1546        match result {
1547            Ok(response) if self.policy.as_deref().is_some_and(|p| p.retries_status(response.status_code)) => Attempt::Failed(Some(response)),
1548            Ok(response) => Attempt::Done(response),
1549            Err(()) => Attempt::Failed(None),
1550        }
1551    }
1552
1553    /// How many attempts `request` may get.
1554    fn attempts(&self, request: &Request) -> u32 {
1555        match self.policy.as_deref().and_then(|p| p.retry.as_ref()) {
1556            Some(retry) if upstream::replayable(request) => 1 + retry.attempts,
1557            _ => 1,
1558        }
1559    }
1560
1561    /// Set the sticky cookie when the client doesn't hold this backend's.
1562    fn stick(&self, request: &Request, backend: &str, response: &mut Response) {
1563        let Some(sticky) = self.policy.as_deref().and_then(|p| p.sticky.as_ref()) else { return };
1564        let id = upstream::backend_id(backend);
1565        if upstream::cookie_value(request, &sticky.cookie).as_deref() == Some(id.as_str()) {
1566            return;
1567        }
1568        let max_age = sticky.max_age_secs.map(|s| format!("; Max-Age={s}")).unwrap_or_default();
1569        response.headers.push(crate::header::Header {
1570            name: "Set-Cookie".into(),
1571            value: format!("{}={id}; Path=/; HttpOnly; SameSite=Lax{max_age}", sticky.cookie).into(),
1572        });
1573    }
1574
1575    /// The same as [`Application::execute`], without blocking a thread while
1576    /// the backend answers, for HTTP and HTTPS backends alike.
1577    #[cfg(feature = "http2")]
1578    pub(crate) async fn execute_async(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1579        let mut tried: Vec<String> = Vec::new();
1580        let mut last: Option<Response> = None;
1581        for attempt in 0..self.attempts(request) {
1582            let prepared = match self.prepare(request, conn, &tried) {
1583                Ok(p) => p,
1584                Err(response) => return Ok(last.unwrap_or(response)),
1585            };
1586            if attempt > 0 {
1587                upstream::observe_retry(&self.upstream);
1588            }
1589            let tls = self.tls.then(|| match &self.trust {
1590                Some(trust) => trust.async_tls(),
1591                None => crate::proxy::async_upstream::AsyncTls {
1592                    config: crate::proxy::tls_client_config(),
1593                    pool: crate::proxy::async_upstream::default_tls_pool(),
1594                },
1595            });
1596            let started = std::time::Instant::now();
1597            let result = crate::proxy::async_upstream::forward(
1598                &prepared.request,
1599                crate::proxy::Peer::of(conn),
1600                &prepared.host,
1601                prepared.port,
1602                tls,
1603                self.connect_timeout,
1604                self.read_timeout,
1605            )
1606            .await
1607            .map_err(|_| ());
1608            match self.settle(&prepared.backend, result, started) {
1609                Attempt::Done(mut response) => {
1610                    self.stick(request, &prepared.backend, &mut response);
1611                    return Ok(response);
1612                }
1613                Attempt::Failed(response) => last = response,
1614            }
1615            tried.push(prepared.backend.clone());
1616        }
1617        Ok(last.unwrap_or_else(bad_gateway))
1618    }
1619
1620    /// Whether [`execute_async`](Self::execute_async) can serve this proxy.
1621    pub(crate) fn supports_async(&self) -> bool {
1622        cfg!(feature = "http2")
1623    }
1624}
1625
1626impl Application for DynamicProxy {
1627    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1628        let mut tried: Vec<String> = Vec::new();
1629        let mut last: Option<Response> = None;
1630        for attempt in 0..self.attempts(request) {
1631            let prepared = match self.prepare(request, conn, &tried) {
1632                Ok(p) => p,
1633                Err(response) => return Ok(last.unwrap_or(response)),
1634            };
1635            if attempt > 0 {
1636                upstream::observe_retry(&self.upstream);
1637            }
1638            // Pooled keep-alive connections to the backend, TLS or not (a
1639            // fresh connection per request halved throughput vs. Caddy/nginx).
1640            let started = std::time::Instant::now();
1641            let result = crate::proxy::forward_upstream_trusting(
1642                crate::proxy::shared_pool(),
1643                &prepared.request,
1644                crate::proxy::Peer::of(conn),
1645                &prepared.host,
1646                prepared.port,
1647                self.tls,
1648                self.trust.as_deref(),
1649                self.connect_timeout,
1650                self.read_timeout,
1651            )
1652            .map_err(|_| ());
1653            match self.settle(&prepared.backend, result, started) {
1654                Attempt::Done(mut response) => {
1655                    self.stick(request, &prepared.backend, &mut response);
1656                    return Ok(response);
1657                }
1658                Attempt::Failed(response) => last = response,
1659            }
1660            tried.push(prepared.backend.clone());
1661        }
1662        Ok(last.unwrap_or_else(bad_gateway))
1663    }
1664}
1665
1666fn service_unavailable() -> Response {
1667    use crate::mime_type::MimeType;
1668    use crate::range::Range;
1669    let mut r = Response::new();
1670    r.status_code = *STATUS_CODE_REASON_PHRASE.n503_service_unavailable.status_code;
1671    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n503_service_unavailable.reason_phrase.to_string();
1672    r.headers.push(crate::header::Header { name: "Retry-After".into(), value: "5".into() });
1673    r.content_range_list = vec![Range::get_content_range(b"503 Service Unavailable: every backend is out of rotation".to_vec(), MimeType::TEXT_PLAIN.to_string())];
1674    r
1675}
1676
1677fn bad_gateway() -> Response {
1678    use crate::mime_type::MimeType;
1679    use crate::range::Range;
1680    let cr = Range::get_content_range(
1681        b"502 Bad Gateway".to_vec(),
1682        MimeType::TEXT_PLAIN.to_string(),
1683    );
1684    let mut r = Response::new();
1685    r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
1686    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n502_bad_gateway.reason_phrase.to_string();
1687    r.content_range_list = vec![cr];
1688    r
1689}
1690
1691// ── RedirectAdapter ────────────────────────────────────────────────────────────
1692
1693/// Action adapter that issues HTTP redirects.
1694///
1695/// `$path` in `location_template` is replaced with the request URI at runtime.
1696#[derive(Clone)]
1697pub(crate) struct RedirectAdapter {
1698    location_template: Arc<String>,
1699    status: i16,
1700    reason: Arc<String>,
1701}
1702
1703impl RedirectAdapter {
1704    pub(crate) fn new(location: String, status: u16) -> Self {
1705        let (code, reason) = redirect_status(status);
1706        RedirectAdapter {
1707            location_template: Arc::new(location),
1708            status: code,
1709            reason: Arc::new(reason),
1710        }
1711    }
1712}
1713
1714fn redirect_status(code: u16) -> (i16, String) {
1715    let phrase = match code {
1716        301 => STATUS_CODE_REASON_PHRASE.n301_moved_permanently.reason_phrase,
1717        302 => STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase,
1718        307 => STATUS_CODE_REASON_PHRASE.n307_temporary_redirect.reason_phrase,
1719        308 => STATUS_CODE_REASON_PHRASE.n308_permanent_redirect.reason_phrase,
1720        _ => "Redirect",
1721    };
1722    (code as i16, phrase.to_string())
1723}
1724
1725impl Application for RedirectAdapter {
1726    fn execute(&self, request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1727        let location = self
1728            .location_template
1729            .replace("$path", &request.request_uri);
1730        use crate::header::Header;
1731        let mut r = Response::new();
1732        r.status_code = self.status;
1733        r.reason_phrase = self.reason.as_ref().clone();
1734        r.headers.push(Header { name: "Location".into(), value: location.into() });
1735        Ok(r)
1736    }
1737}
1738
1739// ── RespondAdapter ─────────────────────────────────────────────────────────────
1740
1741/// Action adapter that returns a fixed response body.
1742#[derive(Clone)]
1743pub(crate) struct RespondAdapter {
1744    status: i16,
1745    reason: Arc<String>,
1746    body: Arc<Vec<u8>>,
1747    content_type: Arc<String>,
1748}
1749
1750impl RespondAdapter {
1751    pub(crate) fn new(status: u16, body: String, content_type: String) -> Self {
1752        use crate::response::STATUS_CODE_REASON_PHRASE;
1753        let reason = match status {
1754            200 => STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string(),
1755            201 => STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string(),
1756            204 => STATUS_CODE_REASON_PHRASE.n204_no_content.reason_phrase.to_string(),
1757            400 => STATUS_CODE_REASON_PHRASE.n400_bad_request.reason_phrase.to_string(),
1758            401 => STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string(),
1759            403 => STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string(),
1760            404 => STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string(),
1761            500 => STATUS_CODE_REASON_PHRASE.n500_internal_server_error.reason_phrase.to_string(),
1762            _ => "OK".to_string(),
1763        };
1764        RespondAdapter {
1765            status: status as i16,
1766            reason: Arc::new(reason),
1767            body: Arc::new(body.into_bytes()),
1768            content_type: Arc::new(content_type),
1769        }
1770    }
1771}
1772
1773impl Application for RespondAdapter {
1774    fn execute(&self, _request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1775        use crate::range::Range;
1776        let mut r = Response::new();
1777        r.status_code = self.status;
1778        r.reason_phrase = self.reason.as_ref().clone();
1779        if !self.body.is_empty() {
1780            r.content_range_list = vec![Range::get_content_range(
1781                self.body.as_ref().clone(),
1782                self.content_type.as_ref().clone(),
1783            )];
1784        }
1785        Ok(r)
1786    }
1787}
1788
1789// ── StaticAdapter ──────────────────────────────────────────────────────────────
1790
1791/// Action adapter that serves static files from a configured `root` directory.
1792///
1793/// Unlike `StaticResourceController` (which always resolves paths relative to
1794/// the process's current working directory), this adapter is parameterized
1795/// per-route by `ActionConfig::Static { root, index }` from `rws.config.toml`,
1796/// so a config-driven proxy can serve an arbitrary directory without Rust code.
1797#[derive(Clone)]
1798pub(crate) struct StaticAdapter {
1799    root: Arc<std::path::PathBuf>,
1800    index: Arc<Vec<String>>,
1801}
1802
1803impl StaticAdapter {
1804    pub(crate) fn new(root: String, index: Vec<String>) -> Self {
1805        let index = if index.is_empty() { vec!["index.html".to_string()] } else { index };
1806        StaticAdapter {
1807            root: Arc::new(std::path::PathBuf::from(root)),
1808            index: Arc::new(index),
1809        }
1810    }
1811
1812    /// Resolves `request_uri` against `root`. Returns `None` if the decoded
1813    /// path contains a `..` segment, which would otherwise let a request
1814    /// escape the configured root directory.
1815    fn resolve(&self, request_uri: &str) -> Option<std::path::PathBuf> {
1816        let raw_path = request_uri.split('?').next().unwrap_or(request_uri);
1817        let decoded = crate::url::URL::percent_decode(raw_path);
1818
1819        if decoded.split('/').any(|segment| segment == "..") {
1820            return None;
1821        }
1822
1823        let relative = decoded.trim_start_matches('/');
1824        Some(self.root.join(relative))
1825    }
1826}
1827
1828impl Application for StaticAdapter {
1829    fn execute(&self, request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1830        let mut response = Response::new();
1831
1832        let not_found = |mut response: Response| {
1833            response.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
1834            response.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
1835            response
1836        };
1837
1838        let candidate = match self.resolve(&request.request_uri) {
1839            Some(p) => p,
1840            None => {
1841                response.status_code = *STATUS_CODE_REASON_PHRASE.n403_forbidden.status_code;
1842                response.reason_phrase = STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string();
1843                return Ok(response);
1844            }
1845        };
1846
1847        let mut file_path = candidate;
1848        if file_path.is_dir() {
1849            let indexed = self
1850                .index
1851                .iter()
1852                .map(|name| file_path.join(name))
1853                .find(|p| p.is_file());
1854
1855            file_path = match indexed {
1856                Some(p) => p,
1857                None => return Ok(not_found(response)),
1858            };
1859        }
1860
1861        if !file_path.is_file() {
1862            return Ok(not_found(response));
1863        }
1864
1865        // Defense-in-depth against symlinks inside `root` that point outside it —
1866        // the `..`-segment check above only catches traversal in the request URI.
1867        if let (Ok(root_canon), Ok(file_canon)) =
1868            (self.root.canonicalize(), file_path.canonicalize())
1869        {
1870            if !file_canon.starts_with(&root_canon) {
1871                return Ok(not_found(response));
1872            }
1873        }
1874
1875        let path_str = match file_path.to_str() {
1876            Some(s) => s,
1877            None => return Ok(not_found(response)),
1878        };
1879
1880        match crate::range::Range::get_content_range_of_a_file(path_str) {
1881            Ok(content_range) => {
1882                response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
1883                response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
1884                response.content_range_list = vec![content_range];
1885                Ok(response)
1886            }
1887            Err(_) => Ok(not_found(response)),
1888        }
1889    }
1890}
1891
1892// ── PerRouteRateLimit middleware ───────────────────────────────────────────────
1893
1894/// A per-route rate limiter middleware backed by a shared `RateLimiter`.
1895pub(crate) struct PerRouteRateLimit(pub(crate) Arc<crate::rate_limit::RateLimiter>);
1896
1897impl crate::middleware::Middleware for PerRouteRateLimit {
1898    fn handle(
1899        &self,
1900        request: &Request,
1901        conn: &ConnectionInfo,
1902        next: &dyn Application,
1903    ) -> Result<Response, String> {
1904        use crate::error::{AppError, IntoResponse};
1905        if self.0.check(&conn.client.ip) {
1906            next.execute(request, conn)
1907        } else {
1908            let mut response = AppError::TooManyRequests.into_response();
1909            let retry_after = self.0.retry_after(&conn.client.ip);
1910            response.headers.push(Header {
1911                name: Header::_RETRY_AFTER.into(),
1912                value: retry_after.to_string().into(),
1913            });
1914            Ok(response)
1915        }
1916    }
1917}
1918
1919// ── PerRouteMaxBodySize ───────────────────────────────────────────────────────
1920
1921/// A route-specific request body size limit, from `[route.middleware]
1922/// max_body_size`.
1923///
1924/// Rejects with `413 Payload Too Large` if `request.body` is bigger than the
1925/// configured ceiling. This runs *after* the request has already been fully
1926/// read off the socket — route middleware only ever sees an already-built
1927/// `Request` — so, unlike the global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`
1928/// (checked against the declared `Content-Length` before any of an
1929/// oversized body is buffered), this can't prevent the memory from being
1930/// spent for a single oversized request. What it protects is this route's
1931/// own downstream logic/upstream backend from a body that's within the
1932/// global limit but still too big for this specific route.
1933pub(crate) struct PerRouteMaxBodySize(pub(crate) u64);
1934
1935impl crate::middleware::Middleware for PerRouteMaxBodySize {
1936    fn handle(
1937        &self,
1938        request: &Request,
1939        conn: &ConnectionInfo,
1940        next: &dyn Application,
1941    ) -> Result<Response, String> {
1942        use crate::error::{AppError, IntoResponse};
1943        if request.body.len() as u64 > self.0 {
1944            Ok(AppError::PayloadTooLarge(format!(
1945                "request body of {} bytes exceeds this route's {}-byte limit",
1946                request.body.len(),
1947                self.0,
1948            ))
1949            .into_response())
1950        } else {
1951            next.execute(request, conn)
1952        }
1953    }
1954}
1955
1956// ── BearerAuthMiddleware ───────────────────────────────────────────────────────
1957
1958/// Bearer token authentication middleware.
1959pub(crate) struct BearerAuthMiddleware {
1960    pub(crate) token: Arc<String>,
1961}
1962
1963impl crate::middleware::Middleware for BearerAuthMiddleware {
1964    fn handle(
1965        &self,
1966        request: &Request,
1967        conn: &ConnectionInfo,
1968        next: &dyn Application,
1969    ) -> Result<Response, String> {
1970        use crate::error::{AppError, IntoResponse};
1971        let expected = format!("Bearer {}", self.token);
1972        let authorized = request
1973            .headers
1974            .iter()
1975            .any(|h| h.name.eq_ignore_ascii_case("authorization") && constant_time_eq(h.value.as_bytes(), expected.as_bytes()));
1976        if authorized {
1977            next.execute(request, conn)
1978        } else {
1979            Ok(AppError::Unauthorized.into_response())
1980        }
1981    }
1982}
1983
1984/// Compares in time independent of where the inputs first differ, so response
1985/// timing doesn't reveal how much of a guessed token was right.
1986pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1987    if a.len() != b.len() {
1988        return false;
1989    }
1990    a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
1991}
1992
1993/// Stands in for an auth layer whose configuration can't be honoured (see
1994/// `builder::apply_middleware`): every request gets `503`, never the route.
1995pub(crate) struct AuthMisconfigured;
1996
1997/// A `[route.middleware.wasm]` plugin that couldn't be loaded: the route
1998/// fails closed.
1999pub(crate) struct PluginMisconfigured;
2000
2001impl crate::middleware::Middleware for PluginMisconfigured {
2002    fn handle(&self, _request: &Request, _conn: &ConnectionInfo, _next: &dyn Application) -> Result<Response, String> {
2003        Ok(Response::text(
2004            STATUS_CODE_REASON_PHRASE.n503_service_unavailable,
2005            "503 Service Unavailable: this route's plugin is misconfigured",
2006        ))
2007    }
2008}
2009
2010impl crate::middleware::Middleware for AuthMisconfigured {
2011    fn handle(
2012        &self,
2013        _request: &Request,
2014        _conn: &ConnectionInfo,
2015        _next: &dyn Application,
2016    ) -> Result<Response, String> {
2017        Ok(Response::text(
2018            STATUS_CODE_REASON_PHRASE.n503_service_unavailable,
2019            "503 Service Unavailable: authentication for this route is misconfigured",
2020        ))
2021    }
2022}
2023
2024// ── arc_app helper ─────────────────────────────────────────────────────────────
2025
2026/// Box any `Application + Send + Sync + 'static` into an `Arc<dyn …>`.
2027pub(crate) fn arc_app<A: Application + Send + Sync + 'static>(
2028    a: A,
2029) -> Arc<dyn Application + Send + Sync> {
2030    Arc::new(a)
2031}
2032
2033// ── Public entry points ────────────────────────────────────────────────────────
2034
2035/// Build a `ConfigDrivenApp` from `rws.config.toml` and spawn L4/WS proxy
2036/// threads. Returns the app and a list of thread handles.
2037pub fn build_from_file() -> (ConfigDrivenApp, Vec<std::thread::JoinHandle<()>>) {
2038    builder::build_from_file()
2039}