Skip to main content

praxis_core/config/
mod.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! YAML configuration parsing, defaults, and validation.
5
6use 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// -----------------------------------------------------------------------------
45// Config
46// -----------------------------------------------------------------------------
47
48/// Top-level proxy configuration.
49///
50/// ```
51/// use praxis_core::config::Config;
52///
53/// let config = Config::from_yaml(
54///     r#"
55/// listeners:
56///   - name: web
57///     address: "127.0.0.1:8080"
58///     filter_chains: [main]
59/// filter_chains:
60///   - name: main
61///     filters:
62///       - filter: static_response
63///         status: 200
64/// "#,
65/// )
66/// .unwrap();
67/// assert_eq!(config.listeners[0].address, "127.0.0.1:8080");
68/// ```
69#[derive(Debug, Clone, Deserialize, serde::Serialize)]
70#[serde(deny_unknown_fields)]
71pub struct Config {
72    /// Admin endpoint settings (address and verbosity).
73    #[serde(default)]
74    pub admin: AdminConfig,
75
76    /// Global hard ceilings on request and response body size.
77    #[serde(default)]
78    pub body_limits: BodyLimitsConfig,
79
80    /// Cluster definitions referenced by filters.
81    #[serde(default)]
82    pub clusters: Vec<Cluster>,
83
84    /// Named filter chains.
85    #[serde(default)]
86    pub filter_chains: Vec<FilterChainConfig>,
87
88    /// Consolidated security overrides. All default to `false`.
89    #[serde(default)]
90    pub insecure_options: InsecureOptions,
91
92    /// Proxy listeners to bind.
93    pub listeners: Vec<Listener>,
94
95    /// Runtime configuration knobs.
96    #[serde(default)]
97    pub runtime: RuntimeConfig,
98
99    /// Drain time for graceful shutdown.
100    #[serde(default = "default_shutdown_timeout_secs")]
101    pub shutdown_timeout_secs: u64,
102}
103
104impl Config {
105    /// Parse config from a YAML string.
106    ///
107    /// # Errors
108    ///
109    /// Returns [`ProxyError::Config`] if the YAML is invalid, oversized, or fails validation.
110    ///
111    /// # Security: Error Messages
112    ///
113    /// Parse errors from `serde_yaml` may include context snippets from the input YAML.
114    /// This is acceptable for server-side operator tooling but callers should avoid
115    /// exposing these errors to untrusted end users.
116    ///
117    /// ```
118    /// use praxis_core::config::Config;
119    ///
120    /// let cfg = Config::from_yaml(
121    ///     r#"
122    /// listeners:
123    ///   - name: web
124    ///     address: "127.0.0.1:8080"
125    ///     filter_chains: [main]
126    /// filter_chains:
127    ///   - name: main
128    ///     filters:
129    ///       - filter: static_response
130    ///         status: 200
131    /// "#,
132    /// )
133    /// .unwrap();
134    /// assert_eq!(cfg.listeners[0].address, "127.0.0.1:8080");
135    /// ```
136    ///
137    /// [`ProxyError::Config`]: crate::errors::ProxyError::Config
138    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    /// Load and validate config from a YAML file.
150    ///
151    /// # Errors
152    ///
153    /// Returns [`ProxyError::Config`] if the file cannot be read or contains invalid config.
154    ///
155    /// ```no_run
156    /// use std::path::Path;
157    ///
158    /// use praxis_core::config::Config;
159    ///
160    /// let cfg = Config::from_file(Path::new("praxis.yaml")).unwrap();
161    /// println!("listeners: {}", cfg.listeners.len());
162    /// ```
163    ///
164    /// [`ProxyError::Config`]: crate::errors::ProxyError::Config
165    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    /// Resolve configuration file. Fall back to `praxis.yaml` in the working directory, then `fallback_yaml`.
175    ///
176    /// # Errors
177    ///
178    /// Returns [`ProxyError::Config`] if the resolved config source cannot be loaded or is invalid.
179    ///
180    /// ```no_run
181    /// use praxis_core::config::Config;
182    ///
183    /// let yaml = "listeners: [{name: w, address: '0:80'}]";
184    /// let cfg = Config::load(None, yaml).unwrap();
185    /// ```
186    ///
187    /// [`ProxyError::Config`]: crate::errors::ProxyError::Config
188    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
203/// Serde default for [`Config::shutdown_timeout_secs`].
204fn default_shutdown_timeout_secs() -> u64 {
205    30
206}
207
208// -----------------------------------------------------------------------------
209// Tests
210// -----------------------------------------------------------------------------
211
212#[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    // -------------------------------------------------------------------------
652    // Test Utilities
653    // -------------------------------------------------------------------------
654
655    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    /// Recursively collect all `.yaml` files under `root`.
675    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}