1use crate::config::{CompiledEndpointRules, RouteConfig};
14use crate::error::{ProxyError, Result};
15use std::collections::HashMap;
16use std::sync::Arc;
17use tracing::debug;
18use zeroize::Zeroizing;
19
20pub struct LoadedRoute {
26 pub upstream: String,
28
29 pub upstream_host_port: Option<String>,
33
34 pub endpoint_rules: CompiledEndpointRules,
38
39 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#[derive(Debug)]
62pub struct RouteStore {
63 routes: HashMap<String, LoadedRoute>,
64}
65
66impl RouteStore {
67 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 #[must_use]
123 pub fn empty() -> Self {
124 Self {
125 routes: HashMap::new(),
126 }
127 }
128
129 #[must_use]
131 pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
132 self.routes.get(prefix)
133 }
134
135 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 self.routes.is_empty()
139 }
140
141 #[must_use]
143 pub fn len(&self) -> usize {
144 self.routes.len()
145 }
146
147 #[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 #[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
172fn 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
188fn 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
215fn 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 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
232
233 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 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#[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 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 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 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 std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
719
720 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 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 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}