Skip to main content

nono_proxy/
route.rs

1//! Route store: per-route configuration independent of credentials.
2//!
3//! `RouteStore` holds the route-level configuration (upstream URL, L7 endpoint
4//! rules, custom TLS CA) for **all** configured routes, regardless of whether
5//! they have a credential attached. This decouples L7 filtering from credential
6//! injection — a route can enforce endpoint restrictions without injecting any
7//! secret.
8//!
9//! The `CredentialStore` remains responsible for credential-specific fields
10//! (inject mode, header name/value, raw secret). Both stores are keyed by the
11//! normalised route prefix and are consulted independently by the proxy handlers.
12
13use 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
22/// Route-level configuration loaded at proxy startup.
23///
24/// Contains everything needed to forward and filter a request for a route,
25/// but no credential material. Credential injection is handled separately
26/// by `CredentialStore`.
27pub struct LoadedRoute {
28    /// Upstream URL (e.g., "https://api.openai.com")
29    pub upstream: String,
30
31    /// Pre-normalised `host:port` extracted from `upstream` at load time.
32    /// Used for O(1) lookups in `is_route_upstream()` without per-request
33    /// URL parsing. `None` if the upstream URL cannot be parsed.
34    pub upstream_host_port: Option<String>,
35
36    /// Pre-compiled L7 endpoint rules for method+path filtering.
37    /// When non-empty, only matching requests are allowed (default-deny).
38    /// When empty, all method+path combinations are permitted.
39    pub endpoint_rules: CompiledEndpointRules,
40
41    /// Per-route TLS connector with custom CA trust, if configured.
42    /// Built once at startup from the route's `tls_ca` certificate file.
43    /// When `None`, the shared default connector (webpki roots only) is used.
44    pub tls_connector: Option<tokio_rustls::TlsConnector>,
45
46    /// `true` if this route requires L7 visibility — i.e. it declares
47    /// `credential_key`, `oauth2`, or non-empty `endpoint_rules` and would
48    /// not function as a transparent CONNECT tunnel. Computed once at load
49    /// time so the CONNECT dispatch path doesn't have to re-derive it on
50    /// every request.
51    pub requires_intercept: bool,
52
53    /// `true` if this route was configured to use a managed credential
54    /// source (`credential_key` or `oauth2`). Unlike `requires_intercept`,
55    /// this specifically captures whether the proxy must supply upstream
56    /// authentication itself rather than accept agent-provided credentials.
57    pub requires_managed_credential: bool,
58
59    /// Audit auth mechanism implied by the managed credential configuration.
60    /// Kept even if credential material failed to load so fail-closed denial
61    /// events can describe what auth shape the route expected.
62    pub managed_auth_mechanism: Option<NetworkAuditAuthMechanism>,
63
64    /// Audit injection mode implied by the managed credential configuration.
65    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        // SigV4 signs via the Authorization header — same phantom token shape.
93        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        // SigV4 injects via the Authorization header.
121        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/// Store of all configured routes, keyed by normalised prefix.
137///
138/// Loaded at proxy startup for **all** routes in the config, not just those
139/// with credentials. This ensures L7 endpoint filtering and upstream routing
140/// work independently of credential presence.
141#[derive(Debug)]
142pub struct RouteStore {
143    routes: HashMap<String, LoadedRoute>,
144}
145
146impl RouteStore {
147    /// Load route configuration for all configured routes.
148    ///
149    /// Each route's endpoint rules are compiled at startup so the hot path
150    /// does a regex match, not a glob compile. Routes with a `tls_ca` field
151    /// get a per-route TLS connector built from the custom CA certificate.
152    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            // A route needs L7 visibility if it carries credentials to inject
191            // (`credential_key` or `oauth2`) or if it enforces method/path
192            // rules. Routes without any of these are purely declarative —
193            // they exist to provide a `*_BASE_URL` env var or appear in
194            // `route_upstream_hosts()` — and CONNECT to those still gets
195            // blocked with 403 (the "force SDK cooperation" path).
196            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    /// Create an empty route store (no routes configured).
223    #[must_use]
224    pub fn empty() -> Self {
225        Self {
226            routes: HashMap::new(),
227        }
228    }
229
230    /// Get a loaded route by normalised prefix, if configured.
231    #[must_use]
232    pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
233        self.routes.get(prefix)
234    }
235
236    /// Check if any routes are loaded.
237    #[must_use]
238    pub fn is_empty(&self) -> bool {
239        self.routes.is_empty()
240    }
241
242    /// Number of loaded routes.
243    #[must_use]
244    pub fn len(&self) -> usize {
245        self.routes.len()
246    }
247
248    /// Check whether `host_port` (e.g. `"api.openai.com:443"`) matches
249    /// any route's upstream URL. Uses pre-normalised `host:port` strings
250    /// computed at load time to avoid per-request URL parsing.
251    #[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    /// Return the first route matching `host:port`, or `None`.
263    ///
264    /// Prefer [`lookup_all_by_upstream`](Self::lookup_all_by_upstream)
265    /// when multiple routes may share the same upstream.
266    #[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    /// Return all routes whose upstream matches `host:port`, sorted by
279    /// prefix for deterministic iteration.
280    #[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    /// Whether any route for `host:port` requires TLS interception.
299    #[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    /// All unique upstream `host:port` strings across loaded routes.
312    #[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/// Outcome of route selection for an intercepted request, shared by
322/// `tls_intercept::handle` and tests so the decision has a single source of
323/// truth (the caller maps each variant to its HTTP/audit response).
324#[derive(Debug)]
325pub(crate) enum RouteSelection<'a> {
326    /// An `_ep_` endpoint-authorization route exists for the upstream but no
327    /// rule matched: hard-deny (403). Gates access before credential selection.
328    EndpointDenied,
329    /// More than one credential route matched the request: ambiguous (403).
330    /// Carries the matched route prefixes for the diagnostic.
331    Ambiguous(Vec<&'a str>),
332    /// A route was selected (`Some`) or the request is an un-credentialed
333    /// passthrough (`None`).
334    Selected(Option<(&'a str, &'a LoadedRoute)>),
335}
336
337/// Select the route for an intercepted request from `candidates` sharing one
338/// upstream, applying the endpoint-authorization gate, the ambiguity check, and
339/// credential-first priority.
340///
341/// Candidates are partitioned into four buckets by whether their endpoint rules
342/// matched this request and whether they carry a managed credential:
343///   * `matched_cred` / `matched_passthrough` — endpoint rules matched,
344///   * `catchall_cred` / `catchall_passthrough` — no endpoint rules (every path).
345///
346/// The *credential layer* is `matched_cred` when any credential route matched,
347/// otherwise `catchall_cred` (so a credential catch-all is still in play when
348/// only credential-less `_ep_` routes matched). Two credential routes in the
349/// active layer are ambiguous (the proxy must not silently pick one). Otherwise
350/// selection prefers, in order:
351/// 1. the single credential route from the active layer — a credential catch-all
352///    thus wins over a credential-less `_ep_` match so the managed token is
353///    injected rather than silently dropped,
354/// 2. a matched credential-less route (bare endpoint authorization),
355/// 3. a credential-less catch-all (un-credentialed passthrough).
356#[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    // Endpoint-only authorization layer: a credential catch-all cannot bypass
388    // endpoint restrictions imposed by `_ep_` routes.
389    if has_endpoint_only_route && !endpoint_authorized {
390        return RouteSelection::EndpointDenied;
391    }
392
393    // Ambiguity applies only to credential-injection routes within the active
394    // layer; multiple endpoint-only authorization routes matching is fine (they
395    // all just allow). This catches both overlapping endpoint credential routes
396    // and overlapping credential catch-alls (e.g. two equally-specific wildcard
397    // upstreams).
398    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    /// Whether this route is configured to require a proxy-managed credential
418    /// but the credential material is currently unavailable.
419    #[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
430/// Extract and normalise `host:port` from a URL string.
431///
432/// Defaults to port 443 for `https://` and 80 for `http://` when no
433/// explicit port is present. Returns `None` if the URL cannot be parsed.
434fn 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
446/// Read a PEM file, producing a clear `ProxyError::Config` for common failure modes.
447///
448/// Distinguishes:
449/// - file not found  → "… not found: '…'"
450/// - permission denied → "… permission denied: '…'" (nono process lacks read access)
451/// - other I/O errors  → "failed to read … '…': {os error}"
452fn 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
473/// Root cert store combining webpki roots with the OS trust store.
474///
475/// Loaded once at startup and cloned into each per-route connector.
476fn 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
488/// Build a per-route `TlsConnector`, optionally adding a custom CA
489/// and/or mTLS client certificate on top of `base_root_store`.
490fn 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    // Add custom CA if provided
499    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    // Add client certificate for mTLS if provided
539    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    // Disable TLS session resumption when client certificates are configured.
603    //
604    // With TLS 1.3 PSK resumption the server may skip the CertificateRequest
605    // handshake message, so the client certificate is never re-presented on
606    // resumed connections. Servers that authenticate via x509 client certs
607    // (e.g. Kubernetes API servers) then reject or hang the request because
608    // the client identity is not established. Forcing a full handshake every
609    // time ensures the client certificate is always sent.
610    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/// Compatibility shim: build a connector with only a custom CA (no client cert).
619#[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        // Routes without credential_key should still be loaded into RouteStore
642        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        // L7-only route (no credential): rules alone are enough to require
885        // interception.
886        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        // No credential, no rules — purely declarative route. CONNECT to
919        // this upstream still gets the existing 403 (not intercepted).
920        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    /// Thin adapter over the real `select_route` so tests exercise the shipping
1082    /// decision rather than a mirror, flattening its outcome to a comparable enum.
1083    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    /// Models a real multi-org GitHub profile. Exercises `select_route`:
1097    ///   1 match  → inject that route's credential
1098    ///   0 matches → passthrough (no credential injected)
1099    ///   2+ matches → ambiguous (hard-deny 403)
1100    #[test]
1101    fn test_route_selection_multi_org_profile() {
1102        // Helper to build a route with the given prefix and endpoint path.
1103        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        // --- Profile: two org-scoped routes, no catch-all ---
1129        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        // Private org-a repo → org-a credential
1138        assert_eq!(
1139            select(&candidates, "GET", "/org-a/repo.git/info/refs"),
1140            Selection::Route("github_https_org_a")
1141        );
1142        // Private org-b repo → org-b credential
1143        assert_eq!(
1144            select(&candidates, "GET", "/org-b/repo.git/info/refs"),
1145            Selection::Route("github_https_org_b")
1146        );
1147        // Public repo (e.g. always-further/nono) → passthrough, no cred
1148        assert_eq!(
1149            select(&candidates, "GET", "/always-further/nono.git/info/refs"),
1150            Selection::Passthrough
1151        );
1152        // POST to public repo → also passthrough
1153        assert_eq!(
1154            select(
1155                &candidates,
1156                "POST",
1157                "/always-further/nono.git/git-upload-pack"
1158            ),
1159            Selection::Passthrough
1160        );
1161
1162        // --- Adding a /** catch-all would cause ambiguity ---
1163        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        // org-a request now matches BOTH org_a AND the /** catch-all → ambiguous
1173        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        // Public repo matches only the /** catch-all → 1 match, ok
1178        assert_eq!(
1179            select(&candidates2, "GET", "/always-further/nono.git/info/refs"),
1180            Selection::Route("github_https_all")
1181        );
1182    }
1183
1184    /// A credential-less `_ep_` authorization route (from `allow_domain` with
1185    /// endpoints) must not shadow a credential catch-all on a path the `_ep_`
1186    /// route authorizes — the token has to be injected, not silently dropped.
1187    #[test]
1188    fn test_route_selection_credential_catchall_not_shadowed() {
1189        // `_ep_` endpoint-authorization route: no credential, scoped to /org/**.
1190        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        // Credential catch-all: carries a token, no endpoint rules.
1200        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        // Authorized path (matches the _ep_ rule): the credential catch-all must
1214        // win so the token is injected — not the credential-less _ep_ match.
1215        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        // Non-matching path: the _ep_ gate hard-denies before the catch-all is
1221        // reached, so the catch-all cannot bypass endpoint restrictions.
1222        assert_eq!(
1223            select(&candidates, "GET", "/other/repo"),
1224            Selection::EndpointDenied
1225        );
1226    }
1227
1228    /// Two credential catch-alls for the same upstream are ambiguous: the proxy
1229    /// must not silently pick one to inject, just as with overlapping endpoint
1230    /// credential routes.
1231    #[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        // Both credential catch-alls cover every path → ambiguous, not a silent pick.
1255        assert_eq!(
1256            select(&candidates, "GET", "/any/path"),
1257            Selection::Ambiguous(vec!["github_a", "github_b"])
1258        );
1259    }
1260
1261    /// Self-signed CA for testing. Generated with:
1262    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
1263    ///   -keyout /dev/null -nodes -days 36500 -subj '/CN=nono-test-ca' -out -
1264    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    // --- mTLS (client certificate) tests ---
1331
1332    /// Self-signed client cert + key for testing. Generated with:
1333    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
1334    ///   -keyout client.key -nodes -days 3650 -subj '/CN=nono-test-client' -out client.crt
1335    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        // Remove all permissions so the file exists but can't be read
1443        std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
1444
1445        // Skip if running as root (root bypasses permission checks)
1446        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        // Verifies that an invalid key file produces an appropriate config error.
1491        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        // Verify RouteStore.load() builds a TLS connector when tls_client_cert/key are set.
1514        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}