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.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/// Store of all configured routes, keyed by normalised prefix.
127///
128/// Loaded at proxy startup for **all** routes in the config, not just those
129/// with credentials. This ensures L7 endpoint filtering and upstream routing
130/// work independently of credential presence.
131#[derive(Debug)]
132pub struct RouteStore {
133    routes: HashMap<String, LoadedRoute>,
134}
135
136impl RouteStore {
137    /// Load route configuration for all configured routes.
138    ///
139    /// Each route's endpoint rules are compiled at startup so the hot path
140    /// does a regex match, not a glob compile. Routes with a `tls_ca` field
141    /// get a per-route TLS connector built from the custom CA certificate.
142    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            // A route needs L7 visibility if it carries credentials to inject
178            // (`credential_key` or `oauth2`) or if it enforces method/path
179            // rules. Routes without any of these are purely declarative —
180            // they exist to provide a `*_BASE_URL` env var or appear in
181            // `route_upstream_hosts()` — and CONNECT to those still gets
182            // blocked with 403 (the "force SDK cooperation" path).
183            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    /// Create an empty route store (no routes configured).
209    #[must_use]
210    pub fn empty() -> Self {
211        Self {
212            routes: HashMap::new(),
213        }
214    }
215
216    /// Get a loaded route by normalised prefix, if configured.
217    #[must_use]
218    pub fn get(&self, prefix: &str) -> Option<&LoadedRoute> {
219        self.routes.get(prefix)
220    }
221
222    /// Check if any routes are loaded.
223    #[must_use]
224    pub fn is_empty(&self) -> bool {
225        self.routes.is_empty()
226    }
227
228    /// Number of loaded routes.
229    #[must_use]
230    pub fn len(&self) -> usize {
231        self.routes.len()
232    }
233
234    /// Check whether `host_port` (e.g. `"api.openai.com:443"`) matches
235    /// any route's upstream URL. Uses pre-normalised `host:port` strings
236    /// computed at load time to avoid per-request URL parsing.
237    #[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    /// Look up the route prefix and loaded entry for a CONNECT-style
249    /// `host:port`. Returns `Some((prefix, route))` on a match.
250    ///
251    /// Used by the TLS-intercept CONNECT branch to map the agent's
252    /// `CONNECT api.openai.com:443` target back to a route prefix
253    /// (`"openai"`) so the credential store can be consulted.
254    #[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    /// Returns `true` if the `host:port` matches a route that requires
267    /// TLS interception (has `credential_key`, `oauth2`, or non-empty
268    /// `endpoint_rules`).
269    ///
270    /// Used in the CONNECT dispatch path to choose between transparent
271    /// tunnelling, the existing 403, and the new intercept handler.
272    #[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    /// Return the set of normalised `host:port` strings for all route
279    /// upstreams. Uses pre-normalised values computed at load time.
280    #[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    /// Whether this route is configured to require a proxy-managed credential
291    /// but the credential material is currently unavailable.
292    #[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
302/// Extract and normalise `host:port` from a URL string.
303///
304/// Defaults to port 443 for `https://` and 80 for `http://` when no
305/// explicit port is present. Returns `None` if the URL cannot be parsed.
306fn 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
318/// Read a PEM file, producing a clear `ProxyError::Config` for common failure modes.
319///
320/// Distinguishes:
321/// - file not found  → "… not found: '…'"
322/// - permission denied → "… permission denied: '…'" (nono process lacks read access)
323/// - other I/O errors  → "failed to read … '…': {os error}"
324fn 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
345/// Build a `TlsConnector` with optional custom CA and optional client certificate.
346///
347/// - `ca_path`: PEM-encoded CA certificate file to trust in addition to system roots.
348///   Required for upstreams with self-signed or private CA certificates.
349/// - `client_cert_path`: PEM-encoded client certificate for mTLS. Must be paired with `client_key_path`.
350/// - `client_key_path`: PEM-encoded private key matching `client_cert_path`.
351///
352/// At least one of the three parameters must be `Some`. Returns an error if any
353/// file cannot be read, contains invalid PEM, or the TLS configuration fails.
354fn 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    // Always include system roots
361    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
362
363    // Add custom CA if provided
364    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    // Add client certificate for mTLS if provided
404    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    // Disable TLS session resumption when client certificates are configured.
468    //
469    // With TLS 1.3 PSK resumption the server may skip the CertificateRequest
470    // handshake message, so the client certificate is never re-presented on
471    // resumed connections. Servers that authenticate via x509 client certs
472    // (e.g. Kubernetes API servers) then reject or hang the request because
473    // the client identity is not established. Forcing a full handshake every
474    // time ensures the client certificate is always sent.
475    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/// Compatibility shim: build a connector with only a custom CA (no client cert).
484#[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        // Routes without credential_key should still be loaded into RouteStore
506        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        // L7-only route (no credential): rules alone are enough to require
739        // interception.
740        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        // No credential, no rules — purely declarative route. CONNECT to
772        // this upstream still gets the existing 403 (not intercepted).
773        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    /// Self-signed CA for testing. Generated with:
854    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
855    ///   -keyout /dev/null -nodes -days 36500 -subj '/CN=nono-test-ca' -out -
856    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    // --- mTLS (client certificate) tests ---
923
924    /// Self-signed client cert + key for testing. Generated with:
925    /// openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
926    ///   -keyout client.key -nodes -days 3650 -subj '/CN=nono-test-client' -out client.crt
927    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        // Remove all permissions so the file exists but can't be read
1029        std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o000)).unwrap();
1030
1031        // Skip if running as root (root bypasses permission checks)
1032        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        // Verifies that an invalid key file produces an appropriate config error.
1073        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        // Verify RouteStore.load() builds a TLS connector when tls_client_cert/key are set.
1094        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}