praxis_core/config/validate/
rules.rs1use std::path::{Component, Path};
7
8use tracing::warn;
9
10use super::{
11 cluster::validate_clusters,
12 filter_chain::validate_filter_chains,
13 listener::{validate_listener_names, validate_listeners},
14};
15use crate::{
16 config::{Config, ProtocolKind},
17 errors::ProxyError,
18};
19
20impl Config {
25 pub fn validate(&mut self) -> Result<(), ProxyError> {
38 validate_listeners(&mut self.listeners)?;
39 validate_listener_names(&self.listeners)?;
40 validate_filter_chains(&self.filter_chains, &self.listeners)?;
41 validate_admin_address(self.admin.address.as_deref(), self.insecure_options.allow_public_admin)?;
42
43 let all_tcp = self.listeners.iter().all(|l| l.protocol == ProtocolKind::Tcp);
44 let has_chains = self.listeners.iter().any(|l| !l.filter_chains.is_empty());
45
46 if !all_tcp && !has_chains {
47 return Err(ProxyError::Config(
48 "at least one filter chain required for HTTP listeners".into(),
49 ));
50 }
51
52 validate_clusters(&self.clusters, &self.insecure_options)?;
53 validate_upstream_ca_file(self.runtime.upstream_ca_file.as_deref())?;
54
55 Ok(())
56 }
57}
58
59fn validate_admin_address(addr: Option<&str>, allow_public: bool) -> Result<(), ProxyError> {
65 let Some(addr) = addr else { return Ok(()) };
66 let socket_addr: std::net::SocketAddr = addr
67 .parse()
68 .map_err(|_parse_err| ProxyError::Config(format!("invalid admin_address '{addr}'")))?;
69 if socket_addr.ip().is_unspecified() {
70 if allow_public {
71 warn!(
72 admin_address = %addr,
73 "admin endpoint binds to all interfaces; allowed by insecure_options.allow_public_admin"
74 );
75 } else {
76 return Err(ProxyError::Config(format!(
77 "admin endpoint '{addr}' binds to all interfaces; \
78 bind to 127.0.0.1 or a management network, or set \
79 insecure_options.allow_public_admin: true to allow"
80 )));
81 }
82 }
83 Ok(())
84}
85
86fn validate_upstream_ca_file(ca_file: Option<&str>) -> Result<(), ProxyError> {
92 let Some(path) = ca_file else { return Ok(()) };
93
94 if Path::new(path).components().any(|c| matches!(c, Component::ParentDir)) {
95 return Err(ProxyError::Config(format!(
96 "upstream_ca_file must not contain path traversal (..): {path}"
97 )));
98 }
99
100 if !Path::new(path).exists() {
101 return Err(ProxyError::Config(format!("upstream_ca_file does not exist: {path}")));
102 }
103
104 Ok(())
105}
106
107#[cfg(test)]
112mod tests {
113 use crate::config::{Config, ProtocolKind};
114
115 #[test]
116 fn reject_invalid_admin_address() {
117 let yaml = r#"
118listeners:
119 - name: web
120 address: "0.0.0.0:8080"
121 filter_chains: [main]
122admin:
123 address: "not-valid"
124filter_chains:
125 - name: main
126 filters:
127 - filter: static_response
128 status: 200
129"#;
130 let err = Config::from_yaml(yaml).unwrap_err();
131 assert!(err.to_string().contains("invalid admin_address"), "got: {err}");
132 }
133
134 #[test]
135 fn accept_valid_admin_address() {
136 let yaml = r#"
137listeners:
138 - name: web
139 address: "0.0.0.0:8080"
140 filter_chains: [main]
141admin:
142 address: "127.0.0.1:9901"
143filter_chains:
144 - name: main
145 filters:
146 - filter: static_response
147 status: 200
148"#;
149 let config = Config::from_yaml(yaml).unwrap();
150 assert_eq!(config.admin.address.as_deref(), Some("127.0.0.1:9901"));
151 }
152
153 #[test]
154 fn reject_public_admin_address() {
155 let yaml = r#"
156listeners:
157 - name: web
158 address: "0.0.0.0:8080"
159 filter_chains: [main]
160admin:
161 address: "0.0.0.0:9901"
162filter_chains:
163 - name: main
164 filters:
165 - filter: static_response
166 status: 200
167"#;
168 let err = Config::from_yaml(yaml).unwrap_err();
169 assert!(
170 err.to_string().contains("binds to all interfaces"),
171 "should reject public admin: {err}"
172 );
173 }
174
175 #[test]
176 fn allow_public_admin_with_override() {
177 let yaml = r#"
178listeners:
179 - name: web
180 address: "0.0.0.0:8080"
181 filter_chains: [main]
182admin:
183 address: "0.0.0.0:9901"
184insecure_options:
185 allow_public_admin: true
186filter_chains:
187 - name: main
188 filters:
189 - filter: static_response
190 status: 200
191"#;
192 let config = Config::from_yaml(yaml).unwrap();
193 assert_eq!(
194 config.admin.address.as_deref(),
195 Some("0.0.0.0:9901"),
196 "allow_public_admin should permit public admin binding"
197 );
198 }
199
200 #[test]
201 fn reject_upstream_ca_file_traversal() {
202 let yaml = r#"
203listeners:
204 - name: web
205 address: "0.0.0.0:8080"
206 filter_chains: [main]
207runtime:
208 upstream_ca_file: /etc/../../tmp/evil-ca.pem
209filter_chains:
210 - name: main
211 filters:
212 - filter: static_response
213 status: 200
214"#;
215 let err = Config::from_yaml(yaml).unwrap_err();
216 assert!(
217 err.to_string().contains("path traversal"),
218 "should reject traversal: {err}"
219 );
220 }
221
222 #[test]
223 fn reject_upstream_ca_file_missing() {
224 let yaml = r#"
225listeners:
226 - name: web
227 address: "0.0.0.0:8080"
228 filter_chains: [main]
229runtime:
230 upstream_ca_file: /nonexistent/ca.pem
231filter_chains:
232 - name: main
233 filters:
234 - filter: static_response
235 status: 200
236"#;
237 let err = Config::from_yaml(yaml).unwrap_err();
238 assert!(
239 err.to_string().contains("does not exist"),
240 "should reject missing file: {err}"
241 );
242 }
243
244 #[test]
245 fn accept_upstream_ca_file_when_file_exists() {
246 let dir = std::env::temp_dir().join("praxis-ca-test");
247 std::fs::create_dir_all(&dir).unwrap();
248 let ca_path = dir.join("test-ca.pem");
249 std::fs::write(
250 &ca_path,
251 "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n",
252 )
253 .unwrap();
254
255 let yaml = format!(
256 r#"
257listeners:
258 - name: web
259 address: "0.0.0.0:8080"
260 filter_chains: [main]
261runtime:
262 upstream_ca_file: {path}
263filter_chains:
264 - name: main
265 filters:
266 - filter: static_response
267 status: 200
268"#,
269 path = ca_path.display()
270 );
271 let config = Config::from_yaml(&yaml).unwrap();
272 assert_eq!(
273 config.runtime.upstream_ca_file.as_deref(),
274 Some(ca_path.to_str().unwrap()),
275 "upstream_ca_file should be preserved"
276 );
277
278 std::fs::remove_dir_all(&dir).ok();
279 }
280
281 #[test]
282 fn reject_no_filter_chains_for_http() {
283 let yaml = r#"
284listeners:
285 - name: web
286 address: "0.0.0.0:80"
287"#;
288 let err = Config::from_yaml(yaml).unwrap_err();
289 assert!(err.to_string().contains("at least one filter chain"));
290 }
291
292 #[test]
293 fn tcp_only_config_needs_no_pipeline() {
294 let yaml = r#"
295listeners:
296 - name: db
297 address: "0.0.0.0:5432"
298 protocol: tcp
299 upstream: "10.0.0.1:5432"
300"#;
301 let config = Config::from_yaml(yaml).unwrap();
302 assert_eq!(
303 config.listeners[0].protocol,
304 ProtocolKind::Tcp,
305 "protocol should be Tcp"
306 );
307 }
308
309 #[test]
310 fn reject_invalid_yaml() {
311 let err = Config::from_yaml("not: [valid: yaml: {{").unwrap_err();
312 assert!(err.to_string().contains("invalid YAML"));
313 }
314}