1use 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 RESPOND_ACTION_KEYS: &[&str] = &["status", "body", "content_type"];
20const REWRITE_RULE_KEYS: &[&str] = &["type", "name", "value", "prefix", "from", "to", "code", "reason"];
21
22fn known_keys(section: &str) -> Option<Vec<&'static str>> {
26 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", "protocol"],
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" => {
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.openapi" => super::mcp_gateway::OPENAPI_KEYS.to_vec(),
63 "route.action.mcp.allow" => super::mcp_gateway::ALLOW_KEYS.to_vec(),
64 "route.action.mcp.limit" => super::mcp_gateway::LIMIT_KEYS.to_vec(),
65 "route.action.llm" => super::llm::LLM_KEYS.to_vec(),
66 "route.action.a2a" => super::a2a_gateway::KEYS.to_vec(),
67 "route.action.llm.provider" => super::llm::PROVIDER_KEYS.to_vec(),
68 "route.action.llm.key" => super::llm::KEY_KEYS.to_vec(),
69 "route.middleware" => vec!["timeout_ms", "max_body_size", "early_hints"],
70 "route.middleware.rate_limit" => vec!["max_requests", "window_secs"],
71 "route.middleware.cache" => vec!["ttl_secs", "vary_by", "dir", "max_size", "purge_token_env"],
72 "route.middleware.auth" => vec!["type", "token_env", "secret_env", "htpasswd_file"],
73 "route.middleware.ip_filter" => vec!["allow", "deny"],
74 "route.middleware.mirror" => vec!["upstream", "backends", "percent"],
75 "route.middleware.authz" => vec!["relation", "object"],
76 "authz" => vec!["store", "tuples"],
77 "route.middleware.wasm" => vec!["module", "fuel", "memory_mb", "config"],
78 "route.middleware.rewrite.request" | "route.middleware.rewrite.response" => REWRITE_RULE_KEYS.to_vec(),
79 "tcp_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms"],
80 "udp_proxy" => vec!["name", "listen", "backends", "reply_timeout_ms", "buffer_size"],
81 "ws_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms", "read_timeout_ms"],
82 _ => return None,
83 };
84 Some(keys)
85}
86
87fn strip_indices(section: &str) -> String {
89 let mut out = String::with_capacity(section.len());
90 let mut in_index = false;
91 for c in section.chars() {
92 match c {
93 '[' => in_index = true,
94 ']' => in_index = false,
95 _ if !in_index => out.push(c),
96 _ => {}
97 }
98 }
99 out
100}
101
102pub fn check_unknown_keys(toml: &str, report: &mut ConfigReport) {
104 let map = parser::parse(toml);
105 let mut sections: Vec<&String> = map.keys().collect();
106 sections.sort();
107 for section in sections {
108 let root = section.split(['.', '[']).next().unwrap_or("");
109 if !["route", "upstream", "tcp_proxy", "udp_proxy", "ws_proxy", "middleware", "authz"].contains(&root) {
110 continue;
111 }
112 let shape = strip_indices(section);
113 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.") {
115 continue;
116 }
117 match known_keys(&shape) {
118 None => report.error(format!("[{}]: unknown table — ignored", shape)),
119 Some(keys) => {
120 for (key, _) in &map[section] {
121 if !keys.contains(&key.as_str()) {
122 report.error(format!("[{}] {}: unknown key — ignored", shape, key));
123 }
124 }
125 }
126 }
127 }
128}
129
130fn is_host_port(backend: &str) -> bool {
133 let (rest, has_scheme) = match ["http://", "https://", "h2://", "h2s://", "grpc://", "grpcs://", "ws://", "wss://"]
134 .iter()
135 .find_map(|scheme| backend.strip_prefix(scheme))
136 {
137 Some(rest) => (rest, true),
138 None => (backend, false),
139 };
140 let rest = rest.trim_end_matches('/');
141 if rest.is_empty() || rest.contains('/') || rest.contains(char::is_whitespace) {
142 return false;
143 }
144 match rest.rsplit_once(':') {
145 Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0),
146 None => has_scheme,
147 }
148}
149
150impl ProxyConfig {
151 pub fn validate(&self, report: &mut ConfigReport) {
155 let authz = super::builder::config_authorizer_with(self, Some(std::sync::Arc::new(crate::authz::MemoryStore::new())));
157 if self.authz.as_ref().is_some_and(|a| a.store == "sql")
158 && !cfg!(any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql"))
159 {
160 report.error("[authz]: store = \"sql\" needs a build with a model-sqlite / model-postgres / model-mysql feature".to_string());
161 }
162 if let Some(Err(e)) = &authz {
163 report.error(format!("[authz]: {e}"));
164 }
165 let mut names = HashSet::new();
166 for (i, upstream) in self.upstreams.iter().enumerate() {
167 let label = if upstream.name.is_empty() { format!("[[upstream]] #{}", i + 1) } else { format!("upstream '{}'", upstream.name) };
168 if upstream.name.is_empty() {
169 report.error(format!("{label}: missing `name` — no route can refer to it"));
170 } else if !names.insert(upstream.name.as_str()) {
171 report.error(format!("{label}: declared more than once"));
172 }
173 if upstream.backends.is_empty() {
174 report.error(format!("{label}: `backends` is empty"));
175 }
176 match upstream.protocol.to_ascii_lowercase().as_str() {
177 "" | "http1" => {}
178 "h3" => {
179 if !cfg!(feature = "http3") {
180 report.error(format!("{label}: protocol = \"h3\" needs a build with the http3 feature (the default)"));
181 }
182 if let Some(plain) = upstream.backends.iter().find(|b| !b.starts_with("https://")) {
183 report.error(format!("{label}: protocol = \"h3\" needs https:// backends (QUIC is always TLS); '{plain}' isn't"));
184 }
185 }
186 other => report.error(format!("{label}: protocol must be \"http1\" or \"h3\", not \"{other}\"")),
187 }
188 for backend in &upstream.backends {
189 if !is_host_port(backend) {
190 report.error(format!("{label}: backend '{backend}' is not host:port"));
191 }
192 }
193 let client = match (&upstream.tls_client_cert_file, &upstream.tls_client_key_file) {
194 (Some(cert), Some(key)) => Some((cert.as_str(), key.as_str())),
195 (None, None) => None,
196 _ => {
197 report.error(format!("{label}: tls_client_cert_file and tls_client_key_file go together"));
198 None
199 }
200 };
201 if upstream.tls_ca_file.is_some() || client.is_some() {
202 if !crate::config_check::offline() {
203 if let Err(e) = crate::proxy::UpstreamTls::new(upstream.tls_ca_file.as_deref(), client) {
204 report.error(format!("{label}: {e}; its TLS backends would answer 502"));
205 }
206 }
207 if !upstream.tls {
208 report.warn(format!("{label}: TLS settings are set but no backend is https://, h2s:// or grpcs://, so they aren't used"));
209 }
210 }
211 if !STRATEGIES.contains(&upstream.strategy.as_str()) {
212 report.error(format!(
213 "{label}: unknown strategy '{}' (expected one of {})",
214 upstream.strategy,
215 STRATEGIES.join(", ")
216 ));
217 }
218 if !upstream.weights.is_empty() {
219 if upstream.weights.len() != upstream.backends.len() {
220 report.error(format!("{label}: `weights` has {} entries for {} backends (one each, in order)", upstream.weights.len(), upstream.backends.len()));
221 }
222 if upstream.weights.contains(&0) {
223 report.error(format!("{label}: `weights` must be whole numbers of 1 or more"));
224 }
225 if upstream.strategy == "ip_hash" {
226 report.warn(format!("{label}: `weights` don't apply to strategy ip_hash"));
227 }
228 }
229 if let Some(sticky) = &upstream.sticky {
230 let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
231 if sticky.cookie.is_empty() || !sticky.cookie.chars().all(token) {
232 report.error(format!("{label}: sticky cookie name '{}' isn't a valid cookie name", sticky.cookie));
233 }
234 }
235 if let Some(breaker) = &upstream.circuit_breaker {
236 if breaker.failure_threshold == 0 {
237 report.error(format!("{label}: circuit_breaker.failure_threshold must be 1 or more"));
238 }
239 if breaker.recovery_secs == 0 {
240 report.warn(format!("{label}: circuit_breaker.recovery_secs = 0 probes an open backend on the next request"));
241 }
242 }
243 if let Some(retry) = &upstream.retry {
244 if retry.attempts == 0 {
245 report.warn(format!("{label}: retry.attempts = 0 never retries"));
246 } else if retry.attempts as usize >= upstream.backends.len() {
247 report.warn(format!(
248 "{label}: retry.attempts = {} but there are {} backends — a request goes to each at most once",
249 retry.attempts,
250 upstream.backends.len()
251 ));
252 }
253 if let Some(status) = retry.status.iter().find(|s| !(400..=599).contains(*s)) {
254 report.error(format!("{label}: retry.status {status} isn't an error status (400–599)"));
255 }
256 }
257 #[cfg(not(any(feature = "http-client", feature = "http2")))]
258 if upstream.tls {
259 report.error(format!("{label}: https:// backends need the `http2` or `http-client` feature, which this build doesn't include"));
260 }
261 }
262
263 for (i, route) in self.routes.iter().enumerate() {
264 let label = if route.name.is_empty() { format!("route #{}", i + 1) } else { format!("route '{}'", route.name) };
265 if matches!(route.action, ActionConfig::Proxy { .. })
267 && 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")))
268 {
269 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"));
270 }
271 match &route.action {
272 ActionConfig::Proxy { upstream, .. } | ActionConfig::Grpc { upstream, .. } => {
273 if upstream.is_empty() {
274 report.error(format!("{label}: proxy action has no `upstream`"));
275 } else if !names.contains(upstream.as_str()) {
276 if is_host_port(upstream) {
277 report.warn(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]; using it as a backend address (no health checks)"));
278 } else {
279 report.error(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]"));
280 }
281 }
282 }
283 ActionConfig::Static { root, .. } => {
284 if root.is_empty() {
285 report.error(format!("{label}: static action has no `root`"));
286 } else if !crate::config_check::offline() && !std::path::Path::new(root).is_dir() {
287 report.warn(format!("{label}: static root '{root}' is not a directory (yet)"));
288 }
289 }
290 ActionConfig::Redirect { location, status } => {
291 if location.is_empty() {
292 report.error(format!("{label}: redirect action has no `location`"));
293 }
294 if !(300..=399).contains(status) {
295 report.error(format!("{label}: redirect status {status} is not a 3xx code"));
296 }
297 }
298 ActionConfig::Respond { status, .. } => {
299 if !(100..=599).contains(status) {
300 report.error(format!("{label}: respond status {status} is not an HTTP status code"));
301 }
302 }
303 ActionConfig::Mcp(gateway) => {
304 super::mcp_gateway::validate(gateway, route.match_.path.as_deref(), &label, report);
305 }
306 ActionConfig::Llm(llm) => {
307 super::llm::validate(llm, &label, report);
308 }
309 ActionConfig::A2a(gateway) => {
310 super::a2a_gateway::validate(gateway, route.match_.path.as_deref(), &label, report);
311 }
312 ActionConfig::Unknown(kind) if kind.is_empty() => {
313 report.error(format!("{label}: [route.action] has no `type`"));
314 }
315 ActionConfig::Unknown(kind) => {
316 report.error(format!("{label}: unknown action type '{kind}' (expected proxy, grpc, static, redirect, respond, mcp, llm or a2a)"));
317 }
318 }
319 let has_auth = route.middleware.auth.is_some() || self.global_middleware.auth.is_some();
320 validate_middleware(&label, &route.middleware, authz.as_ref(), has_auth, report);
321
322 let m = &route.match_;
323 if m.host.is_none() && m.path.is_none() && m.method.is_none() && m.content_type.is_none() && i + 1 < self.routes.len() {
324 report.warn(format!("{label}: matches every request, so the routes after it are never reached"));
325 }
326 }
327
328 validate_middleware("[middleware]", &self.global_middleware, authz.as_ref(), self.global_middleware.auth.is_some(), report);
329 if self.global_middleware.authz.is_some() {
330 report.warn("[middleware.authz]: applies to every request, /healthz, /readyz and /metrics included — probes without a caller get 401".to_string());
331 }
332
333 let listeners = self.tcp_proxies.iter().map(|p| ("tcp_proxy", &p.name, &p.listen, &p.backends))
334 .chain(self.udp_proxies.iter().map(|p| ("udp_proxy", &p.name, &p.listen, &p.backends)))
335 .chain(self.ws_proxies.iter().map(|p| ("ws_proxy", &p.name, &p.listen, &p.backends)));
336 for (kind, name, listen, backends) in listeners {
337 let label = format!("[[{kind}]] '{name}'");
338 if listen.parse::<std::net::SocketAddr>().is_err() {
339 report.error(format!("{label}: listen '{listen}' is not an address like 0.0.0.0:5432"));
340 }
341 if backends.is_empty() {
342 report.error(format!("{label}: `backends` is empty"));
343 }
344 for backend in backends {
345 if !is_host_port(backend) {
346 report.error(format!("{label}: backend '{backend}' is not host:port"));
347 }
348 }
349 }
350 }
351}
352
353fn validate_middleware(
354 label: &str,
355 mw: &MiddlewareConfig,
356 authz: Option<&Result<crate::authz::Authorizer, String>>,
357 has_auth: bool,
358 report: &mut ConfigReport,
359) {
360 for link in &mw.early_hints {
361 if !crate::early_hints::valid(link) {
362 report.error(format!("{label}: early_hints value {link:?} is empty or holds a line break"));
363 } else if !link.trim_start().starts_with('<') {
364 report.warn(format!("{label}: early_hints value {link:?} doesn't start with <URL>; browsers will ignore it"));
365 }
366 }
367 if let Some(rule) = &mw.authz {
368 let schema = authz.and_then(|a| a.as_ref().ok()).map(|a| a.schema());
369 match (authz, crate::authz::ObjectTemplate::parse(&rule.object)) {
370 (None, _) => report.error(format!("{label}: [middleware.authz] needs an [authz] section")),
371 (_, Err(e)) => report.error(format!("{label}: [middleware.authz] {e}")),
372 (Some(_), Ok(template)) => {
373 let kind = template.object_type().unwrap_or_default();
374 if rule.relation.is_empty() {
375 report.error(format!("{label}: [middleware.authz] needs `relation`"));
376 } else if let Some(schema) = schema {
377 if !schema.has_relation(kind, &rule.relation) {
378 report.error(format!("{label}: [middleware.authz] {kind}#{} isn't defined in [authz]", rule.relation));
379 }
380 }
381 }
382 }
383 if !has_auth {
384 report.warn(format!("{label}: [middleware.authz] without auth here or in [middleware]: no caller is known, every request gets 401"));
385 }
386 }
387 if let Some(m) = &mw.mirror {
388 match (m.upstream.is_empty(), m.backends.is_empty()) {
389 (true, true) => report.error(format!("{label}: [middleware.mirror] needs `upstream` or `backends`")),
390 (false, false) => report.error(format!("{label}: [middleware.mirror] has both `upstream` and `backends`; use one")),
391 _ => {}
392 }
393 for b in &m.backends {
394 if crate::proxy_config::health::parse_backend_url(b).is_none() {
395 report.error(format!("{label}: mirror backend '{b}' is not a backend address"));
396 }
397 }
398 if !(m.percent.is_finite() && (0.0..=100.0).contains(&m.percent)) {
399 report.error(format!("{label}: mirror percent must be a number from 0 to 100"));
400 } else if m.percent == 0.0 {
401 report.warn(format!("{label}: mirror percent is 0: nothing is mirrored"));
402 }
403 }
404 if let Some(wasm) = &mw.wasm {
405 if wasm.module.is_empty() {
406 report.error(format!("{label}: [middleware.wasm] has no `module`; the route answers 503"));
407 } else if cfg!(not(feature = "wasm-plugins")) {
408 report.error(format!("{label}: [middleware.wasm] needs the wasm-plugins feature, which this build doesn't include; the route answers 503"));
409 } else if !crate::config_check::offline() {
410 #[cfg(feature = "wasm-plugins")]
411 if let Err(e) = crate::wasm_plugin::WasmLayer::from_file(&wasm.module) {
412 report.error(format!("{label}: {e}; the route answers 503"));
413 }
414 }
415 if wasm.fuel == 0 || wasm.memory_mb == 0 {
416 report.error(format!("{label}: [middleware.wasm] fuel and memory_mb must be above 0"));
417 }
418 }
419 match &mw.auth {
420 Some(AuthConfig::Bearer { token_env }) | Some(AuthConfig::Jwt { secret_env: token_env }) => {
421 if token_env.is_empty() {
422 report.error(format!("{label}: auth has no `token_env` / `secret_env`; the route answers 503"));
423 } else if !crate::config_check::offline() && std::env::var(token_env).unwrap_or_default().is_empty() {
424 report.error(format!("{label}: auth secret env var '{token_env}' is unset or empty; the route answers 503 until it is set"));
425 }
426 }
427 Some(AuthConfig::Basic { htpasswd_file }) => {
428 #[cfg(feature = "auth")]
429 if !crate::config_check::offline() {
430 if let Err(e) = crate::auth::BasicAuthLayer::from_htpasswd_file(htpasswd_file) {
431 report.error(format!("{label}: basic auth: {e}; the route answers 503"));
432 }
433 }
434 #[cfg(not(feature = "auth"))]
435 let _ = htpasswd_file;
436 }
437 None => {}
438 }
439 #[cfg(not(feature = "auth"))]
440 if matches!(mw.auth, Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::Basic { .. })) {
441 report.error(format!("{label}: jwt and basic auth need the `auth` feature, which this build doesn't include; the route answers 503"));
442 }
443
444 for entry in mw.ip_allow.iter().chain(&mw.ip_deny) {
445 if !crate::ip_filter::is_valid_entry(entry) {
446 report.error(format!("{label}: ip_filter entry '{entry}' is not an IP address or CIDR range — ignored"));
447 }
448 }
449 if !mw.ip_allow.is_empty() && !mw.ip_deny.is_empty() {
450 report.warn(format!("{label}: ip_filter has both `allow` and `deny`; only `allow` is used"));
451 }
452 if let Some(cache) = &mw.cache {
453 if crate::proxy_config::cache_max_bytes(&cache.max_size).is_none() {
454 report.error(format!("{label}: cache max_size {:?} is not a size (e.g. \"512M\")", cache.max_size));
455 }
456 if !cache.purge_token_env.is_empty() && !crate::config_check::offline() && std::env::var(&cache.purge_token_env).unwrap_or_default().trim().is_empty() {
457 report.error(format!("{label}: cache purge_token_env {} is unset or empty; PURGE is refused", cache.purge_token_env));
458 }
459 if !cache.max_size.is_empty() && cache.dir.is_empty() {
460 report.warn(format!("{label}: cache max_size only applies with `dir` (the memory cache is bounded by entries)"));
461 }
462 if !cache.dir.is_empty() && !crate::config_check::offline() {
463 let probe = std::path::Path::new(&cache.dir).join(format!(".rws-check-{}", std::process::id()));
464 let writable = std::fs::create_dir_all(&cache.dir).and_then(|_| std::fs::write(&probe, b"")).is_ok();
465 let _ = std::fs::remove_file(&probe);
466 if !writable {
467 report.error(format!("{label}: cache dir {:?} isn't writable; the route caches in memory instead", cache.dir));
468 }
469 }
470 }
471 if let Some(rl) = &mw.rate_limit {
472 if rl.max_requests == 0 || rl.window_secs == 0 {
473 report.error(format!("{label}: rate_limit needs max_requests and window_secs above 0"));
474 }
475 }
476 for rule in mw.rewrite_request.iter().chain(&mw.rewrite_response) {
477 let known = ["header_set", "header_remove", "uri_set", "uri_strip_prefix", "strip_prefix",
478 "uri_add_prefix", "add_prefix", "status", "body_replace"];
479 if !known.contains(&rule.type_.as_str()) {
480 report.error(format!("{label}: unknown rewrite rule type '{}' — ignored", rule.type_));
481 }
482 }
483}