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 for route in routes {
146 let normalized_prefix = route.prefix.trim_matches('/').to_string();
147
148 debug!(
149 "Loading route '{}' -> {}",
150 normalized_prefix, route.upstream
151 );
152
153 let endpoint_rules = CompiledEndpointRules::compile(&route.endpoint_rules)
154 .map_err(|e| ProxyError::Config(format!("route '{}': {}", normalized_prefix, e)))?;
155
156 let tls_connector = if route.tls_ca.is_some()
157 || route.tls_client_cert.is_some()
158 || route.tls_client_key.is_some()
159 {
160 debug!(
161 "Building TLS connector for route '{}' (ca={}, client_cert={})",
162 normalized_prefix,
163 route.tls_ca.is_some(),
164 route.tls_client_cert.is_some(),
165 );
166 Some(build_tls_connector(
167 route.tls_ca.as_deref(),
168 route.tls_client_cert.as_deref(),
169 route.tls_client_key.as_deref(),
170 )?)
171 } else {
172 None
173 };
174
175 let upstream_host_port = extract_host_port(&route.upstream);
176
177 let requires_managed_credential =
184 route.credential_key.is_some() || route.oauth2.is_some();
185 let requires_intercept =
186 requires_managed_credential || !route.endpoint_rules.is_empty();
187 let managed_auth_mechanism = auth_mechanism_for_route(route);
188 let managed_injection_mode = injection_mode_for_route(route);
189
190 loaded.insert(
191 normalized_prefix,
192 LoadedRoute {
193 upstream: route.upstream.clone(),
194 upstream_host_port,
195 endpoint_rules,
196 tls_connector,
197 requires_intercept,
198 requires_managed_credential,
199 managed_auth_mechanism,
200 managed_injection_mode,
201 },
202 );
203 }
204
205 Ok(Self { routes: loaded })
206 }
207
208 #[must_use]
210 pub fn empty() -> Self {
211 Self {
212 routes: HashMap::new(),
213 }
214 }
215
216 #[must_use]
218 pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
219 self.routes.get(prefix)
220 }
221
222 #[must_use]
224 pub fn is_empty(&self) -> bool {
225 self.routes.is_empty()
226 }
227
228 #[must_use]
230 pub fn len(&self) -> usize {
231 self.routes.len()
232 }
233
234 #[must_use]
238 pub fn is_route_upstream(&self, host_port: &str) -> bool {
239 let normalised = host_port.to_lowercase();
240 self.routes.values().any(|route| {
241 route
242 .upstream_host_port
243 .as_ref()
244 .is_some_and(|hp| *hp == normalised)
245 })
246 }
247
248 #[must_use]
255 pub fn lookup_by_upstream(&self, host_port: &str) -> Option<(&str, &LoadedRoute)> {
256 let normalised = host_port.to_lowercase();
257 self.routes.iter().find_map(|(prefix, route)| {
258 route
259 .upstream_host_port
260 .as_ref()
261 .filter(|hp| **hp == normalised)
262 .map(|_| (prefix.as_str(), route))
263 })
264 }
265
266 #[must_use]
273 pub fn has_intercept_route(&self, host_port: &str) -> bool {
274 self.lookup_by_upstream(host_port)
275 .is_some_and(|(_, route)| route.requires_intercept)
276 }
277
278 #[must_use]
281 pub fn route_upstream_hosts(&self) -> std::collections::HashSet<String> {
282 self.routes
283 .values()
284 .filter_map(|route| route.upstream_host_port.clone())
285 .collect()
286 }
287}
288
289impl LoadedRoute {
290 #[must_use]
293 pub fn missing_managed_credential(
294 &self,
295 has_static_credential: bool,
296 has_oauth2: bool,
297 ) -> bool {
298 self.requires_managed_credential && !has_static_credential && !has_oauth2
299 }
300}
301
302fn extract_host_port(url: &str) -> Option<String> {
307 let parsed = url::Url::parse(url).ok()?;
308 let host = parsed.host_str()?;
309 let default_port = match parsed.scheme() {
310 "https" => 443,
311 "http" => 80,
312 _ => return None,
313 };
314 let port = parsed.port().unwrap_or(default_port);
315 Some(format!("{}:{}", host.to_lowercase(), port))
316}
317
318fn read_pem_file(path: &std::path::Path, label: &str) -> Result<Zeroizing<Vec<u8>>> {
325 std::fs::read(path)
326 .map(Zeroizing::new)
327 .map_err(|e| match e.kind() {
328 std::io::ErrorKind::NotFound => {
329 ProxyError::Config(format!("{} file not found: '{}'", label, path.display()))
330 }
331 std::io::ErrorKind::PermissionDenied => ProxyError::Config(format!(
332 "{} permission denied: '{}' (check that nono can read this file)",
333 label,
334 path.display()
335 )),
336 _ => ProxyError::Config(format!(
337 "failed to read {} '{}': {}",
338 label,
339 path.display(),
340 e
341 )),
342 })
343}
344
345fn build_tls_connector(
355 ca_path: Option<&str>,
356 client_cert_path: Option<&str>,
357 client_key_path: Option<&str>,
358) -> Result<tokio_rustls::TlsConnector> {
359 let mut root_store = rustls::RootCertStore::empty();
360 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
362
363 if let Some(ca_path) = ca_path {
365 let ca_path = std::path::Path::new(ca_path);
366 let ca_pem = read_pem_file(ca_path, "CA certificate")?;
367
368 let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_slice_iter(ca_pem.as_ref())
369 .collect::<std::result::Result<Vec<_>, _>>()
370 .map_err(|e| {
371 ProxyError::Config(format!(
372 "failed to parse CA certificate '{}': {}",
373 ca_path.display(),
374 e
375 ))
376 })?;
377
378 if certs.is_empty() {
379 return Err(ProxyError::Config(format!(
380 "CA certificate file '{}' contains no valid PEM certificates",
381 ca_path.display()
382 )));
383 }
384
385 for cert in certs {
386 root_store.add(cert).map_err(|e| {
387 ProxyError::Config(format!(
388 "invalid CA certificate in '{}': {}",
389 ca_path.display(),
390 e
391 ))
392 })?;
393 }
394 }
395
396 let builder = rustls::ClientConfig::builder_with_provider(Arc::new(
397 rustls::crypto::ring::default_provider(),
398 ))
399 .with_safe_default_protocol_versions()
400 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
401 .with_root_certificates(root_store);
402
403 let tls_config = match (client_cert_path, client_key_path) {
405 (Some(cert_path), Some(key_path)) => {
406 let cert_path = std::path::Path::new(cert_path);
407 let key_path = std::path::Path::new(key_path);
408
409 let cert_pem = read_pem_file(cert_path, "client certificate")?;
410 let key_pem = read_pem_file(key_path, "client key")?;
411
412 let cert_chain: Vec<rustls::pki_types::CertificateDer> =
413 rustls::pki_types::CertificateDer::pem_slice_iter(cert_pem.as_ref())
414 .collect::<std::result::Result<Vec<_>, _>>()
415 .map_err(|e| {
416 ProxyError::Config(format!(
417 "failed to parse client certificate '{}': {}",
418 cert_path.display(),
419 e
420 ))
421 })?;
422
423 if cert_chain.is_empty() {
424 return Err(ProxyError::Config(format!(
425 "client certificate file '{}' contains no valid PEM certificates",
426 cert_path.display()
427 )));
428 }
429
430 let private_key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_ref())
431 .map_err(|e| match e {
432 rustls::pki_types::pem::Error::NoItemsFound => ProxyError::Config(format!(
433 "client key file '{}' contains no valid PEM private key",
434 key_path.display()
435 )),
436 _ => ProxyError::Config(format!(
437 "failed to parse client key '{}': {}",
438 key_path.display(),
439 e
440 )),
441 })?;
442
443 builder
444 .with_client_auth_cert(cert_chain, private_key)
445 .map_err(|e| {
446 ProxyError::Config(format!(
447 "invalid client certificate/key pair ('{}', '{}'): {}",
448 cert_path.display(),
449 key_path.display(),
450 e
451 ))
452 })?
453 }
454 (Some(_), None) => {
455 return Err(ProxyError::Config(
456 "tls_client_cert is set but tls_client_key is missing".to_string(),
457 ));
458 }
459 (None, Some(_)) => {
460 return Err(ProxyError::Config(
461 "tls_client_key is set but tls_client_cert is missing".to_string(),
462 ));
463 }
464 (None, None) => builder.with_no_client_auth(),
465 };
466
467 let mut tls_config = tls_config;
476 if client_cert_path.is_some() {
477 tls_config.resumption = rustls::client::Resumption::disabled();
478 }
479
480 Ok(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
481}
482
483#[cfg(test)]
485fn build_tls_connector_with_ca(ca_path: &str) -> Result<tokio_rustls::TlsConnector> {
486 build_tls_connector(Some(ca_path), None, None)
487}
488
489#[cfg(test)]
490#[allow(clippy::unwrap_used)]
491mod tests {
492 use super::*;
493 use crate::config::EndpointRule;
494
495 #[test]
496 fn test_empty_route_store() {
497 let store = RouteStore::empty();
498 assert!(store.is_empty());
499 assert_eq!(store.len(), 0);
500 assert!(store.get("openai").is_none());
501 }
502
503 #[test]
504 fn test_load_routes_without_credentials() {
505 let routes = vec![RouteConfig {
507 prefix: "/openai".to_string(),
508 upstream: "https://api.openai.com".to_string(),
509 credential_key: None,
510 inject_mode: Default::default(),
511 inject_header: "Authorization".to_string(),
512 credential_format: "Bearer {}".to_string(),
513 path_pattern: None,
514 path_replacement: None,
515 query_param_name: None,
516 proxy: None,
517 env_var: None,
518 endpoint_rules: vec![
519 EndpointRule {
520 method: "POST".to_string(),
521 path: "/v1/chat/completions".to_string(),
522 },
523 EndpointRule {
524 method: "GET".to_string(),
525 path: "/v1/models".to_string(),
526 },
527 ],
528 tls_ca: None,
529 tls_client_cert: None,
530 tls_client_key: None,
531 oauth2: None,
532 }];
533
534 let store = RouteStore::load(&routes).unwrap();
535 assert_eq!(store.len(), 1);
536
537 let route = store.get("openai").unwrap();
538 assert_eq!(route.upstream, "https://api.openai.com");
539 assert!(route
540 .endpoint_rules
541 .is_allowed("POST", "/v1/chat/completions"));
542 assert!(route.endpoint_rules.is_allowed("GET", "/v1/models"));
543 assert!(!route
544 .endpoint_rules
545 .is_allowed("DELETE", "/v1/files/file-123"));
546 }
547
548 #[test]
549 fn test_load_routes_normalises_prefix() {
550 let routes = vec![RouteConfig {
551 prefix: "/anthropic/".to_string(),
552 upstream: "https://api.anthropic.com".to_string(),
553 credential_key: None,
554 inject_mode: Default::default(),
555 inject_header: "Authorization".to_string(),
556 credential_format: "Bearer {}".to_string(),
557 path_pattern: None,
558 path_replacement: None,
559 query_param_name: None,
560 proxy: None,
561 env_var: None,
562 endpoint_rules: vec![],
563 tls_ca: None,
564 tls_client_cert: None,
565 tls_client_key: None,
566 oauth2: None,
567 }];
568
569 let store = RouteStore::load(&routes).unwrap();
570 assert!(store.get("anthropic").is_some());
571 assert!(store.get("/anthropic/").is_none());
572 }
573
574 #[test]
575 fn test_is_route_upstream() {
576 let routes = vec![RouteConfig {
577 prefix: "openai".to_string(),
578 upstream: "https://api.openai.com".to_string(),
579 credential_key: None,
580 inject_mode: Default::default(),
581 inject_header: "Authorization".to_string(),
582 credential_format: "Bearer {}".to_string(),
583 path_pattern: None,
584 path_replacement: None,
585 query_param_name: None,
586 proxy: None,
587 env_var: None,
588 endpoint_rules: vec![],
589 tls_ca: None,
590 tls_client_cert: None,
591 tls_client_key: None,
592 oauth2: None,
593 }];
594
595 let store = RouteStore::load(&routes).unwrap();
596 assert!(store.is_route_upstream("api.openai.com:443"));
597 assert!(!store.is_route_upstream("github.com:443"));
598 }
599
600 #[test]
601 fn test_route_upstream_hosts() {
602 let routes = vec![
603 RouteConfig {
604 prefix: "openai".to_string(),
605 upstream: "https://api.openai.com".to_string(),
606 credential_key: None,
607 inject_mode: Default::default(),
608 inject_header: "Authorization".to_string(),
609 credential_format: "Bearer {}".to_string(),
610 path_pattern: None,
611 path_replacement: None,
612 query_param_name: None,
613 proxy: None,
614 env_var: None,
615 endpoint_rules: vec![],
616 tls_ca: None,
617 tls_client_cert: None,
618 tls_client_key: None,
619 oauth2: None,
620 },
621 RouteConfig {
622 prefix: "anthropic".to_string(),
623 upstream: "https://api.anthropic.com".to_string(),
624 credential_key: None,
625 inject_mode: Default::default(),
626 inject_header: "Authorization".to_string(),
627 credential_format: "Bearer {}".to_string(),
628 path_pattern: None,
629 path_replacement: None,
630 query_param_name: None,
631 proxy: None,
632 env_var: None,
633 endpoint_rules: vec![],
634 tls_ca: None,
635 tls_client_cert: None,
636 tls_client_key: None,
637 oauth2: None,
638 },
639 ];
640
641 let store = RouteStore::load(&routes).unwrap();
642 let hosts = store.route_upstream_hosts();
643 assert!(hosts.contains("api.openai.com:443"));
644 assert!(hosts.contains("api.anthropic.com:443"));
645 assert_eq!(hosts.len(), 2);
646 }
647
648 #[test]
649 fn test_extract_host_port_https() {
650 assert_eq!(
651 extract_host_port("https://api.openai.com"),
652 Some("api.openai.com:443".to_string())
653 );
654 }
655
656 #[test]
657 fn test_extract_host_port_with_port() {
658 assert_eq!(
659 extract_host_port("https://api.example.com:8443"),
660 Some("api.example.com:8443".to_string())
661 );
662 }
663
664 #[test]
665 fn test_extract_host_port_http() {
666 assert_eq!(
667 extract_host_port("http://internal-service"),
668 Some("internal-service:80".to_string())
669 );
670 }
671
672 #[test]
673 fn test_extract_host_port_normalises_case() {
674 assert_eq!(
675 extract_host_port("https://API.Example.COM"),
676 Some("api.example.com:443".to_string())
677 );
678 }
679
680 #[test]
681 fn test_loaded_route_debug() {
682 let route = LoadedRoute {
683 upstream: "https://api.openai.com".to_string(),
684 upstream_host_port: Some("api.openai.com:443".to_string()),
685 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
686 tls_connector: None,
687 requires_intercept: false,
688 requires_managed_credential: false,
689 managed_auth_mechanism: None,
690 managed_injection_mode: None,
691 };
692 let debug_output = format!("{:?}", route);
693 assert!(debug_output.contains("api.openai.com"));
694 assert!(debug_output.contains("has_custom_tls_ca"));
695 assert!(debug_output.contains("requires_intercept"));
696 assert!(debug_output.contains("requires_managed_credential"));
697 assert!(debug_output.contains("managed_auth_mechanism"));
698 assert!(debug_output.contains("managed_injection_mode"));
699 }
700
701 #[test]
702 fn test_requires_intercept_credential_only() {
703 let routes = vec![RouteConfig {
704 prefix: "openai".to_string(),
705 upstream: "https://api.openai.com".to_string(),
706 credential_key: Some("openai_api_key".to_string()),
707 inject_mode: Default::default(),
708 inject_header: "Authorization".to_string(),
709 credential_format: "Bearer {}".to_string(),
710 path_pattern: None,
711 path_replacement: None,
712 query_param_name: None,
713 proxy: None,
714 env_var: None,
715 endpoint_rules: vec![],
716 tls_ca: None,
717 tls_client_cert: None,
718 tls_client_key: None,
719 oauth2: None,
720 }];
721 let store = RouteStore::load(&routes).unwrap();
722 let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
723 assert!(store.has_intercept_route("api.openai.com:443"));
724 assert!(hit.1.requires_managed_credential);
725 assert_eq!(
726 hit.1.managed_auth_mechanism,
727 Some(NetworkAuditAuthMechanism::PhantomHeader)
728 );
729 assert_eq!(
730 hit.1.managed_injection_mode,
731 Some(NetworkAuditInjectionMode::Header)
732 );
733 assert!(!store.has_intercept_route("api.example.com:443"));
734 }
735
736 #[test]
737 fn test_requires_intercept_endpoint_rules_only() {
738 let routes = vec![RouteConfig {
741 prefix: "internal".to_string(),
742 upstream: "https://internal.example.com".to_string(),
743 credential_key: None,
744 inject_mode: Default::default(),
745 inject_header: "Authorization".to_string(),
746 credential_format: "Bearer {}".to_string(),
747 path_pattern: None,
748 path_replacement: None,
749 query_param_name: None,
750 proxy: None,
751 env_var: None,
752 endpoint_rules: vec![EndpointRule {
753 method: "GET".to_string(),
754 path: "/v1/items".to_string(),
755 }],
756 tls_ca: None,
757 tls_client_cert: None,
758 tls_client_key: None,
759 oauth2: None,
760 }];
761 let store = RouteStore::load(&routes).unwrap();
762 let hit = store
763 .lookup_by_upstream("internal.example.com:443")
764 .unwrap();
765 assert!(store.has_intercept_route("internal.example.com:443"));
766 assert!(!hit.1.requires_managed_credential);
767 }
768
769 #[test]
770 fn test_requires_intercept_declarative_only() {
771 let routes = vec![RouteConfig {
774 prefix: "alias".to_string(),
775 upstream: "https://aliased.example.com".to_string(),
776 credential_key: None,
777 inject_mode: Default::default(),
778 inject_header: "Authorization".to_string(),
779 credential_format: "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![],
786 tls_ca: None,
787 tls_client_cert: None,
788 tls_client_key: None,
789 oauth2: None,
790 }];
791 let store = RouteStore::load(&routes).unwrap();
792 assert!(store.is_route_upstream("aliased.example.com:443"));
793 assert!(!store.has_intercept_route("aliased.example.com:443"));
794 }
795
796 #[test]
797 fn test_missing_managed_credential_policy() {
798 let managed = LoadedRoute {
799 upstream: "https://api.openai.com".to_string(),
800 upstream_host_port: Some("api.openai.com:443".to_string()),
801 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
802 tls_connector: None,
803 requires_intercept: true,
804 requires_managed_credential: true,
805 managed_auth_mechanism: Some(NetworkAuditAuthMechanism::PhantomHeader),
806 managed_injection_mode: Some(NetworkAuditInjectionMode::Header),
807 };
808 assert!(managed.missing_managed_credential(false, false));
809 assert!(!managed.missing_managed_credential(true, false));
810 assert!(!managed.missing_managed_credential(false, true));
811
812 let l7_only = LoadedRoute {
813 upstream: "https://internal.example.com".to_string(),
814 upstream_host_port: Some("internal.example.com:443".to_string()),
815 endpoint_rules: CompiledEndpointRules::compile(&[]).unwrap(),
816 tls_connector: None,
817 requires_intercept: true,
818 requires_managed_credential: false,
819 managed_auth_mechanism: None,
820 managed_injection_mode: None,
821 };
822 assert!(!l7_only.missing_managed_credential(false, false));
823 }
824
825 #[test]
826 fn test_lookup_by_upstream_returns_prefix() {
827 let routes = vec![RouteConfig {
828 prefix: "openai".to_string(),
829 upstream: "https://api.openai.com".to_string(),
830 credential_key: Some("openai_api_key".to_string()),
831 inject_mode: Default::default(),
832 inject_header: "Authorization".to_string(),
833 credential_format: "Bearer {}".to_string(),
834 path_pattern: None,
835 path_replacement: None,
836 query_param_name: None,
837 proxy: None,
838 env_var: None,
839 endpoint_rules: vec![],
840 tls_ca: None,
841 tls_client_cert: None,
842 tls_client_key: None,
843 oauth2: None,
844 }];
845 let store = RouteStore::load(&routes).unwrap();
846 let hit = store.lookup_by_upstream("api.openai.com:443").unwrap();
847 assert_eq!(hit.0, "openai");
848 assert!(hit.1.requires_intercept);
849 assert!(hit.1.requires_managed_credential);
850 assert!(store.lookup_by_upstream("api.example.com:443").is_none());
851 }
852
853 const TEST_CA_PEM: &str = "\
857-----BEGIN CERTIFICATE-----
858MIIBnjCCAUWgAwIBAgIUT0bpOJJvHdOdZt+gW1stR8VBgXowCgYIKoZIzj0EAwIw
859FzEVMBMGA1UEAwwMbm9uby10ZXN0LWNhMCAXDTI1MDEwMTAwMDAwMFoYDzIxMjQx
860MjA3MDAwMDAwWjAXMRUwEwYDVQQDDAxub25vLXRlc3QtY2EwWTATBgcqhkjOPQIB
861BggqhkjOPQMBBwNCAAR8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
862AAAAAAAAAAAAAAAAAAAAo1MwUTAdBgNVHQ4EFgQUAAAAAAAAAAAAAAAAAAAAAAAA
863AAAAMB8GA1UdIwQYMBaAFAAAAAAAAAAAAAAAAAAAAAAAAAAAADAPBgNVHRMBAf8E
864BTADAQH/MAoGCCqGSM49BAMCA0cAMEQCIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
865AAAAAAAICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
866-----END CERTIFICATE-----";
867
868 #[test]
869 fn test_build_tls_connector_with_valid_ca() {
870 let dir = tempfile::tempdir().unwrap();
871 let ca_path = dir.path().join("ca.pem");
872 std::fs::write(&ca_path, TEST_CA_PEM).unwrap();
873
874 let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
875 match result {
876 Ok(connector) => {
877 drop(connector);
878 }
879 Err(ProxyError::Config(msg)) => {
880 assert!(
881 msg.contains("invalid CA certificate") || msg.contains("CA certificate"),
882 "unexpected error: {}",
883 msg
884 );
885 }
886 Err(e) => panic!("unexpected error type: {}", e),
887 }
888 }
889
890 #[test]
891 fn test_build_tls_connector_missing_file() {
892 let result = build_tls_connector_with_ca("/nonexistent/path/ca.pem");
893 let err = result
894 .err()
895 .expect("should fail for missing file")
896 .to_string();
897 assert!(
898 err.contains("CA certificate file not found"),
899 "unexpected error: {}",
900 err
901 );
902 }
903
904 #[test]
905 fn test_build_tls_connector_empty_pem() {
906 let dir = tempfile::tempdir().unwrap();
907 let ca_path = dir.path().join("empty.pem");
908 std::fs::write(&ca_path, "not a certificate\n").unwrap();
909
910 let result = build_tls_connector_with_ca(ca_path.to_str().unwrap());
911 let err = result
912 .err()
913 .expect("should fail for invalid PEM")
914 .to_string();
915 assert!(
916 err.contains("no valid PEM certificates"),
917 "unexpected error: {}",
918 err
919 );
920 }
921
922 const TEST_CLIENT_CERT_PEM: &str = "\
928-----BEGIN CERTIFICATE-----
929MIIBijCCATGgAwIBAgIUEoEb+0z+4CTRCzN98MqeTEXgdO8wCgYIKoZIzj0EAwIw
930GzEZMBcGA1UEAwwQbm9uby10ZXN0LWNsaWVudDAeFw0yNjA0MTAwMDIwNTdaFw0z
931NjA0MDcwMDIwNTdaMBsxGTAXBgNVBAMMEG5vbm8tdGVzdC1jbGllbnQwWTATBgcq
932hkjOPQIBBggqhkjOPQMBBwNCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1h56ZLEEq
933HfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQo1MwUTAdBgNVHQ4E
934FgQUTiHidg8uqgrJ1qlaVvR+XSebAlEwHwYDVR0jBBgwFoAUTiHidg8uqgrJ1qla
935VvR+XSebAlEwDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiA9PwBU
936f832cQkGS9cyYaU7Ij5U8Rcy/g4J7Ckf2nKX3gIgG0aarAFcIzAi5VpxbCwEScnr
937m0lHTyp6E7ut7llwMBY=
938-----END CERTIFICATE-----";
939
940 const TEST_CLIENT_KEY_PEM: &str = "\
941-----BEGIN PRIVATE KEY-----
942MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgskOkyJkTwlMZkm/L
943eEleLY6bARaHFnqauYJqxNoJWvihRANCAASt6g2Zt0STlgF+wZ64JzdDRlpPeNr1
944h56ZLEEqHfVWFhJWIKRSabtxYPV/VJyMv+lo3L0QwSKsouHs3dtF1zVQ
945-----END PRIVATE KEY-----";
946
947 #[test]
948 fn test_build_tls_connector_cert_without_key_errors() {
949 let dir = tempfile::tempdir().unwrap();
950 let cert_path = dir.path().join("client.crt");
951 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
952
953 let result = build_tls_connector(None, Some(cert_path.to_str().unwrap()), None);
954 let err = result
955 .err()
956 .expect("should fail with half-pair")
957 .to_string();
958 assert!(
959 err.contains("tls_client_cert is set but tls_client_key is missing"),
960 "unexpected error: {}",
961 err
962 );
963 }
964
965 #[test]
966 fn test_build_tls_connector_key_without_cert_errors() {
967 let dir = tempfile::tempdir().unwrap();
968 let key_path = dir.path().join("client.key");
969 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
970
971 let result = build_tls_connector(None, None, Some(key_path.to_str().unwrap()));
972 let err = result
973 .err()
974 .expect("should fail with half-pair")
975 .to_string();
976 assert!(
977 err.contains("tls_client_key is set but tls_client_cert is missing"),
978 "unexpected error: {}",
979 err
980 );
981 }
982
983 #[test]
984 fn test_build_tls_connector_missing_client_cert_file() {
985 let dir = tempfile::tempdir().unwrap();
986 let key_path = dir.path().join("client.key");
987 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
988
989 let result = build_tls_connector(
990 None,
991 Some("/nonexistent/client.crt"),
992 Some(key_path.to_str().unwrap()),
993 );
994 let err = result.err().expect("should fail").to_string();
995 assert!(
996 err.contains("client certificate file not found"),
997 "unexpected error: {}",
998 err
999 );
1000 }
1001
1002 #[test]
1003 fn test_build_tls_connector_missing_client_key_file() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let cert_path = dir.path().join("client.crt");
1006 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1007
1008 let result = build_tls_connector(
1009 None,
1010 Some(cert_path.to_str().unwrap()),
1011 Some("/nonexistent/client.key"),
1012 );
1013 let err = result.err().expect("should fail").to_string();
1014 assert!(
1015 err.contains("client key file not found"),
1016 "unexpected error: {}",
1017 err
1018 );
1019 }
1020
1021 #[test]
1022 #[cfg(unix)]
1023 fn test_build_tls_connector_permission_denied() {
1024 use std::os::unix::fs::PermissionsExt;
1025 let dir = tempfile::tempdir().unwrap();
1026 let cert_path = dir.path().join("client.crt");
1027 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1028 std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
1030
1031 if std::fs::read(&cert_path).is_ok() {
1033 return;
1034 }
1035
1036 let result = build_tls_connector(
1037 None,
1038 Some(cert_path.to_str().unwrap()),
1039 Some("/nonexistent/key"),
1040 );
1041 let err = result.err().expect("should fail").to_string();
1042 assert!(
1043 err.contains("permission denied"),
1044 "expected permission denied error, got: {}",
1045 err
1046 );
1047 }
1048
1049 #[test]
1050 fn test_build_tls_connector_empty_client_cert_pem() {
1051 let dir = tempfile::tempdir().unwrap();
1052 let cert_path = dir.path().join("client.crt");
1053 let key_path = dir.path().join("client.key");
1054 std::fs::write(&cert_path, "not a certificate\n").unwrap();
1055 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1056
1057 let result = build_tls_connector(
1058 None,
1059 Some(cert_path.to_str().unwrap()),
1060 Some(key_path.to_str().unwrap()),
1061 );
1062 let err = result.err().expect("should fail").to_string();
1063 assert!(
1064 err.contains("no valid PEM certificates"),
1065 "unexpected error: {}",
1066 err
1067 );
1068 }
1069
1070 #[test]
1071 fn test_build_tls_connector_empty_client_key_pem() {
1072 let dir = tempfile::tempdir().unwrap();
1074 let cert_path = dir.path().join("client.crt");
1075 let key_path = dir.path().join("client.key");
1076 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1077 std::fs::write(&key_path, "not a key\n").unwrap();
1078
1079 let result = build_tls_connector(
1080 None,
1081 Some(cert_path.to_str().unwrap()),
1082 Some(key_path.to_str().unwrap()),
1083 );
1084 let err = result
1085 .err()
1086 .expect("should fail with invalid PEM")
1087 .to_string();
1088 assert!(err.contains("client key"), "unexpected error: {}", err);
1089 }
1090
1091 #[test]
1092 fn test_route_store_loads_mtls_route() {
1093 let dir = tempfile::tempdir().unwrap();
1095 let cert_path = dir.path().join("client.crt");
1096 let key_path = dir.path().join("client.key");
1097 std::fs::write(&cert_path, TEST_CLIENT_CERT_PEM).unwrap();
1098 std::fs::write(&key_path, TEST_CLIENT_KEY_PEM).unwrap();
1099
1100 let routes = vec![RouteConfig {
1101 prefix: "k8s".to_string(),
1102 upstream: "https://192.168.64.1:6443".to_string(),
1103 credential_key: None,
1104 inject_mode: Default::default(),
1105 inject_header: "Authorization".to_string(),
1106 credential_format: "Bearer {}".to_string(),
1107 path_pattern: None,
1108 path_replacement: None,
1109 query_param_name: None,
1110 proxy: None,
1111 env_var: None,
1112 endpoint_rules: vec![],
1113 tls_ca: None,
1114 tls_client_cert: Some(cert_path.to_str().unwrap().to_string()),
1115 tls_client_key: Some(key_path.to_str().unwrap().to_string()),
1116 oauth2: None,
1117 }];
1118
1119 let store = RouteStore::load(&routes).expect("should load mTLS route");
1120 let route = store.get("k8s").unwrap();
1121 assert!(
1122 route.tls_connector.is_some(),
1123 "connector must be built when tls_client_cert/key are set"
1124 );
1125 }
1126}