Skip to main content

nono_proxy/
route.rs

1//! Route store: per-route configuration independent of credentials.
2//!
3//! `RouteStore` holds the route-level configuration (upstream URL, L7 endpoint
4//! rules, custom TLS CA) for **all** configured routes, regardless of whether
5//! they have a credential attached. This decouples L7 filtering from credential
6//! injection — a route can enforce endpoint restrictions without injecting any
7//! secret.
8//!
9//! The `CredentialStore` remains responsible for credential-specific fields
10//! (inject mode, header name/value, raw secret). Both stores are keyed by the
11//! normalised route prefix and are consulted independently by the proxy handlers.
12
13use crate::config::{CompiledEndpointRules, RouteConfig};
14use crate::error::{ProxyError, Result};
15use rustls::pki_types::pem::PemObject;
16use std::collections::HashMap;
17use std::sync::Arc;
18use tracing::debug;
19use zeroize::Zeroizing;
20
21/// Route-level configuration loaded at proxy startup.
22///
23/// Contains everything needed to forward and filter a request for a route,
24/// but no credential material. Credential injection is handled separately
25/// by `CredentialStore`.
26pub struct LoadedRoute {
27    /// Upstream URL (e.g., "https://api.openai.com")
28    pub upstream: String,
29
30    /// Pre-normalised `host:port` extracted from `upstream` at load time.
31    /// Used for O(1) lookups in `is_route_upstream()` without per-request
32    /// URL parsing. `None` if the upstream URL cannot be parsed.
33    pub upstream_host_port: Option<String>,
34
35    /// Pre-compiled L7 endpoint rules for method+path filtering.
36    /// When non-empty, only matching requests are allowed (default-deny).
37    /// When empty, all method+path combinations are permitted.
38    pub endpoint_rules: CompiledEndpointRules,
39
40    /// Per-route TLS connector with custom CA trust, if configured.
41    /// Built once at startup from the route's `tls_ca` certificate file.
42    /// When `None`, the shared default connector (webpki roots only) is used.
43    pub tls_connector: Option<tokio_rustls::TlsConnector>,
44}
45
46impl std::fmt::Debug for LoadedRoute {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("LoadedRoute")
49            .field("upstream", &self.upstream)
50            .field("upstream_host_port", &self.upstream_host_port)
51            .field("endpoint_rules", &self.endpoint_rules)
52            .field("has_custom_tls_ca", &self.tls_connector.is_some())
53            .finish()
54    }
55}
56
57/// Store of all configured routes, keyed by normalised prefix.
58///
59/// Loaded at proxy startup for **all** routes in the config, not just those
60/// with credentials. This ensures L7 endpoint filtering and upstream routing
61/// work independently of credential presence.
62#[derive(Debug)]
63pub struct RouteStore {
64    routes: HashMap<String, LoadedRoute>,
65}
66
67impl RouteStore {
68    /// Load route configuration for all configured routes.
69    ///
70    /// Each route's endpoint rules are compiled at startup so the hot path
71    /// does a regex match, not a glob compile. Routes with a `tls_ca` field
72    /// get a per-route TLS connector built from the custom CA certificate.
73    pub fn load(routes: &[RouteConfig]) -> Result<Self> {
74        let mut loaded = HashMap::new();
75
76        for route in routes {
77            let normalized_prefix = route.prefix.trim_matches('/').to_string();
78
79            debug!(
80                "Loading route '{}' -> {}",
81                normalized_prefix, route.upstream
82            );
83
84            let endpoint_rules = CompiledEndpointRules::compile(&route.endpoint_rules)
85                .map_err(|e| ProxyError::Config(format!("route '{}': {}", normalized_prefix, e)))?;
86
87            let tls_connector = if route.tls_ca.is_some()
88                || route.tls_client_cert.is_some()
89                || route.tls_client_key.is_some()
90            {
91                debug!(
92                    "Building TLS connector for route '{}' (ca={}, client_cert={})",
93                    normalized_prefix,
94                    route.tls_ca.is_some(),
95                    route.tls_client_cert.is_some(),
96                );
97                Some(build_tls_connector(
98                    route.tls_ca.as_deref(),
99                    route.tls_client_cert.as_deref(),
100                    route.tls_client_key.as_deref(),
101                )?)
102            } else {
103                None
104            };
105
106            let upstream_host_port = extract_host_port(&route.upstream);
107
108            loaded.insert(
109                normalized_prefix,
110                LoadedRoute {
111                    upstream: route.upstream.clone(),
112                    upstream_host_port,
113                    endpoint_rules,
114                    tls_connector,
115                },
116            );
117        }
118
119        Ok(Self { routes: loaded })
120    }
121
122    /// Create an empty route store (no routes configured).
123    #[must_use]
124    pub fn empty() -> Self {
125        Self {
126            routes: HashMap::new(),
127        }
128    }
129
130    /// Get a loaded route by normalised prefix, if configured.
131    #[must_use]
132    pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
133        self.routes.get(prefix)
134    }
135
136    /// Check if any routes are loaded.
137    #[must_use]
138    pub fn is_empty(&self) -> bool {
139        self.routes.is_empty()
140    }
141
142    /// Number of loaded routes.
143    #[must_use]
144    pub fn len(&self) -> usize {
145        self.routes.len()
146    }
147
148    /// Check whether `host_port` (e.g. `"api.openai.com:443"`) matches
149    /// any route's upstream URL. Uses pre-normalised `host:port` strings
150    /// computed at load time to avoid per-request URL parsing.
151    #[must_use]
152    pub fn is_route_upstream(&self, host_port: &str) -> bool {
153        let normalised = host_port.to_lowercase();
154        self.routes.values().any(|route| {
155            route
156                .upstream_host_port
157                .as_ref()
158                .is_some_and(|hp| *hp == normalised)
159        })
160    }
161
162    /// Return the set of normalised `host:port` strings for all route
163    /// upstreams. Uses pre-normalised values computed at load time.
164    #[must_use]
165    pub fn route_upstream_hosts(&self) -> std::collections::HashSet<String> {
166        self.routes
167            .values()
168            .filter_map(|route| route.upstream_host_port.clone())
169            .collect()
170    }
171}
172
173/// Extract and normalise `host:port` from a URL string.
174///
175/// Defaults to port 443 for `https://` and 80 for `http://` when no
176/// explicit port is present. Returns `None` if the URL cannot be parsed.
177fn extract_host_port(url: &str) -> Option<String> {
178    let parsed = url::Url::parse(url).ok()?;
179    let host = parsed.host_str()?;
180    let default_port = match parsed.scheme() {
181        "https" => 443,
182        "http" => 80,
183        _ => return None,
184    };
185    let port = parsed.port().unwrap_or(default_port);
186    Some(format!("{}:{}", host.to_lowercase(), port))
187}
188
189/// Read a PEM file, producing a clear `ProxyError::Config` for common failure modes.
190///
191/// Distinguishes:
192/// - file not found  → "… not found: '…'"
193/// - permission denied → "… permission denied: '…'" (nono process lacks read access)
194/// - other I/O errors  → "failed to read … '…': {os error}"
195fn read_pem_file(path: &std::path::Path, label: &str) -> Result<Zeroizing<Vec<u8>>> {
196    std::fs::read(path)
197        .map(Zeroizing::new)
198        .map_err(|e| match e.kind() {
199            std::io::ErrorKind::NotFound => {
200                ProxyError::Config(format!("{} file not found: '{}'", label, path.display()))
201            }
202            std::io::ErrorKind::PermissionDenied => ProxyError::Config(format!(
203                "{} permission denied: '{}' (check that nono can read this file)",
204                label,
205                path.display()
206            )),
207            _ => ProxyError::Config(format!(
208                "failed to read {} '{}': {}",
209                label,
210                path.display(),
211                e
212            )),
213        })
214}
215
216/// Build a `TlsConnector` with optional custom CA and optional client certificate.
217///
218/// - `ca_path`: PEM-encoded CA certificate file to trust in addition to system roots.
219///   Required for upstreams with self-signed or private CA certificates.
220/// - `client_cert_path`: PEM-encoded client certificate for mTLS. Must be paired with `client_key_path`.
221/// - `client_key_path`: PEM-encoded private key matching `client_cert_path`.
222///
223/// At least one of the three parameters must be `Some`. Returns an error if any
224/// file cannot be read, contains invalid PEM, or the TLS configuration fails.
225fn build_tls_connector(
226    ca_path: Option<&str>,
227    client_cert_path: Option<&str>,
228    client_key_path: Option<&str>,
229) -> Result<tokio_rustls::TlsConnector> {
230    let mut root_store = rustls::RootCertStore::empty();
231    // Always include system roots
232    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
233
234    // Add custom CA if provided
235    if let Some(ca_path) = ca_path {
236        let ca_path = std::path::Path::new(ca_path);
237        let ca_pem = read_pem_file(ca_path, "CA certificate")?;
238
239        let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref())
240            .collect::<std::result::Result<Vec<_>, _>>()
241            .map_err(|e| {
242                ProxyError::Config(format!(
243                    "failed to parse CA certificate '{}': {}",
244                    ca_path.display(),
245                    e
246                ))
247            })?;
248
249        if certs.is_empty() {
250            return Err(ProxyError::Config(format!(
251                "CA certificate file '{}' contains no valid PEM certificates",
252                ca_path.display()
253            )));
254        }
255
256        for cert in certs {
257            root_store.add(cert).map_err(|e| {
258                ProxyError::Config(format!(
259                    "invalid CA certificate in '{}': {}",
260                    ca_path.display(),
261                    e
262                ))
263            })?;
264        }
265    }
266
267    let builder = rustls::ClientConfig::builder_with_provider(Arc::new(
268        rustls::crypto::ring::default_provider(),
269    ))
270    .with_safe_default_protocol_versions()
271    .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
272    .with_root_certificates(root_store);
273
274    // Add client certificate for mTLS if provided
275    let tls_config = match (client_cert_path, client_key_path) {
276        (Some(cert_path), Some(key_path)) => {
277            let cert_path = std::path::Path::new(cert_path);
278            let key_path = std::path::Path::new(key_path);
279
280            let cert_pem = read_pem_file(cert_path, "client certificate")?;
281            let key_pem = read_pem_file(key_path, "client key")?;
282
283            let cert_chain: Vec<rustls::pki_types::CertificateDer> =
284                rustls::pki_types::CertificateDer::pem_slice_iter(cert_pem.as_ref())
285                    .collect::<std::result::Result<Vec<_>, _>>()
286                    .map_err(|e| {
287                        ProxyError::Config(format!(
288                            "failed to parse client certificate '{}': {}",
289                            cert_path.display(),
290                            e
291                        ))
292                    })?;
293
294            if cert_chain.is_empty() {
295                return Err(ProxyError::Config(format!(
296                    "client certificate file '{}' contains no valid PEM certificates",
297                    cert_path.display()
298                )));
299            }
300
301            let private_key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_ref())
302                .map_err(|e| match e {
303                    rustls::pki_types::pem::Error::NoItemsFound => ProxyError::Config(format!(
304                        "client key file '{}' contains no valid PEM private key",
305                        key_path.display()
306                    )),
307                    _ => ProxyError::Config(format!(
308                        "failed to parse client key '{}': {}",
309                        key_path.display(),
310                        e
311                    )),
312                })?;
313
314            builder
315                .with_client_auth_cert(cert_chain, private_key)
316                .map_err(|e| {
317                    ProxyError::Config(format!(
318                        "invalid client certificate/key pair ('{}', '{}'): {}",
319                        cert_path.display(),
320                        key_path.display(),
321                        e
322                    ))
323                })?
324        }
325        (Some(_), None) => {
326            return Err(ProxyError::Config(
327                "tls_client_cert is set but tls_client_key is missing".to_string(),
328            ));
329        }
330        (None, Some(_)) => {
331            return Err(ProxyError::Config(
332                "tls_client_key is set but tls_client_cert is missing".to_string(),
333            ));
334        }
335        (None, None) => builder.with_no_client_auth(),
336    };
337
338    // Disable TLS session resumption when client certificates are configured.
339    //
340    // With TLS 1.3 PSK resumption the server may skip the CertificateRequest
341    // handshake message, so the client certificate is never re-presented on
342    // resumed connections. Servers that authenticate via x509 client certs
343    // (e.g. Kubernetes API servers) then reject or hang the request because
344    // the client identity is not established. Forcing a full handshake every
345    // time ensures the client certificate is always sent.
346    let mut tls_config = tls_config;
347    if client_cert_path.is_some() {
348        tls_config.resumption = rustls::client::Resumption::disabled();
349    }
350
351    Ok(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
352}
353
354/// Compatibility shim: build a connector with only a custom CA (no client cert).
355#[cfg(test)]
356fn build_tls_connector_with_ca(ca_path: &str) -> Result<tokio_rustls::TlsConnector> {
357    build_tls_connector(Some(ca_path), None, None)
358}
359
360#[cfg(test)]
361#[allow(clippy::unwrap_used)]
362mod tests {
363    use super::*;
364    use crate::config::EndpointRule;
365
366    #[test]
367    fn test_empty_route_store() {
368        let store = RouteStore::empty();
369        assert!(store.is_empty());
370        assert_eq!(store.len(), 0);
371        assert!(store.get("openai").is_none());
372    }
373
374    #[test]
375    fn test_load_routes_without_credentials() {
376        // Routes without credential_key should still be loaded into RouteStore
377        let routes = vec![RouteConfig {
378            prefix: "/openai".to_string(),
379            upstream: "https://api.openai.com".to_string(),
380            credential_key: None,
381            inject_mode: Default::default(),
382            inject_header: "Authorization".to_string(),
383            credential_format: "Bearer {}".to_string(),
384            path_pattern: None,
385            path_replacement: None,
386            query_param_name: None,
387            proxy: None,
388            env_var: None,
389            endpoint_rules: vec![
390                EndpointRule {
391                    method: "POST".to_string(),
392                    path: "/v1/chat/completions".to_string(),
393                },
394                EndpointRule {
395                    method: "GET".to_string(),
396                    path: "/v1/models".to_string(),
397                },
398            ],
399            tls_ca: None,
400            tls_client_cert: None,
401            tls_client_key: None,
402            oauth2: None,
403        }];
404
405        let store = RouteStore::load(&routes).unwrap();
406        assert_eq!(store.len(), 1);
407
408        let route = store.get("openai").unwrap();
409        assert_eq!(route.upstream, "https://api.openai.com");
410        assert!(route
411            .endpoint_rules
412            .is_allowed("POST", "/v1/chat/completions"));
413        assert!(route.endpoint_rules.is_allowed("GET", "/v1/models"));
414        assert!(!route
415            .endpoint_rules
416            .is_allowed("DELETE", "/v1/files/file-123"));
417    }
418
419    #[test]
420    fn test_load_routes_normalises_prefix() {
421        let routes = vec![RouteConfig {
422            prefix: "/anthropic/".to_string(),
423            upstream: "https://api.anthropic.com".to_string(),
424            credential_key: None,
425            inject_mode: Default::default(),
426            inject_header: "Authorization".to_string(),
427            credential_format: "Bearer {}".to_string(),
428            path_pattern: None,
429            path_replacement: None,
430            query_param_name: None,
431            proxy: None,
432            env_var: None,
433            endpoint_rules: vec![],
434            tls_ca: None,
435            tls_client_cert: None,
436            tls_client_key: None,
437            oauth2: None,
438        }];
439
440        let store = RouteStore::load(&routes).unwrap();
441        assert!(store.get("anthropic").is_some());
442        assert!(store.get("/anthropic/").is_none());
443    }
444
445    #[test]
446    fn test_is_route_upstream() {
447        let routes = vec![RouteConfig {
448            prefix: "openai".to_string(),
449            upstream: "https://api.openai.com".to_string(),
450            credential_key: None,
451            inject_mode: Default::default(),
452            inject_header: "Authorization".to_string(),
453            credential_format: "Bearer {}".to_string(),
454            path_pattern: None,
455            path_replacement: None,
456            query_param_name: None,
457            proxy: None,
458            env_var: None,
459            endpoint_rules: vec![],
460            tls_ca: None,
461            tls_client_cert: None,
462            tls_client_key: None,
463            oauth2: None,
464        }];
465
466        let store = RouteStore::load(&routes).unwrap();
467        assert!(store.is_route_upstream("api.openai.com:443"));
468        assert!(!store.is_route_upstream("github.com:443"));
469    }
470
471    #[test]
472    fn test_route_upstream_hosts() {
473        let routes = vec![
474            RouteConfig {
475                prefix: "openai".to_string(),
476                upstream: "https://api.openai.com".to_string(),
477                credential_key: None,
478                inject_mode: Default::default(),
479                inject_header: "Authorization".to_string(),
480                credential_format: "Bearer {}".to_string(),
481                path_pattern: None,
482                path_replacement: None,
483                query_param_name: None,
484                proxy: None,
485                env_var: None,
486                endpoint_rules: vec![],
487                tls_ca: None,
488                tls_client_cert: None,
489                tls_client_key: None,
490                oauth2: None,
491            },
492            RouteConfig {
493                prefix: "anthropic".to_string(),
494                upstream: "https://api.anthropic.com".to_string(),
495                credential_key: None,
496                inject_mode: Default::default(),
497                inject_header: "Authorization".to_string(),
498                credential_format: "Bearer {}".to_string(),
499                path_pattern: None,
500                path_replacement: None,
501                query_param_name: None,
502                proxy: None,
503                env_var: None,
504                endpoint_rules: vec![],
505                tls_ca: None,
506                tls_client_cert: None,
507                tls_client_key: None,
508                oauth2: None,
509            },
510        ];
511
512        let store = RouteStore::load(&routes).unwrap();
513        let hosts = store.route_upstream_hosts();
514        assert!(hosts.contains("api.openai.com:443"));
515        assert!(hosts.contains("api.anthropic.com:443"));
516        assert_eq!(hosts.len(), 2);
517    }
518
519    #[test]
520    fn test_extract_host_port_https() {
521        assert_eq!(
522            extract_host_port("https://api.openai.com"),
523            Some("api.openai.com:443".to_string())
524        );
525    }
526
527    #[test]
528    fn test_extract_host_port_with_port() {
529        assert_eq!(
530            extract_host_port("https://api.example.com:8443"),
531            Some("api.example.com:8443".to_string())
532        );
533    }
534
535    #[test]
536    fn test_extract_host_port_http() {
537        assert_eq!(
538            extract_host_port("http://internal-service"),
539            Some("internal-service:80".to_string())
540        );
541    }
542
543    #[test]
544    fn test_extract_host_port_normalises_case() {
545        assert_eq!(
546            extract_host_port("https://API.Example.COM"),
547            Some("api.example.com:443".to_string())
548        );
549    }
550
551    #[test]
552    fn test_loaded_route_debug() {
553        let route = LoadedRoute {
554            upstream: "https://api.openai.com".to_string(),
555            upstream_host_port: Some("api.openai.com:443".to_string()),
556            endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
557            tls_connector: None,
558        };
559        let debug_output = format!("{:?}", route);
560        assert!(debug_output.contains("api.openai.com"));
561        assert!(debug_output.contains("has_custom_tls_ca"));
562    }
563
564    /// Self-signed CA for testing. Generated with:
565    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
566    ///   -keyout /dev/null -nodes -days 36500 -subj '/CN=nono-test-ca' -out -
567    const TEST_CA_PEM: &str = "\
568-----BEGIN CERTIFICATE-----
569MIIBnjCCAUWgAwIBAgIUT0bpOJJvHdOdZt+gW1stR8VBgXowCgYIKoZIzj0EAwIw
570FzEVMBMGA1UEAwwMbm9uby10ZXN0LWNhMCAXDTI1MDEwMTAwMDAwMFoYDzIxMjQx
571MjA3MDAwMDAwWjAXMRUwEwYDVQQDDAxub25vLXRlc3QtY2EwWTATBgcqhkjOPQIB
572BggqhkjOPQMBBwNCAAR8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
573AAAAAAAAAAAAAAAAAAAAo1MwUTAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAA
574AAAAMB8GA1UdIwQYMBaAFAAAAAAAAAAAAAAAAAAAAAAAAAAAADAPBgNVHRMBAf8E
575BTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
576AAAAAAAICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
577-----END CERTIFICATE-----";
578
579    #[test]
580    fn test_build_tls_connector_with_valid_ca() {
581        let dir = tempfile::tempdir().unwrap();
582        let ca_path = dir.path().join("ca.pem");
583        std::fs::write(&ca_path, TEST_CA_PEM).unwrap();
584
585        let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
586        match result {
587            Ok(connector) => {
588                drop(connector);
589            }
590            Err(ProxyError::Config(msg)) => {
591                assert!(
592                    msg.contains("invalid CA certificate") || msg.contains("CA certificate"),
593                    "unexpected error: {}",
594                    msg
595                );
596            }
597            Err(e) => panic!("unexpected error type: {}", e),
598        }
599    }
600
601    #[test]
602    fn test_build_tls_connector_missing_file() {
603        let result = build_tls_connector_with_ca("/nonexistent/path/ca.pem");
604        let err = result
605            .err()
606            .expect("should fail for missing file")
607            .to_string();
608        assert!(
609            err.contains("CA certificate file not found"),
610            "unexpected error: {}",
611            err
612        );
613    }
614
615    #[test]
616    fn test_build_tls_connector_empty_pem() {
617        let dir = tempfile::tempdir().unwrap();
618        let ca_path = dir.path().join("empty.pem");
619        std::fs::write(&ca_path, "not a certificate\n").unwrap();
620
621        let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
622        let err = result
623            .err()
624            .expect("should fail for invalid PEM")
625            .to_string();
626        assert!(
627            err.contains("no valid PEM certificates"),
628            "unexpected error: {}",
629            err
630        );
631    }
632
633    // --- mTLS (client certificate) tests ---
634
635    /// Self-signed client cert + key for testing. Generated with:
636    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
637    ///   -keyout client.key -nodes -days 3650 -subj '/CN=nono-test-client' -out client.crt
638    const TEST_CLIENT_CERT_PEM: &str = "\
639-----BEGIN CERTIFICATE-----
640MIIBijCCATGgAwIBAgIUEoEb+0z+4CTRCzN98MqeTEXgdO8wCgYIKoZIzj0EAwIw
641GzEZMBcGA1UEAwwQbm9uby10ZXN0LWNsaWVudDAeFw0yNjA0MTAwMDIwNTdaFw0z
642NjA0MDcwMDIwNTdaMBsxGTAXBgNVBAMMEG5vbm8tdGVzdC1jbGllbnQwWTATBgcq
643hkjOPQIBBggqhkjOPQMBBwNCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1h56ZLEEq
644HfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQo1MwUTAdBgNVHQ4E
645FgQUTiHidg8uqgrJ1qlaVvR+XSebAlEwHwYDVR0jBBgwFoAUTiHidg8uqgrJ1qla
646VvR+XSebAlEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiA9PwBU
647f832cQkGS9cyYaU7Ij5U8Rcy/g4J7Ckf2nKX3gIgG0aarAFcIzAi5VpxbCwEScnr
648m0lHTyp6E7ut7llwMBY=
649-----END CERTIFICATE-----";
650
651    const TEST_CLIENT_KEY_PEM: &str = "\
652-----BEGIN PRIVATE KEY-----
653MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgskOkyJkTwlMZkm/L
654eEleLY6bARaHFnqauYJqxNoJWvihRANCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1
655h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ
656-----END PRIVATE KEY-----";
657
658    #[test]
659    fn test_build_tls_connector_cert_without_key_errors() {
660        let dir = tempfile::tempdir().unwrap();
661        let cert_path = dir.path().join("client.crt");
662        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
663
664        let result = build_tls_connector(None, Some(cert_path.to_str().unwrap()), None);
665        let err = result
666            .err()
667            .expect("should fail with half-pair")
668            .to_string();
669        assert!(
670            err.contains("tls_client_cert is set but tls_client_key is missing"),
671            "unexpected error: {}",
672            err
673        );
674    }
675
676    #[test]
677    fn test_build_tls_connector_key_without_cert_errors() {
678        let dir = tempfile::tempdir().unwrap();
679        let key_path = dir.path().join("client.key");
680        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
681
682        let result = build_tls_connector(None, None, Some(key_path.to_str().unwrap()));
683        let err = result
684            .err()
685            .expect("should fail with half-pair")
686            .to_string();
687        assert!(
688            err.contains("tls_client_key is set but tls_client_cert is missing"),
689            "unexpected error: {}",
690            err
691        );
692    }
693
694    #[test]
695    fn test_build_tls_connector_missing_client_cert_file() {
696        let dir = tempfile::tempdir().unwrap();
697        let key_path = dir.path().join("client.key");
698        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
699
700        let result = build_tls_connector(
701            None,
702            Some("/nonexistent/client.crt"),
703            Some(key_path.to_str().unwrap()),
704        );
705        let err = result.err().expect("should fail").to_string();
706        assert!(
707            err.contains("client certificate file not found"),
708            "unexpected error: {}",
709            err
710        );
711    }
712
713    #[test]
714    fn test_build_tls_connector_missing_client_key_file() {
715        let dir = tempfile::tempdir().unwrap();
716        let cert_path = dir.path().join("client.crt");
717        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
718
719        let result = build_tls_connector(
720            None,
721            Some(cert_path.to_str().unwrap()),
722            Some("/nonexistent/client.key"),
723        );
724        let err = result.err().expect("should fail").to_string();
725        assert!(
726            err.contains("client key file not found"),
727            "unexpected error: {}",
728            err
729        );
730    }
731
732    #[test]
733    #[cfg(unix)]
734    fn test_build_tls_connector_permission_denied() {
735        use std::os::unix::fs::PermissionsExt;
736        let dir = tempfile::tempdir().unwrap();
737        let cert_path = dir.path().join("client.crt");
738        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
739        // Remove all permissions so the file exists but can't be read
740        std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
741
742        // Skip if running as root (root bypasses permission checks)
743        if std::fs::read(&cert_path).is_ok() {
744            return;
745        }
746
747        let result = build_tls_connector(
748            None,
749            Some(cert_path.to_str().unwrap()),
750            Some("/nonexistent/key"),
751        );
752        let err = result.err().expect("should fail").to_string();
753        assert!(
754            err.contains("permission denied"),
755            "expected permission denied error, got: {}",
756            err
757        );
758    }
759
760    #[test]
761    fn test_build_tls_connector_empty_client_cert_pem() {
762        let dir = tempfile::tempdir().unwrap();
763        let cert_path = dir.path().join("client.crt");
764        let key_path = dir.path().join("client.key");
765        std::fs::write(&cert_path, "not a certificate\n").unwrap();
766        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
767
768        let result = build_tls_connector(
769            None,
770            Some(cert_path.to_str().unwrap()),
771            Some(key_path.to_str().unwrap()),
772        );
773        let err = result.err().expect("should fail").to_string();
774        assert!(
775            err.contains("no valid PEM certificates"),
776            "unexpected error: {}",
777            err
778        );
779    }
780
781    #[test]
782    fn test_build_tls_connector_empty_client_key_pem() {
783        // Verifies that an invalid key file produces an appropriate config error.
784        let dir = tempfile::tempdir().unwrap();
785        let cert_path = dir.path().join("client.crt");
786        let key_path = dir.path().join("client.key");
787        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
788        std::fs::write(&key_path, "not a key\n").unwrap();
789
790        let result = build_tls_connector(
791            None,
792            Some(cert_path.to_str().unwrap()),
793            Some(key_path.to_str().unwrap()),
794        );
795        let err = result
796            .err()
797            .expect("should fail with invalid PEM")
798            .to_string();
799        assert!(err.contains("client key"), "unexpected error: {}", err);
800    }
801
802    #[test]
803    fn test_route_store_loads_mtls_route() {
804        // Verify RouteStore.load() builds a TLS connector when tls_client_cert/key are set.
805        let dir = tempfile::tempdir().unwrap();
806        let cert_path = dir.path().join("client.crt");
807        let key_path = dir.path().join("client.key");
808        std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
809        std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
810
811        let routes = vec![RouteConfig {
812            prefix: "k8s".to_string(),
813            upstream: "https://192.168.64.1:6443".to_string(),
814            credential_key: None,
815            inject_mode: Default::default(),
816            inject_header: "Authorization".to_string(),
817            credential_format: "Bearer {}".to_string(),
818            path_pattern: None,
819            path_replacement: None,
820            query_param_name: None,
821            proxy: None,
822            env_var: None,
823            endpoint_rules: vec![],
824            tls_ca: None,
825            tls_client_cert: Some(cert_path.to_str().unwrap().to_string()),
826            tls_client_key: Some(key_path.to_str().unwrap().to_string()),
827            oauth2: None,
828        }];
829
830        let store = RouteStore::load(&routes).expect("should load mTLS route");
831        let route = store.get("k8s").unwrap();
832        assert!(
833            route.tls_connector.is_some(),
834            "connector must be built when tls_client_cert/key are set"
835        );
836    }
837}