1use std::path::Path;
7
8use serde::Deserialize;
9
10mod admin;
11mod body_limits;
12mod bootstrap;
13mod cluster;
14mod condition;
15mod filters;
16mod insecure_options;
17mod listener;
18mod parse;
19mod route;
20mod runtime;
21mod validate;
22
23pub use admin::AdminConfig;
24pub use body_limits::BodyLimitsConfig;
25pub use bootstrap::{DEFAULT_CONFIG, load_config};
26pub use cluster::{
27 Cluster, ConsistentHashOpts, Endpoint, HealthCheckConfig, HealthCheckType, LoadBalancerStrategy,
28 ParameterisedStrategy, SimpleStrategy,
29};
30pub use condition::{Condition, ConditionMatch, ResponseCondition, ResponseConditionMatch};
31pub use filters::{FilterChainConfig, FilterEntry};
32pub use insecure_options::InsecureOptions;
33pub use listener::{Listener, ListenerTls, ProtocolKind};
34use parse::check_yaml_safety;
35pub use praxis_tls::ClusterTls;
36pub use route::Route;
37pub use runtime::RuntimeConfig;
38
39#[derive(Debug, Clone, Deserialize)]
65pub struct Config {
66 #[serde(default)]
68 pub admin: AdminConfig,
69
70 #[serde(default)]
72 pub body_limits: BodyLimitsConfig,
73
74 #[serde(default)]
76 pub clusters: Vec<Cluster>,
77
78 #[serde(default)]
80 pub filter_chains: Vec<FilterChainConfig>,
81
82 #[serde(default)]
84 pub insecure_options: InsecureOptions,
85
86 pub listeners: Vec<Listener>,
88
89 #[serde(default)]
91 pub runtime: RuntimeConfig,
92
93 #[serde(default = "default_shutdown_timeout_secs")]
95 pub shutdown_timeout_secs: u64,
96}
97
98impl Config {
99 pub fn from_yaml(s: &str) -> Result<Self, crate::errors::ProxyError> {
133 check_yaml_safety(s)?;
134
135 let mut config: Config =
136 serde_yaml::from_str(s).map_err(|e| crate::errors::ProxyError::Config(format!("invalid YAML: {e}")))?;
137
138 config.validate()?;
139
140 Ok(config)
141 }
142
143 pub fn from_file(path: &Path) -> Result<Self, crate::errors::ProxyError> {
160 let content = std::fs::read_to_string(path)
161 .map_err(|e| crate::errors::ProxyError::Config(format!("failed to read {}: {e}", path.display())))?;
162
163 Self::from_yaml(&content)
164 }
165
166 pub fn load(explicit_path: Option<&str>, fallback_yaml: &str) -> Result<Self, crate::errors::ProxyError> {
181 if let Some(path) = explicit_path {
182 Self::from_file(Path::new(path))
183 } else {
184 let default_path = Path::new("praxis.yaml");
185 if default_path.exists() {
186 Self::from_file(default_path)
187 } else {
188 tracing::info!("no config file found, using built-in default");
189 Self::from_yaml(fallback_yaml)
190 }
191 }
192 }
193}
194
195fn default_shutdown_timeout_secs() -> u64 {
197 30
198}
199
200#[cfg(test)]
205mod tests {
206 use std::path::Path;
207
208 use super::Config;
209
210 #[test]
211 fn default_shutdown_timeout_is_30() {
212 let config = Config::from_yaml(VALID_YAML).unwrap();
213 assert_eq!(
214 config.shutdown_timeout_secs, 30,
215 "default shutdown timeout should be 30s"
216 );
217 }
218
219 #[test]
220 fn default_runtime_config() {
221 let config = Config::from_yaml(VALID_YAML).unwrap();
222 assert_eq!(config.runtime.threads, 0, "default threads should be 0");
223 assert!(config.runtime.work_stealing, "default work_stealing should be true");
224 }
225
226 #[test]
227 fn body_limits_default_to_none() {
228 let config = Config::from_yaml(VALID_YAML).unwrap();
229 assert!(
230 config.body_limits.max_request_bytes.is_none(),
231 "max_request_bytes should default to None"
232 );
233 assert!(
234 config.body_limits.max_response_bytes.is_none(),
235 "max_response_bytes should default to None"
236 );
237 }
238
239 #[test]
240 fn insecure_options_default_to_false() {
241 let config = Config::from_yaml(VALID_YAML).unwrap();
242 assert!(
243 !config.insecure_options.skip_pipeline_validation,
244 "skip_pipeline_validation should default to false"
245 );
246 assert!(
247 !config.insecure_options.allow_root,
248 "allow_root should default to false"
249 );
250 assert!(
251 !config.insecure_options.allow_public_admin,
252 "allow_public_admin should default to false"
253 );
254 assert!(
255 !config.insecure_options.allow_unbounded_body,
256 "allow_unbounded_body should default to false"
257 );
258 assert!(
259 !config.insecure_options.allow_tls_without_sni,
260 "allow_tls_without_sni should default to false"
261 );
262 assert!(
263 !config.insecure_options.allow_private_health_checks,
264 "allow_private_health_checks should default to false"
265 );
266 }
267
268 #[test]
269 fn insecure_options_parsed_from_yaml() {
270 let yaml = format!("{VALID_YAML}\ninsecure_options:\n skip_pipeline_validation: true\n allow_root: true");
271 let config = Config::from_yaml(&yaml).unwrap();
272 assert!(
273 config.insecure_options.skip_pipeline_validation,
274 "skip_pipeline_validation should be true when set"
275 );
276 assert!(config.insecure_options.allow_root, "allow_root should be true when set");
277 }
278
279 #[test]
280 fn parse_valid_config() {
281 let config = Config::from_yaml(VALID_YAML).unwrap();
282 assert_eq!(config.listeners.len(), 1, "should have 1 listener");
283 assert_eq!(
284 config.listeners[0].address, "127.0.0.1:8080",
285 "listener address mismatch"
286 );
287 assert_eq!(config.filter_chains.len(), 1, "should have 1 filter chain");
288 assert_eq!(
289 config.filter_chains[0].filters.len(),
290 2,
291 "filter chain should have 2 filters"
292 );
293 }
294
295 #[test]
296 fn parse_config_with_tls() {
297 let yaml = r#"
298listeners:
299 - name: secure
300 address: "0.0.0.0:443"
301 tls:
302 certificates:
303 - cert_path: "/etc/ssl/cert.pem"
304 key_path: "/etc/ssl/key.pem"
305 filter_chains: [main]
306filter_chains:
307 - name: main
308 filters:
309 - filter: static_response
310 status: 200
311"#;
312 let config = Config::from_yaml(yaml).unwrap();
313 let tls = config.listeners[0].tls.as_ref().unwrap();
314 let (cert, _key) = tls.primary_cert_paths();
315 assert_eq!(cert, "/etc/ssl/cert.pem", "cert_path mismatch");
316 }
317
318 #[test]
319 fn load_from_file() {
320 let dir = std::env::temp_dir().join("praxis-config-test");
321 std::fs::create_dir_all(&dir).unwrap();
322
323 let path = dir.join("test.yaml");
324 std::fs::write(&path, VALID_YAML).unwrap();
325
326 let config = Config::from_file(&path).unwrap();
327 assert_eq!(config.listeners.len(), 1, "file-loaded config should have 1 listener");
328
329 std::fs::remove_dir_all(&dir).ok();
330 }
331
332 #[test]
333 fn load_from_missing_file() {
334 let err = Config::from_file(Path::new("/nonexistent/config.yaml")).unwrap_err();
335 assert!(
336 err.to_string().contains("failed to read"),
337 "should report file read failure"
338 );
339 }
340
341 #[test]
342 fn parse_body_limits() {
343 let yaml = r#"
344listeners:
345 - name: web
346 address: "0.0.0.0:80"
347 filter_chains: [main]
348body_limits:
349 max_request_bytes: 10485760
350 max_response_bytes: 5242880
351filter_chains:
352 - name: main
353 filters:
354 - filter: static_response
355 status: 200
356"#;
357 let config = Config::from_yaml(yaml).unwrap();
358 assert_eq!(
359 config.body_limits.max_request_bytes,
360 Some(10_485_760),
361 "request body limit mismatch"
362 );
363 assert_eq!(
364 config.body_limits.max_response_bytes,
365 Some(5_242_880),
366 "response body limit mismatch"
367 );
368 }
369
370 #[test]
371 fn parse_runtime_config() {
372 let yaml = r#"
373listeners:
374 - name: web
375 address: "0.0.0.0:80"
376 filter_chains: [main]
377runtime:
378 threads: 8
379 work_stealing: false
380filter_chains:
381 - name: main
382 filters:
383 - filter: static_response
384 status: 200
385"#;
386 let config = Config::from_yaml(yaml).unwrap();
387 assert_eq!(config.runtime.threads, 8, "threads should be 8");
388 assert!(!config.runtime.work_stealing, "work_stealing should be false");
389 }
390
391 #[test]
392 fn load_returns_err_for_missing_explicit_path() {
393 let err = Config::load(Some("/nonexistent/config.yaml"), "").unwrap_err();
394 assert!(
395 err.to_string().contains("failed to read"),
396 "should report file read failure"
397 );
398 }
399
400 #[test]
401 fn load_uses_fallback_yaml() {
402 let fallback = r#"
403listeners:
404 - name: fallback
405 address: "127.0.0.1:9999"
406 filter_chains: [main]
407filter_chains:
408 - name: main
409 filters:
410 - filter: static_response
411"#;
412 let config = Config::load(None, fallback).unwrap();
413 assert_eq!(config.listeners[0].name, "fallback", "should use fallback config");
414 }
415
416 #[test]
417 fn parse_named_filter_chains() {
418 let yaml = r#"
419listeners:
420 - name: web
421 address: "0.0.0.0:80"
422 filter_chains:
423 - observability
424 - routing
425
426filter_chains:
427 - name: observability
428 filters:
429 - filter: request_id
430 - name: routing
431 filters:
432 - filter: router
433 routes:
434 - path_prefix: "/"
435 cluster: backend
436 - filter: load_balancer
437 clusters:
438 - name: backend
439 endpoints: ["10.0.0.1:80"]
440"#;
441 let config = Config::from_yaml(yaml).unwrap();
442 assert_eq!(config.filter_chains.len(), 2, "should have 2 named chains");
443 assert_eq!(
444 config.filter_chains[0].name, "observability",
445 "first chain name mismatch"
446 );
447 assert_eq!(config.filter_chains[1].name, "routing", "second chain name mismatch");
448 assert_eq!(
449 config.listeners[0].filter_chains,
450 vec!["observability", "routing"],
451 "listener chain references mismatch"
452 );
453 }
454
455 #[test]
456 fn downstream_read_timeout_per_listener_isolation() {
457 let yaml = r#"
458listeners:
459 - name: fast
460 address: "127.0.0.1:8080"
461 downstream_read_timeout_ms: 500
462 filter_chains: [main]
463 - name: slow
464 address: "127.0.0.1:8081"
465 downstream_read_timeout_ms: 30000
466 filter_chains: [main]
467filter_chains:
468 - name: main
469 filters:
470 - filter: static_response
471 status: 200
472"#;
473 let config = Config::from_yaml(yaml).unwrap();
474 assert_eq!(
475 config.listeners[0].downstream_read_timeout_ms,
476 Some(500),
477 "fast listener should have 500ms timeout"
478 );
479 assert_eq!(
480 config.listeners[1].downstream_read_timeout_ms,
481 Some(30000),
482 "slow listener should have 30000ms timeout"
483 );
484 }
485
486 #[test]
487 fn insecure_options_all_flags_settable() {
488 let yaml = format!(
489 "{VALID_YAML}\ninsecure_options:\n allow_unbounded_body: true\n allow_public_admin: true\n allow_tls_without_sni: true\n allow_private_health_checks: true"
490 );
491 let config = Config::from_yaml(&yaml).unwrap();
492 assert!(
493 config.insecure_options.allow_unbounded_body,
494 "allow_unbounded_body should be true"
495 );
496 assert!(
497 config.insecure_options.allow_public_admin,
498 "allow_public_admin should be true"
499 );
500 assert!(
501 config.insecure_options.allow_tls_without_sni,
502 "allow_tls_without_sni should be true"
503 );
504 assert!(
505 config.insecure_options.allow_private_health_checks,
506 "allow_private_health_checks should be true"
507 );
508 }
509
510 #[test]
511 fn all_example_configs_parse() {
512 let root = format!("{}/../examples/configs", env!("CARGO_MANIFEST_DIR"));
513 let mut count = 0;
514 for entry in walkdir(&root) {
515 Config::from_file(&entry).unwrap_or_else(|e| panic!("{}: {e}", entry.display()));
516 count += 1;
517 }
518 assert!(count > 0, "no YAML files found in {root}");
519 }
520
521 #[test]
522 fn parse_admin_config() {
523 let yaml = r#"
524listeners:
525 - name: web
526 address: "0.0.0.0:80"
527 filter_chains: [main]
528admin:
529 address: "127.0.0.1:9901"
530 verbose: true
531filter_chains:
532 - name: main
533 filters:
534 - filter: static_response
535 status: 200
536"#;
537 let config = Config::from_yaml(yaml).unwrap();
538 assert_eq!(
539 config.admin.address.as_deref(),
540 Some("127.0.0.1:9901"),
541 "admin address mismatch"
542 );
543 assert!(config.admin.verbose, "admin verbose should be true");
544 }
545
546 #[test]
547 fn admin_defaults_to_none_and_false() {
548 let config = Config::from_yaml(VALID_YAML).unwrap();
549 assert!(config.admin.address.is_none(), "admin address should default to None");
550 assert!(!config.admin.verbose, "admin verbose should default to false");
551 }
552
553 const VALID_YAML: &str = r#"
558listeners:
559 - name: test
560 address: "127.0.0.1:8080"
561 filter_chains: [main]
562filter_chains:
563 - name: main
564 filters:
565 - filter: router
566 routes:
567 - path_prefix: "/"
568 cluster: "backend"
569 - filter: load_balancer
570 clusters:
571 - name: "backend"
572 endpoints:
573 - "127.0.0.1:3000"
574"#;
575
576 fn walkdir(root: &str) -> Vec<std::path::PathBuf> {
578 let mut files = Vec::new();
579 let mut dirs = vec![std::path::PathBuf::from(root)];
580 while let Some(dir) = dirs.pop() {
581 for entry in std::fs::read_dir(&dir).unwrap() {
582 let path = entry.unwrap().path();
583 if path.is_dir() {
584 dirs.push(path);
585 } else if path.extension().is_some_and(|e| e == "yaml") {
586 files.push(path);
587 }
588 }
589 }
590 files
591 }
592}