1use std::path::Path;
7
8use serde::Deserialize;
9
10mod admin;
11mod body_limits;
12mod bootstrap;
13mod branch_chain;
14mod chain_ref;
15mod cluster;
16mod condition;
17mod filters;
18mod insecure_options;
19mod listener;
20mod parse;
21mod route;
22mod runtime;
23mod validate;
24
25pub use admin::AdminConfig;
26pub use body_limits::{ABSOLUTE_MAX_BODY_BYTES, BodyLimitsConfig, DEFAULT_MAX_BODY_BYTES};
27pub use bootstrap::{DEFAULT_CONFIG, load_config};
28pub use branch_chain::{BranchChainConfig, BranchCondition};
29pub use chain_ref::ChainRef;
30pub use cluster::{
31 Cluster, ConsistentHashOpts, Endpoint, HealthCheckConfig, HealthCheckType, LoadBalancerStrategy,
32 ParameterisedStrategy, SimpleStrategy,
33};
34pub use condition::{Condition, ConditionMatch, ResponseCondition, ResponseConditionMatch};
35pub use filters::{FailureMode, FilterChainConfig, FilterEntry};
36pub use insecure_options::InsecureOptions;
37pub use listener::{Listener, ListenerTls, ProtocolKind};
38use parse::check_yaml_safety;
39pub use praxis_tls::{CachedClusterTls, ClusterTls};
40pub use route::{PathMatch, Route};
41pub use runtime::RuntimeConfig;
42pub use validate::{MAX_BRANCH_DEPTH, MAX_ITERATIONS_CEILING};
43
44#[derive(Debug, Clone, Deserialize, serde::Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct Config {
72 #[serde(default)]
74 pub admin: AdminConfig,
75
76 #[serde(default)]
78 pub body_limits: BodyLimitsConfig,
79
80 #[serde(default)]
82 pub clusters: Vec<Cluster>,
83
84 #[serde(default)]
86 pub filter_chains: Vec<FilterChainConfig>,
87
88 #[serde(default)]
90 pub insecure_options: InsecureOptions,
91
92 pub listeners: Vec<Listener>,
94
95 #[serde(default)]
97 pub runtime: RuntimeConfig,
98
99 #[serde(default = "default_shutdown_timeout_secs")]
101 pub shutdown_timeout_secs: u64,
102}
103
104impl Config {
105 pub fn from_yaml(s: &str) -> Result<Self, crate::errors::ProxyError> {
139 check_yaml_safety(s)?;
140
141 let mut config: Config =
142 serde_yaml::from_str(s).map_err(|e| crate::errors::ProxyError::Config(format!("invalid YAML: {e}")))?;
143
144 config.validate()?;
145
146 Ok(config)
147 }
148
149 pub fn from_file(path: &Path) -> Result<Self, crate::errors::ProxyError> {
166 let content = std::fs::read_to_string(path).map_err(|e| {
167 let display = path.display();
168 crate::errors::ProxyError::Config(format!("failed to read {display}: {e}"))
169 })?;
170
171 Self::from_yaml(&content)
172 }
173
174 pub fn load(explicit_path: Option<&str>, fallback_yaml: &str) -> Result<Self, crate::errors::ProxyError> {
189 if let Some(path) = explicit_path {
190 Self::from_file(Path::new(path))
191 } else {
192 let default_path = Path::new("praxis.yaml");
193 if default_path.exists() {
194 Self::from_file(default_path)
195 } else {
196 tracing::info!("no config file found, using built-in default");
197 Self::from_yaml(fallback_yaml)
198 }
199 }
200 }
201}
202
203fn default_shutdown_timeout_secs() -> u64 {
205 30
206}
207
208#[cfg(test)]
213#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
214#[allow(
215 clippy::unwrap_used,
216 clippy::expect_used,
217 clippy::indexing_slicing,
218 clippy::needless_raw_strings,
219 clippy::needless_raw_string_hashes,
220 clippy::too_many_lines,
221 clippy::panic,
222 reason = "tests use unwrap/expect/indexing/raw strings/panic for brevity"
223)]
224mod tests {
225 use std::path::Path;
226
227 use super::{Config, DEFAULT_MAX_BODY_BYTES};
228
229 #[test]
230 fn default_shutdown_timeout_is_30() {
231 let config = Config::from_yaml(VALID_YAML).unwrap();
232 assert_eq!(
233 config.shutdown_timeout_secs, 30,
234 "default shutdown timeout should be 30s"
235 );
236 }
237
238 #[test]
239 fn default_runtime_config() {
240 let config = Config::from_yaml(VALID_YAML).unwrap();
241 assert_eq!(config.runtime.threads, 0, "default threads should be 0");
242 assert!(config.runtime.work_stealing, "default work_stealing should be true");
243 }
244
245 #[test]
246 fn body_limits_default_to_ten_mib() {
247 let config = Config::from_yaml(VALID_YAML).unwrap();
248 assert_eq!(
249 config.body_limits.max_request_bytes,
250 Some(DEFAULT_MAX_BODY_BYTES),
251 "max_request_bytes should default to 10 MiB"
252 );
253 assert_eq!(
254 config.body_limits.max_response_bytes,
255 Some(DEFAULT_MAX_BODY_BYTES),
256 "max_response_bytes should default to 10 MiB"
257 );
258 }
259
260 #[test]
261 fn insecure_options_default_to_false() {
262 let config = Config::from_yaml(VALID_YAML).unwrap();
263 assert!(
264 !config.insecure_options.skip_pipeline_validation,
265 "skip_pipeline_validation should default to false"
266 );
267 assert!(
268 !config.insecure_options.allow_root,
269 "allow_root should default to false"
270 );
271 assert!(
272 !config.insecure_options.allow_public_admin,
273 "allow_public_admin should default to false"
274 );
275 assert!(
276 !config.insecure_options.allow_unbounded_body,
277 "allow_unbounded_body should default to false"
278 );
279 assert!(
280 !config.insecure_options.allow_tls_without_sni,
281 "allow_tls_without_sni should default to false"
282 );
283 assert!(
284 !config.insecure_options.allow_private_health_checks,
285 "allow_private_health_checks should default to false"
286 );
287 }
288
289 #[test]
290 fn insecure_options_parsed_from_yaml() {
291 let yaml = format!("{VALID_YAML}\ninsecure_options:\n skip_pipeline_validation: true\n allow_root: true");
292 let config = Config::from_yaml(&yaml).unwrap();
293 assert!(
294 config.insecure_options.skip_pipeline_validation,
295 "skip_pipeline_validation should be true when set"
296 );
297 assert!(config.insecure_options.allow_root, "allow_root should be true when set");
298 }
299
300 #[test]
301 fn parse_valid_config() {
302 let config = Config::from_yaml(VALID_YAML).unwrap();
303 assert_eq!(config.listeners.len(), 1, "should have 1 listener");
304 assert_eq!(
305 config.listeners[0].address, "127.0.0.1:8080",
306 "listener address mismatch"
307 );
308 assert_eq!(config.filter_chains.len(), 1, "should have 1 filter chain");
309 assert_eq!(
310 config.filter_chains[0].filters.len(),
311 2,
312 "filter chain should have 2 filters"
313 );
314 }
315
316 #[test]
317 fn parse_config_with_tls() {
318 let yaml = r#"
319listeners:
320 - name: secure
321 address: "0.0.0.0:443"
322 tls:
323 certificates:
324 - cert_path: "/etc/ssl/cert.pem"
325 key_path: "/etc/ssl/key.pem"
326 filter_chains: [main]
327filter_chains:
328 - name: main
329 filters:
330 - filter: static_response
331 status: 200
332"#;
333 let config = Config::from_yaml(yaml).unwrap();
334 let tls = config.listeners[0].tls.as_ref().unwrap();
335 let (cert, _key) = tls.primary_cert_paths();
336 assert_eq!(cert, "/etc/ssl/cert.pem", "cert_path mismatch");
337 }
338
339 #[test]
340 fn load_from_file() {
341 let dir = std::env::temp_dir().join("praxis-config-test");
342 std::fs::create_dir_all(&dir).unwrap();
343
344 let path = dir.join("test.yaml");
345 std::fs::write(&path, VALID_YAML).unwrap();
346
347 let config = Config::from_file(&path).unwrap();
348 assert_eq!(config.listeners.len(), 1, "file-loaded config should have 1 listener");
349
350 drop(std::fs::remove_dir_all(&dir));
351 }
352
353 #[test]
354 fn load_from_missing_file() {
355 let err = Config::from_file(Path::new("/nonexistent/config.yaml")).unwrap_err();
356 assert!(
357 err.to_string().contains("failed to read"),
358 "should report file read failure"
359 );
360 }
361
362 #[test]
363 fn parse_body_limits() {
364 let yaml = r#"
365listeners:
366 - name: web
367 address: "0.0.0.0:80"
368 filter_chains: [main]
369body_limits:
370 max_request_bytes: 10485760
371 max_response_bytes: 5242880
372filter_chains:
373 - name: main
374 filters:
375 - filter: static_response
376 status: 200
377"#;
378 let config = Config::from_yaml(yaml).unwrap();
379 assert_eq!(
380 config.body_limits.max_request_bytes,
381 Some(DEFAULT_MAX_BODY_BYTES),
382 "request body limit mismatch"
383 );
384 assert_eq!(
385 config.body_limits.max_response_bytes,
386 Some(5_242_880),
387 "response body limit mismatch"
388 );
389 }
390
391 #[test]
392 fn parse_runtime_config() {
393 let yaml = r#"
394listeners:
395 - name: web
396 address: "0.0.0.0:80"
397 filter_chains: [main]
398runtime:
399 threads: 8
400 work_stealing: false
401filter_chains:
402 - name: main
403 filters:
404 - filter: static_response
405 status: 200
406"#;
407 let config = Config::from_yaml(yaml).unwrap();
408 assert_eq!(config.runtime.threads, 8, "threads should be 8");
409 assert!(!config.runtime.work_stealing, "work_stealing should be false");
410 }
411
412 #[test]
413 fn load_returns_err_for_missing_explicit_path() {
414 let err = Config::load(Some("/nonexistent/config.yaml"), "").unwrap_err();
415 assert!(
416 err.to_string().contains("failed to read"),
417 "should report file read failure"
418 );
419 }
420
421 #[test]
422 fn load_uses_fallback_yaml() {
423 let fallback = r#"
424listeners:
425 - name: fallback
426 address: "127.0.0.1:9999"
427 filter_chains: [main]
428filter_chains:
429 - name: main
430 filters:
431 - filter: static_response
432"#;
433 let config = Config::load(None, fallback).unwrap();
434 assert_eq!(config.listeners[0].name, "fallback", "should use fallback config");
435 }
436
437 #[test]
438 fn parse_named_filter_chains() {
439 let yaml = r#"
440listeners:
441 - name: web
442 address: "0.0.0.0:80"
443 filter_chains:
444 - observability
445 - routing
446
447filter_chains:
448 - name: observability
449 filters:
450 - filter: request_id
451 - name: routing
452 filters:
453 - filter: router
454 routes:
455 - path_prefix: "/"
456 cluster: backend
457 - filter: load_balancer
458 clusters:
459 - name: backend
460 endpoints: ["10.0.0.1:80"]
461"#;
462 let config = Config::from_yaml(yaml).unwrap();
463 assert_eq!(config.filter_chains.len(), 2, "should have 2 named chains");
464 assert_eq!(
465 config.filter_chains[0].name, "observability",
466 "first chain name mismatch"
467 );
468 assert_eq!(config.filter_chains[1].name, "routing", "second chain name mismatch");
469 assert_eq!(
470 config.listeners[0].filter_chains,
471 vec!["observability", "routing"],
472 "listener chain references mismatch"
473 );
474 }
475
476 #[test]
477 fn downstream_read_timeout_per_listener_isolation() {
478 let yaml = r#"
479listeners:
480 - name: fast
481 address: "127.0.0.1:8080"
482 downstream_read_timeout_ms: 500
483 filter_chains: [main]
484 - name: slow
485 address: "127.0.0.1:8081"
486 downstream_read_timeout_ms: 30000
487 filter_chains: [main]
488filter_chains:
489 - name: main
490 filters:
491 - filter: static_response
492 status: 200
493"#;
494 let config = Config::from_yaml(yaml).unwrap();
495 assert_eq!(
496 config.listeners[0].downstream_read_timeout_ms,
497 Some(500),
498 "fast listener should have 500ms timeout"
499 );
500 assert_eq!(
501 config.listeners[1].downstream_read_timeout_ms,
502 Some(30000),
503 "slow listener should have 30000ms timeout"
504 );
505 }
506
507 #[test]
508 fn insecure_options_all_flags_settable() {
509 let yaml = format!(
510 "{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"
511 );
512 let config = Config::from_yaml(&yaml).unwrap();
513 assert!(
514 config.insecure_options.allow_unbounded_body,
515 "allow_unbounded_body should be true"
516 );
517 assert!(
518 config.insecure_options.allow_public_admin,
519 "allow_public_admin should be true"
520 );
521 assert!(
522 config.insecure_options.allow_tls_without_sni,
523 "allow_tls_without_sni should be true"
524 );
525 assert!(
526 config.insecure_options.allow_private_health_checks,
527 "allow_private_health_checks should be true"
528 );
529 }
530
531 #[test]
532 fn all_example_configs_parse() {
533 let root = format!("{}/../examples/configs", env!("CARGO_MANIFEST_DIR"));
534 let mut count = 0;
535 for entry in walkdir(&root) {
536 Config::from_file(&entry).unwrap_or_else(|e| panic!("{}: {e}", entry.display()));
537 count += 1;
538 }
539 assert!(count > 0, "no YAML files found in {root}");
540 }
541
542 #[test]
543 fn parse_admin_config() {
544 let yaml = r#"
545listeners:
546 - name: web
547 address: "0.0.0.0:80"
548 filter_chains: [main]
549admin:
550 address: "127.0.0.1:9901"
551 verbose: true
552filter_chains:
553 - name: main
554 filters:
555 - filter: static_response
556 status: 200
557"#;
558 let config = Config::from_yaml(yaml).unwrap();
559 assert_eq!(
560 config.admin.address.as_deref(),
561 Some("127.0.0.1:9901"),
562 "admin address mismatch"
563 );
564 assert!(config.admin.verbose, "admin verbose should be true");
565 }
566
567 #[test]
568 fn admin_defaults_to_none_and_false() {
569 let config = Config::from_yaml(VALID_YAML).unwrap();
570 assert!(config.admin.address.is_none(), "admin address should default to None");
571 assert!(!config.admin.verbose, "admin verbose should default to false");
572 }
573
574 #[test]
575 fn reject_unrecognized_top_level_key() {
576 let yaml = format!("{VALID_YAML}\nunrecognized_key: true\n");
577 let err = Config::from_yaml(&yaml).unwrap_err();
578 assert!(
579 err.to_string().contains("unrecognized_key"),
580 "error should name the unknown field"
581 );
582 }
583
584 #[test]
585 fn config_serialize_roundtrip() {
586 let yaml = r#"
587listeners:
588 - name: web
589 address: "127.0.0.1:8080"
590 filter_chains: [main]
591filter_chains:
592 - name: main
593 filters:
594 - filter: static_response
595 status: 200
596"#;
597 let original = Config::from_yaml(yaml).unwrap();
598 let serialized = serde_yaml::to_string(&original).expect("serialization should succeed");
599 let roundtripped: Config = serde_yaml::from_str(&serialized).expect("deserialization should succeed");
600
601 assert_eq!(
602 roundtripped.listeners.len(),
603 original.listeners.len(),
604 "listener count should survive roundtrip"
605 );
606 assert_eq!(
607 roundtripped.listeners[0].name, original.listeners[0].name,
608 "listener name should survive roundtrip"
609 );
610 assert_eq!(
611 roundtripped.listeners[0].address, original.listeners[0].address,
612 "listener address should survive roundtrip"
613 );
614 assert_eq!(
615 roundtripped.filter_chains.len(),
616 original.filter_chains.len(),
617 "filter chain count should survive roundtrip"
618 );
619 assert_eq!(
620 roundtripped.filter_chains[0].name, original.filter_chains[0].name,
621 "filter chain name should survive roundtrip"
622 );
623 assert_eq!(
624 roundtripped.shutdown_timeout_secs, original.shutdown_timeout_secs,
625 "shutdown_timeout_secs should survive roundtrip"
626 );
627 }
628
629 #[test]
630 fn reject_unknown_insecure_options_field() {
631 let yaml = r#"
632listeners:
633 - name: web
634 address: "0.0.0.0:8080"
635 filter_chains: [main]
636insecure_options:
637 alow_root: true
638filter_chains:
639 - name: main
640 filters:
641 - filter: static_response
642 status: 200
643"#;
644 let err = Config::from_yaml(yaml).unwrap_err();
645 assert!(
646 err.to_string().contains("alow_root"),
647 "typo in insecure_options should be rejected: {err}"
648 );
649 }
650
651 const VALID_YAML: &str = r#"
656listeners:
657 - name: test
658 address: "127.0.0.1:8080"
659 filter_chains: [main]
660filter_chains:
661 - name: main
662 filters:
663 - filter: router
664 routes:
665 - path_prefix: "/"
666 cluster: "backend"
667 - filter: load_balancer
668 clusters:
669 - name: "backend"
670 endpoints:
671 - "127.0.0.1:3000"
672"#;
673
674 fn walkdir(root: &str) -> Vec<std::path::PathBuf> {
676 let mut files = Vec::new();
677 let mut dirs = vec![std::path::PathBuf::from(root)];
678 while let Some(dir) = dirs.pop() {
679 for entry in std::fs::read_dir(&dir).unwrap() {
680 let path = entry.unwrap().path();
681 if path.is_dir() {
682 dirs.push(path);
683 } else if path.extension().is_some_and(|e| e == "yaml") {
684 files.push(path);
685 }
686 }
687 }
688 files
689 }
690}