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