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 mod builder;
31pub mod validate;
32
33#[cfg(test)]
34mod tests;
35
36use std::sync::atomic::{AtomicBool, Ordering};
37use std::sync::{Arc, RwLock};
38
39use crate::app::App;
40use crate::application::Application;
41use crate::core::New;
42use crate::request::Request;
43use crate::header::Header;
44use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
45use crate::server::ConnectionInfo;
46use crate::server_config::ServerConfig;
47
48// ── Public config types ────────────────────────────────────────────────────────
49
50#[derive(Debug, Clone)]
51pub struct ProxyConfig {
52    pub upstreams: Vec<UpstreamConfig>,
53    pub routes: Vec<RouteConfig>,
54    pub tcp_proxies: Vec<TcpProxyConfig>,
55    pub udp_proxies: Vec<UdpProxyConfig>,
56    pub ws_proxies: Vec<WsProxyConfig>,
57    pub global_middleware: MiddlewareConfig,
58}
59
60#[derive(Debug, Clone)]
61pub struct UpstreamConfig {
62    pub name: String,
63    pub backends: Vec<String>,
64    pub strategy: String, // "round_robin" | "random" | "ip_hash"
65    pub health_check: Option<HealthCheckConfig>,
66    /// `true` when all backends use `https://` scheme — connections to the
67    /// upstream are made over TLS. Requires the `http-client` or `http2`
68    /// feature (which bring in `rustls` + `webpki-roots`).
69    pub tls: bool,
70    /// `tls_ca_file` — PEM file of CA certificates to trust for this
71    /// upstream's `https://` backends **instead of** the public WebPKI roots
72    /// (a private CA or a self-signed certificate). `None` = WebPKI.
73    pub tls_ca_file: Option<String>,
74    /// `tls_client_cert_file` / `tls_client_key_file` — a client certificate
75    /// (PEM chain and key) presented to TLS backends that require one.
76    /// Since 18.3.0.
77    pub tls_client_cert_file: Option<String>,
78    pub tls_client_key_file: Option<String>,
79}
80
81#[derive(Debug, Clone)]
82pub struct HealthCheckConfig {
83    pub path: String,
84    pub interval_secs: u64,
85    pub timeout_ms: u64,
86    pub healthy_threshold: u32,
87    pub unhealthy_threshold: u32,
88}
89
90#[derive(Debug, Clone)]
91pub struct RouteConfig {
92    pub name: String,
93    pub match_: MatchConfig,
94    pub action: ActionConfig,
95    pub middleware: MiddlewareConfig,
96}
97
98#[derive(Debug, Clone, Default)]
99pub struct MatchConfig {
100    pub host: Option<String>,
101    pub path: Option<String>,
102    pub method: Option<String>,
103    pub content_type: Option<String>,
104}
105
106#[derive(Debug, Clone)]
107pub enum ActionConfig {
108    Proxy {
109        upstream: String,
110        connect_timeout_ms: u64,
111        read_timeout_ms: u64,
112        strip_path_prefix: Option<String>,
113        add_path_prefix: Option<String>,
114    },
115    Grpc {
116        upstream: String,
117        connect_timeout_ms: u64,
118        read_timeout_ms: u64,
119    },
120    Static {
121        root: String,
122        index: Vec<String>,
123    },
124    Redirect {
125        location: String,
126        status: u16,
127    },
128    Respond {
129        status: u16,
130        body: String,
131        content_type: String,
132    },
133    Mcp,
134    Unknown(String),
135}
136
137#[derive(Debug, Clone, Default)]
138pub struct MiddlewareConfig {
139    pub rate_limit: Option<RateLimitConfig>,
140    pub cache: Option<CacheConfig>,
141    pub auth: Option<AuthConfig>,
142    pub rewrite_request: Vec<RewriteRuleConfig>,
143    pub rewrite_response: Vec<RewriteRuleConfig>,
144    pub ip_allow: Vec<String>,
145    pub ip_deny: Vec<String>,
146    /// `timeout_ms` — if the route (including all its other middleware)
147    /// doesn't produce a response within this many milliseconds, the client
148    /// gets `504 Gateway Timeout` instead of waiting further. See
149    /// `crate::timeout` for the underlying mechanism and its limitations.
150    pub timeout_ms: Option<u64>,
151    /// `max_body_size` — reject this route's request with `413 Payload Too
152    /// Large` if its body exceeds this many bytes.
153    ///
154    /// This is a *route-specific* ceiling on top of the global, process-wide
155    /// `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (see `crate::entry_point::get_max_body_size`)
156    /// — e.g. a small JSON API route can be capped stricter than a
157    /// file-upload route without lowering the global limit for everyone.
158    /// It is **not** a substitute for the global limit: by the time any
159    /// route-level middleware runs, `Request` (including its body) has
160    /// already been fully read off the socket — route matching itself
161    /// requires the complete request, so a per-route check can only run
162    /// after that memory is already spent, not before. The global limit is
163    /// what protects total server memory from an oversized upload;
164    /// `max_body_size` protects only this route's own logic/upstream from
165    /// an oversized-but-still-within-the-global-limit body.
166    pub max_body_size: Option<u64>,
167}
168
169#[derive(Debug, Clone)]
170pub struct RateLimitConfig {
171    pub max_requests: u32,
172    pub window_secs: u64,
173}
174
175#[derive(Debug, Clone)]
176pub struct CacheConfig {
177    pub ttl_secs: u64,
178    pub vary_by: Vec<String>,
179}
180
181#[derive(Debug, Clone)]
182pub enum AuthConfig {
183    /// `auth = { type = "basic", htpasswd_file = ".htpasswd" }`. See
184    /// `crate::auth::BasicAuthLayer::from_htpasswd_file` for the supported
185    /// file format (plain text and `{SHA256}` only — not Apache's `{SHA}`,
186    /// `$apr1$`, or bcrypt).
187    Basic { htpasswd_file: String },
188    /// `auth = { type = "jwt", secret_env = "JWT_SECRET" }`. Requires the
189    /// `auth` feature; verifies HS256 JWTs via `crate::auth::JwtLayer`.
190    Jwt { secret_env: String },
191    Bearer { token_env: String },
192}
193
194#[derive(Debug, Clone, Default)]
195pub struct RewriteRuleConfig {
196    pub type_: String,
197    pub name: Option<String>,
198    pub value: Option<String>,
199    pub prefix: Option<String>,
200    pub from: Option<String>,
201    pub to: Option<String>,
202    pub code: Option<u16>,
203    pub reason: Option<String>,
204}
205
206#[derive(Debug, Clone)]
207pub struct TcpProxyConfig {
208    pub name: String,
209    pub listen: String,
210    pub backends: Vec<String>,
211    pub connect_timeout_ms: u64,
212}
213
214#[derive(Debug, Clone)]
215pub struct UdpProxyConfig {
216    pub name: String,
217    pub listen: String,
218    pub backends: Vec<String>,
219    pub reply_timeout_ms: u64,
220    pub buffer_size: usize,
221}
222
223#[derive(Debug, Clone)]
224pub struct WsProxyConfig {
225    pub name: String,
226    pub listen: String,
227    pub backends: Vec<String>,
228    pub connect_timeout_ms: u64,
229    pub read_timeout_ms: u64,
230    /// Optional `[ws_proxy.health_check]` — same shape and semantics as
231    /// `[upstream.health_check]`. The probe is a plain HTTP `GET {path}`
232    /// against the backend's `host:port` (the `ws://`/`wss://` scheme only
233    /// determines whether the probe connects over TLS); most WebSocket
234    /// backends serve a regular HTTP health endpoint alongside their upgrade
235    /// route, the same way nginx/Traefik health-check WS upstreams.
236    pub health_check: Option<HealthCheckConfig>,
237}
238
239// ── ProxyConfig loading ────────────────────────────────────────────────────────
240
241
242/// An action's settings may sit directly under `[route.action]` next to
243/// `type` (the short form README.md and the docs use), or in a nested
244/// `[route.action.<type>]` table (the long form). Copy short-form keys into
245/// the nested table so the parser reads one place; a key set in both keeps
246/// the nested value.
247fn merge_action_keys(map: &mut parser::SectionMap, action_section: &str, action_type: &str) {
248    if action_type.is_empty() {
249        return;
250    }
251    let short_form: Vec<(String, String)> = match map.get(action_section) {
252        Some(entries) => entries.iter().filter(|(k, _)| k != "type").cloned().collect(),
253        None => return,
254    };
255    let nested = map.entry(format!("{action_section}.{action_type}")).or_default();
256    for (key, value) in short_form {
257        if !nested.iter().any(|(k, _)| *k == key) {
258            nested.push((key, value));
259        }
260    }
261}
262
263impl ProxyConfig {
264    /// Returns `true` if `rws.config.toml` (or `RWS_CONFIG_FILE`) contains
265    /// `[[route]]` or `[[upstream]]` sections, meaning config-driven mode
266    /// should be used.
267    pub fn is_proxy_mode() -> bool {
268        let path = config_file_path();
269        match std::fs::read_to_string(&path) {
270            Ok(contents) => {
271                contents.contains("[[route]]") || contents.contains("[[upstream]]")
272            }
273            Err(_) => false,
274        }
275    }
276
277    /// Parse the config file and return a `ProxyConfig`.
278    pub fn load() -> Self {
279        let path = config_file_path();
280        let contents = std::fs::read_to_string(&path).unwrap_or_default();
281        Self::from_str(&contents)
282    }
283
284    /// Parse `toml` text directly into a `ProxyConfig`. Used in tests.
285    pub fn from_str(toml: &str) -> Self {
286        use parser::{get_array, get_str, get_u32, get_u64, section_exists};
287
288        let mut map = parser::parse(toml);
289
290        // ── upstreams ──────────────────────────────────────────────────────────
291        let mut upstreams = Vec::new();
292        let mut i = 0;
293        loop {
294            let sec = format!("upstream[{}]", i);
295            if !section_exists(&map, &sec) {
296                break;
297            }
298            let name = get_str(&map, &sec, "name");
299            let backends = get_array(&map, &sec, "backends");
300            let strategy = {
301                let s = get_str(&map, &sec, "strategy");
302                if s.is_empty() { "round_robin".to_string() } else { s }
303            };
304            let hc_sec = format!("{}.health_check", sec);
305            let health_check = if section_exists(&map, &hc_sec) {
306                Some(HealthCheckConfig {
307                    path: {
308                        let p = get_str(&map, &hc_sec, "path");
309                        if p.is_empty() { "/health".to_string() } else { p }
310                    },
311                    interval_secs: get_u64(&map, &hc_sec, "interval_secs", 30),
312                    timeout_ms: get_u64(&map, &hc_sec, "timeout_ms", 5000),
313                    healthy_threshold: get_u32(&map, &hc_sec, "healthy_threshold", 2),
314                    unhealthy_threshold: get_u32(&map, &hc_sec, "unhealthy_threshold", 3),
315                })
316            } else {
317                None
318            };
319            let tls = backends.iter().any(|b| ["https://", "h2s://", "grpcs://"].iter().any(|s| b.starts_with(s)));
320            let tls_ca_file = Some(get_str(&map, &sec, "tls_ca_file")).filter(|v| !v.is_empty());
321            let tls_client_cert_file = Some(get_str(&map, &sec, "tls_client_cert_file")).filter(|v| !v.is_empty());
322            let tls_client_key_file = Some(get_str(&map, &sec, "tls_client_key_file")).filter(|v| !v.is_empty());
323            upstreams.push(UpstreamConfig {
324                name, backends, strategy, health_check, tls, tls_ca_file, tls_client_cert_file, tls_client_key_file,
325            });
326            i += 1;
327        }
328
329        // ── routes ─────────────────────────────────────────────────────────────
330        let mut routes = Vec::new();
331        let mut i = 0;
332        loop {
333            let sec = format!("route[{}]", i);
334            if !section_exists(&map, &sec) {
335                break;
336            }
337            let name = get_str(&map, &sec, "name");
338
339            // match
340            let m_sec = format!("{}.match", sec);
341            let match_ = MatchConfig {
342                host: {
343                    let h = get_str(&map, &m_sec, "host");
344                    if h.is_empty() { None } else { Some(h) }
345                },
346                path: {
347                    let p = get_str(&map, &m_sec, "path");
348                    if p.is_empty() { None } else { Some(p) }
349                },
350                method: {
351                    let m = get_str(&map, &m_sec, "method");
352                    if m.is_empty() { None } else { Some(m.to_uppercase()) }
353                },
354                content_type: {
355                    let c = get_str(&map, &m_sec, "content_type");
356                    if c.is_empty() { None } else { Some(c) }
357                },
358            };
359
360            // action
361            let a_sec = format!("{}.action", sec);
362            let action_type = get_str(&map, &a_sec, "type");
363            merge_action_keys(&mut map, &a_sec, &action_type);
364            let action = match action_type.as_str() {
365                "proxy" => {
366                    let p_sec = format!("{}.action.proxy", sec);
367                    ActionConfig::Proxy {
368                        upstream: get_str(&map, &p_sec, "upstream"),
369                        connect_timeout_ms: get_u64(&map, &p_sec, "connect_timeout_ms", 5000),
370                        read_timeout_ms: get_u64(&map, &p_sec, "read_timeout_ms", 30000),
371                        strip_path_prefix: {
372                            let v = get_str(&map, &p_sec, "strip_path_prefix");
373                            if v.is_empty() { None } else { Some(v) }
374                        },
375                        add_path_prefix: {
376                            let v = get_str(&map, &p_sec, "add_path_prefix");
377                            if v.is_empty() { None } else { Some(v) }
378                        },
379                    }
380                }
381                "grpc" => {
382                    let p_sec = format!("{}.action.grpc", sec);
383                    ActionConfig::Grpc {
384                        upstream: get_str(&map, &p_sec, "upstream"),
385                        connect_timeout_ms: get_u64(&map, &p_sec, "connect_timeout_ms", 5000),
386                        read_timeout_ms: get_u64(&map, &p_sec, "read_timeout_ms", 30000),
387                    }
388                }
389                "static" => {
390                    let s_sec = format!("{}.action.static", sec);
391                    ActionConfig::Static {
392                        root: get_str(&map, &s_sec, "root"),
393                        index: get_array(&map, &s_sec, "index"),
394                    }
395                }
396                "redirect" => {
397                    let r_sec = format!("{}.action.redirect", sec);
398                    ActionConfig::Redirect {
399                        location: get_str(&map, &r_sec, "location"),
400                        status: get_u64(&map, &r_sec, "status", 301) as u16,
401                    }
402                }
403                "respond" => {
404                    let r_sec = format!("{}.action.respond", sec);
405                    ActionConfig::Respond {
406                        status: get_u64(&map, &r_sec, "status", 200) as u16,
407                        body: get_str(&map, &r_sec, "body"),
408                        content_type: {
409                            let ct = get_str(&map, &r_sec, "content_type");
410                            if ct.is_empty() { "text/plain".to_string() } else { ct }
411                        },
412                    }
413                }
414                "mcp" => ActionConfig::Mcp,
415                other => ActionConfig::Unknown(other.to_string()),
416            };
417
418            // middleware
419            let mw_sec = format!("{}.middleware", sec);
420            let middleware = parse_middleware_config(&map, &mw_sec, i);
421
422            routes.push(RouteConfig { name, match_, action, middleware });
423            i += 1;
424        }
425
426        // ── tcp_proxy ──────────────────────────────────────────────────────────
427        let mut tcp_proxies = Vec::new();
428        let mut i = 0;
429        loop {
430            let sec = format!("tcp_proxy[{}]", i);
431            if !section_exists(&map, &sec) {
432                break;
433            }
434            tcp_proxies.push(TcpProxyConfig {
435                name: get_str(&map, &sec, "name"),
436                listen: get_str(&map, &sec, "listen"),
437                backends: get_array(&map, &sec, "backends"),
438                connect_timeout_ms: get_u64(&map, &sec, "connect_timeout_ms", 5000),
439            });
440            i += 1;
441        }
442
443        // ── udp_proxy ──────────────────────────────────────────────────────────
444        let mut udp_proxies = Vec::new();
445        let mut i = 0;
446        loop {
447            let sec = format!("udp_proxy[{}]", i);
448            if !section_exists(&map, &sec) {
449                break;
450            }
451            udp_proxies.push(UdpProxyConfig {
452                name: get_str(&map, &sec, "name"),
453                listen: get_str(&map, &sec, "listen"),
454                backends: get_array(&map, &sec, "backends"),
455                reply_timeout_ms: get_u64(&map, &sec, "reply_timeout_ms", 5000),
456                buffer_size: get_u64(&map, &sec, "buffer_size", 65536) as usize,
457            });
458            i += 1;
459        }
460
461        // ── ws_proxy ───────────────────────────────────────────────────────────
462        let mut ws_proxies = Vec::new();
463        let mut i = 0;
464        loop {
465            let sec = format!("ws_proxy[{}]", i);
466            if !section_exists(&map, &sec) {
467                break;
468            }
469            let hc_sec = format!("{}.health_check", sec);
470            let health_check = if section_exists(&map, &hc_sec) {
471                Some(HealthCheckConfig {
472                    path: {
473                        let p = get_str(&map, &hc_sec, "path");
474                        if p.is_empty() { "/health".to_string() } else { p }
475                    },
476                    interval_secs: get_u64(&map, &hc_sec, "interval_secs", 30),
477                    timeout_ms: get_u64(&map, &hc_sec, "timeout_ms", 5000),
478                    healthy_threshold: get_u32(&map, &hc_sec, "healthy_threshold", 2),
479                    unhealthy_threshold: get_u32(&map, &hc_sec, "unhealthy_threshold", 3),
480                })
481            } else {
482                None
483            };
484            ws_proxies.push(WsProxyConfig {
485                name: get_str(&map, &sec, "name"),
486                listen: get_str(&map, &sec, "listen"),
487                backends: get_array(&map, &sec, "backends"),
488                connect_timeout_ms: get_u64(&map, &sec, "connect_timeout_ms", 5000),
489                read_timeout_ms: get_u64(&map, &sec, "read_timeout_ms", 30000),
490                health_check,
491            });
492            i += 1;
493        }
494
495        // ── global middleware ──────────────────────────────────────────────────
496        let global_middleware = parse_middleware_config(&map, "middleware", usize::MAX);
497
498        ProxyConfig {
499            upstreams,
500            routes,
501            tcp_proxies,
502            udp_proxies,
503            ws_proxies,
504            global_middleware,
505        }
506    }
507}
508
509/// Parse a `MiddlewareConfig` from the section map at a given base path.
510/// `route_idx` is used only to build inner-array section paths for rewrite rules.
511fn parse_middleware_config(
512    map: &parser::SectionMap,
513    mw_sec: &str,
514    route_idx: usize,
515) -> MiddlewareConfig {
516    use parser::{get_array, get_str, get_u32, get_u64, section_exists};
517
518    let rl_sec = format!("{}.rate_limit", mw_sec);
519    let rate_limit = if section_exists(map, &rl_sec) {
520        Some(RateLimitConfig {
521            max_requests: get_u32(map, &rl_sec, "max_requests", 1000),
522            window_secs: get_u64(map, &rl_sec, "window_secs", 60),
523        })
524    } else {
525        None
526    };
527
528    let c_sec = format!("{}.cache", mw_sec);
529    let cache = if section_exists(map, &c_sec) {
530        Some(CacheConfig {
531            ttl_secs: get_u64(map, &c_sec, "ttl_secs", 60),
532            vary_by: get_array(map, &c_sec, "vary_by"),
533        })
534    } else {
535        None
536    };
537
538    let a_sec = format!("{}.auth", mw_sec);
539    let auth = if section_exists(map, &a_sec) {
540        let auth_type = get_str(map, &a_sec, "type");
541        match auth_type.as_str() {
542            "bearer" => Some(AuthConfig::Bearer {
543                token_env: get_str(map, &a_sec, "token_env"),
544            }),
545            "jwt" => Some(AuthConfig::Jwt {
546                secret_env: get_str(map, &a_sec, "secret_env"),
547            }),
548            "basic" => Some(AuthConfig::Basic {
549                htpasswd_file: get_str(map, &a_sec, "htpasswd_file"),
550            }),
551            _ => None,
552        }
553    } else {
554        None
555    };
556
557    // Rewrite rules — the section paths use route_idx for route-scoped rules
558    // or a flat path for global middleware. We look for:
559    //   route[N].middleware.rewrite.request[0], [1], …
560    //   route[N].middleware.rewrite.response[0], [1], …
561    // For global: middleware.rewrite.request[0], etc.
562    let rewrite_request = collect_rewrite_rules(map, mw_sec, "request");
563    let rewrite_response = collect_rewrite_rules(map, mw_sec, "response");
564
565    let ip_sec = format!("{}.ip_filter", mw_sec);
566    let ip_allow = if section_exists(map, &ip_sec) {
567        get_array(map, &ip_sec, "allow")
568    } else {
569        vec![]
570    };
571    let ip_deny = if section_exists(map, &ip_sec) {
572        get_array(map, &ip_sec, "deny")
573    } else {
574        vec![]
575    };
576
577    let _ = route_idx; // used implicitly via mw_sec paths
578
579    // Flat scalar directly under [route.middleware] (or the global
580    // [middleware] table), not a nested sub-table like rate_limit/cache —
581    // 0/absent both mean "no timeout configured".
582    let timeout_ms = match get_u64(map, mw_sec, "timeout_ms", 0) {
583        0 => None,
584        ms => Some(ms),
585    };
586
587    // Same flat-scalar convention as timeout_ms — 0/absent means "no
588    // route-specific limit" (the global RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES
589    // still applies regardless).
590    let max_body_size = match get_u64(map, mw_sec, "max_body_size", 0) {
591        0 => None,
592        n => Some(n),
593    };
594
595    MiddlewareConfig {
596        rate_limit, cache, auth, rewrite_request, rewrite_response, ip_allow, ip_deny,
597        timeout_ms, max_body_size,
598    }
599}
600
601/// Collect `[[{mw_sec}.rewrite.{direction}]]` entries.
602fn collect_rewrite_rules(
603    map: &parser::SectionMap,
604    mw_sec: &str,
605    direction: &str,
606) -> Vec<RewriteRuleConfig> {
607    use parser::{get_str, get_u64};
608
609    let mut rules = Vec::new();
610    let mut j = 0;
611    loop {
612        let rsec = format!("{}.rewrite.{}[{}]", mw_sec, direction, j);
613        if !parser::section_exists(map, &rsec) {
614            break;
615        }
616        let code_val = get_u64(map, &rsec, "code", 0);
617        rules.push(RewriteRuleConfig {
618            type_: get_str(map, &rsec, "type"),
619            name: {
620                let v = get_str(map, &rsec, "name");
621                if v.is_empty() { None } else { Some(v) }
622            },
623            value: {
624                let v = get_str(map, &rsec, "value");
625                if v.is_empty() { None } else { Some(v) }
626            },
627            prefix: {
628                let v = get_str(map, &rsec, "prefix");
629                if v.is_empty() { None } else { Some(v) }
630            },
631            from: {
632                let v = get_str(map, &rsec, "from");
633                if v.is_empty() { None } else { Some(v) }
634            },
635            to: {
636                let v = get_str(map, &rsec, "to");
637                if v.is_empty() { None } else { Some(v) }
638            },
639            code: if code_val == 0 { None } else { Some(code_val as u16) },
640            reason: {
641                let v = get_str(map, &rsec, "reason");
642                if v.is_empty() { None } else { Some(v) }
643            },
644        });
645        j += 1;
646    }
647    rules
648}
649
650pub(crate) fn config_file_path() -> String {
651    std::env::var("RWS_CONFIG_FILE").unwrap_or_else(|_| "rws.config.toml".to_string())
652}
653
654// ── ConfigDrivenApp ────────────────────────────────────────────────────────────
655
656/// A compiled route: a matcher paired with a handler application.
657pub(crate) struct CompiledRoute {
658    pub(crate) matcher: RouteMatcher,
659    /// Shared, type-erased handler. `Arc` makes `Clone` cheap (pointer copy).
660    pub(crate) handler: Arc<dyn Application + Send + Sync>,
661    /// The route's proxy when it can be served without blocking a thread
662    /// (plain-HTTP upstream, no route middleware) — see
663    /// [`DynamicProxy::execute_async`].
664    #[cfg_attr(not(feature = "http2"), allow(dead_code))]
665    pub(crate) async_proxy: Option<Arc<DynamicProxy>>,
666}
667
668/// Matching criteria for a single route.
669#[derive(Clone, Default)]
670pub(crate) struct RouteMatcher {
671    /// Optional SNI hostname / `Host` header match.
672    pub(crate) host: Option<String>,
673    /// Path prefix to match (derived from `path = "/v1/*"`).
674    pub(crate) path_prefix: Option<String>,
675    /// Exact path to match (derived from `path = "/v1/ping"`).
676    pub(crate) path_exact: Option<String>,
677    /// Uppercase HTTP method, or `None` for any.
678    pub(crate) method: Option<String>,
679    /// `Content-Type` prefix (e.g. `"application/grpc"`).
680    pub(crate) content_type_prefix: Option<String>,
681}
682
683impl RouteMatcher {
684    pub(crate) fn from_match_config(cfg: &MatchConfig) -> Self {
685        let (path_prefix, path_exact) = match &cfg.path {
686            Some(p) if p.ends_with('*') => {
687                // "/v1/*" → prefix "/v1/"
688                let stripped = p.trim_end_matches('*').to_string();
689                (Some(stripped), None)
690            }
691            Some(p) => (None, Some(p.clone())),
692            None => (None, None),
693        };
694        let content_type_prefix = cfg.content_type.as_ref().map(|ct| {
695            if ct.ends_with('*') {
696                ct.trim_end_matches('*').to_string()
697            } else {
698                ct.clone()
699            }
700        });
701        RouteMatcher {
702            host: cfg.host.clone(),
703            path_prefix,
704            path_exact,
705            method: cfg.method.clone(),
706            content_type_prefix,
707        }
708    }
709
710    /// Returns `true` if `request` and `conn` match all configured criteria.
711    pub(crate) fn matches(&self, request: &Request, conn: &ConnectionInfo) -> bool {
712        // Host matching: SNI first, then Host header
713        if let Some(ref expected_host) = self.host {
714            let actual_host = conn
715                .sni_hostname
716                .as_deref()
717                .or_else(|| {
718                    request
719                        .headers
720                        .iter()
721                        .find(|h| h.name.eq_ignore_ascii_case("host"))
722                        .map(|h| h.value.as_ref())
723                })
724                .unwrap_or("");
725            if !host_matches(expected_host, actual_host) {
726                return false;
727            }
728        }
729
730        // Method matching
731        if let Some(ref m) = self.method {
732            if request.method.to_uppercase() != m.as_str() {
733                return false;
734            }
735        }
736
737        // Path matching: strip query string for comparison
738        let path = request.request_uri.split('?').next().unwrap_or(&request.request_uri);
739        if let Some(ref prefix) = self.path_prefix {
740            if !path.starts_with(prefix.as_str()) {
741                return false;
742            }
743        } else if let Some(ref exact) = self.path_exact {
744            if path != exact.as_str() {
745                return false;
746            }
747        }
748
749        // Content-Type prefix matching
750        if let Some(ref ct_prefix) = self.content_type_prefix {
751            let actual_ct = request
752                .headers
753                .iter()
754                .find(|h| h.name.eq_ignore_ascii_case("content-type"))
755                .map(|h| h.value.as_ref())
756                .unwrap_or("");
757            if !actual_ct.starts_with(ct_prefix.as_str()) {
758                return false;
759            }
760        }
761
762        true
763    }
764}
765
766/// An `Application` that routes requests based on a parsed `ProxyConfig`.
767///
768/// Clones share one live configuration: a hot reload
769/// ([`ConfigDrivenApp::reload_from_str`], run on `SIGHUP` for an app built by
770/// [`build_from_file`]) swaps it for every clone at once. In-flight requests
771/// finish on the configuration they started with.
772#[derive(Clone)]
773pub struct ConfigDrivenApp {
774    current: Arc<RwLock<Arc<Generation>>>,
775    /// Serializes reloads: one SIGHUP reaches every accept loop.
776    reload_lock: Arc<std::sync::Mutex<()>>,
777}
778
779/// One loaded configuration: its compiled routes plus what a reload needs.
780struct Generation {
781    dispatch: RouteDispatch,
782    /// `dispatch` wrapped in the top-level `[middleware]` table; `None` when
783    /// that table is empty.
784    entry: Option<Arc<dyn Application + Send + Sync>>,
785    config: ProxyConfig,
786    /// The file text this generation was built from, to skip no-op reloads.
787    source: Option<String>,
788    /// Set when this generation is replaced: stops its health checkers.
789    health_stop: Arc<AtomicBool>,
790}
791
792impl Generation {
793    fn new(routes: Arc<Vec<CompiledRoute>>, fallback: App, config: ProxyConfig, health_stop: Arc<AtomicBool>, source: Option<String>) -> Self {
794        let dispatch = RouteDispatch { routes, fallback };
795        let entry = if config.global_middleware.is_empty() {
796            None
797        } else {
798            Some(builder::apply_middleware(arc_app(dispatch.clone()), &config.global_middleware))
799        };
800        Generation { dispatch, entry, config, source, health_stop }
801    }
802}
803
804/// First-match routing over the compiled routes, then the fallback.
805#[derive(Clone)]
806struct RouteDispatch {
807    routes: Arc<Vec<CompiledRoute>>,
808    /// Fallback for unmatched requests — handles /healthz, /readyz, /metrics,
809    /// static files, and the 404 controller. Reads `RWS_CONFIG_*` env vars
810    /// per request (`App::new()`'s default) unless pinned via
811    /// [`ConfigDrivenApp::with_config`].
812    fallback: App,
813}
814
815impl Application for RouteDispatch {
816    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
817        for route in self.routes.iter() {
818            if route.matcher.matches(request, conn) {
819                return route.handler.execute(request, conn);
820            }
821        }
822        self.fallback.execute(request, conn)
823    }
824
825    fn streams_body(&self, request: &Request, conn: &ConnectionInfo) -> bool {
826        match self.routes.iter().find(|r| r.matcher.matches(request, conn)) {
827            Some(route) => route.handler.streams_body(request, conn),
828            None => self.fallback.streams_body(request, conn),
829        }
830    }
831}
832
833impl MiddlewareConfig {
834    /// `true` when no layer is configured.
835    pub fn is_empty(&self) -> bool {
836        self.rate_limit.is_none() && self.cache.is_none() && self.auth.is_none()
837            && self.rewrite_request.is_empty() && self.rewrite_response.is_empty()
838            && self.ip_allow.is_empty() && self.ip_deny.is_empty()
839            && self.timeout_ms.is_none() && self.max_body_size.is_none()
840    }
841}
842
843/// What a successful [`ConfigDrivenApp::reload_from_str`] changed.
844#[derive(Debug, Clone, PartialEq)]
845pub enum ReloadOutcome {
846    /// The configuration text is unchanged; nothing was rebuilt.
847    Unchanged,
848    /// New routes are live. `warnings` includes `[[tcp_proxy]]` /
849    /// `[[udp_proxy]]` / `[[ws_proxy]]` changes, which need a restart.
850    Reloaded { routes: usize, warnings: Vec<String> },
851}
852
853impl ConfigDrivenApp {
854    pub(crate) fn new(routes: Vec<CompiledRoute>, config: ProxyConfig, health_stop: Arc<AtomicBool>, source: Option<String>) -> Self {
855        use crate::core::New;
856        let generation = Generation::new(Arc::new(routes), App::new(), config, health_stop, source);
857        ConfigDrivenApp {
858            current: Arc::new(RwLock::new(Arc::new(generation))),
859            reload_lock: Arc::new(std::sync::Mutex::new(())),
860        }
861    }
862
863    fn current(&self) -> Arc<Generation> {
864        Arc::clone(&self.current.read().unwrap_or_else(|e| e.into_inner()))
865    }
866
867    /// Pin the fallback [`App`] (used for any request none of the
868    /// config-driven routes match) to an explicit [`ServerConfig`], instead
869    /// of reading `RWS_CONFIG_*` environment variables per request.
870    ///
871    /// Mirrors [`App::with_config`] / [`crate::state::AppWithState::with_config`]
872    /// — same rationale: safe for parallel tests, and lets multiple
873    /// differently-configured proxy instances coexist in one process. The
874    /// pinned fallback is kept across hot reloads.
875    ///
876    /// ```rust,no_run
877    /// use rust_web_server::proxy_config::build_from_file;
878    /// use rust_web_server::server_config::ServerConfig;
879    ///
880    /// let (app, _handles) = build_from_file();
881    /// let app = app.with_config(ServerConfig::default());
882    /// ```
883    pub fn with_config(self, config: ServerConfig) -> Self {
884        {
885            let mut slot = self.current.write().unwrap_or_else(|e| e.into_inner());
886            let old = Arc::clone(&slot);
887            *slot = Arc::new(Generation::new(
888                Arc::clone(&old.dispatch.routes),
889                App::with_config(config),
890                old.config.clone(),
891                Arc::clone(&old.health_stop),
892                old.source.clone(),
893            ));
894        }
895        self
896    }
897
898    /// Replace the routes, upstreams and `[middleware]` with those in `toml`.
899    ///
900    /// The new configuration is validated first ([`ProxyConfig::validate`]
901    /// plus unknown keys); if it has errors, the running configuration stays
902    /// in place and the errors are returned — a typo never takes the proxy
903    /// down. Otherwise the new routes go live for every clone of this app,
904    /// the previous configuration's health checkers stop, and new ones start.
905    /// `[[tcp_proxy]]` / `[[udp_proxy]]` / `[[ws_proxy]]` listeners are not
906    /// rebound; a change to them is reported as a warning.
907    pub fn reload_from_str(&self, toml: &str) -> Result<ReloadOutcome, crate::config_check::ConfigReport> {
908        let _serialized = self.reload_lock.lock().unwrap_or_else(|e| e.into_inner());
909        let old = self.current();
910        if old.source.as_deref() == Some(toml) {
911            return Ok(ReloadOutcome::Unchanged);
912        }
913        let config = ProxyConfig::from_str(toml);
914        let mut report = crate::config_check::ConfigReport::default();
915        validate::check_unknown_keys(toml, &mut report);
916        config.validate(&mut report);
917        if !report.is_ok() {
918            return Err(report);
919        }
920
921        let mut warnings = report.warnings;
922        let listeners = |c: &ProxyConfig| format!("{:?}{:?}{:?}", c.tcp_proxies, c.udp_proxies, c.ws_proxies);
923        if listeners(&config) != listeners(&old.config) {
924            warnings.push("[[tcp_proxy]] / [[udp_proxy]] / [[ws_proxy]] changes need a restart; the old listeners keep running".to_string());
925        }
926
927        let health_stop = Arc::new(AtomicBool::new(false));
928        let routes = builder::compile_routes(&config, &health_stop);
929        let route_count = routes.len();
930        let generation = Generation::new(Arc::new(routes), old.dispatch.fallback.clone(), config, health_stop, Some(toml.to_string()));
931        let replaced = {
932            let mut slot = self.current.write().unwrap_or_else(|e| e.into_inner());
933            std::mem::replace(&mut *slot, Arc::new(generation))
934        };
935        replaced.health_stop.store(true, Ordering::Relaxed);
936        Ok(ReloadOutcome::Reloaded { routes: route_count, warnings })
937    }
938
939    /// Re-read the configuration file (`RWS_CONFIG_FILE` or
940    /// `rws.config.toml`) and [`reload_from_str`](Self::reload_from_str) it,
941    /// logging the outcome. Registered as a [`crate::config_reload`] hook by
942    /// [`build_from_file`].
943    pub fn reload_from_file(&self) {
944        let path = config_file_path();
945        let toml = match std::fs::read_to_string(&path) {
946            Ok(toml) => toml,
947            Err(e) => {
948                eprintln!("[proxy_config] reload: cannot read {path}: {e} — keeping the running routes");
949                return;
950            }
951        };
952        match self.reload_from_str(&toml) {
953            Ok(ReloadOutcome::Unchanged) => {}
954            Ok(ReloadOutcome::Reloaded { routes, warnings }) => {
955                for w in &warnings {
956                    eprintln!("[proxy_config] Warning: {w}");
957                }
958                println!("[proxy_config] reloaded {path}: {routes} route{}", if routes == 1 { "" } else { "s" });
959            }
960            Err(report) => {
961                for e in &report.errors {
962                    eprintln!("[proxy_config] reload ERROR: {e}");
963                }
964                eprintln!("[proxy_config] {path} has errors — keeping the running routes");
965            }
966        }
967    }
968}
969
970/// The request with its target normalized ([`normalize_request_target`]),
971/// borrowed when it already is.
972fn normalized(request: &Request) -> std::borrow::Cow<'_, Request> {
973    match normalize_request_target(&request.request_uri) {
974        std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(request),
975        std::borrow::Cow::Owned(target) => {
976            let mut owned = request.clone();
977            owned.request_uri = target;
978            std::borrow::Cow::Owned(owned)
979        }
980    }
981}
982
983/// A request target's path normalized before routes match it, and sent
984/// upstream that way (#50): unreserved characters percent-decoded (`%61` →
985/// `a`; other escapes kept, hex upper-cased), repeated slashes collapsed,
986/// `.` and `..` segments resolved (never above `/`). Otherwise `/%61dmin`,
987/// `//admin` or `/x/../admin` missed a protected `/admin` route and reached
988/// the backend through a catch-all route. The query is kept as is; a
989/// target that isn't a path (`*`) is returned unchanged.
990pub(crate) fn normalize_request_target(target: &str) -> std::borrow::Cow<'_, str> {
991    let (path, query) = match target.find('?') {
992        Some(i) => (&target[..i], &target[i..]),
993        None => (target, ""),
994    };
995    if !path.starts_with('/') || !(path.contains('%') || path.contains("//") || path.contains("/.")) {
996        return std::borrow::Cow::Borrowed(target);
997    }
998    let bytes = path.as_bytes();
999    let mut decoded = String::with_capacity(path.len());
1000    let mut i = 0;
1001    while i < bytes.len() {
1002        let hex = |b: u8| (b as char).to_digit(16);
1003        if bytes[i] == b'%' && i + 2 < bytes.len() {
1004            if let (Some(h), Some(l)) = (hex(bytes[i + 1]), hex(bytes[i + 2])) {
1005                let value = (h * 16 + l) as u8;
1006                if value.is_ascii_alphanumeric() || matches!(value, b'-' | b'.' | b'_' | b'~') {
1007                    decoded.push(value as char);
1008                } else {
1009                    decoded.push('%');
1010                    decoded.push(bytes[i + 1].to_ascii_uppercase() as char);
1011                    decoded.push(bytes[i + 2].to_ascii_uppercase() as char);
1012                }
1013                i += 3;
1014                continue;
1015            }
1016        }
1017        // A `%` that starts no escape is a literal percent sign: encoded, so
1018        // what follows can't form a new escape with it (normalizing twice
1019        // must change nothing — the fuzzer found `%%611` → `%a1`).
1020        if bytes[i] == b'%' {
1021            decoded.push_str("%25");
1022            i += 1;
1023            continue;
1024        }
1025        // `path` is a &str: copy whole UTF-8 sequences.
1026        let ch = path[i..].chars().next().unwrap_or('\u{FFFD}');
1027        decoded.push(ch);
1028        i += ch.len_utf8().max(1);
1029    }
1030    let mut segments: Vec<&str> = Vec::new();
1031    let parts: Vec<&str> = decoded.split('/').collect();
1032    let last = parts.len().saturating_sub(1);
1033    let mut trailing_slash = false;
1034    for (n, segment) in parts.iter().enumerate() {
1035        match *segment {
1036            "" => trailing_slash = n == last,
1037            "." => trailing_slash = n == last,
1038            ".." => {
1039                segments.pop();
1040                trailing_slash = n == last;
1041            }
1042            s => {
1043                segments.push(s);
1044                trailing_slash = false;
1045            }
1046        }
1047    }
1048    let mut out = String::with_capacity(decoded.len() + query.len());
1049    out.push('/');
1050    out.push_str(&segments.join("/"));
1051    if trailing_slash && !segments.is_empty() {
1052        out.push('/');
1053    }
1054    out.push_str(query);
1055    if out == target {
1056        std::borrow::Cow::Borrowed(target)
1057    } else {
1058        std::borrow::Cow::Owned(out)
1059    }
1060}
1061
1062/// A route's `host` against the request's (SNI or `Host`): ignoring ASCII
1063/// case, and ignoring a port the route doesn't name (#50).
1064fn host_matches(expected: &str, actual: &str) -> bool {
1065    let actual = if expected.contains(':') {
1066        actual
1067    } else if let Some(rest) = actual.strip_prefix('[') {
1068        // [IPv6]:port
1069        rest.split(']').next().unwrap_or(rest)
1070    } else {
1071        actual.rsplit_once(':').map_or(actual, |(host, port)| if port.bytes().all(|b| b.is_ascii_digit()) { host } else { actual })
1072    };
1073    let expected = expected.trim_start_matches('[').trim_end_matches(']');
1074    actual.eq_ignore_ascii_case(expected)
1075}
1076
1077impl Application for ConfigDrivenApp {
1078    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1079        let request = normalized(request);
1080        let generation = self.current();
1081        match &generation.entry {
1082            Some(entry) => entry.execute(&request, conn),
1083            None => generation.dispatch.execute(&request, conn),
1084        }
1085    }
1086
1087    /// The matched route decides: a `grpc` route streams request bodies
1088    /// (client-streaming and bidirectional calls), the others don't yet.
1089    fn streams_body(&self, request: &Request, conn: &ConnectionInfo) -> bool {
1090        let request = normalized(request);
1091        let generation = self.current();
1092        match &generation.entry {
1093            Some(entry) => entry.streams_body(&request, conn),
1094            None => generation.dispatch.streams_body(&request, conn),
1095        }
1096    }
1097
1098    /// Routes proxy upstream or run configured middleware: only requests
1099    /// that match no route and fall through to the static files run inline,
1100    /// and only without a global `[middleware]` table.
1101    fn runs_inline(&self, request: &Request, conn: &ConnectionInfo) -> bool {
1102        let request = normalized(request);
1103        let generation = self.current();
1104        generation.entry.is_none()
1105            && !generation.dispatch.routes.iter().any(|r| r.matcher.matches(&request, conn))
1106            && App::is_inline_request(&request)
1107    }
1108
1109    /// A request for a proxy route with a plain-HTTP upstream and no
1110    /// middleware (route or global) waits for the backend without holding
1111    /// a thread.
1112    #[cfg(feature = "http2")]
1113    fn execute_async<'a>(&'a self, request: &'a Request, conn: &'a ConnectionInfo) -> Option<crate::application::AsyncResponse<'a>> {
1114        let generation = self.current();
1115        if generation.entry.is_some() {
1116            return None;
1117        }
1118        let request = normalized(request);
1119        let route = generation.dispatch.routes.iter().find(|r| r.matcher.matches(&request, conn))?;
1120        let proxy = Arc::clone(route.async_proxy.as_ref()?);
1121        Some(Box::pin(async move { proxy.execute_async(&request, conn).await }))
1122    }
1123}
1124
1125// ── H2Route ───────────────────────────────────────────────────────────────────
1126
1127/// The `grpc` action: requests forwarded over HTTP/2 to the upstream's live
1128/// backends in turn, bodies streamed both ways, trailers kept; gRPC-web
1129/// requests translated to gRPC on the way ([`GrpcWebLayer`](crate::proxy::GrpcWebLayer)).
1130#[cfg(feature = "http2")]
1131pub(crate) struct H2Route {
1132    live: Arc<RwLock<Vec<String>>>,
1133    tls: bool,
1134    trust: Option<Arc<crate::proxy::UpstreamTls>>,
1135    connect_timeout: std::time::Duration,
1136    read_timeout: std::time::Duration,
1137    counter: std::sync::atomic::AtomicUsize,
1138}
1139
1140#[cfg(feature = "http2")]
1141impl H2Route {
1142    pub(crate) fn new(
1143        live: Arc<RwLock<Vec<String>>>,
1144        tls: bool,
1145        trust: Option<Arc<crate::proxy::UpstreamTls>>,
1146        connect_timeout_ms: u64,
1147        read_timeout_ms: u64,
1148    ) -> H2Route {
1149        H2Route {
1150            live,
1151            tls,
1152            trust,
1153            connect_timeout: std::time::Duration::from_millis(connect_timeout_ms),
1154            read_timeout: std::time::Duration::from_millis(read_timeout_ms),
1155            counter: std::sync::atomic::AtomicUsize::new(0),
1156        }
1157    }
1158}
1159
1160#[cfg(feature = "http2")]
1161impl Application for H2Route {
1162    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1163        use crate::middleware::Middleware;
1164        crate::proxy::GrpcWebLayer::new().handle(request, conn, &H2Forward(self))
1165    }
1166
1167    fn streams_body(&self, _request: &Request, _conn: &ConnectionInfo) -> bool {
1168        true
1169    }
1170}
1171
1172/// [`H2Route`] after the gRPC-web translation.
1173#[cfg(feature = "http2")]
1174struct H2Forward<'a>(&'a H2Route);
1175
1176#[cfg(feature = "http2")]
1177impl Application for H2Forward<'_> {
1178    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1179        let route = self.0;
1180        let backends = route.live.read().unwrap_or_else(|e| e.into_inner()).clone();
1181        if backends.is_empty() {
1182            return Ok(crate::proxy::bad_gateway());
1183        }
1184        // A streamed request body can be sent once: no second backend then.
1185        let attempts = if matches!(request.body, crate::request::Body::Stream(_)) { 1 } else { backends.len() };
1186        let start = route.counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1187        for attempt in 0..attempts {
1188            let url = &backends[(start + attempt) % backends.len()];
1189            let result = crate::proxy::forward_h2(
1190                request, &conn.client.ip, url, route.tls, route.trust.as_deref(), route.connect_timeout, route.read_timeout,
1191            );
1192            match result {
1193                Ok(response) => return Ok(response),
1194                Err(e) => eprintln!("grpc route: {e}"),
1195            }
1196        }
1197        Ok(crate::proxy::bad_gateway())
1198    }
1199}
1200
1201// ── NullApp ────────────────────────────────────────────────────────────────────
1202
1203/// A dead-end `Application` that always returns 404.
1204/// Used as the `next` parameter when calling `Middleware::handle` directly.
1205#[derive(Clone, Copy)]
1206pub(crate) struct NullApp;
1207
1208impl Application for NullApp {
1209    fn execute(&self, _request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1210        let mut r = Response::new();
1211        r.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
1212        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
1213        Ok(r)
1214    }
1215}
1216
1217// ── DynamicProxy ──────────────────────────────────────────────────────────────
1218
1219use std::collections::HashMap;
1220use std::collections::hash_map::DefaultHasher;
1221use std::hash::{Hash, Hasher};
1222use std::sync::atomic::AtomicUsize;
1223use std::time::{Duration, SystemTime, UNIX_EPOCH};
1224
1225/// Backend-selection strategy for `DynamicProxy`, configured via the
1226/// `strategy` field on `[[upstream]]` in `rws.config.toml`. Unknown or empty
1227/// values fall back to `RoundRobin`, matching the parser's own default.
1228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1229pub(crate) enum LoadBalanceStrategy {
1230    RoundRobin,
1231    Random,
1232    IpHash,
1233    LeastConnections,
1234}
1235
1236impl LoadBalanceStrategy {
1237    fn parse(s: &str) -> Self {
1238        match s {
1239            "random" => LoadBalanceStrategy::Random,
1240            "ip_hash" => LoadBalanceStrategy::IpHash,
1241            "least_connections" => LoadBalanceStrategy::LeastConnections,
1242            _ => LoadBalanceStrategy::RoundRobin,
1243        }
1244    }
1245}
1246
1247/// A proxy adapter that reads its backend list from a shared, health-checker-
1248/// maintained live list at request time. Supports dynamic removal/restoration
1249/// of backends without restarting.
1250///
1251/// This type is `Clone + Send + Sync` and implements `Application`.
1252#[derive(Clone)]
1253pub(crate) struct DynamicProxy {
1254    live: Arc<RwLock<Vec<String>>>,
1255    counter: Arc<AtomicUsize>,
1256    connect_timeout: Duration,
1257    read_timeout: Duration,
1258    strip_prefix: Option<Arc<String>>,
1259    add_prefix: Option<Arc<String>>,
1260    tls: bool,
1261    strategy: LoadBalanceStrategy,
1262    connections: Arc<RwLock<HashMap<String, Arc<AtomicUsize>>>>,
1263    /// The upstream's `tls_ca_file` trust settings; `None` = WebPKI roots.
1264    trust: Option<Arc<crate::proxy::UpstreamTls>>,
1265}
1266
1267impl DynamicProxy {
1268    /// Trust the given CA for TLS backends instead of the WebPKI roots.
1269    pub(crate) fn with_trust(mut self, trust: Option<Arc<crate::proxy::UpstreamTls>>) -> Self {
1270        self.trust = trust;
1271        self
1272    }
1273
1274    pub(crate) fn new(
1275        live: Arc<RwLock<Vec<String>>>,
1276        connect_timeout_ms: u64,
1277        read_timeout_ms: u64,
1278        strip_prefix: Option<String>,
1279        add_prefix: Option<String>,
1280        tls: bool,
1281        strategy: String,
1282    ) -> Self {
1283        DynamicProxy {
1284            live,
1285            counter: Arc::new(AtomicUsize::new(0)),
1286            connect_timeout: Duration::from_millis(connect_timeout_ms),
1287            read_timeout: Duration::from_millis(read_timeout_ms),
1288            strip_prefix: strip_prefix.map(Arc::new),
1289            add_prefix: add_prefix.map(Arc::new),
1290            tls,
1291            strategy: LoadBalanceStrategy::parse(&strategy),
1292            connections: Arc::new(RwLock::new(HashMap::new())),
1293            trust: None,
1294        }
1295    }
1296
1297    fn next_backend(&self, client_ip: &str) -> Option<String> {
1298        let live = self.live.read().unwrap();
1299        if live.is_empty() {
1300            return None;
1301        }
1302
1303        let idx = match self.strategy {
1304            LoadBalanceStrategy::RoundRobin => {
1305                self.counter.fetch_add(1, Ordering::Relaxed) % live.len()
1306            }
1307            LoadBalanceStrategy::Random => {
1308                let nanos = SystemTime::now()
1309                    .duration_since(UNIX_EPOCH)
1310                    .map(|d| d.subsec_nanos())
1311                    .unwrap_or(0) as usize;
1312                let salt = self.counter.fetch_add(1, Ordering::Relaxed);
1313                nanos.wrapping_add(salt) % live.len()
1314            }
1315            LoadBalanceStrategy::IpHash => {
1316                let mut hasher = DefaultHasher::new();
1317                client_ip.hash(&mut hasher);
1318                (hasher.finish() as usize) % live.len()
1319            }
1320            LoadBalanceStrategy::LeastConnections => {
1321                let connections = self.connections.read().unwrap();
1322                live.iter()
1323                    .enumerate()
1324                    .min_by_key(|(_, backend)| {
1325                        connections
1326                            .get(*backend)
1327                            .map(|c| c.load(Ordering::Relaxed))
1328                            .unwrap_or(0)
1329                    })
1330                    .map(|(i, _)| i)
1331                    .unwrap_or(0)
1332            }
1333        };
1334
1335        Some(live[idx].clone())
1336    }
1337
1338    /// Returns the shared in-flight connection counter for `backend`,
1339    /// creating it on first use. Only consulted under `LeastConnections`.
1340    fn connection_counter(&self, backend: &str) -> Arc<AtomicUsize> {
1341        if let Some(counter) = self.connections.read().unwrap().get(backend) {
1342            return Arc::clone(counter);
1343        }
1344        let mut connections = self.connections.write().unwrap();
1345        Arc::clone(
1346            connections
1347                .entry(backend.to_string())
1348                .or_insert_with(|| Arc::new(AtomicUsize::new(0))),
1349        )
1350    }
1351}
1352
1353/// Decrements a backend's in-flight connection count when the request
1354/// finishes (including early returns), keeping `least_connections` accurate.
1355struct ConnectionGuard {
1356    counter: Arc<AtomicUsize>,
1357}
1358
1359impl Drop for ConnectionGuard {
1360    fn drop(&mut self) {
1361        self.counter.fetch_sub(1, Ordering::Relaxed);
1362    }
1363}
1364
1365/// A request ready to send: the chosen backend and the (possibly rewritten)
1366/// request. Holding it keeps the backend's least-connections count raised.
1367struct Prepared<'a> {
1368    host: String,
1369    port: u16,
1370    request: std::borrow::Cow<'a, Request>,
1371    _guard: Option<ConnectionGuard>,
1372}
1373
1374impl DynamicProxy {
1375    /// Pick a backend and apply the path rewriting; `Err` is the response to
1376    /// send instead (no live backend, unparsable backend address).
1377    fn prepare<'a>(&self, request: &'a Request, conn: &ConnectionInfo) -> Result<Prepared<'a>, Response> {
1378        let backend = self.next_backend(&conn.client.ip).ok_or_else(bad_gateway)?;
1379
1380        let guard = if self.strategy == LoadBalanceStrategy::LeastConnections {
1381            let counter = self.connection_counter(&backend);
1382            counter.fetch_add(1, Ordering::Relaxed);
1383            Some(ConnectionGuard { counter })
1384        } else {
1385            None
1386        };
1387
1388        let (host, port, _) = crate::proxy_config::health::parse_backend_url(&backend).ok_or_else(bad_gateway)?;
1389
1390        // Apply path rewriting if configured
1391        let request = if self.strip_prefix.is_some() || self.add_prefix.is_some() {
1392            let mut req_clone = request.clone();
1393            if let Some(ref sp) = self.strip_prefix {
1394                if let Some(stripped) = req_clone.request_uri.strip_prefix(sp.as_str()) {
1395                    req_clone.request_uri = if stripped.is_empty() || !stripped.starts_with('/') {
1396                        format!("/{}", stripped)
1397                    } else {
1398                        stripped.to_string()
1399                    };
1400                }
1401            }
1402            if let Some(ref ap) = self.add_prefix {
1403                req_clone.request_uri = format!("{}{}", ap, req_clone.request_uri);
1404            }
1405            std::borrow::Cow::Owned(req_clone)
1406        } else {
1407            std::borrow::Cow::Borrowed(request)
1408        };
1409
1410        Ok(Prepared { host, port, request, _guard: guard })
1411    }
1412
1413    /// The same as [`Application::execute`], without blocking a thread while
1414    /// the backend answers, for HTTP and HTTPS backends alike.
1415    #[cfg(feature = "http2")]
1416    pub(crate) async fn execute_async(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1417        let prepared = match self.prepare(request, conn) {
1418            Ok(p) => p,
1419            Err(response) => return Ok(response),
1420        };
1421        let tls = self.tls.then(|| match &self.trust {
1422            Some(trust) => trust.async_tls(),
1423            None => crate::proxy::async_upstream::AsyncTls {
1424                config: crate::proxy::tls_client_config(),
1425                pool: crate::proxy::async_upstream::default_tls_pool(),
1426            },
1427        });
1428        let result = crate::proxy::async_upstream::forward(
1429            &prepared.request,
1430            &conn.client.ip,
1431            &prepared.host,
1432            prepared.port,
1433            tls,
1434            self.connect_timeout,
1435            self.read_timeout,
1436        )
1437        .await;
1438        Ok(result.unwrap_or_else(|_| bad_gateway()))
1439    }
1440
1441    /// Whether [`execute_async`](Self::execute_async) can serve this proxy.
1442    pub(crate) fn supports_async(&self) -> bool {
1443        cfg!(feature = "http2")
1444    }
1445}
1446
1447impl Application for DynamicProxy {
1448    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
1449        let prepared = match self.prepare(request, conn) {
1450            Ok(p) => p,
1451            Err(response) => return Ok(response),
1452        };
1453
1454        // Pooled keep-alive connections to the backend, TLS or not (a fresh
1455        // connection per request halved throughput vs. Caddy/nginx).
1456        let result = crate::proxy::forward_upstream_trusting(
1457            crate::proxy::shared_pool(),
1458            &prepared.request,
1459            &conn.client.ip,
1460            &prepared.host,
1461            prepared.port,
1462            self.tls,
1463            self.trust.as_deref(),
1464            self.connect_timeout,
1465            self.read_timeout,
1466        );
1467
1468        match result {
1469            Ok(r) => Ok(r),
1470            Err(_) => Ok(bad_gateway()),
1471        }
1472    }
1473}
1474
1475fn bad_gateway() -> Response {
1476    use crate::mime_type::MimeType;
1477    use crate::range::Range;
1478    let cr = Range::get_content_range(
1479        b"502 Bad Gateway".to_vec(),
1480        MimeType::TEXT_PLAIN.to_string(),
1481    );
1482    let mut r = Response::new();
1483    r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
1484    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n502_bad_gateway.reason_phrase.to_string();
1485    r.content_range_list = vec![cr];
1486    r
1487}
1488
1489// ── RedirectAdapter ────────────────────────────────────────────────────────────
1490
1491/// Action adapter that issues HTTP redirects.
1492///
1493/// `$path` in `location_template` is replaced with the request URI at runtime.
1494#[derive(Clone)]
1495pub(crate) struct RedirectAdapter {
1496    location_template: Arc<String>,
1497    status: i16,
1498    reason: Arc<String>,
1499}
1500
1501impl RedirectAdapter {
1502    pub(crate) fn new(location: String, status: u16) -> Self {
1503        let (code, reason) = redirect_status(status);
1504        RedirectAdapter {
1505            location_template: Arc::new(location),
1506            status: code,
1507            reason: Arc::new(reason),
1508        }
1509    }
1510}
1511
1512fn redirect_status(code: u16) -> (i16, String) {
1513    let phrase = match code {
1514        301 => STATUS_CODE_REASON_PHRASE.n301_moved_permanently.reason_phrase,
1515        302 => STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase,
1516        307 => STATUS_CODE_REASON_PHRASE.n307_temporary_redirect.reason_phrase,
1517        308 => STATUS_CODE_REASON_PHRASE.n308_permanent_redirect.reason_phrase,
1518        _ => "Redirect",
1519    };
1520    (code as i16, phrase.to_string())
1521}
1522
1523impl Application for RedirectAdapter {
1524    fn execute(&self, request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1525        let location = self
1526            .location_template
1527            .replace("$path", &request.request_uri);
1528        use crate::header::Header;
1529        let mut r = Response::new();
1530        r.status_code = self.status;
1531        r.reason_phrase = self.reason.as_ref().clone();
1532        r.headers.push(Header { name: "Location".into(), value: location.into() });
1533        Ok(r)
1534    }
1535}
1536
1537// ── RespondAdapter ─────────────────────────────────────────────────────────────
1538
1539/// Action adapter that returns a fixed response body.
1540#[derive(Clone)]
1541pub(crate) struct RespondAdapter {
1542    status: i16,
1543    reason: Arc<String>,
1544    body: Arc<Vec<u8>>,
1545    content_type: Arc<String>,
1546}
1547
1548impl RespondAdapter {
1549    pub(crate) fn new(status: u16, body: String, content_type: String) -> Self {
1550        use crate::response::STATUS_CODE_REASON_PHRASE;
1551        let reason = match status {
1552            200 => STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string(),
1553            201 => STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string(),
1554            204 => STATUS_CODE_REASON_PHRASE.n204_no_content.reason_phrase.to_string(),
1555            400 => STATUS_CODE_REASON_PHRASE.n400_bad_request.reason_phrase.to_string(),
1556            401 => STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string(),
1557            403 => STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string(),
1558            404 => STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string(),
1559            500 => STATUS_CODE_REASON_PHRASE.n500_internal_server_error.reason_phrase.to_string(),
1560            _ => "OK".to_string(),
1561        };
1562        RespondAdapter {
1563            status: status as i16,
1564            reason: Arc::new(reason),
1565            body: Arc::new(body.into_bytes()),
1566            content_type: Arc::new(content_type),
1567        }
1568    }
1569}
1570
1571impl Application for RespondAdapter {
1572    fn execute(&self, _request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1573        use crate::range::Range;
1574        let mut r = Response::new();
1575        r.status_code = self.status;
1576        r.reason_phrase = self.reason.as_ref().clone();
1577        if !self.body.is_empty() {
1578            r.content_range_list = vec![Range::get_content_range(
1579                self.body.as_ref().clone(),
1580                self.content_type.as_ref().clone(),
1581            )];
1582        }
1583        Ok(r)
1584    }
1585}
1586
1587// ── StaticAdapter ──────────────────────────────────────────────────────────────
1588
1589/// Action adapter that serves static files from a configured `root` directory.
1590///
1591/// Unlike `StaticResourceController` (which always resolves paths relative to
1592/// the process's current working directory), this adapter is parameterized
1593/// per-route by `ActionConfig::Static { root, index }` from `rws.config.toml`,
1594/// so a config-driven proxy can serve an arbitrary directory without Rust code.
1595#[derive(Clone)]
1596pub(crate) struct StaticAdapter {
1597    root: Arc<std::path::PathBuf>,
1598    index: Arc<Vec<String>>,
1599}
1600
1601impl StaticAdapter {
1602    pub(crate) fn new(root: String, index: Vec<String>) -> Self {
1603        let index = if index.is_empty() { vec!["index.html".to_string()] } else { index };
1604        StaticAdapter {
1605            root: Arc::new(std::path::PathBuf::from(root)),
1606            index: Arc::new(index),
1607        }
1608    }
1609
1610    /// Resolves `request_uri` against `root`. Returns `None` if the decoded
1611    /// path contains a `..` segment, which would otherwise let a request
1612    /// escape the configured root directory.
1613    fn resolve(&self, request_uri: &str) -> Option<std::path::PathBuf> {
1614        let raw_path = request_uri.split('?').next().unwrap_or(request_uri);
1615        let decoded = crate::url::URL::percent_decode(raw_path);
1616
1617        if decoded.split('/').any(|segment| segment == "..") {
1618            return None;
1619        }
1620
1621        let relative = decoded.trim_start_matches('/');
1622        Some(self.root.join(relative))
1623    }
1624}
1625
1626impl Application for StaticAdapter {
1627    fn execute(&self, request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1628        let mut response = Response::new();
1629
1630        let not_found = |mut response: Response| {
1631            response.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
1632            response.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
1633            response
1634        };
1635
1636        let candidate = match self.resolve(&request.request_uri) {
1637            Some(p) => p,
1638            None => {
1639                response.status_code = *STATUS_CODE_REASON_PHRASE.n403_forbidden.status_code;
1640                response.reason_phrase = STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string();
1641                return Ok(response);
1642            }
1643        };
1644
1645        let mut file_path = candidate;
1646        if file_path.is_dir() {
1647            let indexed = self
1648                .index
1649                .iter()
1650                .map(|name| file_path.join(name))
1651                .find(|p| p.is_file());
1652
1653            file_path = match indexed {
1654                Some(p) => p,
1655                None => return Ok(not_found(response)),
1656            };
1657        }
1658
1659        if !file_path.is_file() {
1660            return Ok(not_found(response));
1661        }
1662
1663        // Defense-in-depth against symlinks inside `root` that point outside it —
1664        // the `..`-segment check above only catches traversal in the request URI.
1665        if let (Ok(root_canon), Ok(file_canon)) =
1666            (self.root.canonicalize(), file_path.canonicalize())
1667        {
1668            if !file_canon.starts_with(&root_canon) {
1669                return Ok(not_found(response));
1670            }
1671        }
1672
1673        let path_str = match file_path.to_str() {
1674            Some(s) => s,
1675            None => return Ok(not_found(response)),
1676        };
1677
1678        match crate::range::Range::get_content_range_of_a_file(path_str) {
1679            Ok(content_range) => {
1680                response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
1681                response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
1682                response.content_range_list = vec![content_range];
1683                Ok(response)
1684            }
1685            Err(_) => Ok(not_found(response)),
1686        }
1687    }
1688}
1689
1690// ── PerRouteRateLimit middleware ───────────────────────────────────────────────
1691
1692/// A per-route rate limiter middleware backed by a shared `RateLimiter`.
1693pub(crate) struct PerRouteRateLimit(pub(crate) Arc<crate::rate_limit::RateLimiter>);
1694
1695impl crate::middleware::Middleware for PerRouteRateLimit {
1696    fn handle(
1697        &self,
1698        request: &Request,
1699        conn: &ConnectionInfo,
1700        next: &dyn Application,
1701    ) -> Result<Response, String> {
1702        use crate::error::{AppError, IntoResponse};
1703        if self.0.check(&conn.client.ip) {
1704            next.execute(request, conn)
1705        } else {
1706            let mut response = AppError::TooManyRequests.into_response();
1707            let retry_after = self.0.retry_after(&conn.client.ip);
1708            response.headers.push(Header {
1709                name: Header::_RETRY_AFTER.into(),
1710                value: retry_after.to_string().into(),
1711            });
1712            Ok(response)
1713        }
1714    }
1715}
1716
1717// ── PerRouteMaxBodySize ───────────────────────────────────────────────────────
1718
1719/// A route-specific request body size limit, from `[route.middleware]
1720/// max_body_size`.
1721///
1722/// Rejects with `413 Payload Too Large` if `request.body` is bigger than the
1723/// configured ceiling. This runs *after* the request has already been fully
1724/// read off the socket — route middleware only ever sees an already-built
1725/// `Request` — so, unlike the global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`
1726/// (checked against the declared `Content-Length` before any of an
1727/// oversized body is buffered), this can't prevent the memory from being
1728/// spent for a single oversized request. What it protects is this route's
1729/// own downstream logic/upstream backend from a body that's within the
1730/// global limit but still too big for this specific route.
1731pub(crate) struct PerRouteMaxBodySize(pub(crate) u64);
1732
1733impl crate::middleware::Middleware for PerRouteMaxBodySize {
1734    fn handle(
1735        &self,
1736        request: &Request,
1737        conn: &ConnectionInfo,
1738        next: &dyn Application,
1739    ) -> Result<Response, String> {
1740        use crate::error::{AppError, IntoResponse};
1741        if request.body.len() as u64 > self.0 {
1742            Ok(AppError::PayloadTooLarge(format!(
1743                "request body of {} bytes exceeds this route's {}-byte limit",
1744                request.body.len(),
1745                self.0,
1746            ))
1747            .into_response())
1748        } else {
1749            next.execute(request, conn)
1750        }
1751    }
1752}
1753
1754// ── BearerAuthMiddleware ───────────────────────────────────────────────────────
1755
1756/// Bearer token authentication middleware.
1757pub(crate) struct BearerAuthMiddleware {
1758    pub(crate) token: Arc<String>,
1759}
1760
1761impl crate::middleware::Middleware for BearerAuthMiddleware {
1762    fn handle(
1763        &self,
1764        request: &Request,
1765        conn: &ConnectionInfo,
1766        next: &dyn Application,
1767    ) -> Result<Response, String> {
1768        use crate::error::{AppError, IntoResponse};
1769        let expected = format!("Bearer {}", self.token);
1770        let authorized = request
1771            .headers
1772            .iter()
1773            .any(|h| h.name.eq_ignore_ascii_case("authorization") && constant_time_eq(h.value.as_bytes(), expected.as_bytes()));
1774        if authorized {
1775            next.execute(request, conn)
1776        } else {
1777            Ok(AppError::Unauthorized.into_response())
1778        }
1779    }
1780}
1781
1782/// Compares in time independent of where the inputs first differ, so response
1783/// timing doesn't reveal how much of a guessed token was right.
1784pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
1785    if a.len() != b.len() {
1786        return false;
1787    }
1788    a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
1789}
1790
1791/// Stands in for an auth layer whose configuration can't be honoured (see
1792/// `builder::apply_middleware`): every request gets `503`, never the route.
1793pub(crate) struct AuthMisconfigured;
1794
1795impl crate::middleware::Middleware for AuthMisconfigured {
1796    fn handle(
1797        &self,
1798        _request: &Request,
1799        _conn: &ConnectionInfo,
1800        _next: &dyn Application,
1801    ) -> Result<Response, String> {
1802        Ok(Response::text(
1803            STATUS_CODE_REASON_PHRASE.n503_service_unavailable,
1804            "503 Service Unavailable: authentication for this route is misconfigured",
1805        ))
1806    }
1807}
1808
1809// ── arc_app helper ─────────────────────────────────────────────────────────────
1810
1811/// Box any `Application + Send + Sync + 'static` into an `Arc<dyn …>`.
1812pub(crate) fn arc_app<A: Application + Send + Sync + 'static>(
1813    a: A,
1814) -> Arc<dyn Application + Send + Sync> {
1815    Arc::new(a)
1816}
1817
1818// ── Public entry points ────────────────────────────────────────────────────────
1819
1820/// Build a `ConfigDrivenApp` from `rws.config.toml` and spawn L4/WS proxy
1821/// threads. Returns the app and a list of thread handles.
1822pub fn build_from_file() -> (ConfigDrivenApp, Vec<std::thread::JoinHandle<()>>) {
1823    builder::build_from_file()
1824}