Skip to main content

rust_web_server/proxy_config/
mod.rs

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