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"];
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 RESPOND_ACTION_KEYS: &[&str] = &["status", "body", "content_type"];
20const REWRITE_RULE_KEYS: &[&str] = &["type", "name", "value", "prefix", "from", "to", "code", "reason"];
21
22/// The keys the parser reads in a section, by the section's path with array
23/// indices removed (`route[2].middleware.auth` → `route.middleware.auth`).
24/// `None` for a section the parser never reads at all.
25fn known_keys(section: &str) -> Option<Vec<&'static str>> {
26    // The global `[middleware]` table has the same shape as `[route.middleware]`.
27    let route_form;
28    let section = if section == "middleware" || section.starts_with("middleware.") {
29        route_form = format!("route.{section}");
30        route_form.as_str()
31    } else {
32        section
33    };
34    let keys: Vec<&'static str> = match section {
35        "upstream" => vec!["name", "backends", "strategy", "weights", "tls_ca_file", "tls_client_cert_file", "tls_client_key_file"],
36        "upstream.sticky" => vec!["cookie", "max_age_secs"],
37        "upstream.circuit_breaker" => vec!["failure_threshold", "recovery_secs", "half_open_probes"],
38        "upstream.retry" => vec!["attempts", "status"],
39        "upstream.health_check" | "ws_proxy.health_check" => HEALTH_CHECK_KEYS.to_vec(),
40        "route" => vec!["name"],
41        "route.match" => vec!["host", "path", "method", "content_type"],
42        // `[route.action]` takes `type` plus the short form of every action's keys.
43        "route.action" => {
44            let mut keys = vec!["type"];
45            for group in [PROXY_ACTION_KEYS, GRPC_ACTION_KEYS, STATIC_ACTION_KEYS, REDIRECT_ACTION_KEYS, RESPOND_ACTION_KEYS] {
46                for key in group {
47                    if !keys.contains(key) {
48                        keys.push(key);
49                    }
50                }
51            }
52            keys
53        }
54        "route.action.proxy" => PROXY_ACTION_KEYS.to_vec(),
55        "route.action.grpc" => GRPC_ACTION_KEYS.to_vec(),
56        "route.action.static" => STATIC_ACTION_KEYS.to_vec(),
57        "route.action.redirect" => REDIRECT_ACTION_KEYS.to_vec(),
58        "route.action.respond" => RESPOND_ACTION_KEYS.to_vec(),
59        "route.action.mcp" => super::mcp_gateway::GATEWAY_KEYS.to_vec(),
60        "route.action.mcp.upstream" => super::mcp_gateway::UPSTREAM_KEYS.to_vec(),
61        "route.action.mcp.caller" => super::mcp_gateway::CALLER_KEYS.to_vec(),
62        "route.action.mcp.allow" => super::mcp_gateway::ALLOW_KEYS.to_vec(),
63        "route.action.mcp.limit" => super::mcp_gateway::LIMIT_KEYS.to_vec(),
64        "route.action.llm" => super::llm::LLM_KEYS.to_vec(),
65        "route.action.llm.provider" => super::llm::PROVIDER_KEYS.to_vec(),
66        "route.action.llm.key" => super::llm::KEY_KEYS.to_vec(),
67        "route.middleware" => vec!["timeout_ms", "max_body_size", "early_hints"],
68        "route.middleware.rate_limit" => vec!["max_requests", "window_secs"],
69        "route.middleware.cache" => vec!["ttl_secs", "vary_by", "dir", "max_size", "purge_token_env"],
70        "route.middleware.auth" => vec!["type", "token_env", "secret_env", "htpasswd_file"],
71        "route.middleware.ip_filter" => vec!["allow", "deny"],
72        "route.middleware.mirror" => vec!["upstream", "backends", "percent"],
73        "route.middleware.wasm" => vec!["module", "fuel", "memory_mb", "config"],
74        "route.middleware.rewrite.request" | "route.middleware.rewrite.response" => REWRITE_RULE_KEYS.to_vec(),
75        "tcp_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms"],
76        "udp_proxy" => vec!["name", "listen", "backends", "reply_timeout_ms", "buffer_size"],
77        "ws_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms", "read_timeout_ms"],
78        _ => return None,
79    };
80    Some(keys)
81}
82
83/// `route[2].middleware.rewrite.request[0]` → `route.middleware.rewrite.request`.
84fn strip_indices(section: &str) -> String {
85    let mut out = String::with_capacity(section.len());
86    let mut in_index = false;
87    for c in section.chars() {
88        match c {
89            '[' => in_index = true,
90            ']' => in_index = false,
91            _ if !in_index => out.push(c),
92            _ => {}
93        }
94    }
95    out
96}
97
98/// Report keys and tables in the proxy sections that the parser ignores.
99pub fn check_unknown_keys(toml: &str, report: &mut ConfigReport) {
100    let map = parser::parse(toml);
101    let mut sections: Vec<&String> = map.keys().collect();
102    sections.sort();
103    for section in sections {
104        let root = section.split(['.', '[']).next().unwrap_or("");
105        if !["route", "upstream", "tcp_proxy", "udp_proxy", "ws_proxy", "middleware"].contains(&root) {
106            continue;
107        }
108        let shape = strip_indices(section);
109        // Header names: any key.
110        if shape == "route.action.mcp.upstream.headers_env" {
111            continue;
112        }
113        match known_keys(&shape) {
114            None => report.error(format!("[{}]: unknown table — ignored", shape)),
115            Some(keys) => {
116                for (key, _) in &map[section] {
117                    if !keys.contains(&key.as_str()) {
118                        report.error(format!("[{}] {}: unknown key — ignored", shape, key));
119                    }
120                }
121            }
122        }
123    }
124}
125
126/// `host:port`, or a URL with an `http://`, `https://`, `h2://`, `ws://` or
127/// `wss://` scheme, whose port may be left to the scheme's default.
128fn is_host_port(backend: &str) -> bool {
129    let (rest, has_scheme) = match ["http://", "https://", "h2://", "h2s://", "grpc://", "grpcs://", "ws://", "wss://"]
130        .iter()
131        .find_map(|scheme| backend.strip_prefix(scheme))
132    {
133        Some(rest) => (rest, true),
134        None => (backend, false),
135    };
136    let rest = rest.trim_end_matches('/');
137    if rest.is_empty() || rest.contains('/') || rest.contains(char::is_whitespace) {
138        return false;
139    }
140    match rest.rsplit_once(':') {
141        Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0),
142        None => has_scheme,
143    }
144}
145
146impl ProxyConfig {
147    /// Check the configuration for mistakes the parser lets through. Reads
148    /// the auth secrets' environment variables and htpasswd files, so run it
149    /// in the environment the server will run in.
150    pub fn validate(&self, report: &mut ConfigReport) {
151        let mut names = HashSet::new();
152        for (i, upstream) in self.upstreams.iter().enumerate() {
153            let label = if upstream.name.is_empty() { format!("[[upstream]] #{}", i + 1) } else { format!("upstream '{}'", upstream.name) };
154            if upstream.name.is_empty() {
155                report.error(format!("{label}: missing `name` — no route can refer to it"));
156            } else if !names.insert(upstream.name.as_str()) {
157                report.error(format!("{label}: declared more than once"));
158            }
159            if upstream.backends.is_empty() {
160                report.error(format!("{label}: `backends` is empty"));
161            }
162            for backend in &upstream.backends {
163                if !is_host_port(backend) {
164                    report.error(format!("{label}: backend '{backend}' is not host:port"));
165                }
166            }
167            let client = match (&upstream.tls_client_cert_file, &upstream.tls_client_key_file) {
168                (Some(cert), Some(key)) => Some((cert.as_str(), key.as_str())),
169                (None, None) => None,
170                _ => {
171                    report.error(format!("{label}: tls_client_cert_file and tls_client_key_file go together"));
172                    None
173                }
174            };
175            if upstream.tls_ca_file.is_some() || client.is_some() {
176                if !crate::config_check::offline() {
177                    if let Err(e) = crate::proxy::UpstreamTls::new(upstream.tls_ca_file.as_deref(), client) {
178                        report.error(format!("{label}: {e}; its TLS backends would answer 502"));
179                    }
180                }
181                if !upstream.tls {
182                    report.warn(format!("{label}: TLS settings are set but no backend is https://, h2s:// or grpcs://, so they aren't used"));
183                }
184            }
185            if !STRATEGIES.contains(&upstream.strategy.as_str()) {
186                report.error(format!(
187                    "{label}: unknown strategy '{}' (expected one of {})",
188                    upstream.strategy,
189                    STRATEGIES.join(", ")
190                ));
191            }
192            if !upstream.weights.is_empty() {
193                if upstream.weights.len() != upstream.backends.len() {
194                    report.error(format!("{label}: `weights` has {} entries for {} backends (one each, in order)", upstream.weights.len(), upstream.backends.len()));
195                }
196                if upstream.weights.contains(&0) {
197                    report.error(format!("{label}: `weights` must be whole numbers of 1 or more"));
198                }
199                if upstream.strategy == "ip_hash" {
200                    report.warn(format!("{label}: `weights` don't apply to strategy ip_hash"));
201                }
202            }
203            if let Some(sticky) = &upstream.sticky {
204                let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
205                if sticky.cookie.is_empty() || !sticky.cookie.chars().all(token) {
206                    report.error(format!("{label}: sticky cookie name '{}' isn't a valid cookie name", sticky.cookie));
207                }
208            }
209            if let Some(breaker) = &upstream.circuit_breaker {
210                if breaker.failure_threshold == 0 {
211                    report.error(format!("{label}: circuit_breaker.failure_threshold must be 1 or more"));
212                }
213                if breaker.recovery_secs == 0 {
214                    report.warn(format!("{label}: circuit_breaker.recovery_secs = 0 probes an open backend on the next request"));
215                }
216            }
217            if let Some(retry) = &upstream.retry {
218                if retry.attempts == 0 {
219                    report.warn(format!("{label}: retry.attempts = 0 never retries"));
220                } else if retry.attempts as usize >= upstream.backends.len() {
221                    report.warn(format!(
222                        "{label}: retry.attempts = {} but there are {} backends — a request goes to each at most once",
223                        retry.attempts,
224                        upstream.backends.len()
225                    ));
226                }
227                if let Some(status) = retry.status.iter().find(|s| !(400..=599).contains(*s)) {
228                    report.error(format!("{label}: retry.status {status} isn't an error status (400–599)"));
229                }
230            }
231            #[cfg(not(any(feature = "http-client", feature = "http2")))]
232            if upstream.tls {
233                report.error(format!("{label}: https:// backends need the `http2` or `http-client` feature, which this build doesn't include"));
234            }
235        }
236
237        for (i, route) in self.routes.iter().enumerate() {
238            let label = if route.name.is_empty() { format!("route #{}", i + 1) } else { format!("route '{}'", route.name) };
239            match &route.action {
240                ActionConfig::Proxy { upstream, .. } | ActionConfig::Grpc { upstream, .. } => {
241                    if upstream.is_empty() {
242                        report.error(format!("{label}: proxy action has no `upstream`"));
243                    } else if !names.contains(upstream.as_str()) {
244                        if is_host_port(upstream) {
245                            report.warn(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]; using it as a backend address (no health checks)"));
246                        } else {
247                            report.error(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]"));
248                        }
249                    }
250                }
251                ActionConfig::Static { root, .. } => {
252                    if root.is_empty() {
253                        report.error(format!("{label}: static action has no `root`"));
254                    } else if !crate::config_check::offline() && !std::path::Path::new(root).is_dir() {
255                        report.warn(format!("{label}: static root '{root}' is not a directory (yet)"));
256                    }
257                }
258                ActionConfig::Redirect { location, status } => {
259                    if location.is_empty() {
260                        report.error(format!("{label}: redirect action has no `location`"));
261                    }
262                    if !(300..=399).contains(status) {
263                        report.error(format!("{label}: redirect status {status} is not a 3xx code"));
264                    }
265                }
266                ActionConfig::Respond { status, .. } => {
267                    if !(100..=599).contains(status) {
268                        report.error(format!("{label}: respond status {status} is not an HTTP status code"));
269                    }
270                }
271                ActionConfig::Mcp(gateway) => {
272                    super::mcp_gateway::validate(gateway, route.match_.path.as_deref(), &label, report);
273                }
274                ActionConfig::Llm(llm) => {
275                    super::llm::validate(llm, &label, report);
276                }
277                ActionConfig::Unknown(kind) if kind.is_empty() => {
278                    report.error(format!("{label}: [route.action] has no `type`"));
279                }
280                ActionConfig::Unknown(kind) => {
281                    report.error(format!("{label}: unknown action type '{kind}' (expected proxy, grpc, static, redirect, respond, mcp or llm)"));
282                }
283            }
284            validate_middleware(&label, &route.middleware, report);
285
286            let m = &route.match_;
287            if m.host.is_none() && m.path.is_none() && m.method.is_none() && m.content_type.is_none() && i + 1 < self.routes.len() {
288                report.warn(format!("{label}: matches every request, so the routes after it are never reached"));
289            }
290        }
291
292        validate_middleware("[middleware]", &self.global_middleware, report);
293
294        let listeners = self.tcp_proxies.iter().map(|p| ("tcp_proxy", &p.name, &p.listen, &p.backends))
295            .chain(self.udp_proxies.iter().map(|p| ("udp_proxy", &p.name, &p.listen, &p.backends)))
296            .chain(self.ws_proxies.iter().map(|p| ("ws_proxy", &p.name, &p.listen, &p.backends)));
297        for (kind, name, listen, backends) in listeners {
298            let label = format!("[[{kind}]] '{name}'");
299            if listen.parse::<std::net::SocketAddr>().is_err() {
300                report.error(format!("{label}: listen '{listen}' is not an address like 0.0.0.0:5432"));
301            }
302            if backends.is_empty() {
303                report.error(format!("{label}: `backends` is empty"));
304            }
305            for backend in backends {
306                if !is_host_port(backend) {
307                    report.error(format!("{label}: backend '{backend}' is not host:port"));
308                }
309            }
310        }
311    }
312}
313
314fn validate_middleware(label: &str, mw: &MiddlewareConfig, report: &mut ConfigReport) {
315    for link in &mw.early_hints {
316        if !crate::early_hints::valid(link) {
317            report.error(format!("{label}: early_hints value {link:?} is empty or holds a line break"));
318        } else if !link.trim_start().starts_with('<') {
319            report.warn(format!("{label}: early_hints value {link:?} doesn't start with <URL>; browsers will ignore it"));
320        }
321    }
322    if let Some(m) = &mw.mirror {
323        match (m.upstream.is_empty(), m.backends.is_empty()) {
324            (true, true) => report.error(format!("{label}: [middleware.mirror] needs `upstream` or `backends`")),
325            (false, false) => report.error(format!("{label}: [middleware.mirror] has both `upstream` and `backends`; use one")),
326            _ => {}
327        }
328        for b in &m.backends {
329            if crate::proxy_config::health::parse_backend_url(b).is_none() {
330                report.error(format!("{label}: mirror backend '{b}' is not a backend address"));
331            }
332        }
333        if !(m.percent.is_finite() && (0.0..=100.0).contains(&m.percent)) {
334            report.error(format!("{label}: mirror percent must be a number from 0 to 100"));
335        } else if m.percent == 0.0 {
336            report.warn(format!("{label}: mirror percent is 0: nothing is mirrored"));
337        }
338    }
339    if let Some(wasm) = &mw.wasm {
340        if wasm.module.is_empty() {
341            report.error(format!("{label}: [middleware.wasm] has no `module`; the route answers 503"));
342        } else if cfg!(not(feature = "wasm-plugins")) {
343            report.error(format!("{label}: [middleware.wasm] needs the wasm-plugins feature, which this build doesn't include; the route answers 503"));
344        } else if !crate::config_check::offline() {
345            #[cfg(feature = "wasm-plugins")]
346            if let Err(e) = crate::wasm_plugin::WasmLayer::from_file(&wasm.module) {
347                report.error(format!("{label}: {e}; the route answers 503"));
348            }
349        }
350        if wasm.fuel == 0 || wasm.memory_mb == 0 {
351            report.error(format!("{label}: [middleware.wasm] fuel and memory_mb must be above 0"));
352        }
353    }
354    match &mw.auth {
355        Some(AuthConfig::Bearer { token_env }) | Some(AuthConfig::Jwt { secret_env: token_env }) => {
356            if token_env.is_empty() {
357                report.error(format!("{label}: auth has no `token_env` / `secret_env`; the route answers 503"));
358            } else if !crate::config_check::offline() && std::env::var(token_env).unwrap_or_default().is_empty() {
359                report.error(format!("{label}: auth secret env var '{token_env}' is unset or empty; the route answers 503 until it is set"));
360            }
361        }
362        Some(AuthConfig::Basic { htpasswd_file }) => {
363            #[cfg(feature = "auth")]
364            if !crate::config_check::offline() {
365                if let Err(e) = crate::auth::BasicAuthLayer::from_htpasswd_file(htpasswd_file) {
366                    report.error(format!("{label}: basic auth: {e}; the route answers 503"));
367                }
368            }
369            #[cfg(not(feature = "auth"))]
370            let _ = htpasswd_file;
371        }
372        None => {}
373    }
374    #[cfg(not(feature = "auth"))]
375    if matches!(mw.auth, Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::Basic { .. })) {
376        report.error(format!("{label}: jwt and basic auth need the `auth` feature, which this build doesn't include; the route answers 503"));
377    }
378
379    for entry in mw.ip_allow.iter().chain(&mw.ip_deny) {
380        if !crate::ip_filter::is_valid_entry(entry) {
381            report.error(format!("{label}: ip_filter entry '{entry}' is not an IP address or CIDR range — ignored"));
382        }
383    }
384    if !mw.ip_allow.is_empty() && !mw.ip_deny.is_empty() {
385        report.warn(format!("{label}: ip_filter has both `allow` and `deny`; only `allow` is used"));
386    }
387    if let Some(cache) = &mw.cache {
388        if crate::proxy_config::cache_max_bytes(&cache.max_size).is_none() {
389            report.error(format!("{label}: cache max_size {:?} is not a size (e.g. \"512M\")", cache.max_size));
390        }
391        if !cache.purge_token_env.is_empty() && !crate::config_check::offline() && std::env::var(&cache.purge_token_env).unwrap_or_default().trim().is_empty() {
392            report.error(format!("{label}: cache purge_token_env {} is unset or empty; PURGE is refused", cache.purge_token_env));
393        }
394        if !cache.max_size.is_empty() && cache.dir.is_empty() {
395            report.warn(format!("{label}: cache max_size only applies with `dir` (the memory cache is bounded by entries)"));
396        }
397        if !cache.dir.is_empty() && !crate::config_check::offline() {
398            let probe = std::path::Path::new(&cache.dir).join(format!(".rws-check-{}", std::process::id()));
399            let writable = std::fs::create_dir_all(&cache.dir).and_then(|_| std::fs::write(&probe, b"")).is_ok();
400            let _ = std::fs::remove_file(&probe);
401            if !writable {
402                report.error(format!("{label}: cache dir {:?} isn't writable; the route caches in memory instead", cache.dir));
403            }
404        }
405    }
406    if let Some(rl) = &mw.rate_limit {
407        if rl.max_requests == 0 || rl.window_secs == 0 {
408            report.error(format!("{label}: rate_limit needs max_requests and window_secs above 0"));
409        }
410    }
411    for rule in mw.rewrite_request.iter().chain(&mw.rewrite_response) {
412        let known = ["header_set", "header_remove", "uri_set", "uri_strip_prefix", "strip_prefix",
413                     "uri_add_prefix", "add_prefix", "status", "body_replace"];
414        if !known.contains(&rule.type_.as_str()) {
415            report.error(format!("{label}: unknown rewrite rule type '{}' — ignored", rule.type_));
416        }
417    }
418}