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", "tls_ca_file"],
36        "upstream.health_check" | "ws_proxy.health_check" => HEALTH_CHECK_KEYS.to_vec(),
37        "route" => vec!["name"],
38        "route.match" => vec!["host", "path", "method", "content_type"],
39        // `[route.action]` takes `type` plus the short form of every action's keys.
40        "route.action" => {
41            let mut keys = vec!["type"];
42            for group in [PROXY_ACTION_KEYS, GRPC_ACTION_KEYS, STATIC_ACTION_KEYS, REDIRECT_ACTION_KEYS, RESPOND_ACTION_KEYS] {
43                for key in group {
44                    if !keys.contains(key) {
45                        keys.push(key);
46                    }
47                }
48            }
49            keys
50        }
51        "route.action.proxy" => PROXY_ACTION_KEYS.to_vec(),
52        "route.action.grpc" => GRPC_ACTION_KEYS.to_vec(),
53        "route.action.static" => STATIC_ACTION_KEYS.to_vec(),
54        "route.action.redirect" => REDIRECT_ACTION_KEYS.to_vec(),
55        "route.action.respond" => RESPOND_ACTION_KEYS.to_vec(),
56        "route.middleware" => vec!["timeout_ms", "max_body_size"],
57        "route.middleware.rate_limit" => vec!["max_requests", "window_secs"],
58        "route.middleware.cache" => vec!["ttl_secs", "vary_by"],
59        "route.middleware.auth" => vec!["type", "token_env", "secret_env", "htpasswd_file"],
60        "route.middleware.ip_filter" => vec!["allow", "deny"],
61        "route.middleware.rewrite.request" | "route.middleware.rewrite.response" => REWRITE_RULE_KEYS.to_vec(),
62        "tcp_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms"],
63        "udp_proxy" => vec!["name", "listen", "backends", "reply_timeout_ms", "buffer_size"],
64        "ws_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms", "read_timeout_ms"],
65        _ => return None,
66    };
67    Some(keys)
68}
69
70/// `route[2].middleware.rewrite.request[0]` → `route.middleware.rewrite.request`.
71fn strip_indices(section: &str) -> String {
72    let mut out = String::with_capacity(section.len());
73    let mut in_index = false;
74    for c in section.chars() {
75        match c {
76            '[' => in_index = true,
77            ']' => in_index = false,
78            _ if !in_index => out.push(c),
79            _ => {}
80        }
81    }
82    out
83}
84
85/// Report keys and tables in the proxy sections that the parser ignores.
86pub fn check_unknown_keys(toml: &str, report: &mut ConfigReport) {
87    let map = parser::parse(toml);
88    let mut sections: Vec<&String> = map.keys().collect();
89    sections.sort();
90    for section in sections {
91        let root = section.split(['.', '[']).next().unwrap_or("");
92        if !["route", "upstream", "tcp_proxy", "udp_proxy", "ws_proxy", "middleware"].contains(&root) {
93            continue;
94        }
95        let shape = strip_indices(section);
96        match known_keys(&shape) {
97            None => report.error(format!("[{}]: unknown table — ignored", shape)),
98            Some(keys) => {
99                for (key, _) in &map[section] {
100                    if !keys.contains(&key.as_str()) {
101                        report.error(format!("[{}] {}: unknown key — ignored", shape, key));
102                    }
103                }
104            }
105        }
106    }
107}
108
109/// `host:port`, or a URL with an `http://`, `https://`, `h2://`, `ws://` or
110/// `wss://` scheme, whose port may be left to the scheme's default.
111fn is_host_port(backend: &str) -> bool {
112    let (rest, has_scheme) = match ["http://", "https://", "h2://", "ws://", "wss://"]
113        .iter()
114        .find_map(|scheme| backend.strip_prefix(scheme))
115    {
116        Some(rest) => (rest, true),
117        None => (backend, false),
118    };
119    let rest = rest.trim_end_matches('/');
120    if rest.is_empty() || rest.contains('/') || rest.contains(char::is_whitespace) {
121        return false;
122    }
123    match rest.rsplit_once(':') {
124        Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0),
125        None => has_scheme,
126    }
127}
128
129impl ProxyConfig {
130    /// Check the configuration for mistakes the parser lets through. Reads
131    /// the auth secrets' environment variables and htpasswd files, so run it
132    /// in the environment the server will run in.
133    pub fn validate(&self, report: &mut ConfigReport) {
134        let mut names = HashSet::new();
135        for (i, upstream) in self.upstreams.iter().enumerate() {
136            let label = if upstream.name.is_empty() { format!("[[upstream]] #{}", i + 1) } else { format!("upstream '{}'", upstream.name) };
137            if upstream.name.is_empty() {
138                report.error(format!("{label}: missing `name` — no route can refer to it"));
139            } else if !names.insert(upstream.name.as_str()) {
140                report.error(format!("{label}: declared more than once"));
141            }
142            if upstream.backends.is_empty() {
143                report.error(format!("{label}: `backends` is empty"));
144            }
145            for backend in &upstream.backends {
146                if !is_host_port(backend) {
147                    report.error(format!("{label}: backend '{backend}' is not host:port"));
148                }
149            }
150            if let Some(ca) = &upstream.tls_ca_file {
151                if let Err(e) = crate::proxy::UpstreamTls::from_ca_file(ca) {
152                    report.error(format!("{label}: {e}; its https:// backends would answer 502"));
153                }
154                if !upstream.tls {
155                    report.warn(format!("{label}: tls_ca_file is set but no backend is https://, so it isn't used"));
156                }
157            }
158            if !STRATEGIES.contains(&upstream.strategy.as_str()) {
159                report.error(format!(
160                    "{label}: unknown strategy '{}' (expected one of {})",
161                    upstream.strategy,
162                    STRATEGIES.join(", ")
163                ));
164            }
165            #[cfg(not(any(feature = "http-client", feature = "http2")))]
166            if upstream.tls {
167                report.error(format!("{label}: https:// backends need the `http2` or `http-client` feature, which this build doesn't include"));
168            }
169        }
170
171        for (i, route) in self.routes.iter().enumerate() {
172            let label = if route.name.is_empty() { format!("route #{}", i + 1) } else { format!("route '{}'", route.name) };
173            match &route.action {
174                ActionConfig::Proxy { upstream, .. } | ActionConfig::Grpc { upstream, .. } => {
175                    if upstream.is_empty() {
176                        report.error(format!("{label}: proxy action has no `upstream`"));
177                    } else if !names.contains(upstream.as_str()) {
178                        if is_host_port(upstream) {
179                            report.warn(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]; using it as a backend address (no health checks)"));
180                        } else {
181                            report.error(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]"));
182                        }
183                    }
184                }
185                ActionConfig::Static { root, .. } => {
186                    if root.is_empty() {
187                        report.error(format!("{label}: static action has no `root`"));
188                    } else if !std::path::Path::new(root).is_dir() {
189                        report.warn(format!("{label}: static root '{root}' is not a directory (yet)"));
190                    }
191                }
192                ActionConfig::Redirect { location, status } => {
193                    if location.is_empty() {
194                        report.error(format!("{label}: redirect action has no `location`"));
195                    }
196                    if !(300..=399).contains(status) {
197                        report.error(format!("{label}: redirect status {status} is not a 3xx code"));
198                    }
199                }
200                ActionConfig::Respond { status, .. } => {
201                    if !(100..=599).contains(status) {
202                        report.error(format!("{label}: respond status {status} is not an HTTP status code"));
203                    }
204                }
205                ActionConfig::Mcp => {
206                    report.warn(format!("{label}: `mcp` actions are not served from the config file; requests fall through to the static files"));
207                }
208                ActionConfig::Unknown(kind) if kind.is_empty() => {
209                    report.error(format!("{label}: [route.action] has no `type`"));
210                }
211                ActionConfig::Unknown(kind) => {
212                    report.error(format!("{label}: unknown action type '{kind}' (expected proxy, grpc, static, redirect or respond)"));
213                }
214            }
215            validate_middleware(&label, &route.middleware, report);
216
217            let m = &route.match_;
218            if m.host.is_none() && m.path.is_none() && m.method.is_none() && m.content_type.is_none() && i + 1 < self.routes.len() {
219                report.warn(format!("{label}: matches every request, so the routes after it are never reached"));
220            }
221        }
222
223        validate_middleware("[middleware]", &self.global_middleware, report);
224
225        let listeners = self.tcp_proxies.iter().map(|p| ("tcp_proxy", &p.name, &p.listen, &p.backends))
226            .chain(self.udp_proxies.iter().map(|p| ("udp_proxy", &p.name, &p.listen, &p.backends)))
227            .chain(self.ws_proxies.iter().map(|p| ("ws_proxy", &p.name, &p.listen, &p.backends)));
228        for (kind, name, listen, backends) in listeners {
229            let label = format!("[[{kind}]] '{name}'");
230            if listen.parse::<std::net::SocketAddr>().is_err() {
231                report.error(format!("{label}: listen '{listen}' is not an address like 0.0.0.0:5432"));
232            }
233            if backends.is_empty() {
234                report.error(format!("{label}: `backends` is empty"));
235            }
236            for backend in backends {
237                if !is_host_port(backend) {
238                    report.error(format!("{label}: backend '{backend}' is not host:port"));
239                }
240            }
241        }
242    }
243}
244
245fn validate_middleware(label: &str, mw: &MiddlewareConfig, report: &mut ConfigReport) {
246    match &mw.auth {
247        Some(AuthConfig::Bearer { token_env }) | Some(AuthConfig::Jwt { secret_env: token_env }) => {
248            if token_env.is_empty() {
249                report.error(format!("{label}: auth has no `token_env` / `secret_env`; the route answers 503"));
250            } else if std::env::var(token_env).unwrap_or_default().is_empty() {
251                report.error(format!("{label}: auth secret env var '{token_env}' is unset or empty; the route answers 503 until it is set"));
252            }
253        }
254        Some(AuthConfig::Basic { htpasswd_file }) => {
255            #[cfg(feature = "auth")]
256            if let Err(e) = crate::auth::BasicAuthLayer::from_htpasswd_file(htpasswd_file) {
257                report.error(format!("{label}: basic auth: {e}; the route answers 503"));
258            }
259            #[cfg(not(feature = "auth"))]
260            let _ = htpasswd_file;
261        }
262        None => {}
263    }
264    #[cfg(not(feature = "auth"))]
265    if matches!(mw.auth, Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::Basic { .. })) {
266        report.error(format!("{label}: jwt and basic auth need the `auth` feature, which this build doesn't include; the route answers 503"));
267    }
268
269    for entry in mw.ip_allow.iter().chain(&mw.ip_deny) {
270        if !crate::ip_filter::is_valid_entry(entry) {
271            report.error(format!("{label}: ip_filter entry '{entry}' is not an IP address or CIDR range — ignored"));
272        }
273    }
274    if !mw.ip_allow.is_empty() && !mw.ip_deny.is_empty() {
275        report.warn(format!("{label}: ip_filter has both `allow` and `deny`; only `allow` is used"));
276    }
277    if let Some(rl) = &mw.rate_limit {
278        if rl.max_requests == 0 || rl.window_secs == 0 {
279            report.error(format!("{label}: rate_limit needs max_requests and window_secs above 0"));
280        }
281    }
282    for rule in mw.rewrite_request.iter().chain(&mw.rewrite_response) {
283        let known = ["header_set", "header_remove", "uri_set", "uri_strip_prefix", "strip_prefix",
284                     "uri_add_prefix", "add_prefix", "status", "body_replace"];
285        if !known.contains(&rule.type_.as_str()) {
286            report.error(format!("{label}: unknown rewrite rule type '{}' — ignored", rule.type_));
287        }
288    }
289}