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;
31
32#[cfg(test)]
33mod tests;
34
35use std::sync::Arc;
36
37use crate::app::App;
38use crate::application::Application;
39use crate::core::New;
40use crate::request::Request;
41use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
42use crate::server::ConnectionInfo;
43use crate::server_config::ServerConfig;
44
45// ── Public config types ────────────────────────────────────────────────────────
46
47#[derive(Debug, Clone)]
48pub struct ProxyConfig {
49    pub upstreams: Vec<UpstreamConfig>,
50    pub routes: Vec<RouteConfig>,
51    pub tcp_proxies: Vec<TcpProxyConfig>,
52    pub udp_proxies: Vec<UdpProxyConfig>,
53    pub ws_proxies: Vec<WsProxyConfig>,
54    pub global_middleware: MiddlewareConfig,
55}
56
57#[derive(Debug, Clone)]
58pub struct UpstreamConfig {
59    pub name: String,
60    pub backends: Vec<String>,
61    pub strategy: String, // "round_robin" | "random" | "ip_hash"
62    pub health_check: Option<HealthCheckConfig>,
63    /// `true` when all backends use `https://` scheme — connections to the
64    /// upstream are made over TLS. Requires the `http-client` or `http2`
65    /// feature (which bring in `rustls` + `webpki-roots`).
66    pub tls: bool,
67}
68
69#[derive(Debug, Clone)]
70pub struct HealthCheckConfig {
71    pub path: String,
72    pub interval_secs: u64,
73    pub timeout_ms: u64,
74    pub healthy_threshold: u32,
75    pub unhealthy_threshold: u32,
76}
77
78#[derive(Debug, Clone)]
79pub struct RouteConfig {
80    pub name: String,
81    pub match_: MatchConfig,
82    pub action: ActionConfig,
83    pub middleware: MiddlewareConfig,
84}
85
86#[derive(Debug, Clone, Default)]
87pub struct MatchConfig {
88    pub host: Option<String>,
89    pub path: Option<String>,
90    pub method: Option<String>,
91    pub content_type: Option<String>,
92}
93
94#[derive(Debug, Clone)]
95pub enum ActionConfig {
96    Proxy {
97        upstream: String,
98        connect_timeout_ms: u64,
99        read_timeout_ms: u64,
100        strip_path_prefix: Option<String>,
101        add_path_prefix: Option<String>,
102    },
103    Grpc {
104        upstream: String,
105        connect_timeout_ms: u64,
106        read_timeout_ms: u64,
107    },
108    Static {
109        root: String,
110        index: Vec<String>,
111    },
112    Redirect {
113        location: String,
114        status: u16,
115    },
116    Respond {
117        status: u16,
118        body: String,
119        content_type: String,
120    },
121    Mcp,
122    Unknown(String),
123}
124
125#[derive(Debug, Clone, Default)]
126pub struct MiddlewareConfig {
127    pub rate_limit: Option<RateLimitConfig>,
128    pub cache: Option<CacheConfig>,
129    pub auth: Option<AuthConfig>,
130    pub rewrite_request: Vec<RewriteRuleConfig>,
131    pub rewrite_response: Vec<RewriteRuleConfig>,
132    pub ip_allow: Vec<String>,
133    pub ip_deny: Vec<String>,
134    /// `timeout_ms` — if the route (including all its other middleware)
135    /// doesn't produce a response within this many milliseconds, the client
136    /// gets `504 Gateway Timeout` instead of waiting further. See
137    /// `crate::timeout` for the underlying mechanism and its limitations.
138    pub timeout_ms: Option<u64>,
139    /// `max_body_size` — reject this route's request with `413 Payload Too
140    /// Large` if its body exceeds this many bytes.
141    ///
142    /// This is a *route-specific* ceiling on top of the global, process-wide
143    /// `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES` (see `crate::entry_point::get_max_body_size`)
144    /// — e.g. a small JSON API route can be capped stricter than a
145    /// file-upload route without lowering the global limit for everyone.
146    /// It is **not** a substitute for the global limit: by the time any
147    /// route-level middleware runs, `Request` (including its body) has
148    /// already been fully read off the socket — route matching itself
149    /// requires the complete request, so a per-route check can only run
150    /// after that memory is already spent, not before. The global limit is
151    /// what protects total server memory from an oversized upload;
152    /// `max_body_size` protects only this route's own logic/upstream from
153    /// an oversized-but-still-within-the-global-limit body.
154    pub max_body_size: Option<u64>,
155}
156
157#[derive(Debug, Clone)]
158pub struct RateLimitConfig {
159    pub max_requests: u32,
160    pub window_secs: u64,
161}
162
163#[derive(Debug, Clone)]
164pub struct CacheConfig {
165    pub ttl_secs: u64,
166    pub vary_by: Vec<String>,
167}
168
169#[derive(Debug, Clone)]
170pub enum AuthConfig {
171    /// `auth = { type = "basic", htpasswd_file = ".htpasswd" }`. See
172    /// `crate::auth::BasicAuthLayer::from_htpasswd_file` for the supported
173    /// file format (plain text and `{SHA256}` only — not Apache's `{SHA}`,
174    /// `$apr1$`, or bcrypt).
175    Basic { htpasswd_file: String },
176    /// `auth = { type = "jwt", secret_env = "JWT_SECRET" }`. Requires the
177    /// `auth` feature; verifies HS256 JWTs via `crate::auth::JwtLayer`.
178    Jwt { secret_env: String },
179    Bearer { token_env: String },
180}
181
182#[derive(Debug, Clone, Default)]
183pub struct RewriteRuleConfig {
184    pub type_: String,
185    pub name: Option<String>,
186    pub value: Option<String>,
187    pub prefix: Option<String>,
188    pub from: Option<String>,
189    pub to: Option<String>,
190    pub code: Option<u16>,
191    pub reason: Option<String>,
192}
193
194#[derive(Debug, Clone)]
195pub struct TcpProxyConfig {
196    pub name: String,
197    pub listen: String,
198    pub backends: Vec<String>,
199    pub connect_timeout_ms: u64,
200}
201
202#[derive(Debug, Clone)]
203pub struct UdpProxyConfig {
204    pub name: String,
205    pub listen: String,
206    pub backends: Vec<String>,
207    pub reply_timeout_ms: u64,
208    pub buffer_size: usize,
209}
210
211#[derive(Debug, Clone)]
212pub struct WsProxyConfig {
213    pub name: String,
214    pub listen: String,
215    pub backends: Vec<String>,
216    pub connect_timeout_ms: u64,
217    pub read_timeout_ms: u64,
218    /// Optional `[ws_proxy.health_check]` — same shape and semantics as
219    /// `[upstream.health_check]`. The probe is a plain HTTP `GET {path}`
220    /// against the backend's `host:port` (the `ws://`/`wss://` scheme only
221    /// determines whether the probe connects over TLS); most WebSocket
222    /// backends serve a regular HTTP health endpoint alongside their upgrade
223    /// route, the same way nginx/Traefik health-check WS upstreams.
224    pub health_check: Option<HealthCheckConfig>,
225}
226
227// ── ProxyConfig loading ────────────────────────────────────────────────────────
228
229impl ProxyConfig {
230    /// Returns `true` if `rws.config.toml` (or `RWS_CONFIG_FILE`) contains
231    /// `[[route]]` or `[[upstream]]` sections, meaning config-driven mode
232    /// should be used.
233    pub fn is_proxy_mode() -> bool {
234        let path = config_file_path();
235        match std::fs::read_to_string(&path) {
236            Ok(contents) => {
237                contents.contains("[[route]]") || contents.contains("[[upstream]]")
238            }
239            Err(_) => false,
240        }
241    }
242
243    /// Parse the config file and return a `ProxyConfig`.
244    pub fn load() -> Self {
245        let path = config_file_path();
246        let contents = std::fs::read_to_string(&path).unwrap_or_default();
247        Self::from_str(&contents)
248    }
249
250    /// Parse `toml` text directly into a `ProxyConfig`. Used in tests.
251    pub fn from_str(toml: &str) -> Self {
252        use parser::{get_array, get_str, get_u32, get_u64, section_exists};
253
254        let map = parser::parse(toml);
255
256        // ── upstreams ──────────────────────────────────────────────────────────
257        let mut upstreams = Vec::new();
258        let mut i = 0;
259        loop {
260            let sec = format!("upstream[{}]", i);
261            if !section_exists(&map, &sec) {
262                break;
263            }
264            let name = get_str(&map, &sec, "name");
265            let backends = get_array(&map, &sec, "backends");
266            let strategy = {
267                let s = get_str(&map, &sec, "strategy");
268                if s.is_empty() { "round_robin".to_string() } else { s }
269            };
270            let hc_sec = format!("{}.health_check", sec);
271            let health_check = if section_exists(&map, &hc_sec) {
272                Some(HealthCheckConfig {
273                    path: {
274                        let p = get_str(&map, &hc_sec, "path");
275                        if p.is_empty() { "/health".to_string() } else { p }
276                    },
277                    interval_secs: get_u64(&map, &hc_sec, "interval_secs", 30),
278                    timeout_ms: get_u64(&map, &hc_sec, "timeout_ms", 5000),
279                    healthy_threshold: get_u32(&map, &hc_sec, "healthy_threshold", 2),
280                    unhealthy_threshold: get_u32(&map, &hc_sec, "unhealthy_threshold", 3),
281                })
282            } else {
283                None
284            };
285            let tls = backends.iter().any(|b| b.starts_with("https://"));
286            upstreams.push(UpstreamConfig { name, backends, strategy, health_check, tls });
287            i += 1;
288        }
289
290        // ── routes ─────────────────────────────────────────────────────────────
291        let mut routes = Vec::new();
292        let mut i = 0;
293        loop {
294            let sec = format!("route[{}]", i);
295            if !section_exists(&map, &sec) {
296                break;
297            }
298            let name = get_str(&map, &sec, "name");
299
300            // match
301            let m_sec = format!("{}.match", sec);
302            let match_ = MatchConfig {
303                host: {
304                    let h = get_str(&map, &m_sec, "host");
305                    if h.is_empty() { None } else { Some(h) }
306                },
307                path: {
308                    let p = get_str(&map, &m_sec, "path");
309                    if p.is_empty() { None } else { Some(p) }
310                },
311                method: {
312                    let m = get_str(&map, &m_sec, "method");
313                    if m.is_empty() { None } else { Some(m.to_uppercase()) }
314                },
315                content_type: {
316                    let c = get_str(&map, &m_sec, "content_type");
317                    if c.is_empty() { None } else { Some(c) }
318                },
319            };
320
321            // action
322            let a_sec = format!("{}.action", sec);
323            let action_type = get_str(&map, &a_sec, "type");
324            let action = match action_type.as_str() {
325                "proxy" => {
326                    let p_sec = format!("{}.action.proxy", sec);
327                    ActionConfig::Proxy {
328                        upstream: get_str(&map, &p_sec, "upstream"),
329                        connect_timeout_ms: get_u64(&map, &p_sec, "connect_timeout_ms", 5000),
330                        read_timeout_ms: get_u64(&map, &p_sec, "read_timeout_ms", 30000),
331                        strip_path_prefix: {
332                            let v = get_str(&map, &p_sec, "strip_path_prefix");
333                            if v.is_empty() { None } else { Some(v) }
334                        },
335                        add_path_prefix: {
336                            let v = get_str(&map, &p_sec, "add_path_prefix");
337                            if v.is_empty() { None } else { Some(v) }
338                        },
339                    }
340                }
341                "grpc" => {
342                    let p_sec = format!("{}.action.grpc", sec);
343                    ActionConfig::Grpc {
344                        upstream: get_str(&map, &p_sec, "upstream"),
345                        connect_timeout_ms: get_u64(&map, &p_sec, "connect_timeout_ms", 5000),
346                        read_timeout_ms: get_u64(&map, &p_sec, "read_timeout_ms", 30000),
347                    }
348                }
349                "static" => {
350                    let s_sec = format!("{}.action.static", sec);
351                    ActionConfig::Static {
352                        root: get_str(&map, &s_sec, "root"),
353                        index: get_array(&map, &s_sec, "index"),
354                    }
355                }
356                "redirect" => {
357                    let r_sec = format!("{}.action.redirect", sec);
358                    ActionConfig::Redirect {
359                        location: get_str(&map, &r_sec, "location"),
360                        status: get_u64(&map, &r_sec, "status", 301) as u16,
361                    }
362                }
363                "respond" => {
364                    let r_sec = format!("{}.action.respond", sec);
365                    ActionConfig::Respond {
366                        status: get_u64(&map, &r_sec, "status", 200) as u16,
367                        body: get_str(&map, &r_sec, "body"),
368                        content_type: {
369                            let ct = get_str(&map, &r_sec, "content_type");
370                            if ct.is_empty() { "text/plain".to_string() } else { ct }
371                        },
372                    }
373                }
374                "mcp" => ActionConfig::Mcp,
375                other => ActionConfig::Unknown(other.to_string()),
376            };
377
378            // middleware
379            let mw_sec = format!("{}.middleware", sec);
380            let middleware = parse_middleware_config(&map, &mw_sec, i);
381
382            routes.push(RouteConfig { name, match_, action, middleware });
383            i += 1;
384        }
385
386        // ── tcp_proxy ──────────────────────────────────────────────────────────
387        let mut tcp_proxies = Vec::new();
388        let mut i = 0;
389        loop {
390            let sec = format!("tcp_proxy[{}]", i);
391            if !section_exists(&map, &sec) {
392                break;
393            }
394            tcp_proxies.push(TcpProxyConfig {
395                name: get_str(&map, &sec, "name"),
396                listen: get_str(&map, &sec, "listen"),
397                backends: get_array(&map, &sec, "backends"),
398                connect_timeout_ms: get_u64(&map, &sec, "connect_timeout_ms", 5000),
399            });
400            i += 1;
401        }
402
403        // ── udp_proxy ──────────────────────────────────────────────────────────
404        let mut udp_proxies = Vec::new();
405        let mut i = 0;
406        loop {
407            let sec = format!("udp_proxy[{}]", i);
408            if !section_exists(&map, &sec) {
409                break;
410            }
411            udp_proxies.push(UdpProxyConfig {
412                name: get_str(&map, &sec, "name"),
413                listen: get_str(&map, &sec, "listen"),
414                backends: get_array(&map, &sec, "backends"),
415                reply_timeout_ms: get_u64(&map, &sec, "reply_timeout_ms", 5000),
416                buffer_size: get_u64(&map, &sec, "buffer_size", 65536) as usize,
417            });
418            i += 1;
419        }
420
421        // ── ws_proxy ───────────────────────────────────────────────────────────
422        let mut ws_proxies = Vec::new();
423        let mut i = 0;
424        loop {
425            let sec = format!("ws_proxy[{}]", i);
426            if !section_exists(&map, &sec) {
427                break;
428            }
429            let hc_sec = format!("{}.health_check", sec);
430            let health_check = if section_exists(&map, &hc_sec) {
431                Some(HealthCheckConfig {
432                    path: {
433                        let p = get_str(&map, &hc_sec, "path");
434                        if p.is_empty() { "/health".to_string() } else { p }
435                    },
436                    interval_secs: get_u64(&map, &hc_sec, "interval_secs", 30),
437                    timeout_ms: get_u64(&map, &hc_sec, "timeout_ms", 5000),
438                    healthy_threshold: get_u32(&map, &hc_sec, "healthy_threshold", 2),
439                    unhealthy_threshold: get_u32(&map, &hc_sec, "unhealthy_threshold", 3),
440                })
441            } else {
442                None
443            };
444            ws_proxies.push(WsProxyConfig {
445                name: get_str(&map, &sec, "name"),
446                listen: get_str(&map, &sec, "listen"),
447                backends: get_array(&map, &sec, "backends"),
448                connect_timeout_ms: get_u64(&map, &sec, "connect_timeout_ms", 5000),
449                read_timeout_ms: get_u64(&map, &sec, "read_timeout_ms", 30000),
450                health_check,
451            });
452            i += 1;
453        }
454
455        // ── global middleware ──────────────────────────────────────────────────
456        let global_middleware = parse_middleware_config(&map, "middleware", usize::MAX);
457
458        ProxyConfig {
459            upstreams,
460            routes,
461            tcp_proxies,
462            udp_proxies,
463            ws_proxies,
464            global_middleware,
465        }
466    }
467}
468
469/// Parse a `MiddlewareConfig` from the section map at a given base path.
470/// `route_idx` is used only to build inner-array section paths for rewrite rules.
471fn parse_middleware_config(
472    map: &parser::SectionMap,
473    mw_sec: &str,
474    route_idx: usize,
475) -> MiddlewareConfig {
476    use parser::{get_array, get_str, get_u32, get_u64, section_exists};
477
478    let rl_sec = format!("{}.rate_limit", mw_sec);
479    let rate_limit = if section_exists(map, &rl_sec) {
480        Some(RateLimitConfig {
481            max_requests: get_u32(map, &rl_sec, "max_requests", 1000),
482            window_secs: get_u64(map, &rl_sec, "window_secs", 60),
483        })
484    } else {
485        None
486    };
487
488    let c_sec = format!("{}.cache", mw_sec);
489    let cache = if section_exists(map, &c_sec) {
490        Some(CacheConfig {
491            ttl_secs: get_u64(map, &c_sec, "ttl_secs", 60),
492            vary_by: get_array(map, &c_sec, "vary_by"),
493        })
494    } else {
495        None
496    };
497
498    let a_sec = format!("{}.auth", mw_sec);
499    let auth = if section_exists(map, &a_sec) {
500        let auth_type = get_str(map, &a_sec, "type");
501        match auth_type.as_str() {
502            "bearer" => Some(AuthConfig::Bearer {
503                token_env: get_str(map, &a_sec, "token_env"),
504            }),
505            "jwt" => Some(AuthConfig::Jwt {
506                secret_env: get_str(map, &a_sec, "secret_env"),
507            }),
508            "basic" => Some(AuthConfig::Basic {
509                htpasswd_file: get_str(map, &a_sec, "htpasswd_file"),
510            }),
511            _ => None,
512        }
513    } else {
514        None
515    };
516
517    // Rewrite rules — the section paths use route_idx for route-scoped rules
518    // or a flat path for global middleware. We look for:
519    //   route[N].middleware.rewrite.request[0], [1], …
520    //   route[N].middleware.rewrite.response[0], [1], …
521    // For global: middleware.rewrite.request[0], etc.
522    let rewrite_request = collect_rewrite_rules(map, mw_sec, "request");
523    let rewrite_response = collect_rewrite_rules(map, mw_sec, "response");
524
525    let ip_sec = format!("{}.ip_filter", mw_sec);
526    let ip_allow = if section_exists(map, &ip_sec) {
527        get_array(map, &ip_sec, "allow")
528    } else {
529        vec![]
530    };
531    let ip_deny = if section_exists(map, &ip_sec) {
532        get_array(map, &ip_sec, "deny")
533    } else {
534        vec![]
535    };
536
537    let _ = route_idx; // used implicitly via mw_sec paths
538
539    // Flat scalar directly under [route.middleware] (or the global
540    // [middleware] table), not a nested sub-table like rate_limit/cache —
541    // 0/absent both mean "no timeout configured".
542    let timeout_ms = match get_u64(map, mw_sec, "timeout_ms", 0) {
543        0 => None,
544        ms => Some(ms),
545    };
546
547    // Same flat-scalar convention as timeout_ms — 0/absent means "no
548    // route-specific limit" (the global RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES
549    // still applies regardless).
550    let max_body_size = match get_u64(map, mw_sec, "max_body_size", 0) {
551        0 => None,
552        n => Some(n),
553    };
554
555    MiddlewareConfig {
556        rate_limit, cache, auth, rewrite_request, rewrite_response, ip_allow, ip_deny,
557        timeout_ms, max_body_size,
558    }
559}
560
561/// Collect `[[{mw_sec}.rewrite.{direction}]]` entries.
562fn collect_rewrite_rules(
563    map: &parser::SectionMap,
564    mw_sec: &str,
565    direction: &str,
566) -> Vec<RewriteRuleConfig> {
567    use parser::{get_str, get_u64};
568
569    let mut rules = Vec::new();
570    let mut j = 0;
571    loop {
572        let rsec = format!("{}.rewrite.{}[{}]", mw_sec, direction, j);
573        if !parser::section_exists(map, &rsec) {
574            break;
575        }
576        let code_val = get_u64(map, &rsec, "code", 0);
577        rules.push(RewriteRuleConfig {
578            type_: get_str(map, &rsec, "type"),
579            name: {
580                let v = get_str(map, &rsec, "name");
581                if v.is_empty() { None } else { Some(v) }
582            },
583            value: {
584                let v = get_str(map, &rsec, "value");
585                if v.is_empty() { None } else { Some(v) }
586            },
587            prefix: {
588                let v = get_str(map, &rsec, "prefix");
589                if v.is_empty() { None } else { Some(v) }
590            },
591            from: {
592                let v = get_str(map, &rsec, "from");
593                if v.is_empty() { None } else { Some(v) }
594            },
595            to: {
596                let v = get_str(map, &rsec, "to");
597                if v.is_empty() { None } else { Some(v) }
598            },
599            code: if code_val == 0 { None } else { Some(code_val as u16) },
600            reason: {
601                let v = get_str(map, &rsec, "reason");
602                if v.is_empty() { None } else { Some(v) }
603            },
604        });
605        j += 1;
606    }
607    rules
608}
609
610fn config_file_path() -> String {
611    std::env::var("RWS_CONFIG_FILE").unwrap_or_else(|_| "rws.config.toml".to_string())
612}
613
614// ── ConfigDrivenApp ────────────────────────────────────────────────────────────
615
616/// A compiled route: a matcher paired with a handler application.
617pub(crate) struct CompiledRoute {
618    pub(crate) matcher: RouteMatcher,
619    /// Shared, type-erased handler. `Arc` makes `Clone` cheap (pointer copy).
620    pub(crate) handler: Arc<dyn Application + Send + Sync>,
621}
622
623/// Matching criteria for a single route.
624#[derive(Clone, Default)]
625pub(crate) struct RouteMatcher {
626    /// Optional SNI hostname / `Host` header match.
627    pub(crate) host: Option<String>,
628    /// Path prefix to match (derived from `path = "/v1/*"`).
629    pub(crate) path_prefix: Option<String>,
630    /// Exact path to match (derived from `path = "/v1/ping"`).
631    pub(crate) path_exact: Option<String>,
632    /// Uppercase HTTP method, or `None` for any.
633    pub(crate) method: Option<String>,
634    /// `Content-Type` prefix (e.g. `"application/grpc"`).
635    pub(crate) content_type_prefix: Option<String>,
636}
637
638impl RouteMatcher {
639    pub(crate) fn from_match_config(cfg: &MatchConfig) -> Self {
640        let (path_prefix, path_exact) = match &cfg.path {
641            Some(p) if p.ends_with('*') => {
642                // "/v1/*" → prefix "/v1/"
643                let stripped = p.trim_end_matches('*').to_string();
644                (Some(stripped), None)
645            }
646            Some(p) => (None, Some(p.clone())),
647            None => (None, None),
648        };
649        let content_type_prefix = cfg.content_type.as_ref().map(|ct| {
650            if ct.ends_with('*') {
651                ct.trim_end_matches('*').to_string()
652            } else {
653                ct.clone()
654            }
655        });
656        RouteMatcher {
657            host: cfg.host.clone(),
658            path_prefix,
659            path_exact,
660            method: cfg.method.clone(),
661            content_type_prefix,
662        }
663    }
664
665    /// Returns `true` if `request` and `conn` match all configured criteria.
666    pub(crate) fn matches(&self, request: &Request, conn: &ConnectionInfo) -> bool {
667        // Host matching: SNI first, then Host header
668        if let Some(ref expected_host) = self.host {
669            let actual_host = conn
670                .sni_hostname
671                .as_deref()
672                .or_else(|| {
673                    request
674                        .headers
675                        .iter()
676                        .find(|h| h.name.eq_ignore_ascii_case("host"))
677                        .map(|h| h.value.as_str())
678                })
679                .unwrap_or("");
680            if actual_host != expected_host.as_str() {
681                return false;
682            }
683        }
684
685        // Method matching
686        if let Some(ref m) = self.method {
687            if request.method.to_uppercase() != m.as_str() {
688                return false;
689            }
690        }
691
692        // Path matching: strip query string for comparison
693        let path = request.request_uri.split('?').next().unwrap_or(&request.request_uri);
694        if let Some(ref prefix) = self.path_prefix {
695            if !path.starts_with(prefix.as_str()) {
696                return false;
697            }
698        } else if let Some(ref exact) = self.path_exact {
699            if path != exact.as_str() {
700                return false;
701            }
702        }
703
704        // Content-Type prefix matching
705        if let Some(ref ct_prefix) = self.content_type_prefix {
706            let actual_ct = request
707                .headers
708                .iter()
709                .find(|h| h.name.eq_ignore_ascii_case("content-type"))
710                .map(|h| h.value.as_str())
711                .unwrap_or("");
712            if !actual_ct.starts_with(ct_prefix.as_str()) {
713                return false;
714            }
715        }
716
717        true
718    }
719}
720
721/// An `Application` that routes requests based on a parsed `ProxyConfig`.
722///
723/// `Clone` is cheap: `routes` is an `Arc<Vec<...>>` (pointer copy), and
724/// `fallback` is `App`, itself cheap to clone (an `Option<Arc<ServerConfig>>`).
725#[derive(Clone)]
726pub struct ConfigDrivenApp {
727    routes: Arc<Vec<CompiledRoute>>,
728    /// Fallback for unmatched requests — handles /healthz, /readyz, /metrics,
729    /// static files, and the 404 controller. Reads `RWS_CONFIG_*` env vars
730    /// per request (`App::new()`'s default) unless pinned via
731    /// [`ConfigDrivenApp::with_config`].
732    fallback: App,
733}
734
735impl ConfigDrivenApp {
736    pub(crate) fn new(routes: Vec<CompiledRoute>) -> Self {
737        use crate::core::New;
738        ConfigDrivenApp {
739            routes: Arc::new(routes),
740            fallback: App::new(),
741        }
742    }
743
744    /// Pin the fallback [`App`] (used for any request none of the
745    /// config-driven routes match) to an explicit [`ServerConfig`], instead
746    /// of reading `RWS_CONFIG_*` environment variables per request.
747    ///
748    /// Mirrors [`App::with_config`] / [`crate::state::AppWithState::with_config`]
749    /// — same rationale: safe for parallel tests, and lets multiple
750    /// differently-configured proxy instances coexist in one process.
751    ///
752    /// ```rust,no_run
753    /// use rust_web_server::proxy_config::build_from_file;
754    /// use rust_web_server::server_config::ServerConfig;
755    ///
756    /// let (app, _handles) = build_from_file();
757    /// let app = app.with_config(ServerConfig::default());
758    /// ```
759    pub fn with_config(mut self, config: ServerConfig) -> Self {
760        self.fallback = App::with_config(config);
761        self
762    }
763}
764
765impl Application for ConfigDrivenApp {
766    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
767        for route in self.routes.iter() {
768            if route.matcher.matches(request, conn) {
769                return route.handler.execute(request, conn);
770            }
771        }
772        self.fallback.execute(request, conn)
773    }
774}
775
776// ── NullApp ────────────────────────────────────────────────────────────────────
777
778/// A dead-end `Application` that always returns 404.
779/// Used as the `next` parameter when calling `Middleware::handle` directly.
780#[derive(Clone, Copy)]
781pub(crate) struct NullApp;
782
783impl Application for NullApp {
784    fn execute(&self, _request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
785        let mut r = Response::new();
786        r.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
787        r.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
788        Ok(r)
789    }
790}
791
792// ── DynamicProxy ──────────────────────────────────────────────────────────────
793
794use std::collections::HashMap;
795use std::collections::hash_map::DefaultHasher;
796use std::hash::{Hash, Hasher};
797use std::sync::atomic::{AtomicUsize, Ordering};
798use std::sync::RwLock;
799use std::time::{Duration, SystemTime, UNIX_EPOCH};
800
801/// Backend-selection strategy for `DynamicProxy`, configured via the
802/// `strategy` field on `[[upstream]]` in `rws.config.toml`. Unknown or empty
803/// values fall back to `RoundRobin`, matching the parser's own default.
804#[derive(Clone, Copy, Debug, PartialEq, Eq)]
805pub(crate) enum LoadBalanceStrategy {
806    RoundRobin,
807    Random,
808    IpHash,
809    LeastConnections,
810}
811
812impl LoadBalanceStrategy {
813    fn parse(s: &str) -> Self {
814        match s {
815            "random" => LoadBalanceStrategy::Random,
816            "ip_hash" => LoadBalanceStrategy::IpHash,
817            "least_connections" => LoadBalanceStrategy::LeastConnections,
818            _ => LoadBalanceStrategy::RoundRobin,
819        }
820    }
821}
822
823/// A proxy adapter that reads its backend list from a shared, health-checker-
824/// maintained live list at request time. Supports dynamic removal/restoration
825/// of backends without restarting.
826///
827/// This type is `Clone + Send + Sync` and implements `Application`.
828#[derive(Clone)]
829pub(crate) struct DynamicProxy {
830    live: Arc<RwLock<Vec<String>>>,
831    counter: Arc<AtomicUsize>,
832    connect_timeout: Duration,
833    read_timeout: Duration,
834    strip_prefix: Option<Arc<String>>,
835    add_prefix: Option<Arc<String>>,
836    tls: bool,
837    strategy: LoadBalanceStrategy,
838    connections: Arc<RwLock<HashMap<String, Arc<AtomicUsize>>>>,
839}
840
841impl DynamicProxy {
842    pub(crate) fn new(
843        live: Arc<RwLock<Vec<String>>>,
844        connect_timeout_ms: u64,
845        read_timeout_ms: u64,
846        strip_prefix: Option<String>,
847        add_prefix: Option<String>,
848        tls: bool,
849        strategy: String,
850    ) -> Self {
851        DynamicProxy {
852            live,
853            counter: Arc::new(AtomicUsize::new(0)),
854            connect_timeout: Duration::from_millis(connect_timeout_ms),
855            read_timeout: Duration::from_millis(read_timeout_ms),
856            strip_prefix: strip_prefix.map(Arc::new),
857            add_prefix: add_prefix.map(Arc::new),
858            tls,
859            strategy: LoadBalanceStrategy::parse(&strategy),
860            connections: Arc::new(RwLock::new(HashMap::new())),
861        }
862    }
863
864    fn next_backend(&self, client_ip: &str) -> Option<String> {
865        let live = self.live.read().unwrap();
866        if live.is_empty() {
867            return None;
868        }
869
870        let idx = match self.strategy {
871            LoadBalanceStrategy::RoundRobin => {
872                self.counter.fetch_add(1, Ordering::Relaxed) % live.len()
873            }
874            LoadBalanceStrategy::Random => {
875                let nanos = SystemTime::now()
876                    .duration_since(UNIX_EPOCH)
877                    .map(|d| d.subsec_nanos())
878                    .unwrap_or(0) as usize;
879                let salt = self.counter.fetch_add(1, Ordering::Relaxed);
880                nanos.wrapping_add(salt) % live.len()
881            }
882            LoadBalanceStrategy::IpHash => {
883                let mut hasher = DefaultHasher::new();
884                client_ip.hash(&mut hasher);
885                (hasher.finish() as usize) % live.len()
886            }
887            LoadBalanceStrategy::LeastConnections => {
888                let connections = self.connections.read().unwrap();
889                live.iter()
890                    .enumerate()
891                    .min_by_key(|(_, backend)| {
892                        connections
893                            .get(*backend)
894                            .map(|c| c.load(Ordering::Relaxed))
895                            .unwrap_or(0)
896                    })
897                    .map(|(i, _)| i)
898                    .unwrap_or(0)
899            }
900        };
901
902        Some(live[idx].clone())
903    }
904
905    /// Returns the shared in-flight connection counter for `backend`,
906    /// creating it on first use. Only consulted under `LeastConnections`.
907    fn connection_counter(&self, backend: &str) -> Arc<AtomicUsize> {
908        if let Some(counter) = self.connections.read().unwrap().get(backend) {
909            return Arc::clone(counter);
910        }
911        let mut connections = self.connections.write().unwrap();
912        Arc::clone(
913            connections
914                .entry(backend.to_string())
915                .or_insert_with(|| Arc::new(AtomicUsize::new(0))),
916        )
917    }
918}
919
920/// Decrements a backend's in-flight connection count when the request
921/// finishes (including early returns), keeping `least_connections` accurate.
922struct ConnectionGuard {
923    counter: Arc<AtomicUsize>,
924}
925
926impl Drop for ConnectionGuard {
927    fn drop(&mut self) {
928        self.counter.fetch_sub(1, Ordering::Relaxed);
929    }
930}
931
932impl Application for DynamicProxy {
933    fn execute(&self, request: &Request, conn: &ConnectionInfo) -> Result<Response, String> {
934        let backend = match self.next_backend(&conn.client.ip) {
935            Some(b) => b,
936            None => {
937                return Ok(bad_gateway());
938            }
939        };
940
941        let _connection_guard = if self.strategy == LoadBalanceStrategy::LeastConnections {
942            let counter = self.connection_counter(&backend);
943            counter.fetch_add(1, Ordering::Relaxed);
944            Some(ConnectionGuard { counter })
945        } else {
946            None
947        };
948
949        let (host, port, _) = match crate::proxy_config::health::parse_backend_url(&backend) {
950            Some(t) => t,
951            None => return Ok(bad_gateway()),
952        };
953
954        // Apply path rewriting if configured
955        let mut req_clone;
956        let effective_request = if self.strip_prefix.is_some() || self.add_prefix.is_some() {
957            req_clone = request.clone();
958            if let Some(ref sp) = self.strip_prefix {
959                if let Some(stripped) = req_clone.request_uri.strip_prefix(sp.as_str()) {
960                    req_clone.request_uri = if stripped.is_empty() || !stripped.starts_with('/') {
961                        format!("/{}", stripped)
962                    } else {
963                        stripped.to_string()
964                    };
965                }
966            }
967            if let Some(ref ap) = self.add_prefix {
968                req_clone.request_uri = format!("{}{}", ap, req_clone.request_uri);
969            }
970            &req_clone
971        } else {
972            request
973        };
974
975        let result = if self.tls {
976            #[cfg(any(feature = "http-client", feature = "http2"))]
977            {
978                crate::proxy::proxy_https1(
979                    effective_request,
980                    &conn.client.ip,
981                    &host,
982                    port,
983                    self.connect_timeout,
984                    self.read_timeout,
985                )
986            }
987            #[cfg(not(any(feature = "http-client", feature = "http2")))]
988            {
989                eprintln!("[proxy] HTTPS upstream requires http-client or http2 feature");
990                Err("TLS upstream not supported in this build".to_string())
991            }
992        } else {
993            crate::proxy::proxy_http1(
994                effective_request,
995                &conn.client.ip,
996                &host,
997                port,
998                self.connect_timeout,
999                self.read_timeout,
1000            )
1001        };
1002
1003        match result {
1004            Ok(r) => Ok(r),
1005            Err(_) => Ok(bad_gateway()),
1006        }
1007    }
1008}
1009
1010fn bad_gateway() -> Response {
1011    use crate::mime_type::MimeType;
1012    use crate::range::Range;
1013    let cr = Range::get_content_range(
1014        b"502 Bad Gateway".to_vec(),
1015        MimeType::TEXT_PLAIN.to_string(),
1016    );
1017    let mut r = Response::new();
1018    r.status_code = *STATUS_CODE_REASON_PHRASE.n502_bad_gateway.status_code;
1019    r.reason_phrase = STATUS_CODE_REASON_PHRASE.n502_bad_gateway.reason_phrase.to_string();
1020    r.content_range_list = vec![cr];
1021    r
1022}
1023
1024// ── RedirectAdapter ────────────────────────────────────────────────────────────
1025
1026/// Action adapter that issues HTTP redirects.
1027///
1028/// `$path` in `location_template` is replaced with the request URI at runtime.
1029#[derive(Clone)]
1030pub(crate) struct RedirectAdapter {
1031    location_template: Arc<String>,
1032    status: i16,
1033    reason: Arc<String>,
1034}
1035
1036impl RedirectAdapter {
1037    pub(crate) fn new(location: String, status: u16) -> Self {
1038        let (code, reason) = redirect_status(status);
1039        RedirectAdapter {
1040            location_template: Arc::new(location),
1041            status: code,
1042            reason: Arc::new(reason),
1043        }
1044    }
1045}
1046
1047fn redirect_status(code: u16) -> (i16, String) {
1048    let phrase = match code {
1049        301 => STATUS_CODE_REASON_PHRASE.n301_moved_permanently.reason_phrase,
1050        302 => STATUS_CODE_REASON_PHRASE.n302_found.reason_phrase,
1051        307 => STATUS_CODE_REASON_PHRASE.n307_temporary_redirect.reason_phrase,
1052        308 => STATUS_CODE_REASON_PHRASE.n308_permanent_redirect.reason_phrase,
1053        _ => "Redirect",
1054    };
1055    (code as i16, phrase.to_string())
1056}
1057
1058impl Application for RedirectAdapter {
1059    fn execute(&self, request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1060        let location = self
1061            .location_template
1062            .replace("$path", &request.request_uri);
1063        use crate::header::Header;
1064        let mut r = Response::new();
1065        r.status_code = self.status;
1066        r.reason_phrase = self.reason.as_ref().clone();
1067        r.headers.push(Header { name: "Location".to_string(), value: location });
1068        Ok(r)
1069    }
1070}
1071
1072// ── RespondAdapter ─────────────────────────────────────────────────────────────
1073
1074/// Action adapter that returns a fixed response body.
1075#[derive(Clone)]
1076pub(crate) struct RespondAdapter {
1077    status: i16,
1078    reason: Arc<String>,
1079    body: Arc<Vec<u8>>,
1080    content_type: Arc<String>,
1081}
1082
1083impl RespondAdapter {
1084    pub(crate) fn new(status: u16, body: String, content_type: String) -> Self {
1085        use crate::response::STATUS_CODE_REASON_PHRASE;
1086        let reason = match status {
1087            200 => STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string(),
1088            201 => STATUS_CODE_REASON_PHRASE.n201_created.reason_phrase.to_string(),
1089            204 => STATUS_CODE_REASON_PHRASE.n204_no_content.reason_phrase.to_string(),
1090            400 => STATUS_CODE_REASON_PHRASE.n400_bad_request.reason_phrase.to_string(),
1091            401 => STATUS_CODE_REASON_PHRASE.n401_unauthorized.reason_phrase.to_string(),
1092            403 => STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string(),
1093            404 => STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string(),
1094            500 => STATUS_CODE_REASON_PHRASE.n500_internal_server_error.reason_phrase.to_string(),
1095            _ => "OK".to_string(),
1096        };
1097        RespondAdapter {
1098            status: status as i16,
1099            reason: Arc::new(reason),
1100            body: Arc::new(body.into_bytes()),
1101            content_type: Arc::new(content_type),
1102        }
1103    }
1104}
1105
1106impl Application for RespondAdapter {
1107    fn execute(&self, _request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1108        use crate::range::Range;
1109        let mut r = Response::new();
1110        r.status_code = self.status;
1111        r.reason_phrase = self.reason.as_ref().clone();
1112        if !self.body.is_empty() {
1113            r.content_range_list = vec![Range::get_content_range(
1114                self.body.as_ref().clone(),
1115                self.content_type.as_ref().clone(),
1116            )];
1117        }
1118        Ok(r)
1119    }
1120}
1121
1122// ── StaticAdapter ──────────────────────────────────────────────────────────────
1123
1124/// Action adapter that serves static files from a configured `root` directory.
1125///
1126/// Unlike `StaticResourceController` (which always resolves paths relative to
1127/// the process's current working directory), this adapter is parameterized
1128/// per-route by `ActionConfig::Static { root, index }` from `rws.config.toml`,
1129/// so a config-driven proxy can serve an arbitrary directory without Rust code.
1130#[derive(Clone)]
1131pub(crate) struct StaticAdapter {
1132    root: Arc<std::path::PathBuf>,
1133    index: Arc<Vec<String>>,
1134}
1135
1136impl StaticAdapter {
1137    pub(crate) fn new(root: String, index: Vec<String>) -> Self {
1138        let index = if index.is_empty() { vec!["index.html".to_string()] } else { index };
1139        StaticAdapter {
1140            root: Arc::new(std::path::PathBuf::from(root)),
1141            index: Arc::new(index),
1142        }
1143    }
1144
1145    /// Resolves `request_uri` against `root`. Returns `None` if the decoded
1146    /// path contains a `..` segment, which would otherwise let a request
1147    /// escape the configured root directory.
1148    fn resolve(&self, request_uri: &str) -> Option<std::path::PathBuf> {
1149        let raw_path = request_uri.split('?').next().unwrap_or(request_uri);
1150        let decoded = crate::url::URL::percent_decode(raw_path);
1151
1152        if decoded.split('/').any(|segment| segment == "..") {
1153            return None;
1154        }
1155
1156        let relative = decoded.trim_start_matches('/');
1157        Some(self.root.join(relative))
1158    }
1159}
1160
1161impl Application for StaticAdapter {
1162    fn execute(&self, request: &Request, _conn: &ConnectionInfo) -> Result<Response, String> {
1163        let mut response = Response::new();
1164
1165        let not_found = |mut response: Response| {
1166            response.status_code = *STATUS_CODE_REASON_PHRASE.n404_not_found.status_code;
1167            response.reason_phrase = STATUS_CODE_REASON_PHRASE.n404_not_found.reason_phrase.to_string();
1168            response
1169        };
1170
1171        let candidate = match self.resolve(&request.request_uri) {
1172            Some(p) => p,
1173            None => {
1174                response.status_code = *STATUS_CODE_REASON_PHRASE.n403_forbidden.status_code;
1175                response.reason_phrase = STATUS_CODE_REASON_PHRASE.n403_forbidden.reason_phrase.to_string();
1176                return Ok(response);
1177            }
1178        };
1179
1180        let mut file_path = candidate;
1181        if file_path.is_dir() {
1182            let indexed = self
1183                .index
1184                .iter()
1185                .map(|name| file_path.join(name))
1186                .find(|p| p.is_file());
1187
1188            file_path = match indexed {
1189                Some(p) => p,
1190                None => return Ok(not_found(response)),
1191            };
1192        }
1193
1194        if !file_path.is_file() {
1195            return Ok(not_found(response));
1196        }
1197
1198        // Defense-in-depth against symlinks inside `root` that point outside it —
1199        // the `..`-segment check above only catches traversal in the request URI.
1200        if let (Ok(root_canon), Ok(file_canon)) =
1201            (self.root.canonicalize(), file_path.canonicalize())
1202        {
1203            if !file_canon.starts_with(&root_canon) {
1204                return Ok(not_found(response));
1205            }
1206        }
1207
1208        let path_str = match file_path.to_str() {
1209            Some(s) => s,
1210            None => return Ok(not_found(response)),
1211        };
1212
1213        match crate::range::Range::get_content_range_of_a_file(path_str) {
1214            Ok(content_range) => {
1215                response.status_code = *STATUS_CODE_REASON_PHRASE.n200_ok.status_code;
1216                response.reason_phrase = STATUS_CODE_REASON_PHRASE.n200_ok.reason_phrase.to_string();
1217                response.content_range_list = vec![content_range];
1218                Ok(response)
1219            }
1220            Err(_) => Ok(not_found(response)),
1221        }
1222    }
1223}
1224
1225// ── PerRouteRateLimit middleware ───────────────────────────────────────────────
1226
1227/// A per-route rate limiter middleware backed by a shared `RateLimiter`.
1228pub(crate) struct PerRouteRateLimit(pub(crate) Arc<crate::rate_limit::RateLimiter>);
1229
1230impl crate::middleware::Middleware for PerRouteRateLimit {
1231    fn handle(
1232        &self,
1233        request: &Request,
1234        conn: &ConnectionInfo,
1235        next: &dyn Application,
1236    ) -> Result<Response, String> {
1237        use crate::error::{AppError, IntoResponse};
1238        if self.0.check(&conn.client.ip) {
1239            next.execute(request, conn)
1240        } else {
1241            Ok(AppError::TooManyRequests.into_response())
1242        }
1243    }
1244}
1245
1246// ── PerRouteMaxBodySize ───────────────────────────────────────────────────────
1247
1248/// A route-specific request body size limit, from `[route.middleware]
1249/// max_body_size`.
1250///
1251/// Rejects with `413 Payload Too Large` if `request.body` is bigger than the
1252/// configured ceiling. This runs *after* the request has already been fully
1253/// read off the socket — route middleware only ever sees an already-built
1254/// `Request` — so, unlike the global `RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES`
1255/// (checked against the declared `Content-Length` before any of an
1256/// oversized body is buffered), this can't prevent the memory from being
1257/// spent for a single oversized request. What it protects is this route's
1258/// own downstream logic/upstream backend from a body that's within the
1259/// global limit but still too big for this specific route.
1260pub(crate) struct PerRouteMaxBodySize(pub(crate) u64);
1261
1262impl crate::middleware::Middleware for PerRouteMaxBodySize {
1263    fn handle(
1264        &self,
1265        request: &Request,
1266        conn: &ConnectionInfo,
1267        next: &dyn Application,
1268    ) -> Result<Response, String> {
1269        use crate::error::{AppError, IntoResponse};
1270        if request.body.len() as u64 > self.0 {
1271            Ok(AppError::PayloadTooLarge(format!(
1272                "request body of {} bytes exceeds this route's {}-byte limit",
1273                request.body.len(),
1274                self.0,
1275            ))
1276            .into_response())
1277        } else {
1278            next.execute(request, conn)
1279        }
1280    }
1281}
1282
1283// ── BearerAuthMiddleware ───────────────────────────────────────────────────────
1284
1285/// Bearer token authentication middleware.
1286pub(crate) struct BearerAuthMiddleware {
1287    pub(crate) token: Arc<String>,
1288}
1289
1290impl crate::middleware::Middleware for BearerAuthMiddleware {
1291    fn handle(
1292        &self,
1293        request: &Request,
1294        conn: &ConnectionInfo,
1295        next: &dyn Application,
1296    ) -> Result<Response, String> {
1297        use crate::error::{AppError, IntoResponse};
1298        let expected = format!("Bearer {}", self.token);
1299        let authorized = request
1300            .headers
1301            .iter()
1302            .any(|h| h.name.eq_ignore_ascii_case("authorization") && h.value == expected);
1303        if authorized {
1304            next.execute(request, conn)
1305        } else {
1306            Ok(AppError::Unauthorized.into_response())
1307        }
1308    }
1309}
1310
1311// ── arc_app helper ─────────────────────────────────────────────────────────────
1312
1313/// Box any `Application + Send + Sync + 'static` into an `Arc<dyn …>`.
1314pub(crate) fn arc_app<A: Application + Send + Sync + 'static>(
1315    a: A,
1316) -> Arc<dyn Application + Send + Sync> {
1317    Arc::new(a)
1318}
1319
1320// ── Public entry points ────────────────────────────────────────────────────────
1321
1322/// Build a `ConfigDrivenApp` from `rws.config.toml` and spawn L4/WS proxy
1323/// threads. Returns the app and a list of thread handles.
1324pub fn build_from_file() -> (ConfigDrivenApp, Vec<std::thread::JoinHandle<()>>) {
1325    builder::build_from_file()
1326}