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