Skip to main content

rust_web_server/proxy_config/
mod.rs

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