1use 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
21pub struct LoadedRoute {
27 pub upstream: String,
29
30 pub upstream_host_port: Option<String>,
34
35 pub endpoint_rules: CompiledEndpointRules,
39
40 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#[derive(Debug)]
63pub struct RouteStore {
64 routes: HashMap<String, LoadedRoute>,
65}
66
67impl RouteStore {
68 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 #[must_use]
124 pub fn empty() -> Self {
125 Self {
126 routes: HashMap::new(),
127 }
128 }
129
130 #[must_use]
132 pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
133 self.routes.get(prefix)
134 }
135
136 #[must_use]
138 pub fn is_empty(&self) -> bool {
139 self.routes.is_empty()
140 }
141
142 #[must_use]
144 pub fn len(&self) -> usize {
145 self.routes.len()
146 }
147
148 #[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 #[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
173fn 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
189fn 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
216fn 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 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
233
234 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 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 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#[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 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 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 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 std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
741
742 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 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 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}