Skip to main content

praxis_core/config/validate/
rules.rs

1// SPDX-License-Identifier: LGPL-3.0-only
2// Copyright (c) 2024 Shane Utt
3
4//! Top-level configuration validation orchestration.
5
6use 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
20// -----------------------------------------------------------------------------
21// Config Validation
22// -----------------------------------------------------------------------------
23
24impl Config {
25    /// Validate config constraints.
26    ///
27    /// # Errors
28    ///
29    /// Returns [`ProxyError::Config`] if any constraint is violated.
30    ///
31    /// ```
32    /// use praxis_core::config::Config;
33    ///
34    /// let err = Config::from_yaml("listeners: []\n").unwrap_err();
35    /// assert!(err.to_string().contains("at least one listener"));
36    /// ```
37    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
59// -----------------------------------------------------------------------------
60// Admin Address Validation
61// -----------------------------------------------------------------------------
62
63/// Reject admin addresses that bind to all interfaces unless explicitly allowed.
64fn 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
86// -----------------------------------------------------------------------------
87// Upstream CA File Validation
88// -----------------------------------------------------------------------------
89
90/// Reject `upstream_ca_file` paths that contain directory traversal or do not exist.
91fn 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// -----------------------------------------------------------------------------
108// Tests
109// -----------------------------------------------------------------------------
110
111#[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}