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