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"];
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"],
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.middleware" => vec!["timeout_ms", "max_body_size"],
60 "route.middleware.rate_limit" => vec!["max_requests", "window_secs"],
61 "route.middleware.cache" => vec!["ttl_secs", "vary_by"],
62 "route.middleware.auth" => vec!["type", "token_env", "secret_env", "htpasswd_file"],
63 "route.middleware.ip_filter" => vec!["allow", "deny"],
64 "route.middleware.rewrite.request" | "route.middleware.rewrite.response" => REWRITE_RULE_KEYS.to_vec(),
65 "tcp_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms"],
66 "udp_proxy" => vec!["name", "listen", "backends", "reply_timeout_ms", "buffer_size"],
67 "ws_proxy" => vec!["name", "listen", "backends", "connect_timeout_ms", "read_timeout_ms"],
68 _ => return None,
69 };
70 Some(keys)
71}
72
73fn strip_indices(section: &str) -> String {
75 let mut out = String::with_capacity(section.len());
76 let mut in_index = false;
77 for c in section.chars() {
78 match c {
79 '[' => in_index = true,
80 ']' => in_index = false,
81 _ if !in_index => out.push(c),
82 _ => {}
83 }
84 }
85 out
86}
87
88pub fn check_unknown_keys(toml: &str, report: &mut ConfigReport) {
90 let map = parser::parse(toml);
91 let mut sections: Vec<&String> = map.keys().collect();
92 sections.sort();
93 for section in sections {
94 let root = section.split(['.', '[']).next().unwrap_or("");
95 if !["route", "upstream", "tcp_proxy", "udp_proxy", "ws_proxy", "middleware"].contains(&root) {
96 continue;
97 }
98 let shape = strip_indices(section);
99 match known_keys(&shape) {
100 None => report.error(format!("[{}]: unknown table — ignored", shape)),
101 Some(keys) => {
102 for (key, _) in &map[section] {
103 if !keys.contains(&key.as_str()) {
104 report.error(format!("[{}] {}: unknown key — ignored", shape, key));
105 }
106 }
107 }
108 }
109 }
110}
111
112fn is_host_port(backend: &str) -> bool {
115 let (rest, has_scheme) = match ["http://", "https://", "h2://", "h2s://", "grpc://", "grpcs://", "ws://", "wss://"]
116 .iter()
117 .find_map(|scheme| backend.strip_prefix(scheme))
118 {
119 Some(rest) => (rest, true),
120 None => (backend, false),
121 };
122 let rest = rest.trim_end_matches('/');
123 if rest.is_empty() || rest.contains('/') || rest.contains(char::is_whitespace) {
124 return false;
125 }
126 match rest.rsplit_once(':') {
127 Some((host, port)) => !host.is_empty() && port.parse::<u16>().is_ok_and(|p| p > 0),
128 None => has_scheme,
129 }
130}
131
132impl ProxyConfig {
133 pub fn validate(&self, report: &mut ConfigReport) {
137 let mut names = HashSet::new();
138 for (i, upstream) in self.upstreams.iter().enumerate() {
139 let label = if upstream.name.is_empty() { format!("[[upstream]] #{}", i + 1) } else { format!("upstream '{}'", upstream.name) };
140 if upstream.name.is_empty() {
141 report.error(format!("{label}: missing `name` — no route can refer to it"));
142 } else if !names.insert(upstream.name.as_str()) {
143 report.error(format!("{label}: declared more than once"));
144 }
145 if upstream.backends.is_empty() {
146 report.error(format!("{label}: `backends` is empty"));
147 }
148 for backend in &upstream.backends {
149 if !is_host_port(backend) {
150 report.error(format!("{label}: backend '{backend}' is not host:port"));
151 }
152 }
153 let client = match (&upstream.tls_client_cert_file, &upstream.tls_client_key_file) {
154 (Some(cert), Some(key)) => Some((cert.as_str(), key.as_str())),
155 (None, None) => None,
156 _ => {
157 report.error(format!("{label}: tls_client_cert_file and tls_client_key_file go together"));
158 None
159 }
160 };
161 if upstream.tls_ca_file.is_some() || client.is_some() {
162 if !crate::config_check::offline() {
163 if let Err(e) = crate::proxy::UpstreamTls::new(upstream.tls_ca_file.as_deref(), client) {
164 report.error(format!("{label}: {e}; its TLS backends would answer 502"));
165 }
166 }
167 if !upstream.tls {
168 report.warn(format!("{label}: TLS settings are set but no backend is https://, h2s:// or grpcs://, so they aren't used"));
169 }
170 }
171 if !STRATEGIES.contains(&upstream.strategy.as_str()) {
172 report.error(format!(
173 "{label}: unknown strategy '{}' (expected one of {})",
174 upstream.strategy,
175 STRATEGIES.join(", ")
176 ));
177 }
178 if !upstream.weights.is_empty() {
179 if upstream.weights.len() != upstream.backends.len() {
180 report.error(format!("{label}: `weights` has {} entries for {} backends (one each, in order)", upstream.weights.len(), upstream.backends.len()));
181 }
182 if upstream.weights.contains(&0) {
183 report.error(format!("{label}: `weights` must be whole numbers of 1 or more"));
184 }
185 if upstream.strategy == "ip_hash" {
186 report.warn(format!("{label}: `weights` don't apply to strategy ip_hash"));
187 }
188 }
189 if let Some(sticky) = &upstream.sticky {
190 let token = |c: char| c.is_ascii_alphanumeric() || "!#$%&'*+-.^_`|~".contains(c);
191 if sticky.cookie.is_empty() || !sticky.cookie.chars().all(token) {
192 report.error(format!("{label}: sticky cookie name '{}' isn't a valid cookie name", sticky.cookie));
193 }
194 }
195 if let Some(breaker) = &upstream.circuit_breaker {
196 if breaker.failure_threshold == 0 {
197 report.error(format!("{label}: circuit_breaker.failure_threshold must be 1 or more"));
198 }
199 if breaker.recovery_secs == 0 {
200 report.warn(format!("{label}: circuit_breaker.recovery_secs = 0 probes an open backend on the next request"));
201 }
202 }
203 if let Some(retry) = &upstream.retry {
204 if retry.attempts == 0 {
205 report.warn(format!("{label}: retry.attempts = 0 never retries"));
206 } else if retry.attempts as usize >= upstream.backends.len() {
207 report.warn(format!(
208 "{label}: retry.attempts = {} but there are {} backends — a request goes to each at most once",
209 retry.attempts,
210 upstream.backends.len()
211 ));
212 }
213 if let Some(status) = retry.status.iter().find(|s| !(400..=599).contains(*s)) {
214 report.error(format!("{label}: retry.status {status} isn't an error status (400–599)"));
215 }
216 }
217 #[cfg(not(any(feature = "http-client", feature = "http2")))]
218 if upstream.tls {
219 report.error(format!("{label}: https:// backends need the `http2` or `http-client` feature, which this build doesn't include"));
220 }
221 }
222
223 for (i, route) in self.routes.iter().enumerate() {
224 let label = if route.name.is_empty() { format!("route #{}", i + 1) } else { format!("route '{}'", route.name) };
225 match &route.action {
226 ActionConfig::Proxy { upstream, .. } | ActionConfig::Grpc { upstream, .. } => {
227 if upstream.is_empty() {
228 report.error(format!("{label}: proxy action has no `upstream`"));
229 } else if !names.contains(upstream.as_str()) {
230 if is_host_port(upstream) {
231 report.warn(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]; using it as a backend address (no health checks)"));
232 } else {
233 report.error(format!("{label}: upstream '{upstream}' is not a declared [[upstream]]"));
234 }
235 }
236 }
237 ActionConfig::Static { root, .. } => {
238 if root.is_empty() {
239 report.error(format!("{label}: static action has no `root`"));
240 } else if !crate::config_check::offline() && !std::path::Path::new(root).is_dir() {
241 report.warn(format!("{label}: static root '{root}' is not a directory (yet)"));
242 }
243 }
244 ActionConfig::Redirect { location, status } => {
245 if location.is_empty() {
246 report.error(format!("{label}: redirect action has no `location`"));
247 }
248 if !(300..=399).contains(status) {
249 report.error(format!("{label}: redirect status {status} is not a 3xx code"));
250 }
251 }
252 ActionConfig::Respond { status, .. } => {
253 if !(100..=599).contains(status) {
254 report.error(format!("{label}: respond status {status} is not an HTTP status code"));
255 }
256 }
257 ActionConfig::Mcp => {
258 report.warn(format!("{label}: `mcp` actions are not served from the config file; requests fall through to the static files"));
259 }
260 ActionConfig::Unknown(kind) if kind.is_empty() => {
261 report.error(format!("{label}: [route.action] has no `type`"));
262 }
263 ActionConfig::Unknown(kind) => {
264 report.error(format!("{label}: unknown action type '{kind}' (expected proxy, grpc, static, redirect or respond)"));
265 }
266 }
267 validate_middleware(&label, &route.middleware, report);
268
269 let m = &route.match_;
270 if m.host.is_none() && m.path.is_none() && m.method.is_none() && m.content_type.is_none() && i + 1 < self.routes.len() {
271 report.warn(format!("{label}: matches every request, so the routes after it are never reached"));
272 }
273 }
274
275 validate_middleware("[middleware]", &self.global_middleware, report);
276
277 let listeners = self.tcp_proxies.iter().map(|p| ("tcp_proxy", &p.name, &p.listen, &p.backends))
278 .chain(self.udp_proxies.iter().map(|p| ("udp_proxy", &p.name, &p.listen, &p.backends)))
279 .chain(self.ws_proxies.iter().map(|p| ("ws_proxy", &p.name, &p.listen, &p.backends)));
280 for (kind, name, listen, backends) in listeners {
281 let label = format!("[[{kind}]] '{name}'");
282 if listen.parse::<std::net::SocketAddr>().is_err() {
283 report.error(format!("{label}: listen '{listen}' is not an address like 0.0.0.0:5432"));
284 }
285 if backends.is_empty() {
286 report.error(format!("{label}: `backends` is empty"));
287 }
288 for backend in backends {
289 if !is_host_port(backend) {
290 report.error(format!("{label}: backend '{backend}' is not host:port"));
291 }
292 }
293 }
294 }
295}
296
297fn validate_middleware(label: &str, mw: &MiddlewareConfig, report: &mut ConfigReport) {
298 match &mw.auth {
299 Some(AuthConfig::Bearer { token_env }) | Some(AuthConfig::Jwt { secret_env: token_env }) => {
300 if token_env.is_empty() {
301 report.error(format!("{label}: auth has no `token_env` / `secret_env`; the route answers 503"));
302 } else if !crate::config_check::offline() && std::env::var(token_env).unwrap_or_default().is_empty() {
303 report.error(format!("{label}: auth secret env var '{token_env}' is unset or empty; the route answers 503 until it is set"));
304 }
305 }
306 Some(AuthConfig::Basic { htpasswd_file }) => {
307 #[cfg(feature = "auth")]
308 if !crate::config_check::offline() {
309 if let Err(e) = crate::auth::BasicAuthLayer::from_htpasswd_file(htpasswd_file) {
310 report.error(format!("{label}: basic auth: {e}; the route answers 503"));
311 }
312 }
313 #[cfg(not(feature = "auth"))]
314 let _ = htpasswd_file;
315 }
316 None => {}
317 }
318 #[cfg(not(feature = "auth"))]
319 if matches!(mw.auth, Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::Basic { .. })) {
320 report.error(format!("{label}: jwt and basic auth need the `auth` feature, which this build doesn't include; the route answers 503"));
321 }
322
323 for entry in mw.ip_allow.iter().chain(&mw.ip_deny) {
324 if !crate::ip_filter::is_valid_entry(entry) {
325 report.error(format!("{label}: ip_filter entry '{entry}' is not an IP address or CIDR range — ignored"));
326 }
327 }
328 if !mw.ip_allow.is_empty() && !mw.ip_deny.is_empty() {
329 report.warn(format!("{label}: ip_filter has both `allow` and `deny`; only `allow` is used"));
330 }
331 if let Some(rl) = &mw.rate_limit {
332 if rl.max_requests == 0 || rl.window_secs == 0 {
333 report.error(format!("{label}: rate_limit needs max_requests and window_secs above 0"));
334 }
335 }
336 for rule in mw.rewrite_request.iter().chain(&mw.rewrite_response) {
337 let known = ["header_set", "header_remove", "uri_set", "uri_strip_prefix", "strip_prefix",
338 "uri_add_prefix", "add_prefix", "status", "body_replace"];
339 if !known.contains(&rule.type_.as_str()) {
340 report.error(format!("{label}: unknown rewrite rule type '{}' — ignored", rule.type_));
341 }
342 }
343}