1use crate::config::{CompiledEndpointRules, RouteConfig};
14use crate::error::{ProxyError, Result};
15use nono::undo::{NetworkAuditAuthMechanism, NetworkAuditInjectionMode};
16use rustls::pki_types::pem::PemObject;
17use std::collections::HashMap;
18use std::sync::Arc;
19use tracing::debug;
20use zeroize::Zeroizing;
21
22pub struct LoadedRoute {
28 pub upstream: String,
30
31 pub upstream_host_port: Option<String>,
35
36 pub endpoint_rules: CompiledEndpointRules,
40
41 pub tls_connector: Option<tokio_rustls::TlsConnector>,
45
46 pub requires_intercept: bool,
52
53 pub requires_managed_credential: bool,
58
59 pub managed_auth_mechanism: Option<NetworkAuditAuthMechanism>,
63
64 pub managed_injection_mode: Option<NetworkAuditInjectionMode>,
66}
67
68impl std::fmt::Debug for LoadedRoute {
69 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70 f.debug_struct("LoadedRoute")
71 .field("upstream", &self.upstream)
72 .field("upstream_host_port", &self.upstream_host_port)
73 .field("endpoint_rules", &self.endpoint_rules)
74 .field("has_custom_tls_ca", &self.tls_connector.is_some())
75 .field("requires_intercept", &self.requires_intercept)
76 .field(
77 "requires_managed_credential",
78 &self.requires_managed_credential,
79 )
80 .field("managed_auth_mechanism", &self.managed_auth_mechanism)
81 .field("managed_injection_mode", &self.managed_injection_mode)
82 .finish()
83 }
84}
85
86fn auth_mechanism_for_route(route: &RouteConfig) -> Option<NetworkAuditAuthMechanism> {
87 if route.oauth2.is_some() {
88 return Some(NetworkAuditAuthMechanism::PhantomHeader);
89 }
90
91 if route.aws_auth.is_some() {
92 return Some(NetworkAuditAuthMechanism::PhantomHeader);
94 }
95
96 if route.credential_key.is_some() {
97 let proxy_mode = route
98 .proxy
99 .as_ref()
100 .and_then(|p| p.inject_mode.clone())
101 .unwrap_or_else(|| route.inject_mode.clone());
102 return Some(match proxy_mode {
103 crate::config::InjectMode::Header | crate::config::InjectMode::BasicAuth => {
104 NetworkAuditAuthMechanism::PhantomHeader
105 }
106 crate::config::InjectMode::UrlPath => NetworkAuditAuthMechanism::PhantomPath,
107 crate::config::InjectMode::QueryParam => NetworkAuditAuthMechanism::PhantomQuery,
108 });
109 }
110
111 None
112}
113
114fn injection_mode_for_route(route: &RouteConfig) -> Option<NetworkAuditInjectionMode> {
115 if route.oauth2.is_some() {
116 return Some(NetworkAuditInjectionMode::OAuth2);
117 }
118
119 if route.aws_auth.is_some() {
120 return Some(NetworkAuditInjectionMode::Header);
122 }
123
124 if route.credential_key.is_some() {
125 return Some(match route.inject_mode {
126 crate::config::InjectMode::Header => NetworkAuditInjectionMode::Header,
127 crate::config::InjectMode::UrlPath => NetworkAuditInjectionMode::UrlPath,
128 crate::config::InjectMode::QueryParam => NetworkAuditInjectionMode::QueryParam,
129 crate::config::InjectMode::BasicAuth => NetworkAuditInjectionMode::BasicAuth,
130 });
131 }
132
133 None
134}
135
136#[derive(Debug)]
142pub struct RouteStore {
143 routes: HashMap<String, LoadedRoute>,
144}
145
146impl RouteStore {
147 pub fn load(routes: &[RouteConfig]) -> Result<Self> {
153 let mut loaded = HashMap::new();
154
155 let base_root_store = build_base_root_store();
156
157 for route in routes {
158 let normalized_prefix = route.prefix.trim_matches('/').to_string();
159
160 debug!(
161 "Loading route '{}' -> {}",
162 normalized_prefix, route.upstream
163 );
164
165 let endpoint_rules = CompiledEndpointRules::compile(&route.endpoint_rules)
166 .map_err(|e| ProxyError::Config(format!("route '{}': {}", normalized_prefix, e)))?;
167
168 let tls_connector = if route.tls_ca.is_some()
169 || route.tls_client_cert.is_some()
170 || route.tls_client_key.is_some()
171 {
172 debug!(
173 "Building TLS connector for route '{}' (ca={}, client_cert={})",
174 normalized_prefix,
175 route.tls_ca.is_some(),
176 route.tls_client_cert.is_some(),
177 );
178 Some(build_tls_connector(
179 &base_root_store,
180 route.tls_ca.as_deref(),
181 route.tls_client_cert.as_deref(),
182 route.tls_client_key.as_deref(),
183 )?)
184 } else {
185 None
186 };
187
188 let upstream_host_port = extract_host_port(&route.upstream);
189
190 let requires_managed_credential = route.credential_key.is_some()
197 || route.oauth2.is_some()
198 || route.aws_auth.is_some();
199 let requires_intercept =
200 requires_managed_credential || !route.endpoint_rules.is_empty();
201 let managed_auth_mechanism = auth_mechanism_for_route(route);
202 let managed_injection_mode = injection_mode_for_route(route);
203
204 loaded.insert(
205 normalized_prefix,
206 LoadedRoute {
207 upstream: route.upstream.clone(),
208 upstream_host_port,
209 endpoint_rules,
210 tls_connector,
211 requires_intercept,
212 requires_managed_credential,
213 managed_auth_mechanism,
214 managed_injection_mode,
215 },
216 );
217 }
218
219 Ok(Self { routes: loaded })
220 }
221
222 #[must_use]
224 pub fn empty() -> Self {
225 Self {
226 routes: HashMap::new(),
227 }
228 }
229
230 #[must_use]
232 pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
233 self.routes.get(prefix)
234 }
235
236 #[must_use]
238 pub fn is_empty(&self) -> bool {
239 self.routes.is_empty()
240 }
241
242 #[must_use]
244 pub fn len(&self) -> usize {
245 self.routes.len()
246 }
247
248 #[must_use]
252 pub fn is_route_upstream(&self, host_port: &str) -> bool {
253 let normalised = host_port.to_lowercase();
254 self.routes.values().any(|route| {
255 route
256 .upstream_host_port
257 .as_ref()
258 .is_some_and(|hp| *hp == normalised)
259 })
260 }
261
262 #[must_use]
267 pub fn lookup_by_upstream(&self, host_port: &str) -> Option<(&str, &LoadedRoute)> {
268 let normalised = host_port.to_lowercase();
269 self.routes.iter().find_map(|(prefix, route)| {
270 route
271 .upstream_host_port
272 .as_ref()
273 .filter(|hp| **hp == normalised)
274 .map(|_| (prefix.as_str(), route))
275 })
276 }
277
278 #[must_use]
281 pub fn lookup_all_by_upstream(&self, host_port: &str) -> Vec<(&str, &LoadedRoute)> {
282 let normalised = host_port.to_lowercase();
283 let mut matches: Vec<_> = self
284 .routes
285 .iter()
286 .filter(|(_, route)| {
287 route
288 .upstream_host_port
289 .as_ref()
290 .is_some_and(|hp| *hp == normalised)
291 })
292 .map(|(prefix, route)| (prefix.as_str(), route))
293 .collect();
294 matches.sort_by_key(|(prefix, _)| *prefix);
295 matches
296 }
297
298 #[must_use]
300 pub fn has_intercept_route(&self, host_port: &str) -> bool {
301 let normalised = host_port.to_lowercase();
302 self.routes.values().any(|route| {
303 route
304 .upstream_host_port
305 .as_ref()
306 .is_some_and(|hp| *hp == normalised)
307 && route.requires_intercept
308 })
309 }
310
311 #[must_use]
313 pub fn route_upstream_hosts(&self) -> std::collections::HashSet<String> {
314 self.routes
315 .values()
316 .filter_map(|route| route.upstream_host_port.clone())
317 .collect()
318 }
319}
320
321#[derive(Debug)]
325pub(crate) enum RouteSelection<'a> {
326 EndpointDenied,
329 Ambiguous(Vec<&'a str>),
332 Selected(Option<(&'a str, &'a LoadedRoute)>),
335}
336
337#[must_use]
357pub(crate) fn select_route<'a>(
358 candidates: &'a [(&'a str, &'a LoadedRoute)],
359 method: &str,
360 path: &str,
361) -> RouteSelection<'a> {
362 let mut matched_cred: Vec<(&str, &LoadedRoute)> = Vec::new();
363 let mut matched_passthrough: Vec<(&str, &LoadedRoute)> = Vec::new();
364 let mut catchall_cred: Vec<(&str, &LoadedRoute)> = Vec::new();
365 let mut catchall_passthrough: Vec<(&str, &LoadedRoute)> = Vec::new();
366 let mut has_endpoint_only_route = false;
367 let mut endpoint_authorized = false;
368 for (prefix, route) in candidates {
369 if route.endpoint_rules.is_empty() {
370 if route.requires_managed_credential {
371 catchall_cred.push((prefix, route));
372 } else {
373 catchall_passthrough.push((prefix, route));
374 }
375 } else if route.endpoint_rules.is_allowed(method, path) {
376 if route.requires_managed_credential {
377 matched_cred.push((prefix, route));
378 } else {
379 matched_passthrough.push((prefix, route));
380 endpoint_authorized = true;
381 }
382 } else if !route.requires_managed_credential {
383 has_endpoint_only_route = true;
384 }
385 }
386
387 if has_endpoint_only_route && !endpoint_authorized {
390 return RouteSelection::EndpointDenied;
391 }
392
393 let credential_layer: &[(&str, &LoadedRoute)] = if matched_cred.is_empty() {
399 &catchall_cred
400 } else {
401 &matched_cred
402 };
403 if credential_layer.len() > 1 {
404 let names = credential_layer.iter().map(|(p, _)| *p).collect();
405 return RouteSelection::Ambiguous(names);
406 }
407
408 let selected = credential_layer
409 .first()
410 .copied()
411 .or_else(|| matched_passthrough.first().copied())
412 .or_else(|| catchall_passthrough.first().copied());
413 RouteSelection::Selected(selected)
414}
415
416impl LoadedRoute {
417 #[must_use]
420 pub fn missing_managed_credential(
421 &self,
422 has_static_credential: bool,
423 has_oauth2: bool,
424 has_aws: bool,
425 ) -> bool {
426 self.requires_managed_credential && !has_static_credential && !has_oauth2 && !has_aws
427 }
428}
429
430fn extract_host_port(url: &str) -> Option<String> {
435 let parsed = url::Url::parse(url).ok()?;
436 let host = parsed.host_str()?;
437 let default_port = match parsed.scheme() {
438 "https" => 443,
439 "http" => 80,
440 _ => return None,
441 };
442 let port = parsed.port().unwrap_or(default_port);
443 Some(format!("{}:{}", host.to_lowercase(), port))
444}
445
446fn read_pem_file(path: &std::path::Path, label: &str) -> Result<Zeroizing<Vec<u8>>> {
453 std::fs::read(path)
454 .map(Zeroizing::new)
455 .map_err(|e| match e.kind() {
456 std::io::ErrorKind::NotFound => {
457 ProxyError::Config(format!("{} file not found: '{}'", label, path.display()))
458 }
459 std::io::ErrorKind::PermissionDenied => ProxyError::Config(format!(
460 "{} permission denied: '{}' (check that nono can read this file)",
461 label,
462 path.display()
463 )),
464 _ => ProxyError::Config(format!(
465 "failed to read {} '{}': {}",
466 label,
467 path.display(),
468 e
469 )),
470 })
471}
472
473fn build_base_root_store() -> rustls::RootCertStore {
477 let mut store = rustls::RootCertStore::empty();
478 store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
479 let native = rustls_native_certs::load_native_certs();
480 for cert in native.certs {
481 if let Err(e) = store.add(cert) {
482 debug!("skipping unparseable native cert: {e}");
483 }
484 }
485 store
486}
487
488fn build_tls_connector(
491 base_root_store: &rustls::RootCertStore,
492 ca_path: Option<&str>,
493 client_cert_path: Option<&str>,
494 client_key_path: Option<&str>,
495) -> Result<tokio_rustls::TlsConnector> {
496 let mut root_store = base_root_store.clone();
497
498 if let Some(ca_path) = ca_path {
500 let ca_path = std::path::Path::new(ca_path);
501 let ca_pem = read_pem_file(ca_path, "CA certificate")?;
502
503 let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref())
504 .collect::<std::result::Result<Vec<_>, _>>()
505 .map_err(|e| {
506 ProxyError::Config(format!(
507 "failed to parse CA certificate '{}': {}",
508 ca_path.display(),
509 e
510 ))
511 })?;
512
513 if certs.is_empty() {
514 return Err(ProxyError::Config(format!(
515 "CA certificate file '{}' contains no valid PEM certificates",
516 ca_path.display()
517 )));
518 }
519
520 for cert in certs {
521 root_store.add(cert).map_err(|e| {
522 ProxyError::Config(format!(
523 "invalid CA certificate in '{}': {}",
524 ca_path.display(),
525 e
526 ))
527 })?;
528 }
529 }
530
531 let builder = rustls::ClientConfig::builder_with_provider(Arc::new(
532 rustls::crypto::ring::default_provider(),
533 ))
534 .with_safe_default_protocol_versions()
535 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
536 .with_root_certificates(root_store);
537
538 let tls_config = match (client_cert_path, client_key_path) {
540 (Some(cert_path), Some(key_path)) => {
541 let cert_path = std::path::Path::new(cert_path);
542 let key_path = std::path::Path::new(key_path);
543
544 let cert_pem = read_pem_file(cert_path, "client certificate")?;
545 let key_pem = read_pem_file(key_path, "client key")?;
546
547 let cert_chain: Vec<rustls::pki_types::CertificateDer> =
548 rustls::pki_types::CertificateDer::pem_slice_iter(cert_pem.as_ref())
549 .collect::<std::result::Result<Vec<_>, _>>()
550 .map_err(|e| {
551 ProxyError::Config(format!(
552 "failed to parse client certificate '{}': {}",
553 cert_path.display(),
554 e
555 ))
556 })?;
557
558 if cert_chain.is_empty() {
559 return Err(ProxyError::Config(format!(
560 "client certificate file '{}' contains no valid PEM certificates",
561 cert_path.display()
562 )));
563 }
564
565 let private_key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_ref())
566 .map_err(|e| match e {
567 rustls::pki_types::pem::Error::NoItemsFound => ProxyError::Config(format!(
568 "client key file '{}' contains no valid PEM private key",
569 key_path.display()
570 )),
571 _ => ProxyError::Config(format!(
572 "failed to parse client key '{}': {}",
573 key_path.display(),
574 e
575 )),
576 })?;
577
578 builder
579 .with_client_auth_cert(cert_chain, private_key)
580 .map_err(|e| {
581 ProxyError::Config(format!(
582 "invalid client certificate/key pair ('{}', '{}'): {}",
583 cert_path.display(),
584 key_path.display(),
585 e
586 ))
587 })?
588 }
589 (Some(_), None) => {
590 return Err(ProxyError::Config(
591 "tls_client_cert is set but tls_client_key is missing".to_string(),
592 ));
593 }
594 (None, Some(_)) => {
595 return Err(ProxyError::Config(
596 "tls_client_key is set but tls_client_cert is missing".to_string(),
597 ));
598 }
599 (None, None) => builder.with_no_client_auth(),
600 };
601
602 let mut tls_config = tls_config;
611 if client_cert_path.is_some() {
612 tls_config.resumption = rustls::client::Resumption::disabled();
613 }
614
615 Ok(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
616}
617
618#[cfg(test)]
620fn build_tls_connector_with_ca(ca_path: &str) -> Result<tokio_rustls::TlsConnector> {
621 let base = build_base_root_store();
622 build_tls_connector(&base, Some(ca_path), None, None)
623}
624
625#[cfg(test)]
626#[allow(clippy::unwrap_used)]
627mod tests {
628 use super::*;
629 use crate::config::EndpointRule;
630
631 #[test]
632 fn test_empty_route_store() {
633 let store = RouteStore::empty();
634 assert!(store.is_empty());
635 assert_eq!(store.len(), 0);
636 assert!(store.get("openai").is_none());
637 }
638
639 #[test]
640 fn test_load_routes_without_credentials() {
641 let routes = vec![RouteConfig {
643 prefix: "/openai".to_string(),
644 upstream: "https://api.openai.com".to_string(),
645 credential_key: None,
646 inject_mode: Default::default(),
647 inject_header: "Authorization".to_string(),
648 credential_format: Some("Bearer {}".to_string()),
649 path_pattern: None,
650 path_replacement: None,
651 query_param_name: None,
652 proxy: None,
653 env_var: None,
654 endpoint_rules: vec![
655 EndpointRule {
656 method: "POST".to_string(),
657 path: "/v1/chat/completions".to_string(),
658 },
659 EndpointRule {
660 method: "GET".to_string(),
661 path: "/v1/models".to_string(),
662 },
663 ],
664 tls_ca: None,
665 tls_client_cert: None,
666 tls_client_key: None,
667 oauth2: None,
668 aws_auth: None,
669 }];
670
671 let store = RouteStore::load(&routes).unwrap();
672 assert_eq!(store.len(), 1);
673
674 let route = store.get("openai").unwrap();
675 assert_eq!(route.upstream, "https://api.openai.com");
676 assert!(
677 route
678 .endpoint_rules
679 .is_allowed("POST", "/v1/chat/completions")
680 );
681 assert!(route.endpoint_rules.is_allowed("GET", "/v1/models"));
682 assert!(
683 !route
684 .endpoint_rules
685 .is_allowed("DELETE", "/v1/files/file-123")
686 );
687 }
688
689 #[test]
690 fn test_load_routes_normalises_prefix() {
691 let routes = vec![RouteConfig {
692 prefix: "/anthropic/".to_string(),
693 upstream: "https://api.anthropic.com".to_string(),
694 credential_key: None,
695 inject_mode: Default::default(),
696 inject_header: "Authorization".to_string(),
697 credential_format: Some("Bearer {}".to_string()),
698 path_pattern: None,
699 path_replacement: None,
700 query_param_name: None,
701 proxy: None,
702 env_var: None,
703 endpoint_rules: vec![],
704 tls_ca: None,
705 tls_client_cert: None,
706 tls_client_key: None,
707 oauth2: None,
708 aws_auth: None,
709 }];
710
711 let store = RouteStore::load(&routes).unwrap();
712 assert!(store.get("anthropic").is_some());
713 assert!(store.get("/anthropic/").is_none());
714 }
715
716 #[test]
717 fn test_is_route_upstream() {
718 let routes = vec![RouteConfig {
719 prefix: "openai".to_string(),
720 upstream: "https://api.openai.com".to_string(),
721 credential_key: None,
722 inject_mode: Default::default(),
723 inject_header: "Authorization".to_string(),
724 credential_format: Some("Bearer {}".to_string()),
725 path_pattern: None,
726 path_replacement: None,
727 query_param_name: None,
728 proxy: None,
729 env_var: None,
730 endpoint_rules: vec![],
731 tls_ca: None,
732 tls_client_cert: None,
733 tls_client_key: None,
734 oauth2: None,
735 aws_auth: None,
736 }];
737
738 let store = RouteStore::load(&routes).unwrap();
739 assert!(store.is_route_upstream("api.openai.com:443"));
740 assert!(!store.is_route_upstream("github.com:443"));
741 }
742
743 #[test]
744 fn test_route_upstream_hosts() {
745 let routes = vec![
746 RouteConfig {
747 prefix: "openai".to_string(),
748 upstream: "https://api.openai.com".to_string(),
749 credential_key: None,
750 inject_mode: Default::default(),
751 inject_header: "Authorization".to_string(),
752 credential_format: Some("Bearer {}".to_string()),
753 path_pattern: None,
754 path_replacement: None,
755 query_param_name: None,
756 proxy: None,
757 env_var: None,
758 endpoint_rules: vec![],
759 tls_ca: None,
760 tls_client_cert: None,
761 tls_client_key: None,
762 oauth2: None,
763 aws_auth: None,
764 },
765 RouteConfig {
766 prefix: "anthropic".to_string(),
767 upstream: "https://api.anthropic.com".to_string(),
768 credential_key: None,
769 inject_mode: Default::default(),
770 inject_header: "Authorization".to_string(),
771 credential_format: Some("Bearer {}".to_string()),
772 path_pattern: None,
773 path_replacement: None,
774 query_param_name: None,
775 proxy: None,
776 env_var: None,
777 endpoint_rules: vec![],
778 tls_ca: None,
779 tls_client_cert: None,
780 tls_client_key: None,
781 oauth2: None,
782 aws_auth: None,
783 },
784 ];
785
786 let store = RouteStore::load(&routes).unwrap();
787 let hosts = store.route_upstream_hosts();
788 assert!(hosts.contains("api.openai.com:443"));
789 assert!(hosts.contains("api.anthropic.com:443"));
790 assert_eq!(hosts.len(), 2);
791 }
792
793 #[test]
794 fn test_extract_host_port_https() {
795 assert_eq!(
796 extract_host_port("https://api.openai.com"),
797 Some("api.openai.com:443".to_string())
798 );
799 }
800
801 #[test]
802 fn test_extract_host_port_with_port() {
803 assert_eq!(
804 extract_host_port("https://api.example.com:8443"),
805 Some("api.example.com:8443".to_string())
806 );
807 }
808
809 #[test]
810 fn test_extract_host_port_http() {
811 assert_eq!(
812 extract_host_port("http://internal-service"),
813 Some("internal-service:80".to_string())
814 );
815 }
816
817 #[test]
818 fn test_extract_host_port_normalises_case() {
819 assert_eq!(
820 extract_host_port("https://API.Example.COM"),
821 Some("api.example.com:443".to_string())
822 );
823 }
824
825 #[test]
826 fn test_loaded_route_debug() {
827 let route = LoadedRoute {
828 upstream: "https://api.openai.com".to_string(),
829 upstream_host_port: Some("api.openai.com:443".to_string()),
830 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
831 tls_connector: None,
832 requires_intercept: false,
833 requires_managed_credential: false,
834 managed_auth_mechanism: None,
835 managed_injection_mode: None,
836 };
837 let debug_output = format!("{:?}", route);
838 assert!(debug_output.contains("api.openai.com"));
839 assert!(debug_output.contains("has_custom_tls_ca"));
840 assert!(debug_output.contains("requires_intercept"));
841 assert!(debug_output.contains("requires_managed_credential"));
842 assert!(debug_output.contains("managed_auth_mechanism"));
843 assert!(debug_output.contains("managed_injection_mode"));
844 }
845
846 #[test]
847 fn test_requires_intercept_credential_only() {
848 let routes = vec![RouteConfig {
849 prefix: "openai".to_string(),
850 upstream: "https://api.openai.com".to_string(),
851 credential_key: Some("openai_api_key".to_string()),
852 inject_mode: Default::default(),
853 inject_header: "Authorization".to_string(),
854 credential_format: Some("Bearer {}".to_string()),
855 path_pattern: None,
856 path_replacement: None,
857 query_param_name: None,
858 proxy: None,
859 env_var: None,
860 endpoint_rules: vec![],
861 tls_ca: None,
862 tls_client_cert: None,
863 tls_client_key: None,
864 oauth2: None,
865 aws_auth: None,
866 }];
867 let store = RouteStore::load(&routes).unwrap();
868 let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
869 assert!(store.has_intercept_route("api.openai.com:443"));
870 assert!(hit.1.requires_managed_credential);
871 assert_eq!(
872 hit.1.managed_auth_mechanism,
873 Some(NetworkAuditAuthMechanism::PhantomHeader)
874 );
875 assert_eq!(
876 hit.1.managed_injection_mode,
877 Some(NetworkAuditInjectionMode::Header)
878 );
879 assert!(!store.has_intercept_route("api.example.com:443"));
880 }
881
882 #[test]
883 fn test_requires_intercept_endpoint_rules_only() {
884 let routes = vec![RouteConfig {
887 prefix: "internal".to_string(),
888 upstream: "https://internal.example.com".to_string(),
889 credential_key: None,
890 inject_mode: Default::default(),
891 inject_header: "Authorization".to_string(),
892 credential_format: Some("Bearer {}".to_string()),
893 path_pattern: None,
894 path_replacement: None,
895 query_param_name: None,
896 proxy: None,
897 env_var: None,
898 endpoint_rules: vec![EndpointRule {
899 method: "GET".to_string(),
900 path: "/v1/items".to_string(),
901 }],
902 tls_ca: None,
903 tls_client_cert: None,
904 tls_client_key: None,
905 oauth2: None,
906 aws_auth: None,
907 }];
908 let store = RouteStore::load(&routes).unwrap();
909 let hit = store
910 .lookup_by_upstream("internal.example.com:443")
911 .unwrap();
912 assert!(store.has_intercept_route("internal.example.com:443"));
913 assert!(!hit.1.requires_managed_credential);
914 }
915
916 #[test]
917 fn test_requires_intercept_declarative_only() {
918 let routes = vec![RouteConfig {
921 prefix: "alias".to_string(),
922 upstream: "https://aliased.example.com".to_string(),
923 credential_key: None,
924 inject_mode: Default::default(),
925 inject_header: "Authorization".to_string(),
926 credential_format: Some("Bearer {}".to_string()),
927 path_pattern: None,
928 path_replacement: None,
929 query_param_name: None,
930 proxy: None,
931 env_var: None,
932 endpoint_rules: vec![],
933 tls_ca: None,
934 tls_client_cert: None,
935 tls_client_key: None,
936 oauth2: None,
937 aws_auth: None,
938 }];
939 let store = RouteStore::load(&routes).unwrap();
940 assert!(store.is_route_upstream("aliased.example.com:443"));
941 assert!(!store.has_intercept_route("aliased.example.com:443"));
942 }
943
944 #[test]
945 fn test_missing_managed_credential_policy() {
946 let managed = LoadedRoute {
947 upstream: "https://api.openai.com".to_string(),
948 upstream_host_port: Some("api.openai.com:443".to_string()),
949 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
950 tls_connector: None,
951 requires_intercept: true,
952 requires_managed_credential: true,
953 managed_auth_mechanism: Some(NetworkAuditAuthMechanism::PhantomHeader),
954 managed_injection_mode: Some(NetworkAuditInjectionMode::Header),
955 };
956 assert!(managed.missing_managed_credential(false, false, false));
957 assert!(!managed.missing_managed_credential(true, false, false));
958 assert!(!managed.missing_managed_credential(false, true, false));
959 assert!(!managed.missing_managed_credential(false, false, true));
960
961 let l7_only = LoadedRoute {
962 upstream: "https://internal.example.com".to_string(),
963 upstream_host_port: Some("internal.example.com:443".to_string()),
964 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
965 tls_connector: None,
966 requires_intercept: true,
967 requires_managed_credential: false,
968 managed_auth_mechanism: None,
969 managed_injection_mode: None,
970 };
971 assert!(!l7_only.missing_managed_credential(false, false, false));
972 }
973
974 #[test]
975 fn test_lookup_by_upstream_returns_prefix() {
976 let routes = vec![RouteConfig {
977 prefix: "openai".to_string(),
978 upstream: "https://api.openai.com".to_string(),
979 credential_key: Some("openai_api_key".to_string()),
980 inject_mode: Default::default(),
981 inject_header: "Authorization".to_string(),
982 credential_format: Some("Bearer {}".to_string()),
983 path_pattern: None,
984 path_replacement: None,
985 query_param_name: None,
986 proxy: None,
987 env_var: None,
988 endpoint_rules: vec![],
989 tls_ca: None,
990 tls_client_cert: None,
991 tls_client_key: None,
992 oauth2: None,
993 aws_auth: None,
994 }];
995 let store = RouteStore::load(&routes).unwrap();
996 let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
997 assert_eq!(hit.0, "openai");
998 assert!(hit.1.requires_intercept);
999 assert!(hit.1.requires_managed_credential);
1000 assert!(store.lookup_by_upstream("api.example.com:443").is_none());
1001 }
1002
1003 #[test]
1004 fn test_lookup_all_by_upstream_returns_multiple_routes() {
1005 let routes = vec![
1006 RouteConfig {
1007 prefix: "github_org_a".to_string(),
1008 upstream: "https://github.com".to_string(),
1009 credential_key: Some("env://GH_TOKEN_A".to_string()),
1010 inject_mode: Default::default(),
1011 inject_header: "Authorization".to_string(),
1012 credential_format: Some("Bearer {}".to_string()),
1013 path_pattern: None,
1014 path_replacement: None,
1015 query_param_name: None,
1016 proxy: None,
1017 env_var: Some("GH_TOKEN_A".to_string()),
1018 endpoint_rules: vec![crate::config::EndpointRule {
1019 method: "*".to_string(),
1020 path: "/org-a/**".to_string(),
1021 }],
1022 tls_ca: None,
1023 tls_client_cert: None,
1024 tls_client_key: None,
1025 oauth2: None,
1026 aws_auth: None,
1027 },
1028 RouteConfig {
1029 prefix: "github_org_b".to_string(),
1030 upstream: "https://github.com".to_string(),
1031 credential_key: Some("env://GH_TOKEN_B".to_string()),
1032 inject_mode: Default::default(),
1033 inject_header: "Authorization".to_string(),
1034 credential_format: Some("Bearer {}".to_string()),
1035 path_pattern: None,
1036 path_replacement: None,
1037 query_param_name: None,
1038 proxy: None,
1039 env_var: Some("GH_TOKEN_B".to_string()),
1040 endpoint_rules: vec![crate::config::EndpointRule {
1041 method: "*".to_string(),
1042 path: "/org-b/**".to_string(),
1043 }],
1044 tls_ca: None,
1045 tls_client_cert: None,
1046 tls_client_key: None,
1047 oauth2: None,
1048 aws_auth: None,
1049 },
1050 ];
1051 let store = RouteStore::load(&routes).unwrap();
1052
1053 let all = store.lookup_all_by_upstream("github.com:443");
1054 assert_eq!(all.len(), 2, "both routes share the same upstream");
1055
1056 let prefixes: Vec<&str> = all.iter().map(|(p, _)| *p).collect();
1057 assert!(prefixes.contains(&"github_org_a"));
1058 assert!(prefixes.contains(&"github_org_b"));
1059
1060 let (_, route_a) = all.iter().find(|(p, _)| *p == "github_org_a").unwrap();
1061 assert!(route_a.endpoint_rules.is_allowed("GET", "/org-a/repo"));
1062 assert!(!route_a.endpoint_rules.is_allowed("GET", "/org-b/repo"));
1063
1064 let (_, route_b) = all.iter().find(|(p, _)| *p == "github_org_b").unwrap();
1065 assert!(route_b.endpoint_rules.is_allowed("GET", "/org-b/repo"));
1066 assert!(!route_b.endpoint_rules.is_allowed("GET", "/org-a/repo"));
1067
1068 assert!(store.has_intercept_route("github.com:443"));
1069 assert!(store.is_route_upstream("github.com:443"));
1070 assert!(store.lookup_all_by_upstream("other.com:443").is_empty());
1071 }
1072
1073 #[derive(Debug, PartialEq)]
1074 enum Selection<'a> {
1075 Route(&'a str),
1076 Passthrough,
1077 Ambiguous(Vec<&'a str>),
1078 EndpointDenied,
1079 }
1080
1081 fn select<'a>(
1084 candidates: &'a [(&'a str, &'a LoadedRoute)],
1085 method: &str,
1086 path: &str,
1087 ) -> Selection<'a> {
1088 match select_route(candidates, method, path) {
1089 RouteSelection::EndpointDenied => Selection::EndpointDenied,
1090 RouteSelection::Ambiguous(names) => Selection::Ambiguous(names),
1091 RouteSelection::Selected(Some((svc, _))) => Selection::Route(svc),
1092 RouteSelection::Selected(None) => Selection::Passthrough,
1093 }
1094 }
1095
1096 #[test]
1101 fn test_route_selection_multi_org_profile() {
1102 fn gh_route(prefix: &str, env: &str, path: &str) -> RouteConfig {
1104 RouteConfig {
1105 prefix: prefix.to_string(),
1106 upstream: "https://github.com".to_string(),
1107 credential_key: Some(format!("env://{env}")),
1108 inject_mode: Default::default(),
1109 inject_header: "Authorization".to_string(),
1110 credential_format: Some("Bearer {}".to_string()),
1111 path_pattern: None,
1112 path_replacement: None,
1113 query_param_name: None,
1114 proxy: None,
1115 env_var: Some(env.to_string()),
1116 endpoint_rules: vec![crate::config::EndpointRule {
1117 method: "*".to_string(),
1118 path: path.to_string(),
1119 }],
1120 tls_ca: None,
1121 tls_client_cert: None,
1122 tls_client_key: None,
1123 oauth2: None,
1124 aws_auth: None,
1125 }
1126 }
1127
1128 let routes = vec![
1130 gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"),
1131 gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"),
1132 ];
1133 let store = RouteStore::load(&routes).unwrap();
1134 let candidates = store.lookup_all_by_upstream("github.com:443");
1135 assert_eq!(candidates.len(), 2);
1136
1137 assert_eq!(
1139 select(&candidates, "GET", "/org-a/repo.git/info/refs"),
1140 Selection::Route("github_https_org_a")
1141 );
1142 assert_eq!(
1144 select(&candidates, "GET", "/org-b/repo.git/info/refs"),
1145 Selection::Route("github_https_org_b")
1146 );
1147 assert_eq!(
1149 select(&candidates, "GET", "/always-further/nono.git/info/refs"),
1150 Selection::Passthrough
1151 );
1152 assert_eq!(
1154 select(
1155 &candidates,
1156 "POST",
1157 "/always-further/nono.git/git-upload-pack"
1158 ),
1159 Selection::Passthrough
1160 );
1161
1162 let routes_with_catchall = vec![
1164 gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"),
1165 gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"),
1166 gh_route("github_https_all", "GH_TOKEN_A", "/**"),
1167 ];
1168 let store2 = RouteStore::load(&routes_with_catchall).unwrap();
1169 let candidates2 = store2.lookup_all_by_upstream("github.com:443");
1170 assert_eq!(candidates2.len(), 3);
1171
1172 assert_eq!(
1174 select(&candidates2, "GET", "/org-a/repo.git/info/refs"),
1175 Selection::Ambiguous(vec!["github_https_all", "github_https_org_a"])
1176 );
1177 assert_eq!(
1179 select(&candidates2, "GET", "/always-further/nono.git/info/refs"),
1180 Selection::Route("github_https_all")
1181 );
1182 }
1183
1184 #[test]
1188 fn test_route_selection_credential_catchall_not_shadowed() {
1189 let ep_route = RouteConfig {
1191 prefix: "_ep_github.com".to_string(),
1192 upstream: "https://github.com".to_string(),
1193 endpoint_rules: vec![crate::config::EndpointRule {
1194 method: "*".to_string(),
1195 path: "/org/**".to_string(),
1196 }],
1197 ..Default::default()
1198 };
1199 let cred_route = RouteConfig {
1201 prefix: "github_api".to_string(),
1202 upstream: "https://github.com".to_string(),
1203 credential_key: Some("env://GH_TOKEN".to_string()),
1204 credential_format: Some("Bearer {}".to_string()),
1205 env_var: Some("GH_TOKEN".to_string()),
1206 ..Default::default()
1207 };
1208
1209 let store = RouteStore::load(&[ep_route, cred_route]).unwrap();
1210 let candidates = store.lookup_all_by_upstream("github.com:443");
1211 assert_eq!(candidates.len(), 2);
1212
1213 assert_eq!(
1216 select(&candidates, "GET", "/org/repo"),
1217 Selection::Route("github_api"),
1218 "credential catch-all must be selected on the _ep_-authorized path"
1219 );
1220 assert_eq!(
1223 select(&candidates, "GET", "/other/repo"),
1224 Selection::EndpointDenied
1225 );
1226 }
1227
1228 #[test]
1232 fn test_route_selection_dual_credential_catchall_is_ambiguous() {
1233 let cred_a = RouteConfig {
1234 prefix: "github_a".to_string(),
1235 upstream: "https://github.com".to_string(),
1236 credential_key: Some("env://GH_TOKEN_A".to_string()),
1237 credential_format: Some("Bearer {}".to_string()),
1238 env_var: Some("GH_TOKEN_A".to_string()),
1239 ..Default::default()
1240 };
1241 let cred_b = RouteConfig {
1242 prefix: "github_b".to_string(),
1243 upstream: "https://github.com".to_string(),
1244 credential_key: Some("env://GH_TOKEN_B".to_string()),
1245 credential_format: Some("Bearer {}".to_string()),
1246 env_var: Some("GH_TOKEN_B".to_string()),
1247 ..Default::default()
1248 };
1249
1250 let store = RouteStore::load(&[cred_a, cred_b]).unwrap();
1251 let candidates = store.lookup_all_by_upstream("github.com:443");
1252 assert_eq!(candidates.len(), 2);
1253
1254 assert_eq!(
1256 select(&candidates, "GET", "/any/path"),
1257 Selection::Ambiguous(vec!["github_a", "github_b"])
1258 );
1259 }
1260
1261 const TEST_CA_PEM: &str = "\
1265-----BEGIN CERTIFICATE-----
1266MIIBnjCCAUWgAwIBAgIUT0bpOJJvHdOdZt+gW1stR8VBgXowCgYIKoZIzj0EAwIw
1267FzEVMBMGA1UEAwwMbm9uby10ZXN0LWNhMCAXDTI1MDEwMTAwMDAwMFoYDzIxMjQx
1268MjA3MDAwMDAwWjAXMRUwEwYDVQQDDAxub25vLXRlc3QtY2EwWTATBgcqhkjOPQIB
1269BggqhkjOPQMBBwNCAAR8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1270AAAAAAAAAAAAAAAAAAAAo1MwUTAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAA
1271AAAAMB8GA1UdIwQYMBaAFAAAAAAAAAAAAAAAAAAAAAAAAAAAADAPBgNVHRMBAf8E
1272BTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1273AAAAAAAICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1274-----END CERTIFICATE-----";
1275
1276 #[test]
1277 fn test_build_tls_connector_with_valid_ca() {
1278 let dir = tempfile::tempdir().unwrap();
1279 let ca_path = dir.path().join("ca.pem");
1280 std::fs::write(&ca_path, TEST_CA_PEM).unwrap();
1281
1282 let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
1283 match result {
1284 Ok(connector) => {
1285 drop(connector);
1286 }
1287 Err(ProxyError::Config(msg)) => {
1288 assert!(
1289 msg.contains("invalid CA certificate") || msg.contains("CA certificate"),
1290 "unexpected error: {}",
1291 msg
1292 );
1293 }
1294 Err(e) => panic!("unexpected error type: {}", e),
1295 }
1296 }
1297
1298 #[test]
1299 fn test_build_tls_connector_missing_file() {
1300 let result = build_tls_connector_with_ca("/nonexistent/path/ca.pem");
1301 let err = result
1302 .err()
1303 .expect("should fail for missing file")
1304 .to_string();
1305 assert!(
1306 err.contains("CA certificate file not found"),
1307 "unexpected error: {}",
1308 err
1309 );
1310 }
1311
1312 #[test]
1313 fn test_build_tls_connector_empty_pem() {
1314 let dir = tempfile::tempdir().unwrap();
1315 let ca_path = dir.path().join("empty.pem");
1316 std::fs::write(&ca_path, "not a certificate\n").unwrap();
1317
1318 let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
1319 let err = result
1320 .err()
1321 .expect("should fail for invalid PEM")
1322 .to_string();
1323 assert!(
1324 err.contains("no valid PEM certificates"),
1325 "unexpected error: {}",
1326 err
1327 );
1328 }
1329
1330 const TEST_CLIENT_CERT_PEM: &str = "\
1336-----BEGIN CERTIFICATE-----
1337MIIBijCCATGgAwIBAgIUEoEb+0z+4CTRCzN98MqeTEXgdO8wCgYIKoZIzj0EAwIw
1338GzEZMBcGA1UEAwwQbm9uby10ZXN0LWNsaWVudDAeFw0yNjA0MTAwMDIwNTdaFw0z
1339NjA0MDcwMDIwNTdaMBsxGTAXBgNVBAMMEG5vbm8tdGVzdC1jbGllbnQwWTATBgcq
1340hkjOPQIBBggqhkjOPQMBBwNCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1h56ZLEEq
1341HfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQo1MwUTAdBgNVHQ4E
1342FgQUTiHidg8uqgrJ1qlaVvR+XSebAlEwHwYDVR0jBBgwFoAUTiHidg8uqgrJ1qla
1343VvR+XSebAlEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiA9PwBU
1344f832cQkGS9cyYaU7Ij5U8Rcy/g4J7Ckf2nKX3gIgG0aarAFcIzAi5VpxbCwEScnr
1345m0lHTyp6E7ut7llwMBY=
1346-----END CERTIFICATE-----";
1347
1348 const TEST_CLIENT_KEY_PEM: &str = "\
1349-----BEGIN PRIVATE KEY-----
1350MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgskOkyJkTwlMZkm/L
1351eEleLY6bARaHFnqauYJqxNoJWvihRANCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1
1352h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ
1353-----END PRIVATE KEY-----";
1354
1355 #[test]
1356 fn test_build_tls_connector_cert_without_key_errors() {
1357 let dir = tempfile::tempdir().unwrap();
1358 let cert_path = dir.path().join("client.crt");
1359 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1360
1361 let base = build_base_root_store();
1362 let result = build_tls_connector(&base, None, Some(cert_path.to_str().unwrap()), None);
1363 let err = result
1364 .err()
1365 .expect("should fail with half-pair")
1366 .to_string();
1367 assert!(
1368 err.contains("tls_client_cert is set but tls_client_key is missing"),
1369 "unexpected error: {}",
1370 err
1371 );
1372 }
1373
1374 #[test]
1375 fn test_build_tls_connector_key_without_cert_errors() {
1376 let dir = tempfile::tempdir().unwrap();
1377 let key_path = dir.path().join("client.key");
1378 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1379
1380 let base = build_base_root_store();
1381 let result = build_tls_connector(&base, None, None, Some(key_path.to_str().unwrap()));
1382 let err = result
1383 .err()
1384 .expect("should fail with half-pair")
1385 .to_string();
1386 assert!(
1387 err.contains("tls_client_key is set but tls_client_cert is missing"),
1388 "unexpected error: {}",
1389 err
1390 );
1391 }
1392
1393 #[test]
1394 fn test_build_tls_connector_missing_client_cert_file() {
1395 let dir = tempfile::tempdir().unwrap();
1396 let key_path = dir.path().join("client.key");
1397 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1398
1399 let base = build_base_root_store();
1400 let result = build_tls_connector(
1401 &base,
1402 None,
1403 Some("/nonexistent/client.crt"),
1404 Some(key_path.to_str().unwrap()),
1405 );
1406 let err = result.err().expect("should fail").to_string();
1407 assert!(
1408 err.contains("client certificate file not found"),
1409 "unexpected error: {}",
1410 err
1411 );
1412 }
1413
1414 #[test]
1415 fn test_build_tls_connector_missing_client_key_file() {
1416 let dir = tempfile::tempdir().unwrap();
1417 let cert_path = dir.path().join("client.crt");
1418 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1419
1420 let base = build_base_root_store();
1421 let result = build_tls_connector(
1422 &base,
1423 None,
1424 Some(cert_path.to_str().unwrap()),
1425 Some("/nonexistent/client.key"),
1426 );
1427 let err = result.err().expect("should fail").to_string();
1428 assert!(
1429 err.contains("client key file not found"),
1430 "unexpected error: {}",
1431 err
1432 );
1433 }
1434
1435 #[test]
1436 #[cfg(unix)]
1437 fn test_build_tls_connector_permission_denied() {
1438 use std::os::unix::fs::PermissionsExt;
1439 let dir = tempfile::tempdir().unwrap();
1440 let cert_path = dir.path().join("client.crt");
1441 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1442 std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
1444
1445 if std::fs::read(&cert_path).is_ok() {
1447 return;
1448 }
1449
1450 let base = build_base_root_store();
1451 let result = build_tls_connector(
1452 &base,
1453 None,
1454 Some(cert_path.to_str().unwrap()),
1455 Some("/nonexistent/key"),
1456 );
1457 let err = result.err().expect("should fail").to_string();
1458 assert!(
1459 err.contains("permission denied"),
1460 "expected permission denied error, got: {}",
1461 err
1462 );
1463 }
1464
1465 #[test]
1466 fn test_build_tls_connector_empty_client_cert_pem() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let cert_path = dir.path().join("client.crt");
1469 let key_path = dir.path().join("client.key");
1470 std::fs::write(&cert_path, "not a certificate\n").unwrap();
1471 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1472
1473 let base = build_base_root_store();
1474 let result = build_tls_connector(
1475 &base,
1476 None,
1477 Some(cert_path.to_str().unwrap()),
1478 Some(key_path.to_str().unwrap()),
1479 );
1480 let err = result.err().expect("should fail").to_string();
1481 assert!(
1482 err.contains("no valid PEM certificates"),
1483 "unexpected error: {}",
1484 err
1485 );
1486 }
1487
1488 #[test]
1489 fn test_build_tls_connector_empty_client_key_pem() {
1490 let dir = tempfile::tempdir().unwrap();
1492 let cert_path = dir.path().join("client.crt");
1493 let key_path = dir.path().join("client.key");
1494 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1495 std::fs::write(&key_path, "not a key\n").unwrap();
1496
1497 let base = build_base_root_store();
1498 let result = build_tls_connector(
1499 &base,
1500 None,
1501 Some(cert_path.to_str().unwrap()),
1502 Some(key_path.to_str().unwrap()),
1503 );
1504 let err = result
1505 .err()
1506 .expect("should fail with invalid PEM")
1507 .to_string();
1508 assert!(err.contains("client key"), "unexpected error: {}", err);
1509 }
1510
1511 #[test]
1512 fn test_route_store_loads_mtls_route() {
1513 let dir = tempfile::tempdir().unwrap();
1515 let cert_path = dir.path().join("client.crt");
1516 let key_path = dir.path().join("client.key");
1517 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1518 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1519
1520 let routes = vec![RouteConfig {
1521 prefix: "k8s".to_string(),
1522 upstream: "https://192.168.64.1:6443".to_string(),
1523 credential_key: None,
1524 inject_mode: Default::default(),
1525 inject_header: "Authorization".to_string(),
1526 credential_format: Some("Bearer {}".to_string()),
1527 path_pattern: None,
1528 path_replacement: None,
1529 query_param_name: None,
1530 proxy: None,
1531 env_var: None,
1532 endpoint_rules: vec![],
1533 tls_ca: None,
1534 tls_client_cert: Some(cert_path.to_str().unwrap().to_string()),
1535 tls_client_key: Some(key_path.to_str().unwrap().to_string()),
1536 oauth2: None,
1537 aws_auth: None,
1538 }];
1539
1540 let store = RouteStore::load(&routes).expect("should load mTLS route");
1541 let route = store.get("k8s").unwrap();
1542 assert!(
1543 route.tls_connector.is_some(),
1544 "connector must be built when tls_client_cert/key are set"
1545 );
1546 }
1547}