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