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