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.credential_key.is_some() {
92 let proxy_mode = route
93 .proxy
94 .as_ref()
95 .and_then(|p| p.inject_mode.clone())
96 .unwrap_or_else(|| route.inject_mode.clone());
97 return Some(match proxy_mode {
98 crate::config::InjectMode::Header | crate::config::InjectMode::BasicAuth => {
99 NetworkAuditAuthMechanism::PhantomHeader
100 }
101 crate::config::InjectMode::UrlPath => NetworkAuditAuthMechanism::PhantomPath,
102 crate::config::InjectMode::QueryParam => NetworkAuditAuthMechanism::PhantomQuery,
103 });
104 }
105
106 None
107}
108
109fn injection_mode_for_route(route: &RouteConfig) -> Option<NetworkAuditInjectionMode> {
110 if route.oauth2.is_some() {
111 return Some(NetworkAuditInjectionMode::OAuth2);
112 }
113
114 if route.credential_key.is_some() {
115 return Some(match route.inject_mode {
116 crate::config::InjectMode::Header => NetworkAuditInjectionMode::Header,
117 crate::config::InjectMode::UrlPath => NetworkAuditInjectionMode::UrlPath,
118 crate::config::InjectMode::QueryParam => NetworkAuditInjectionMode::QueryParam,
119 crate::config::InjectMode::BasicAuth => NetworkAuditInjectionMode::BasicAuth,
120 });
121 }
122
123 None
124}
125
126#[derive(Debug)]
132pub struct RouteStore {
133 routes: HashMap<String, LoadedRoute>,
134}
135
136impl RouteStore {
137 pub fn load(routes: &[RouteConfig]) -> Result<Self> {
143 let mut loaded = HashMap::new();
144
145 let base_root_store = build_base_root_store();
146
147 for route in routes {
148 let normalized_prefix = route.prefix.trim_matches('/').to_string();
149
150 debug!(
151 "Loading route '{}' -> {}",
152 normalized_prefix, route.upstream
153 );
154
155 let endpoint_rules = CompiledEndpointRules::compile(&route.endpoint_rules)
156 .map_err(|e| ProxyError::Config(format!("route '{}': {}", normalized_prefix, e)))?;
157
158 let tls_connector = if route.tls_ca.is_some()
159 || route.tls_client_cert.is_some()
160 || route.tls_client_key.is_some()
161 {
162 debug!(
163 "Building TLS connector for route '{}' (ca={}, client_cert={})",
164 normalized_prefix,
165 route.tls_ca.is_some(),
166 route.tls_client_cert.is_some(),
167 );
168 Some(build_tls_connector(
169 &base_root_store,
170 route.tls_ca.as_deref(),
171 route.tls_client_cert.as_deref(),
172 route.tls_client_key.as_deref(),
173 )?)
174 } else {
175 None
176 };
177
178 let upstream_host_port = extract_host_port(&route.upstream);
179
180 let requires_managed_credential =
187 route.credential_key.is_some() || route.oauth2.is_some();
188 let requires_intercept =
189 requires_managed_credential || !route.endpoint_rules.is_empty();
190 let managed_auth_mechanism = auth_mechanism_for_route(route);
191 let managed_injection_mode = injection_mode_for_route(route);
192
193 loaded.insert(
194 normalized_prefix,
195 LoadedRoute {
196 upstream: route.upstream.clone(),
197 upstream_host_port,
198 endpoint_rules,
199 tls_connector,
200 requires_intercept,
201 requires_managed_credential,
202 managed_auth_mechanism,
203 managed_injection_mode,
204 },
205 );
206 }
207
208 Ok(Self { routes: loaded })
209 }
210
211 #[must_use]
213 pub fn empty() -> Self {
214 Self {
215 routes: HashMap::new(),
216 }
217 }
218
219 #[must_use]
221 pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
222 self.routes.get(prefix)
223 }
224
225 #[must_use]
227 pub fn is_empty(&self) -> bool {
228 self.routes.is_empty()
229 }
230
231 #[must_use]
233 pub fn len(&self) -> usize {
234 self.routes.len()
235 }
236
237 #[must_use]
241 pub fn is_route_upstream(&self, host_port: &str) -> bool {
242 let normalised = host_port.to_lowercase();
243 self.routes.values().any(|route| {
244 route
245 .upstream_host_port
246 .as_ref()
247 .is_some_and(|hp| *hp == normalised)
248 })
249 }
250
251 #[must_use]
256 pub fn lookup_by_upstream(&self, host_port: &str) -> Option<(&str, &LoadedRoute)> {
257 let normalised = host_port.to_lowercase();
258 self.routes.iter().find_map(|(prefix, route)| {
259 route
260 .upstream_host_port
261 .as_ref()
262 .filter(|hp| **hp == normalised)
263 .map(|_| (prefix.as_str(), route))
264 })
265 }
266
267 #[must_use]
270 pub fn lookup_all_by_upstream(&self, host_port: &str) -> Vec<(&str, &LoadedRoute)> {
271 let normalised = host_port.to_lowercase();
272 let mut matches: Vec<_> = self
273 .routes
274 .iter()
275 .filter(|(_, route)| {
276 route
277 .upstream_host_port
278 .as_ref()
279 .is_some_and(|hp| *hp == normalised)
280 })
281 .map(|(prefix, route)| (prefix.as_str(), route))
282 .collect();
283 matches.sort_by_key(|(prefix, _)| *prefix);
284 matches
285 }
286
287 #[must_use]
289 pub fn has_intercept_route(&self, host_port: &str) -> bool {
290 let normalised = host_port.to_lowercase();
291 self.routes.values().any(|route| {
292 route
293 .upstream_host_port
294 .as_ref()
295 .is_some_and(|hp| *hp == normalised)
296 && route.requires_intercept
297 })
298 }
299
300 #[must_use]
302 pub fn route_upstream_hosts(&self) -> std::collections::HashSet<String> {
303 self.routes
304 .values()
305 .filter_map(|route| route.upstream_host_port.clone())
306 .collect()
307 }
308}
309
310impl LoadedRoute {
311 #[must_use]
314 pub fn missing_managed_credential(
315 &self,
316 has_static_credential: bool,
317 has_oauth2: bool,
318 ) -> bool {
319 self.requires_managed_credential && !has_static_credential && !has_oauth2
320 }
321}
322
323fn extract_host_port(url: &str) -> Option<String> {
328 let parsed = url::Url::parse(url).ok()?;
329 let host = parsed.host_str()?;
330 let default_port = match parsed.scheme() {
331 "https" => 443,
332 "http" => 80,
333 _ => return None,
334 };
335 let port = parsed.port().unwrap_or(default_port);
336 Some(format!("{}:{}", host.to_lowercase(), port))
337}
338
339fn read_pem_file(path: &std::path::Path, label: &str) -> Result<Zeroizing<Vec<u8>>> {
346 std::fs::read(path)
347 .map(Zeroizing::new)
348 .map_err(|e| match e.kind() {
349 std::io::ErrorKind::NotFound => {
350 ProxyError::Config(format!("{} file not found: '{}'", label, path.display()))
351 }
352 std::io::ErrorKind::PermissionDenied => ProxyError::Config(format!(
353 "{} permission denied: '{}' (check that nono can read this file)",
354 label,
355 path.display()
356 )),
357 _ => ProxyError::Config(format!(
358 "failed to read {} '{}': {}",
359 label,
360 path.display(),
361 e
362 )),
363 })
364}
365
366fn build_base_root_store() -> rustls::RootCertStore {
370 let mut store = rustls::RootCertStore::empty();
371 store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
372 let native = rustls_native_certs::load_native_certs();
373 for cert in native.certs {
374 if let Err(e) = store.add(cert) {
375 debug!("skipping unparseable native cert: {e}");
376 }
377 }
378 store
379}
380
381fn build_tls_connector(
384 base_root_store: &rustls::RootCertStore,
385 ca_path: Option<&str>,
386 client_cert_path: Option<&str>,
387 client_key_path: Option<&str>,
388) -> Result<tokio_rustls::TlsConnector> {
389 let mut root_store = base_root_store.clone();
390
391 if let Some(ca_path) = ca_path {
393 let ca_path = std::path::Path::new(ca_path);
394 let ca_pem = read_pem_file(ca_path, "CA certificate")?;
395
396 let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref())
397 .collect::<std::result::Result<Vec<_>, _>>()
398 .map_err(|e| {
399 ProxyError::Config(format!(
400 "failed to parse CA certificate '{}': {}",
401 ca_path.display(),
402 e
403 ))
404 })?;
405
406 if certs.is_empty() {
407 return Err(ProxyError::Config(format!(
408 "CA certificate file '{}' contains no valid PEM certificates",
409 ca_path.display()
410 )));
411 }
412
413 for cert in certs {
414 root_store.add(cert).map_err(|e| {
415 ProxyError::Config(format!(
416 "invalid CA certificate in '{}': {}",
417 ca_path.display(),
418 e
419 ))
420 })?;
421 }
422 }
423
424 let builder = rustls::ClientConfig::builder_with_provider(Arc::new(
425 rustls::crypto::ring::default_provider(),
426 ))
427 .with_safe_default_protocol_versions()
428 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
429 .with_root_certificates(root_store);
430
431 let tls_config = match (client_cert_path, client_key_path) {
433 (Some(cert_path), Some(key_path)) => {
434 let cert_path = std::path::Path::new(cert_path);
435 let key_path = std::path::Path::new(key_path);
436
437 let cert_pem = read_pem_file(cert_path, "client certificate")?;
438 let key_pem = read_pem_file(key_path, "client key")?;
439
440 let cert_chain: Vec<rustls::pki_types::CertificateDer> =
441 rustls::pki_types::CertificateDer::pem_slice_iter(cert_pem.as_ref())
442 .collect::<std::result::Result<Vec<_>, _>>()
443 .map_err(|e| {
444 ProxyError::Config(format!(
445 "failed to parse client certificate '{}': {}",
446 cert_path.display(),
447 e
448 ))
449 })?;
450
451 if cert_chain.is_empty() {
452 return Err(ProxyError::Config(format!(
453 "client certificate file '{}' contains no valid PEM certificates",
454 cert_path.display()
455 )));
456 }
457
458 let private_key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_ref())
459 .map_err(|e| match e {
460 rustls::pki_types::pem::Error::NoItemsFound => ProxyError::Config(format!(
461 "client key file '{}' contains no valid PEM private key",
462 key_path.display()
463 )),
464 _ => ProxyError::Config(format!(
465 "failed to parse client key '{}': {}",
466 key_path.display(),
467 e
468 )),
469 })?;
470
471 builder
472 .with_client_auth_cert(cert_chain, private_key)
473 .map_err(|e| {
474 ProxyError::Config(format!(
475 "invalid client certificate/key pair ('{}', '{}'): {}",
476 cert_path.display(),
477 key_path.display(),
478 e
479 ))
480 })?
481 }
482 (Some(_), None) => {
483 return Err(ProxyError::Config(
484 "tls_client_cert is set but tls_client_key is missing".to_string(),
485 ));
486 }
487 (None, Some(_)) => {
488 return Err(ProxyError::Config(
489 "tls_client_key is set but tls_client_cert is missing".to_string(),
490 ));
491 }
492 (None, None) => builder.with_no_client_auth(),
493 };
494
495 let mut tls_config = tls_config;
504 if client_cert_path.is_some() {
505 tls_config.resumption = rustls::client::Resumption::disabled();
506 }
507
508 Ok(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
509}
510
511#[cfg(test)]
513fn build_tls_connector_with_ca(ca_path: &str) -> Result<tokio_rustls::TlsConnector> {
514 let base = build_base_root_store();
515 build_tls_connector(&base, Some(ca_path), None, None)
516}
517
518#[cfg(test)]
519#[allow(clippy::unwrap_used)]
520mod tests {
521 use super::*;
522 use crate::config::EndpointRule;
523
524 #[test]
525 fn test_empty_route_store() {
526 let store = RouteStore::empty();
527 assert!(store.is_empty());
528 assert_eq!(store.len(), 0);
529 assert!(store.get("openai").is_none());
530 }
531
532 #[test]
533 fn test_load_routes_without_credentials() {
534 let routes = vec![RouteConfig {
536 prefix: "/openai".to_string(),
537 upstream: "https://api.openai.com".to_string(),
538 credential_key: None,
539 inject_mode: Default::default(),
540 inject_header: "Authorization".to_string(),
541 credential_format: Some("Bearer {}".to_string()),
542 path_pattern: None,
543 path_replacement: None,
544 query_param_name: None,
545 proxy: None,
546 env_var: None,
547 endpoint_rules: vec![
548 EndpointRule {
549 method: "POST".to_string(),
550 path: "/v1/chat/completions".to_string(),
551 },
552 EndpointRule {
553 method: "GET".to_string(),
554 path: "/v1/models".to_string(),
555 },
556 ],
557 tls_ca: None,
558 tls_client_cert: None,
559 tls_client_key: None,
560 oauth2: None,
561 }];
562
563 let store = RouteStore::load(&routes).unwrap();
564 assert_eq!(store.len(), 1);
565
566 let route = store.get("openai").unwrap();
567 assert_eq!(route.upstream, "https://api.openai.com");
568 assert!(
569 route
570 .endpoint_rules
571 .is_allowed("POST", "/v1/chat/completions")
572 );
573 assert!(route.endpoint_rules.is_allowed("GET", "/v1/models"));
574 assert!(
575 !route
576 .endpoint_rules
577 .is_allowed("DELETE", "/v1/files/file-123")
578 );
579 }
580
581 #[test]
582 fn test_load_routes_normalises_prefix() {
583 let routes = vec![RouteConfig {
584 prefix: "/anthropic/".to_string(),
585 upstream: "https://api.anthropic.com".to_string(),
586 credential_key: None,
587 inject_mode: Default::default(),
588 inject_header: "Authorization".to_string(),
589 credential_format: Some("Bearer {}".to_string()),
590 path_pattern: None,
591 path_replacement: None,
592 query_param_name: None,
593 proxy: None,
594 env_var: None,
595 endpoint_rules: vec![],
596 tls_ca: None,
597 tls_client_cert: None,
598 tls_client_key: None,
599 oauth2: None,
600 }];
601
602 let store = RouteStore::load(&routes).unwrap();
603 assert!(store.get("anthropic").is_some());
604 assert!(store.get("/anthropic/").is_none());
605 }
606
607 #[test]
608 fn test_is_route_upstream() {
609 let routes = vec![RouteConfig {
610 prefix: "openai".to_string(),
611 upstream: "https://api.openai.com".to_string(),
612 credential_key: None,
613 inject_mode: Default::default(),
614 inject_header: "Authorization".to_string(),
615 credential_format: Some("Bearer {}".to_string()),
616 path_pattern: None,
617 path_replacement: None,
618 query_param_name: None,
619 proxy: None,
620 env_var: None,
621 endpoint_rules: vec![],
622 tls_ca: None,
623 tls_client_cert: None,
624 tls_client_key: None,
625 oauth2: None,
626 }];
627
628 let store = RouteStore::load(&routes).unwrap();
629 assert!(store.is_route_upstream("api.openai.com:443"));
630 assert!(!store.is_route_upstream("github.com:443"));
631 }
632
633 #[test]
634 fn test_route_upstream_hosts() {
635 let routes = vec![
636 RouteConfig {
637 prefix: "openai".to_string(),
638 upstream: "https://api.openai.com".to_string(),
639 credential_key: None,
640 inject_mode: Default::default(),
641 inject_header: "Authorization".to_string(),
642 credential_format: Some("Bearer {}".to_string()),
643 path_pattern: None,
644 path_replacement: None,
645 query_param_name: None,
646 proxy: None,
647 env_var: None,
648 endpoint_rules: vec![],
649 tls_ca: None,
650 tls_client_cert: None,
651 tls_client_key: None,
652 oauth2: None,
653 },
654 RouteConfig {
655 prefix: "anthropic".to_string(),
656 upstream: "https://api.anthropic.com".to_string(),
657 credential_key: None,
658 inject_mode: Default::default(),
659 inject_header: "Authorization".to_string(),
660 credential_format: Some("Bearer {}".to_string()),
661 path_pattern: None,
662 path_replacement: None,
663 query_param_name: None,
664 proxy: None,
665 env_var: None,
666 endpoint_rules: vec![],
667 tls_ca: None,
668 tls_client_cert: None,
669 tls_client_key: None,
670 oauth2: None,
671 },
672 ];
673
674 let store = RouteStore::load(&routes).unwrap();
675 let hosts = store.route_upstream_hosts();
676 assert!(hosts.contains("api.openai.com:443"));
677 assert!(hosts.contains("api.anthropic.com:443"));
678 assert_eq!(hosts.len(), 2);
679 }
680
681 #[test]
682 fn test_extract_host_port_https() {
683 assert_eq!(
684 extract_host_port("https://api.openai.com"),
685 Some("api.openai.com:443".to_string())
686 );
687 }
688
689 #[test]
690 fn test_extract_host_port_with_port() {
691 assert_eq!(
692 extract_host_port("https://api.example.com:8443"),
693 Some("api.example.com:8443".to_string())
694 );
695 }
696
697 #[test]
698 fn test_extract_host_port_http() {
699 assert_eq!(
700 extract_host_port("http://internal-service"),
701 Some("internal-service:80".to_string())
702 );
703 }
704
705 #[test]
706 fn test_extract_host_port_normalises_case() {
707 assert_eq!(
708 extract_host_port("https://API.Example.COM"),
709 Some("api.example.com:443".to_string())
710 );
711 }
712
713 #[test]
714 fn test_loaded_route_debug() {
715 let route = LoadedRoute {
716 upstream: "https://api.openai.com".to_string(),
717 upstream_host_port: Some("api.openai.com:443".to_string()),
718 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
719 tls_connector: None,
720 requires_intercept: false,
721 requires_managed_credential: false,
722 managed_auth_mechanism: None,
723 managed_injection_mode: None,
724 };
725 let debug_output = format!("{:?}", route);
726 assert!(debug_output.contains("api.openai.com"));
727 assert!(debug_output.contains("has_custom_tls_ca"));
728 assert!(debug_output.contains("requires_intercept"));
729 assert!(debug_output.contains("requires_managed_credential"));
730 assert!(debug_output.contains("managed_auth_mechanism"));
731 assert!(debug_output.contains("managed_injection_mode"));
732 }
733
734 #[test]
735 fn test_requires_intercept_credential_only() {
736 let routes = vec![RouteConfig {
737 prefix: "openai".to_string(),
738 upstream: "https://api.openai.com".to_string(),
739 credential_key: Some("openai_api_key".to_string()),
740 inject_mode: Default::default(),
741 inject_header: "Authorization".to_string(),
742 credential_format: Some("Bearer {}".to_string()),
743 path_pattern: None,
744 path_replacement: None,
745 query_param_name: None,
746 proxy: None,
747 env_var: None,
748 endpoint_rules: vec![],
749 tls_ca: None,
750 tls_client_cert: None,
751 tls_client_key: None,
752 oauth2: None,
753 }];
754 let store = RouteStore::load(&routes).unwrap();
755 let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
756 assert!(store.has_intercept_route("api.openai.com:443"));
757 assert!(hit.1.requires_managed_credential);
758 assert_eq!(
759 hit.1.managed_auth_mechanism,
760 Some(NetworkAuditAuthMechanism::PhantomHeader)
761 );
762 assert_eq!(
763 hit.1.managed_injection_mode,
764 Some(NetworkAuditInjectionMode::Header)
765 );
766 assert!(!store.has_intercept_route("api.example.com:443"));
767 }
768
769 #[test]
770 fn test_requires_intercept_endpoint_rules_only() {
771 let routes = vec![RouteConfig {
774 prefix: "internal".to_string(),
775 upstream: "https://internal.example.com".to_string(),
776 credential_key: None,
777 inject_mode: Default::default(),
778 inject_header: "Authorization".to_string(),
779 credential_format: Some("Bearer {}".to_string()),
780 path_pattern: None,
781 path_replacement: None,
782 query_param_name: None,
783 proxy: None,
784 env_var: None,
785 endpoint_rules: vec![EndpointRule {
786 method: "GET".to_string(),
787 path: "/v1/items".to_string(),
788 }],
789 tls_ca: None,
790 tls_client_cert: None,
791 tls_client_key: None,
792 oauth2: None,
793 }];
794 let store = RouteStore::load(&routes).unwrap();
795 let hit = store
796 .lookup_by_upstream("internal.example.com:443")
797 .unwrap();
798 assert!(store.has_intercept_route("internal.example.com:443"));
799 assert!(!hit.1.requires_managed_credential);
800 }
801
802 #[test]
803 fn test_requires_intercept_declarative_only() {
804 let routes = vec![RouteConfig {
807 prefix: "alias".to_string(),
808 upstream: "https://aliased.example.com".to_string(),
809 credential_key: None,
810 inject_mode: Default::default(),
811 inject_header: "Authorization".to_string(),
812 credential_format: Some("Bearer {}".to_string()),
813 path_pattern: None,
814 path_replacement: None,
815 query_param_name: None,
816 proxy: None,
817 env_var: None,
818 endpoint_rules: vec![],
819 tls_ca: None,
820 tls_client_cert: None,
821 tls_client_key: None,
822 oauth2: None,
823 }];
824 let store = RouteStore::load(&routes).unwrap();
825 assert!(store.is_route_upstream("aliased.example.com:443"));
826 assert!(!store.has_intercept_route("aliased.example.com:443"));
827 }
828
829 #[test]
830 fn test_missing_managed_credential_policy() {
831 let managed = LoadedRoute {
832 upstream: "https://api.openai.com".to_string(),
833 upstream_host_port: Some("api.openai.com:443".to_string()),
834 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
835 tls_connector: None,
836 requires_intercept: true,
837 requires_managed_credential: true,
838 managed_auth_mechanism: Some(NetworkAuditAuthMechanism::PhantomHeader),
839 managed_injection_mode: Some(NetworkAuditInjectionMode::Header),
840 };
841 assert!(managed.missing_managed_credential(false, false));
842 assert!(!managed.missing_managed_credential(true, false));
843 assert!(!managed.missing_managed_credential(false, true));
844
845 let l7_only = LoadedRoute {
846 upstream: "https://internal.example.com".to_string(),
847 upstream_host_port: Some("internal.example.com:443".to_string()),
848 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
849 tls_connector: None,
850 requires_intercept: true,
851 requires_managed_credential: false,
852 managed_auth_mechanism: None,
853 managed_injection_mode: None,
854 };
855 assert!(!l7_only.missing_managed_credential(false, false));
856 }
857
858 #[test]
859 fn test_lookup_by_upstream_returns_prefix() {
860 let routes = vec![RouteConfig {
861 prefix: "openai".to_string(),
862 upstream: "https://api.openai.com".to_string(),
863 credential_key: Some("openai_api_key".to_string()),
864 inject_mode: Default::default(),
865 inject_header: "Authorization".to_string(),
866 credential_format: Some("Bearer {}".to_string()),
867 path_pattern: None,
868 path_replacement: None,
869 query_param_name: None,
870 proxy: None,
871 env_var: None,
872 endpoint_rules: vec![],
873 tls_ca: None,
874 tls_client_cert: None,
875 tls_client_key: None,
876 oauth2: None,
877 }];
878 let store = RouteStore::load(&routes).unwrap();
879 let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
880 assert_eq!(hit.0, "openai");
881 assert!(hit.1.requires_intercept);
882 assert!(hit.1.requires_managed_credential);
883 assert!(store.lookup_by_upstream("api.example.com:443").is_none());
884 }
885
886 #[test]
887 fn test_lookup_all_by_upstream_returns_multiple_routes() {
888 let routes = vec![
889 RouteConfig {
890 prefix: "github_org_a".to_string(),
891 upstream: "https://github.com".to_string(),
892 credential_key: Some("env://GH_TOKEN_A".to_string()),
893 inject_mode: Default::default(),
894 inject_header: "Authorization".to_string(),
895 credential_format: Some("Bearer {}".to_string()),
896 path_pattern: None,
897 path_replacement: None,
898 query_param_name: None,
899 proxy: None,
900 env_var: Some("GH_TOKEN_A".to_string()),
901 endpoint_rules: vec![crate::config::EndpointRule {
902 method: "*".to_string(),
903 path: "/org-a/**".to_string(),
904 }],
905 tls_ca: None,
906 tls_client_cert: None,
907 tls_client_key: None,
908 oauth2: None,
909 },
910 RouteConfig {
911 prefix: "github_org_b".to_string(),
912 upstream: "https://github.com".to_string(),
913 credential_key: Some("env://GH_TOKEN_B".to_string()),
914 inject_mode: Default::default(),
915 inject_header: "Authorization".to_string(),
916 credential_format: Some("Bearer {}".to_string()),
917 path_pattern: None,
918 path_replacement: None,
919 query_param_name: None,
920 proxy: None,
921 env_var: Some("GH_TOKEN_B".to_string()),
922 endpoint_rules: vec![crate::config::EndpointRule {
923 method: "*".to_string(),
924 path: "/org-b/**".to_string(),
925 }],
926 tls_ca: None,
927 tls_client_cert: None,
928 tls_client_key: None,
929 oauth2: None,
930 },
931 ];
932 let store = RouteStore::load(&routes).unwrap();
933
934 let all = store.lookup_all_by_upstream("github.com:443");
935 assert_eq!(all.len(), 2, "both routes share the same upstream");
936
937 let prefixes: Vec<&str> = all.iter().map(|(p, _)| *p).collect();
938 assert!(prefixes.contains(&"github_org_a"));
939 assert!(prefixes.contains(&"github_org_b"));
940
941 let (_, route_a) = all.iter().find(|(p, _)| *p == "github_org_a").unwrap();
942 assert!(route_a.endpoint_rules.is_allowed("GET", "/org-a/repo"));
943 assert!(!route_a.endpoint_rules.is_allowed("GET", "/org-b/repo"));
944
945 let (_, route_b) = all.iter().find(|(p, _)| *p == "github_org_b").unwrap();
946 assert!(route_b.endpoint_rules.is_allowed("GET", "/org-b/repo"));
947 assert!(!route_b.endpoint_rules.is_allowed("GET", "/org-a/repo"));
948
949 assert!(store.has_intercept_route("github.com:443"));
950 assert!(store.is_route_upstream("github.com:443"));
951 assert!(store.lookup_all_by_upstream("other.com:443").is_empty());
952 }
953
954 #[test]
960 fn test_route_selection_multi_org_profile() {
961 fn gh_route(prefix: &str, env: &str, path: &str) -> RouteConfig {
963 RouteConfig {
964 prefix: prefix.to_string(),
965 upstream: "https://github.com".to_string(),
966 credential_key: Some(format!("env://{env}")),
967 inject_mode: Default::default(),
968 inject_header: "Authorization".to_string(),
969 credential_format: Some("Bearer {}".to_string()),
970 path_pattern: None,
971 path_replacement: None,
972 query_param_name: None,
973 proxy: None,
974 env_var: Some(env.to_string()),
975 endpoint_rules: vec![crate::config::EndpointRule {
976 method: "*".to_string(),
977 path: path.to_string(),
978 }],
979 tls_ca: None,
980 tls_client_cert: None,
981 tls_client_key: None,
982 oauth2: None,
983 }
984 }
985
986 #[derive(Debug, PartialEq)]
987 enum Selection<'a> {
988 Route(&'a str),
989 Passthrough,
990 Ambiguous(Vec<&'a str>),
991 }
992
993 fn select<'a>(
994 candidates: &'a [(&'a str, &'a LoadedRoute)],
995 method: &str,
996 path: &str,
997 ) -> Selection<'a> {
998 let mut matches: Vec<&str> = Vec::new();
999 let mut catch_all: Option<&str> = None;
1000 for (prefix, route) in candidates {
1001 if route.endpoint_rules.is_empty() {
1002 if catch_all.is_none() {
1003 catch_all = Some(*prefix);
1004 }
1005 } else if route.endpoint_rules.is_allowed(method, path) {
1006 matches.push(prefix);
1007 }
1008 }
1009 if matches.len() > 1 {
1010 Selection::Ambiguous(matches)
1011 } else if let Some(svc) = matches.into_iter().next().or(catch_all) {
1012 Selection::Route(svc)
1013 } else {
1014 Selection::Passthrough
1015 }
1016 }
1017
1018 let routes = vec![
1020 gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"),
1021 gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"),
1022 ];
1023 let store = RouteStore::load(&routes).unwrap();
1024 let candidates = store.lookup_all_by_upstream("github.com:443");
1025 assert_eq!(candidates.len(), 2);
1026
1027 assert_eq!(
1029 select(&candidates, "GET", "/org-a/repo.git/info/refs"),
1030 Selection::Route("github_https_org_a")
1031 );
1032 assert_eq!(
1034 select(&candidates, "GET", "/org-b/repo.git/info/refs"),
1035 Selection::Route("github_https_org_b")
1036 );
1037 assert_eq!(
1039 select(&candidates, "GET", "/always-further/nono.git/info/refs"),
1040 Selection::Passthrough
1041 );
1042 assert_eq!(
1044 select(
1045 &candidates,
1046 "POST",
1047 "/always-further/nono.git/git-upload-pack"
1048 ),
1049 Selection::Passthrough
1050 );
1051
1052 let routes_with_catchall = vec![
1054 gh_route("github_https_org_a", "GH_TOKEN_A", "/org-a/**"),
1055 gh_route("github_https_org_b", "GH_TOKEN_B", "/org-b/**"),
1056 gh_route("github_https_all", "GH_TOKEN_A", "/**"),
1057 ];
1058 let store2 = RouteStore::load(&routes_with_catchall).unwrap();
1059 let candidates2 = store2.lookup_all_by_upstream("github.com:443");
1060 assert_eq!(candidates2.len(), 3);
1061
1062 assert_eq!(
1064 select(&candidates2, "GET", "/org-a/repo.git/info/refs"),
1065 Selection::Ambiguous(vec!["github_https_all", "github_https_org_a"])
1066 );
1067 assert_eq!(
1069 select(&candidates2, "GET", "/always-further/nono.git/info/refs"),
1070 Selection::Route("github_https_all")
1071 );
1072 }
1073
1074 const TEST_CA_PEM: &str = "\
1078-----BEGIN CERTIFICATE-----
1079MIIBnjCCAUWgAwIBAgIUT0bpOJJvHdOdZt+gW1stR8VBgXowCgYIKoZIzj0EAwIw
1080FzEVMBMGA1UEAwwMbm9uby10ZXN0LWNhMCAXDTI1MDEwMTAwMDAwMFoYDzIxMjQx
1081MjA3MDAwMDAwWjAXMRUwEwYDVQQDDAxub25vLXRlc3QtY2EwWTATBgcqhkjOPQIB
1082BggqhkjOPQMBBwNCAAR8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1083AAAAAAAAAAAAAAAAAAAAo1MwUTAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAA
1084AAAAMB8GA1UdIwQYMBaAFAAAAAAAAAAAAAAAAAAAAAAAAAAAADAPBgNVHRMBAf8E
1085BTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1086AAAAAAAICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
1087-----END CERTIFICATE-----";
1088
1089 #[test]
1090 fn test_build_tls_connector_with_valid_ca() {
1091 let dir = tempfile::tempdir().unwrap();
1092 let ca_path = dir.path().join("ca.pem");
1093 std::fs::write(&ca_path, TEST_CA_PEM).unwrap();
1094
1095 let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
1096 match result {
1097 Ok(connector) => {
1098 drop(connector);
1099 }
1100 Err(ProxyError::Config(msg)) => {
1101 assert!(
1102 msg.contains("invalid CA certificate") || msg.contains("CA certificate"),
1103 "unexpected error: {}",
1104 msg
1105 );
1106 }
1107 Err(e) => panic!("unexpected error type: {}", e),
1108 }
1109 }
1110
1111 #[test]
1112 fn test_build_tls_connector_missing_file() {
1113 let result = build_tls_connector_with_ca("/nonexistent/path/ca.pem");
1114 let err = result
1115 .err()
1116 .expect("should fail for missing file")
1117 .to_string();
1118 assert!(
1119 err.contains("CA certificate file not found"),
1120 "unexpected error: {}",
1121 err
1122 );
1123 }
1124
1125 #[test]
1126 fn test_build_tls_connector_empty_pem() {
1127 let dir = tempfile::tempdir().unwrap();
1128 let ca_path = dir.path().join("empty.pem");
1129 std::fs::write(&ca_path, "not a certificate\n").unwrap();
1130
1131 let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
1132 let err = result
1133 .err()
1134 .expect("should fail for invalid PEM")
1135 .to_string();
1136 assert!(
1137 err.contains("no valid PEM certificates"),
1138 "unexpected error: {}",
1139 err
1140 );
1141 }
1142
1143 const TEST_CLIENT_CERT_PEM: &str = "\
1149-----BEGIN CERTIFICATE-----
1150MIIBijCCATGgAwIBAgIUEoEb+0z+4CTRCzN98MqeTEXgdO8wCgYIKoZIzj0EAwIw
1151GzEZMBcGA1UEAwwQbm9uby10ZXN0LWNsaWVudDAeFw0yNjA0MTAwMDIwNTdaFw0z
1152NjA0MDcwMDIwNTdaMBsxGTAXBgNVBAMMEG5vbm8tdGVzdC1jbGllbnQwWTATBgcq
1153hkjOPQIBBggqhkjOPQMBBwNCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1h56ZLEEq
1154HfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQo1MwUTAdBgNVHQ4E
1155FgQUTiHidg8uqgrJ1qlaVvR+XSebAlEwHwYDVR0jBBgwFoAUTiHidg8uqgrJ1qla
1156VvR+XSebAlEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiA9PwBU
1157f832cQkGS9cyYaU7Ij5U8Rcy/g4J7Ckf2nKX3gIgG0aarAFcIzAi5VpxbCwEScnr
1158m0lHTyp6E7ut7llwMBY=
1159-----END CERTIFICATE-----";
1160
1161 const TEST_CLIENT_KEY_PEM: &str = "\
1162-----BEGIN PRIVATE KEY-----
1163MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgskOkyJkTwlMZkm/L
1164eEleLY6bARaHFnqauYJqxNoJWvihRANCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1
1165h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ
1166-----END PRIVATE KEY-----";
1167
1168 #[test]
1169 fn test_build_tls_connector_cert_without_key_errors() {
1170 let dir = tempfile::tempdir().unwrap();
1171 let cert_path = dir.path().join("client.crt");
1172 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1173
1174 let base = build_base_root_store();
1175 let result = build_tls_connector(&base, None, Some(cert_path.to_str().unwrap()), None);
1176 let err = result
1177 .err()
1178 .expect("should fail with half-pair")
1179 .to_string();
1180 assert!(
1181 err.contains("tls_client_cert is set but tls_client_key is missing"),
1182 "unexpected error: {}",
1183 err
1184 );
1185 }
1186
1187 #[test]
1188 fn test_build_tls_connector_key_without_cert_errors() {
1189 let dir = tempfile::tempdir().unwrap();
1190 let key_path = dir.path().join("client.key");
1191 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1192
1193 let base = build_base_root_store();
1194 let result = build_tls_connector(&base, None, None, Some(key_path.to_str().unwrap()));
1195 let err = result
1196 .err()
1197 .expect("should fail with half-pair")
1198 .to_string();
1199 assert!(
1200 err.contains("tls_client_key is set but tls_client_cert is missing"),
1201 "unexpected error: {}",
1202 err
1203 );
1204 }
1205
1206 #[test]
1207 fn test_build_tls_connector_missing_client_cert_file() {
1208 let dir = tempfile::tempdir().unwrap();
1209 let key_path = dir.path().join("client.key");
1210 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1211
1212 let base = build_base_root_store();
1213 let result = build_tls_connector(
1214 &base,
1215 None,
1216 Some("/nonexistent/client.crt"),
1217 Some(key_path.to_str().unwrap()),
1218 );
1219 let err = result.err().expect("should fail").to_string();
1220 assert!(
1221 err.contains("client certificate file not found"),
1222 "unexpected error: {}",
1223 err
1224 );
1225 }
1226
1227 #[test]
1228 fn test_build_tls_connector_missing_client_key_file() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let cert_path = dir.path().join("client.crt");
1231 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1232
1233 let base = build_base_root_store();
1234 let result = build_tls_connector(
1235 &base,
1236 None,
1237 Some(cert_path.to_str().unwrap()),
1238 Some("/nonexistent/client.key"),
1239 );
1240 let err = result.err().expect("should fail").to_string();
1241 assert!(
1242 err.contains("client key file not found"),
1243 "unexpected error: {}",
1244 err
1245 );
1246 }
1247
1248 #[test]
1249 #[cfg(unix)]
1250 fn test_build_tls_connector_permission_denied() {
1251 use std::os::unix::fs::PermissionsExt;
1252 let dir = tempfile::tempdir().unwrap();
1253 let cert_path = dir.path().join("client.crt");
1254 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1255 std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
1257
1258 if std::fs::read(&cert_path).is_ok() {
1260 return;
1261 }
1262
1263 let base = build_base_root_store();
1264 let result = build_tls_connector(
1265 &base,
1266 None,
1267 Some(cert_path.to_str().unwrap()),
1268 Some("/nonexistent/key"),
1269 );
1270 let err = result.err().expect("should fail").to_string();
1271 assert!(
1272 err.contains("permission denied"),
1273 "expected permission denied error, got: {}",
1274 err
1275 );
1276 }
1277
1278 #[test]
1279 fn test_build_tls_connector_empty_client_cert_pem() {
1280 let dir = tempfile::tempdir().unwrap();
1281 let cert_path = dir.path().join("client.crt");
1282 let key_path = dir.path().join("client.key");
1283 std::fs::write(&cert_path, "not a certificate\n").unwrap();
1284 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1285
1286 let base = build_base_root_store();
1287 let result = build_tls_connector(
1288 &base,
1289 None,
1290 Some(cert_path.to_str().unwrap()),
1291 Some(key_path.to_str().unwrap()),
1292 );
1293 let err = result.err().expect("should fail").to_string();
1294 assert!(
1295 err.contains("no valid PEM certificates"),
1296 "unexpected error: {}",
1297 err
1298 );
1299 }
1300
1301 #[test]
1302 fn test_build_tls_connector_empty_client_key_pem() {
1303 let dir = tempfile::tempdir().unwrap();
1305 let cert_path = dir.path().join("client.crt");
1306 let key_path = dir.path().join("client.key");
1307 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1308 std::fs::write(&key_path, "not a key\n").unwrap();
1309
1310 let base = build_base_root_store();
1311 let result = build_tls_connector(
1312 &base,
1313 None,
1314 Some(cert_path.to_str().unwrap()),
1315 Some(key_path.to_str().unwrap()),
1316 );
1317 let err = result
1318 .err()
1319 .expect("should fail with invalid PEM")
1320 .to_string();
1321 assert!(err.contains("client key"), "unexpected error: {}", err);
1322 }
1323
1324 #[test]
1325 fn test_route_store_loads_mtls_route() {
1326 let dir = tempfile::tempdir().unwrap();
1328 let cert_path = dir.path().join("client.crt");
1329 let key_path = dir.path().join("client.key");
1330 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1331 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1332
1333 let routes = vec![RouteConfig {
1334 prefix: "k8s".to_string(),
1335 upstream: "https://192.168.64.1:6443".to_string(),
1336 credential_key: None,
1337 inject_mode: Default::default(),
1338 inject_header: "Authorization".to_string(),
1339 credential_format: Some("Bearer {}".to_string()),
1340 path_pattern: None,
1341 path_replacement: None,
1342 query_param_name: None,
1343 proxy: None,
1344 env_var: None,
1345 endpoint_rules: vec![],
1346 tls_ca: None,
1347 tls_client_cert: Some(cert_path.to_str().unwrap().to_string()),
1348 tls_client_key: Some(key_path.to_str().unwrap().to_string()),
1349 oauth2: None,
1350 }];
1351
1352 let store = RouteStore::load(&routes).expect("should load mTLS route");
1353 let route = store.get("k8s").unwrap();
1354 assert!(
1355 route.tls_connector.is_some(),
1356 "connector must be built when tls_client_cert/key are set"
1357 );
1358 }
1359}