Skip to main content

rust_web_server/proxy_config/
validate.rs

1//! Static checks for the proxy sections of `rws.config.toml`, used by
2//! `rws --check` and printed at startup. The parser itself is lenient — a
3//! typo'd key just leaves a default in place — so this is where a route that
4//! points at a missing upstream, an auth secret that isn't set, or a key the
5//! parser never reads gets reported.
6
7use std::collections::HashSet;
8
9use crate::config_check::ConfigReport;
10use crate::proxy_config::{parser, ActionConfig, AuthConfig, MiddlewareConfig, ProxyConfig};
11
12const STRATEGIES: &[&str] = &["round_robin", "random", "ip_hash", "least_connections"];
13
14const HEALTH_CHECK_KEYS: &[&str] = &["path", "interval_secs", "timeout_ms", "healthy_threshold", "unhealthy_threshold"];
15const PROXY_ACTION_KEYS: &[&str] = &["upstream", "connect_timeout_ms", "read_timeout_ms", "strip_path_prefix", "add_path_prefix", "preserve_host", "upstream_host"];
16const GRPC_ACTION_KEYS: &[&str] = &["upstream", "connect_timeout_ms", "read_timeout_ms"];
17const STATIC_ACTION_KEYS: &[&str] = &["root", "index"];
18const REDIRECT_ACTION_KEYS: &[&str] = &["location", "status"];
19const FASTCGI_ACTION_KEYS: &[&str] = &["address", "root", "index", "split_path", "front_controller", "connect_timeout_ms", "read_timeout_ms", "max_idle"];
20const RESPOND_ACTION_KEYS: &[&str] = &["status", "body", "content_type"];
21const REWRITE_RULE_KEYS: &[&str] = &["type", "name", "value", "prefix", "from", "to", "code", "reason"];
22
23/// The keys the parser reads in a section, by the section's path with array
24/// indices removed (`route[2].middleware.auth` → `route.middleware.auth`).
25/// `None` for a section the parser never reads at all.
26fn known_keys(section: &str) -> Option<Vec<&'static str>> {
27    // The global `[middleware]` table has the same shape as `[route.middleware]`.
28    let route_form;
29    let section = if section == "middleware" || section.starts_with("middleware.") {
30        route_form = format!("route.{section}");
31        route_form.as_str()
32    } else {
33        section
34    };
35    let keys: Vec<&'static str> = match section {
36        "upstream" => vec!["name", "backends", "strategy", "weights", "tls_ca_file", "tls_client_cert_file", "tls_client_key_file", "protocol"],
37        "upstream.sticky" => vec!["cookie", "max_age_secs"],
38        "upstream.circuit_breaker" => vec!["failure_threshold", "recovery_secs", "half_open_probes"],
39        "upstream.retry" => vec!["attempts", "status"],
40        "upstream.health_check" | "ws_proxy.health_check" => HEALTH_CHECK_KEYS.to_vec(),
41        "route" => vec!["name"],
42        "route.match" => vec!["host", "path", "method", "content_type"],
43        // `[route.action]` takes `type` plus the short form of every action's keys.
44        "route.action" => {
45            let mut keys = vec!["type"];
46            for group in [PROXY_ACTION_KEYS, GRPC_ACTION_KEYS, STATIC_ACTION_KEYS, REDIRECT_ACTION_KEYS, RESPOND_ACTION_KEYS, FASTCGI_ACTION_KEYS] {
47                for key in group {
48                    if !keys.contains(key) {
49                        keys.push(key);
50                    }
51                }
52            }
53            keys
54        }
55        "route.action.proxy" => PROXY_ACTION_KEYS.to_vec(),
56        "route.action.grpc" => GRPC_ACTION_KEYS.to_vec(),
57        "route.action.static" => STATIC_ACTION_KEYS.to_vec(),
58        "route.action.fastcgi" => FASTCGI_ACTION_KEYS.to_vec(),
59        "route.action.redirect" => REDIRECT_ACTION_KEYS.to_vec(),
60        "route.action.respond" => RESPOND_ACTION_KEYS.to_vec(),
61        "route.action.mcp" => super::mcp_gateway::GATEWAY_KEYS.to_vec(),
62        "route.action.mcp.upstream" => super::mcp_gateway::UPSTREAM_KEYS.to_vec(),
63        "route.action.mcp.caller" => super::mcp_gateway::CALLER_KEYS.to_vec(),
64        "route.action.mcp.openapi" => super::mcp_gateway::OPENAPI_KEYS.to_vec(),
65        "route.action.mcp.allow" => super::mcp_gateway::ALLOW_KEYS.to_vec(),
66        "route.action.mcp.limit" => super::mcp_gateway::LIMIT_KEYS.to_vec(),
67        "route.action.llm" => super::llm::LLM_KEYS.to_vec(),
68        "route.action.a2a" => super::a2a_gateway::KEYS.to_vec(),
69        "route.action.llm.provider" => super::llm::PROVIDER_KEYS.to_vec(),
70        "route.action.llm.key" => super::llm::KEY_KEYS.to_vec(),
71        "route.middleware" => vec!["timeout_ms", "max_body_size", "early_hints"],
72        "route.middleware.rate_limit" => vec!["max_requests", "window_secs"],
73        "route.middleware.cache" => vec!["ttl_secs", "vary_by", "dir", "max_size", "purge_token_env"],
74        "route.middleware.auth" => vec![
75            "type", "token_env", "secret_env", "previous_secret_env", "htpasswd_file",
76            // oidc
77            "issuer", "provider", "tenant", "client_id", "client_secret_env", "redirect_uri", "scopes", "allowed_emails",
78            "allowed_domains", "allowed_groups", "groups_claim", "cookie_secret_env", "session_ttl_secs", "exclude", "forward_headers",
79            // forward
80            "url", "copy_headers", "timeout_ms",
81        ],
82        "route.middleware.ip_filter" => vec!["allow", "deny"],
83        "route.middleware.mirror" => vec!["upstream", "backends", "percent"],
84        "route.middleware.authz" => vec!["relation", "object"],
85        "route.middleware.waf" => vec!["mode", "paranoia", "threshold", "inspect_body_bytes", "max_args", "exclude"],
86        "route.middleware.waf.rule" => vec!["id", "field", "contains", "prefix", "equals", "cidr", "action", "score"],
87        "authz" => vec!["store", "tuples"],
88        "route.middleware.wasm" => vec!["module", "fuel", "memory_mb", "config"],
89        "route.middleware.rewrite.request" | "route.middleware.rewrite.response" => REWRITE_RULE_KEYS.to_vec(),
90        "tcp_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms"],
91        "udp_proxy" => vec!["name", "listen", "backends", "reply_timeout_ms", "buffer_size"],
92        "ws_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms", "read_timeout_ms"],
93        _ => return None,
94    };
95    Some(keys)
96}
97
98/// `route[2].middleware.rewrite.request[0]` → `route.middleware.rewrite.request`.
99fn strip_indices(section: &str) -> String {
100    let mut out = String::with_capacity(section.len());
101    let mut in_index = false;
102    for c in section.chars() {
103        match c {
104            '[' => in_index = true,
105            ']' => in_index = false,
106            _ if !in_index => out.push(c),
107            _ => {}
108        }
109    }
110    out
111}
112
113/// Report keys and tables in the proxy sections that the parser ignores.
114pub fn check_unknown_keys(toml: &str, report: &mut ConfigReport) {
115    let map = parser::parse(toml);
116    let mut sections: Vec<&String> = map.keys().collect();
117    sections.sort();
118    for section in sections {
119        let root = section.split(['.', '[']).next().unwrap_or("");
120        if !["route", "upstream", "tcp_proxy", "udp_proxy", "ws_proxy", "middleware", "authz"].contains(&root) {
121            continue;
122        }
123        let shape = strip_indices(section);
124        // Header names, relation names: any key.
125        if shape == "route.action.mcp.upstream.headers_env" || shape == "route.action.mcp.openapi.headers_env" || shape == "route.action.a2a.headers_env" || shape.starts_with("authz.type.") || shape == "route.action.params" || shape == "route.action.fastcgi.params" {
126            continue;
127        }
128        match known_keys(&shape) {
129            None => report.error(format!("[{}]: unknown table — ignored", shape)),
130            Some(keys) => {
131                for (key, _) in &map[section] {
132                    if !keys.contains(&key.as_str()) {
133                        report.error(format!("[{}] {}: unknown key — ignored", shape, key));
134                    }
135                }
136            }
137        }
138    }
139}
140
141/// `host:port`, or a URL with an `http://`, `https://`, `h2://`, `ws://` or
142/// `wss://` scheme, whose port may be left to the scheme's default.
143fn is_host_port(backend: &str) -> bool {
144    let (rest, has_scheme) = match ["http://", "https://", "h2://", "h2s://", "grpc://", "grpcs://", "ws://", "wss://"]
145        .iter()
146        .find_map(|scheme| backend.strip_prefix(scheme))
147    {
148        Some(rest) => (rest, true),
149        None => (backend, false),
150    };
151    let rest = rest.trim_end_matches('/');
152    if rest.is_empty() || rest.contains('/') || rest.contains(char::is_whitespace) {
153        return false;
154    }
155    match rest.rsplit_once(':') {
156        Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0),
157        None => has_scheme,
158    }
159}
160
161impl ProxyConfig {
162    /// Check the configuration for mistakes the parser lets through. Reads
163    /// the auth secrets' environment variables and htpasswd files, so run it
164    /// in the environment the server will run in.
165    pub fn validate(&self, report: &mut ConfigReport) {
166        // [authz], built over a throwaway store (nothing is written).
167        let authz = super::builder::config_authorizer_with(self, Some(std::sync::Arc::new(crate::authz::MemoryStore::new())));
168        if self.authz.as_ref().is_some_and(|a| a.store == "sql")
169            && !cfg!(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))
170        {
171            report.error("[authz]: store = \"sql\" needs a build with a model-sqlite / model-postgres / model-mysql feature".to_string());
172        }
173        if let Some(Err(e)) = &authz {
174            report.error(format!("[authz]: {e}"));
175        }
176        let mut names = HashSet::new();
177        for (i, upstream) in self.upstreams.iter().enumerate() {
178            let label = if upstream.name.is_empty() { format!("[[upstream]] #{}", i + 1) } else { format!("upstream '{}'", upstream.name) };
179            if upstream.name.is_empty() {
180                report.error(format!("{label}: missing `name` — no route can refer to it"));
181            } else if !names.insert(upstream.name.as_str()) {
182                report.error(format!("{label}: declared more than once"));
183            }
184            if upstream.backends.is_empty() {
185                report.error(format!("{label}: `backends` is empty"));
186            }
187            match upstream.protocol.to_ascii_lowercase().as_str() {
188                "" | "http1" => {}
189                "h3" => {
190                    if !cfg!(feature = "http3") {
191                        report.error(format!("{label}: protocol = \"h3\" needs a build with the http3 feature (the default)"));
192                    }
193                    if let Some(plain) = upstream.backends.iter().find(|b| !b.starts_with("https://")) {
194                        report.error(format!("{label}: protocol = \"h3\" needs https:// backends (QUIC is always TLS); '{plain}' isn't"));
195                    }
196                }
197                other => report.error(format!("{label}: protocol must be \"http1\" or \"h3\", not \"{other}\"")),
198            }
199            for backend in &upstream.backends {
200                if !is_host_port(backend) {
201                    report.error(format!("{label}: backend '{backend}' is not host:port"));
202                }
203            }
204            let client = match (&upstream.tls_client_cert_file, &upstream.tls_client_key_file) {
205                (Some(cert), Some(key)) => Some((cert.as_str(), key.as_str())),
206                (None, None) => None,
207                _ => {
208                    report.error(format!("{label}: tls_client_cert_file and tls_client_key_file go together"));
209                    None
210                }
211            };
212            if upstream.tls_ca_file.is_some() || client.is_some() {
213                if !crate::config_check::offline() {
214                    if let Err(e) = crate::proxy::UpstreamTls::new(upstream.tls_ca_file.as_deref(), client) {
215                        report.error(format!("{label}: {e}; its TLS backends would answer 502"));
216                    }
217                }
218                if !upstream.tls {
219                    report.warn(format!("{label}: TLS settings are set but no backend is https://, h2s:// or grpcs://, so they aren't used"));
220                }
221            }
222            if !STRATEGIES.contains(&upstream.strategy.as_str()) {
223                report.error(format!(
224                    "{label}: unknown strategy '{}' (expected one of {})",
225                    upstream.strategy,
226                    STRATEGIES.join(", ")
227                ));
228            }
229            if !upstream.weights.is_empty() {
230                if upstream.weights.len() != upstream.backends.len() {
231                    report.error(format!("{label}: `weights` has {} entries for {} backends (one each, in order)", upstream.weights.len(), upstream.backends.len()));
232                }
233                if upstream.weights.contains(&0) {
234                    report.error(format!("{label}: `weights` must be whole numbers of 1 or more"));
235                }
236                if upstream.strategy == "ip_hash" {
237                    report.warn(format!("{label}: `weights` don't apply to strategy ip_hash"));
238                }
239            }
240            if let Some(sticky) = &upstream.sticky {
241                let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
242                if sticky.cookie.is_empty() || !sticky.cookie.chars().all(token) {
243                    report.error(format!("{label}: sticky cookie name '{}' isn't a valid cookie name", sticky.cookie));
244                }
245            }
246            if let Some(breaker) = &upstream.circuit_breaker {
247                if breaker.failure_threshold == 0 {
248                    report.error(format!("{label}: circuit_breaker.failure_threshold must be 1 or more"));
249                }
250                if breaker.recovery_secs == 0 {
251                    report.warn(format!("{label}: circuit_breaker.recovery_secs = 0 probes an open backend on the next request"));
252                }
253            }
254            if let Some(retry) = &upstream.retry {
255                if retry.attempts == 0 {
256                    report.warn(format!("{label}: retry.attempts = 0 never retries"));
257                } else if retry.attempts as usize >= upstream.backends.len() {
258                    report.warn(format!(
259                        "{label}: retry.attempts = {} but there are {} backends — a request goes to each at most once",
260                        retry.attempts,
261                        upstream.backends.len()
262                    ));
263                }
264                if let Some(status) = retry.status.iter().find(|s| !(400..=599).contains(*s)) {
265                    report.error(format!("{label}: retry.status {status} isn't an error status (400–599)"));
266                }
267            }
268            #[cfg(not(any(feature = "http-client", feature = "http2")))]
269            if upstream.tls {
270                report.error(format!("{label}: https:// backends need the `http2` or `http-client` feature, which this build doesn't include"));
271            }
272        }
273
274        for (i, route) in self.routes.iter().enumerate() {
275            let label = if route.name.is_empty() { format!("route #{}", i + 1) } else { format!("route '{}'", route.name) };
276            // The proxy writes the upstream `Host` itself, after the rewrites.
277            if matches!(route.action, ActionConfig::Proxy { .. })
278                && route.middleware.rewrite_request.iter().any(|r| r.type_.starts_with("header_") && r.name.as_deref().is_some_and(|n| n.trim().eq_ignore_ascii_case("host")))
279            {
280                report.warn(format!("{label}: a request rewrite of `Host` doesn't reach the upstream — set `preserve_host = true` or `upstream_host = \"…\"` on the proxy action"));
281            }
282            match &route.action {
283                ActionConfig::Proxy { upstream, .. } | ActionConfig::Grpc { upstream, .. } => {
284                    if upstream.is_empty() {
285                        report.error(format!("{label}: proxy action has no `upstream`"));
286                    } else if !names.contains(upstream.as_str()) {
287                        if is_host_port(upstream) {
288                            report.warn(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]; using it as a backend address (no health checks)"));
289                        } else {
290                            report.error(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]"));
291                        }
292                    }
293                }
294                ActionConfig::Static { root, .. } => {
295                    if root.is_empty() {
296                        report.error(format!("{label}: static action has no `root`"));
297                    } else if !crate::config_check::offline() && !std::path::Path::new(root).is_dir() {
298                        report.warn(format!("{label}: static root '{root}' is not a directory (yet)"));
299                    }
300                }
301                ActionConfig::FastCgi(f) => {
302                    if f.address.is_empty() {
303                        report.error(format!("{label}: fastcgi action has no `address` (unix:/run/php/php-fpm.sock or host:port)"));
304                    } else if let Err(e) = crate::fastcgi::Address::parse(&f.address) {
305                        report.error(format!("{label}: {e}"));
306                    }
307                    if f.root.is_empty() {
308                        report.error(format!("{label}: fastcgi action has no `root`"));
309                    } else if !crate::config_check::offline() && !std::path::Path::new(&f.root).is_dir() {
310                        report.error(format!("{label}: fastcgi root '{}' is not a directory", f.root));
311                    }
312                }
313                ActionConfig::Redirect { location, status } => {
314                    if location.is_empty() {
315                        report.error(format!("{label}: redirect action has no `location`"));
316                    }
317                    if !(300..=399).contains(status) {
318                        report.error(format!("{label}: redirect status {status} is not a 3xx code"));
319                    }
320                }
321                ActionConfig::Respond { status, .. } => {
322                    if !(100..=599).contains(status) {
323                        report.error(format!("{label}: respond status {status} is not an HTTP status code"));
324                    }
325                }
326                ActionConfig::Mcp(gateway) => {
327                    super::mcp_gateway::validate(gateway, route.match_.path.as_deref(), &label, report);
328                }
329                ActionConfig::Llm(llm) => {
330                    super::llm::validate(llm, &label, report);
331                }
332                ActionConfig::A2a(gateway) => {
333                    super::a2a_gateway::validate(gateway, route.match_.path.as_deref(), &label, report);
334                }
335                ActionConfig::Unknown(kind) if kind.is_empty() => {
336                    report.error(format!("{label}: [route.action] has no `type`"));
337                }
338                ActionConfig::Unknown(kind) => {
339                    report.error(format!("{label}: unknown action type '{kind}' (expected proxy, grpc, static, redirect, respond, mcp, llm or a2a)"));
340                }
341            }
342            let has_auth = route.middleware.auth.is_some() || self.global_middleware.auth.is_some();
343            validate_middleware(&label, &route.middleware, authz.as_ref(), has_auth, report);
344
345            let m = &route.match_;
346            if m.host.is_none() && m.path.is_none() && m.method.is_none() && m.content_type.is_none() && i + 1 < self.routes.len() {
347                report.warn(format!("{label}: matches every request, so the routes after it are never reached"));
348            }
349        }
350
351        validate_middleware("[middleware]", &self.global_middleware, authz.as_ref(), self.global_middleware.auth.is_some(), report);
352        if self.global_middleware.authz.is_some() {
353            report.warn("[middleware.authz]: applies to every request, /healthz, /readyz and /metrics included — probes without a caller get 401".to_string());
354        }
355
356        let listeners = self.tcp_proxies.iter().map(|p| ("tcp_proxy", &p.name, &p.listen, &p.backends))
357            .chain(self.udp_proxies.iter().map(|p| ("udp_proxy", &p.name, &p.listen, &p.backends)))
358            .chain(self.ws_proxies.iter().map(|p| ("ws_proxy", &p.name, &p.listen, &p.backends)));
359        for (kind, name, listen, backends) in listeners {
360            let label = format!("[[{kind}]] '{name}'");
361            if listen.parse::<std::net::SocketAddr>().is_err() {
362                report.error(format!("{label}: listen '{listen}' is not an address like 0.0.0.0:5432"));
363            }
364            if backends.is_empty() {
365                report.error(format!("{label}: `backends` is empty"));
366            }
367            for backend in backends {
368                if !is_host_port(backend) {
369                    report.error(format!("{label}: backend '{backend}' is not host:port"));
370                }
371            }
372        }
373    }
374}
375
376fn validate_middleware(
377    label: &str,
378    mw: &MiddlewareConfig,
379    authz: Option<&Result<crate::authz::Authorizer, String>>,
380    has_auth: bool,
381    report: &mut ConfigReport,
382) {
383    for link in &mw.early_hints {
384        if !crate::early_hints::valid(link) {
385            report.error(format!("{label}: early_hints value {link:?} is empty or holds a line break"));
386        } else if !link.trim_start().starts_with('<') {
387            report.warn(format!("{label}: early_hints value {link:?} doesn't start with <URL>; browsers will ignore it"));
388        }
389    }
390    if let Some(rule) = &mw.authz {
391        let schema = authz.and_then(|a| a.as_ref().ok()).map(|a| a.schema());
392        match (authz, crate::authz::ObjectTemplate::parse(&rule.object)) {
393            (None, _) => report.error(format!("{label}: [middleware.authz] needs an [authz] section")),
394            (_, Err(e)) => report.error(format!("{label}: [middleware.authz] {e}")),
395            (Some(_), Ok(template)) => {
396                let kind = template.object_type().unwrap_or_default();
397                if rule.relation.is_empty() {
398                    report.error(format!("{label}: [middleware.authz] needs `relation`"));
399                } else if let Some(schema) = schema {
400                    if !schema.has_relation(kind, &rule.relation) {
401                        report.error(format!("{label}: [middleware.authz] {kind}#{} isn't defined in [authz]", rule.relation));
402                    }
403                }
404            }
405        }
406        if !has_auth {
407            report.warn(format!("{label}: [middleware.authz] without auth here or in [middleware]: no caller is known, every request gets 401"));
408        }
409    }
410    if let Some(m) = &mw.mirror {
411        match (m.upstream.is_empty(), m.backends.is_empty()) {
412            (true, true) => report.error(format!("{label}: [middleware.mirror] needs `upstream` or `backends`")),
413            (false, false) => report.error(format!("{label}: [middleware.mirror] has both `upstream` and `backends`; use one")),
414            _ => {}
415        }
416        for b in &m.backends {
417            if crate::proxy_config::health::parse_backend_url(b).is_none() {
418                report.error(format!("{label}: mirror backend '{b}' is not a backend address"));
419            }
420        }
421        if !(m.percent.is_finite() && (0.0..=100.0).contains(&m.percent)) {
422            report.error(format!("{label}: mirror percent must be a number from 0 to 100"));
423        } else if m.percent == 0.0 {
424            report.warn(format!("{label}: mirror percent is 0: nothing is mirrored"));
425        }
426    }
427    if let Some(wasm) = &mw.wasm {
428        if wasm.module.is_empty() {
429            report.error(format!("{label}: [middleware.wasm] has no `module`; the route answers 503"));
430        } else if cfg!(not(feature = "wasm-plugins")) {
431            report.error(format!("{label}: [middleware.wasm] needs the wasm-plugins feature, which this build doesn't include; the route answers 503"));
432        } else if !crate::config_check::offline() {
433            #[cfg(feature = "wasm-plugins")]
434            if let Err(e) = crate::wasm_plugin::WasmLayer::from_file(&wasm.module) {
435                report.error(format!("{label}: {e}; the route answers 503"));
436            }
437        }
438        if wasm.fuel == 0 || wasm.memory_mb == 0 {
439            report.error(format!("{label}: [middleware.wasm] fuel and memory_mb must be above 0"));
440        }
441    }
442    match &mw.auth {
443        Some(AuthConfig::Bearer { token_env }) | Some(AuthConfig::Jwt { secret_env: token_env, .. }) => {
444            if token_env.is_empty() {
445                report.error(format!("{label}: auth has no `token_env` / `secret_env`; the route answers 503"));
446            } else if !crate::config_check::offline() && std::env::var(token_env).unwrap_or_default().is_empty() {
447                report.error(format!("{label}: auth secret env var '{token_env}' is unset or empty; the route answers 503 until it is set"));
448            }
449        }
450        Some(AuthConfig::Oidc(o)) => {
451            if o.client_id.is_empty() {
452                report.error(format!("{label}: oidc auth has no `client_id`; the route answers 503"));
453            }
454            match (o.issuer.is_empty(), o.provider.as_str()) {
455                (true, "") => report.error(format!("{label}: oidc auth needs `issuer` (discovery URL) or `provider` (google, microsoft, github); the route answers 503")),
456                (true, "google" | "microsoft" | "github") => {}
457                (true, other) => report.error(format!("{label}: oidc provider {other:?}: expected google, microsoft or github (others: set `issuer`)")),
458                (false, _) if !o.issuer.starts_with("https://") && !o.issuer.starts_with("http://") => report.error(format!("{label}: oidc issuer {:?} is not a URL", o.issuer)),
459                _ => {}
460            }
461            if o.client_secret_env.is_empty() {
462                report.warn(format!("{label}: oidc auth has no `client_secret_env`: a public client (PKCE only), which most providers refuse for web apps"));
463            } else if !crate::config_check::offline() && std::env::var(&o.client_secret_env).unwrap_or_default().is_empty() {
464                report.error(format!("{label}: oidc client secret env var '{}' is unset or empty; the route answers 503", o.client_secret_env));
465            }
466            if o.cookie_secret_env.is_empty() {
467                report.warn(format!("{label}: oidc auth without `cookie_secret_env`: sign-ins are lost on restart and don't work across replicas"));
468            } else if !crate::config_check::offline() && std::env::var(&o.cookie_secret_env).unwrap_or_default().len() < 32 {
469                report.error(format!("{label}: oidc cookie secret env var '{}' must hold at least 32 characters; the route answers 503", o.cookie_secret_env));
470            }
471            #[cfg(not(feature = "sso"))]
472            report.error(format!("{label}: oidc auth needs the `sso` feature, which this build doesn't include; the route answers 503"));
473        }
474        Some(AuthConfig::Unknown(kind)) => {
475            report.error(format!("{label}: auth type {kind:?} is not one of bearer, jwt, basic, oidc, forward; the route answers 503"));
476        }
477        Some(AuthConfig::Forward { url, .. }) => {
478            if !(url.starts_with("http://") || url.starts_with("https://")) {
479                report.error(format!("{label}: forward auth `url` {url:?} is not an http(s) URL; the route answers 503"));
480            }
481            #[cfg(not(feature = "auth"))]
482            report.error(format!("{label}: forward auth needs the `auth` feature, which this build doesn't include; the route answers 503"));
483        }
484        Some(AuthConfig::Basic { htpasswd_file }) => {
485            #[cfg(feature = "auth")]
486            if !crate::config_check::offline() {
487                if let Err(e) = crate::auth::BasicAuthLayer::from_htpasswd_file(htpasswd_file) {
488                    report.error(format!("{label}: basic auth: {e}; the route answers 503"));
489                }
490            }
491            #[cfg(not(feature = "auth"))]
492            let _ = htpasswd_file;
493        }
494        None => {}
495    }
496    #[cfg(not(feature = "auth"))]
497    if matches!(mw.auth, Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::Basic { .. })) {
498        report.error(format!("{label}: jwt and basic auth need the `auth` feature, which this build doesn't include; the route answers 503"));
499    }
500
501    if let Some(waf) = &mw.waf {
502        for e in &waf.errors {
503            report.error(format!("{label}: {e}"));
504        }
505        if waf.config.mode == crate::waf::Mode::Detect {
506            report.warn(format!("{label}: [waf] mode = \"detect\": matches are logged, nothing is refused"));
507        }
508    }
509    for entry in mw.ip_allow.iter().chain(&mw.ip_deny) {
510        if !crate::ip_filter::is_valid_entry(entry) {
511            report.error(format!("{label}: ip_filter entry '{entry}' is not an IP address or CIDR range — ignored"));
512        }
513    }
514    if !mw.ip_allow.is_empty() && !mw.ip_deny.is_empty() {
515        report.warn(format!("{label}: ip_filter has both `allow` and `deny`; only `allow` is used"));
516    }
517    if let Some(cache) = &mw.cache {
518        if crate::proxy_config::cache_max_bytes(&cache.max_size).is_none() {
519            report.error(format!("{label}: cache max_size {:?} is not a size (e.g. \"512M\")", cache.max_size));
520        }
521        if !cache.purge_token_env.is_empty() && !crate::config_check::offline() && std::env::var(&cache.purge_token_env).unwrap_or_default().trim().is_empty() {
522            report.error(format!("{label}: cache purge_token_env {} is unset or empty; PURGE is refused", cache.purge_token_env));
523        }
524        if !cache.max_size.is_empty() && cache.dir.is_empty() {
525            report.warn(format!("{label}: cache max_size only applies with `dir` (the memory cache is bounded by entries)"));
526        }
527        if !cache.dir.is_empty() && !crate::config_check::offline() {
528            let probe = std::path::Path::new(&cache.dir).join(format!(".rws-check-{}", std::process::id()));
529            let writable = std::fs::create_dir_all(&cache.dir).and_then(|_| std::fs::write(&probe, b"")).is_ok();
530            let _ = std::fs::remove_file(&probe);
531            if !writable {
532                report.error(format!("{label}: cache dir {:?} isn't writable; the route caches in memory instead", cache.dir));
533            }
534        }
535    }
536    if let Some(rl) = &mw.rate_limit {
537        if rl.max_requests == 0 || rl.window_secs == 0 {
538            report.error(format!("{label}: rate_limit needs max_requests and window_secs above 0"));
539        }
540    }
541    for rule in mw.rewrite_request.iter().chain(&mw.rewrite_response) {
542        let known = ["header_set", "header_remove", "uri_set", "uri_strip_prefix", "strip_prefix",
543                     "uri_add_prefix", "add_prefix", "status", "body_replace"];
544        if !known.contains(&rule.type_.as_str()) {
545            report.error(format!("{label}: unknown rewrite rule type '{}' — ignored", rule.type_));
546        }
547    }
548}