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