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