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