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