Skip to main content

praxis_core/config/
mod.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2// Copyright (c) 2024 Shane Utt
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 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// -----------------------------------------------------------------------------
40// Config
41// -----------------------------------------------------------------------------
42
43/// Top-level proxy configuration.
44///
45/// ```
46/// use praxis_core::config::Config;
47///
48/// let config = Config::from_yaml(
49///     r#"
50/// listeners:
51///   - name: web
52///     address: "127.0.0.1:8080"
53///     filter_chains: [main]
54/// filter_chains:
55///   - name: main
56///     filters:
57///       - filter: static_response
58///         status: 200
59/// "#,
60/// )
61/// .unwrap();
62/// assert_eq!(config.listeners[0].address, "127.0.0.1:8080");
63/// ```
64#[derive(Debug, Clone, Deserialize)]
65pub struct Config {
66    /// Admin endpoint settings (address and verbosity).
67    #[serde(default)]
68    pub admin: AdminConfig,
69
70    /// Global hard ceilings on request and response body size.
71    #[serde(default)]
72    pub body_limits: BodyLimitsConfig,
73
74    /// Cluster definitions referenced by filters.
75    #[serde(default)]
76    pub clusters: Vec<Cluster>,
77
78    /// Named filter chains.
79    #[serde(default)]
80    pub filter_chains: Vec<FilterChainConfig>,
81
82    /// Consolidated security overrides. All default to `false`.
83    #[serde(default)]
84    pub insecure_options: InsecureOptions,
85
86    /// Proxy listeners to bind.
87    pub listeners: Vec<Listener>,
88
89    /// Runtime configuration knobs.
90    #[serde(default)]
91    pub runtime: RuntimeConfig,
92
93    /// Drain time for graceful shutdown.
94    #[serde(default = "default_shutdown_timeout_secs")]
95    pub shutdown_timeout_secs: u64,
96}
97
98impl Config {
99    /// Parse config from a YAML string.
100    ///
101    /// # Errors
102    ///
103    /// Returns [`ProxyError::Config`] if the YAML is invalid, oversized, or fails validation.
104    ///
105    /// # Security: Error Messages
106    ///
107    /// Parse errors from `serde_yaml` may include context snippets from the input YAML.
108    /// This is acceptable for server-side operator tooling but callers should avoid
109    /// exposing these errors to untrusted end users.
110    ///
111    /// ```
112    /// use praxis_core::config::Config;
113    ///
114    /// let cfg = Config::from_yaml(
115    ///     r#"
116    /// listeners:
117    ///   - name: web
118    ///     address: "127.0.0.1:8080"
119    ///     filter_chains: [main]
120    /// filter_chains:
121    ///   - name: main
122    ///     filters:
123    ///       - filter: static_response
124    ///         status: 200
125    /// "#,
126    /// )
127    /// .unwrap();
128    /// assert_eq!(cfg.listeners[0].address, "127.0.0.1:8080");
129    /// ```
130    ///
131    /// [`ProxyError::Config`]: crate::errors::ProxyError::Config
132    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    /// Load and validate config from a YAML file.
144    ///
145    /// # Errors
146    ///
147    /// Returns [`ProxyError::Config`] if the file cannot be read or contains invalid config.
148    ///
149    /// ```no_run
150    /// use std::path::Path;
151    ///
152    /// use praxis_core::config::Config;
153    ///
154    /// let cfg = Config::from_file(Path::new("praxis.yaml")).unwrap();
155    /// println!("listeners: {}", cfg.listeners.len());
156    /// ```
157    ///
158    /// [`ProxyError::Config`]: crate::errors::ProxyError::Config
159    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    /// Resolve configuration file. Fall back to `praxis.yaml` in the working directory, then `fallback_yaml`.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`ProxyError::Config`] if the resolved config source cannot be loaded or is invalid.
171    ///
172    /// ```no_run
173    /// use praxis_core::config::Config;
174    ///
175    /// let yaml = "listeners: [{name: w, address: '0:80'}]";
176    /// let cfg = Config::load(None, yaml).unwrap();
177    /// ```
178    ///
179    /// [`ProxyError::Config`]: crate::errors::ProxyError::Config
180    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
195/// Serde default for [`Config::shutdown_timeout_secs`].
196fn default_shutdown_timeout_secs() -> u64 {
197    30
198}
199
200// -----------------------------------------------------------------------------
201// Tests
202// -----------------------------------------------------------------------------
203
204#[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    // -------------------------------------------------------------------------
554    // Test Utilities
555    // -------------------------------------------------------------------------
556
557    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    /// Recursively collect all `.yaml` files under `root`.
577    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}