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