Skip to main content

nono_proxy/
server.rs

1//! Proxy server: TCP listener, connection dispatch, and lifecycle.
2//!
3//! The server binds to `127.0.0.1:0` (OS-assigned port), accepts TCP
4//! connections, reads the first HTTP line to determine the mode, and
5//! dispatches to the appropriate handler.
6//!
7//! CONNECT method -> [`connect`] or [`external`] handler
8//! Other methods  -> [`reverse`] handler (credential injection)
9
10use crate::audit;
11use crate::config::ProxyConfig;
12use crate::connect;
13use crate::credential::CredentialStore;
14use crate::error::{ProxyError, Result};
15use crate::external;
16use crate::filter::ProxyFilter;
17use crate::reverse;
18use crate::route::RouteStore;
19use crate::token;
20use std::net::SocketAddr;
21use std::sync::atomic::{AtomicUsize, Ordering};
22use std::sync::Arc;
23use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
24use tokio::net::TcpListener;
25use tokio::sync::watch;
26use tracing::{debug, info, warn};
27use zeroize::Zeroizing;
28
29/// Maximum total size of HTTP headers (64 KiB). Prevents OOM from
30/// malicious clients sending unbounded header data.
31const MAX_HEADER_SIZE: usize = 64 * 1024;
32
33/// Handle returned when the proxy server starts.
34///
35/// Contains the assigned port, session token, and a shutdown channel.
36/// Drop the handle or send to `shutdown_tx` to stop the proxy.
37pub struct ProxyHandle {
38    /// The actual port the proxy is listening on
39    pub port: u16,
40    /// Session token for client authentication
41    pub token: Zeroizing<String>,
42    /// Shared in-memory network audit log
43    audit_log: audit::SharedAuditLog,
44    /// Send `true` to trigger graceful shutdown
45    shutdown_tx: watch::Sender<bool>,
46    /// Route prefixes that have credentials actually loaded.
47    /// Routes whose credentials were unavailable are excluded so we
48    /// don't inject phantom tokens that shadow valid external credentials.
49    loaded_routes: std::collections::HashSet<String>,
50    /// Non-credential allowed hosts that should bypass the proxy (NO_PROXY).
51    /// Computed at startup: `allowed_hosts` minus credential upstream hosts.
52    no_proxy_hosts: Vec<String>,
53}
54
55impl ProxyHandle {
56    /// Signal the proxy to shut down gracefully.
57    pub fn shutdown(&self) {
58        let _ = self.shutdown_tx.send(true);
59    }
60
61    /// Drain and return collected network audit events.
62    #[must_use]
63    pub fn drain_audit_events(&self) -> Vec<nono::undo::NetworkAuditEvent> {
64        audit::drain_audit_events(&self.audit_log)
65    }
66
67    /// Environment variables to inject into the child process.
68    ///
69    /// The proxy URL includes `nono:<token>@` userinfo so that standard HTTP
70    /// clients (curl, Python requests, etc.) automatically send
71    /// `Proxy-Authorization: Basic ...` on every request. The raw token is
72    /// also provided via `NONO_PROXY_TOKEN` for nono-aware clients that
73    /// prefer Bearer auth.
74    #[must_use]
75    pub fn env_vars(&self) -> Vec<(String, String)> {
76        let proxy_url = format!("http://nono:{}@127.0.0.1:{}", &*self.token, self.port);
77
78        // Build NO_PROXY: always include loopback, plus non-credential
79        // allowed hosts. Credential upstreams are excluded so their traffic
80        // goes through the reverse proxy for L7 filtering + injection.
81        let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
82        for host in &self.no_proxy_hosts {
83            // Strip port for NO_PROXY (most HTTP clients match on hostname).
84            // Handle IPv6 brackets: "[::1]:443" → "[::1]", "host:443" → "host"
85            let hostname = if host.contains("]:") {
86                // IPv6 with port: split at "]:port"
87                host.rsplit_once("]:")
88                    .map(|(h, _)| format!("{}]", h))
89                    .unwrap_or_else(|| host.clone())
90            } else {
91                host.rsplit_once(':')
92                    .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h.to_string()))
93                    .unwrap_or_else(|| host.clone())
94            };
95            if !no_proxy_parts.contains(&hostname.to_string()) {
96                no_proxy_parts.push(hostname.to_string());
97            }
98        }
99        let no_proxy = no_proxy_parts.join(",");
100
101        let mut vars = vec![
102            ("HTTP_PROXY".to_string(), proxy_url.clone()),
103            ("HTTPS_PROXY".to_string(), proxy_url.clone()),
104            ("NO_PROXY".to_string(), no_proxy.clone()),
105            ("NONO_PROXY_TOKEN".to_string(), self.token.to_string()),
106        ];
107
108        // Lowercase variants for compatibility
109        vars.push(("http_proxy".to_string(), proxy_url.clone()));
110        vars.push(("https_proxy".to_string(), proxy_url));
111        vars.push(("no_proxy".to_string(), no_proxy));
112
113        vars
114    }
115
116    /// Environment variables for reverse proxy credential routes.
117    ///
118    /// Returns two types of env vars per route:
119    /// 1. SDK base URL overrides (e.g., `OPENAI_BASE_URL=http://127.0.0.1:PORT/openai`)
120    /// 2. SDK API key vars set to the session token (e.g., `OPENAI_API_KEY=<token>`)
121    ///
122    /// The SDK sends the session token as its "API key" (phantom token pattern).
123    /// The proxy validates this token and swaps it for the real credential.
124    #[must_use]
125    pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
126        let mut vars = Vec::new();
127        for route in &config.routes {
128            // Strip any leading or trailing '/' from the prefix — prefix should
129            // be a bare service name (e.g., "anthropic"), not a URL path.
130            // Defensively handle both forms to prevent malformed env var names
131            // and double-slashed URLs.
132            let prefix = route.prefix.trim_matches('/');
133
134            // Base URL override (e.g., OPENAI_BASE_URL)
135            let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
136            let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
137            vars.push((base_url_name, url));
138
139            // Only inject phantom token env vars for routes whose credentials
140            // were actually loaded. If a credential was unavailable (e.g.,
141            // GITHUB_TOKEN env var not set), injecting a phantom token would
142            // shadow valid credentials from other sources (keyring, gh auth).
143            if !self.loaded_routes.contains(prefix) {
144                continue;
145            }
146
147            // API key set to session token (phantom token pattern).
148            // Use explicit env_var if set (required for URI manager refs), otherwise
149            // fall back to uppercasing the credential_key (e.g., "openai_api_key" -> "OPENAI_API_KEY").
150            if let Some(ref env_var) = route.env_var {
151                vars.push((env_var.clone(), self.token.to_string()));
152            } else if let Some(ref cred_key) = route.credential_key {
153                let api_key_name = cred_key.to_uppercase();
154                vars.push((api_key_name, self.token.to_string()));
155            }
156        }
157        vars
158    }
159}
160
161/// Shared state for the proxy server.
162struct ProxyState {
163    filter: ProxyFilter,
164    session_token: Zeroizing<String>,
165    /// Route-level configuration (upstream, L7 filtering, custom TLS CA) for all routes.
166    route_store: RouteStore,
167    /// Credential-specific configuration (inject mode, headers, secrets) for routes with credentials.
168    credential_store: CredentialStore,
169    config: ProxyConfig,
170    /// Shared TLS connector for upstream connections (reverse proxy mode).
171    /// Created once at startup to avoid rebuilding the root cert store per request.
172    tls_connector: tokio_rustls::TlsConnector,
173    /// Active connection count for connection limiting.
174    active_connections: AtomicUsize,
175    /// Shared network audit log for this proxy session.
176    audit_log: audit::SharedAuditLog,
177    /// Matcher for hosts that bypass the external proxy and route direct.
178    /// Built once at startup from `ExternalProxyConfig.bypass_hosts`.
179    bypass_matcher: external::BypassMatcher,
180}
181
182/// Start the proxy server.
183///
184/// Binds to `config.bind_addr:config.bind_port` (port 0 = OS-assigned),
185/// generates a session token, and begins accepting connections.
186///
187/// Returns a `ProxyHandle` with the assigned port and session token.
188/// The server runs until the handle is dropped or `shutdown()` is called.
189pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
190    // Generate session token
191    let session_token = token::generate_session_token()?;
192
193    // Bind listener
194    let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
195    let listener = TcpListener::bind(bind_addr)
196        .await
197        .map_err(|e| ProxyError::Bind {
198            addr: bind_addr.to_string(),
199            source: e,
200        })?;
201
202    let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
203        addr: bind_addr.to_string(),
204        source: e,
205    })?;
206    let port = local_addr.port();
207
208    info!("Proxy server listening on {}", local_addr);
209
210    // Load route-level configuration (upstream, L7 filtering, custom TLS CA)
211    // for ALL routes, regardless of credential presence.
212    let route_store = if config.routes.is_empty() {
213        RouteStore::empty()
214    } else {
215        RouteStore::load(&config.routes)?
216    };
217
218    // Load credentials for reverse proxy routes (only routes with credential_key)
219    let credential_store = if config.routes.is_empty() {
220        CredentialStore::empty()
221    } else {
222        CredentialStore::load(&config.routes)?
223    };
224    let loaded_routes = credential_store.loaded_prefixes();
225
226    // Build filter
227    let filter = if config.allowed_hosts.is_empty() {
228        ProxyFilter::allow_all()
229    } else {
230        ProxyFilter::new(&config.allowed_hosts)
231    };
232
233    // Build shared TLS connector (root cert store is expensive to construct).
234    // Use the ring provider explicitly to avoid ambiguity when multiple
235    // crypto providers are in the dependency tree.
236    let mut root_store = rustls::RootCertStore::empty();
237    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
238    let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
239        rustls::crypto::ring::default_provider(),
240    ))
241    .with_safe_default_protocol_versions()
242    .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
243    .with_root_certificates(root_store)
244    .with_no_client_auth();
245    let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
246
247    // Build bypass matcher from external proxy config (once, not per-request)
248    let bypass_matcher = config
249        .external_proxy
250        .as_ref()
251        .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
252        .unwrap_or_else(|| external::BypassMatcher::new(&[]));
253
254    // Shutdown channel
255    let (shutdown_tx, shutdown_rx) = watch::channel(false);
256    let audit_log = audit::new_audit_log();
257
258    // Compute NO_PROXY hosts: allowed_hosts minus route upstreams.
259    // Non-route hosts bypass the proxy (direct connection, still
260    // Landlock-enforced). Route upstreams must go through the proxy
261    // for L7 path filtering and/or credential injection.
262    //
263    // On macOS this MUST be empty: Seatbelt's ProxyOnly mode generates
264    // `(deny network*) (allow network-outbound (remote tcp "localhost:PORT"))`
265    // which blocks ALL direct outbound. Tools that respect NO_PROXY would
266    // attempt direct connections that the sandbox denies (DNS lookup fails).
267    // All traffic must route through the proxy on macOS. See #580.
268    let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
269        Vec::new()
270    } else {
271        let route_hosts = route_store.route_upstream_hosts();
272        config
273            .allowed_hosts
274            .iter()
275            .filter(|host| {
276                let normalised = {
277                    let h = host.to_lowercase();
278                    if h.starts_with('[') {
279                        // IPv6 literal: "[::1]:443" has port, "[::1]" needs default
280                        if h.contains("]:") {
281                            h
282                        } else {
283                            format!("{}:443", h)
284                        }
285                    } else if h.contains(':') {
286                        h
287                    } else {
288                        format!("{}:443", h)
289                    }
290                };
291                !route_hosts.contains(&normalised)
292            })
293            .cloned()
294            .collect()
295    };
296
297    if !no_proxy_hosts.is_empty() {
298        debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
299    }
300
301    let state = Arc::new(ProxyState {
302        filter,
303        session_token: session_token.clone(),
304        route_store,
305        credential_store,
306        config,
307        tls_connector,
308        active_connections: AtomicUsize::new(0),
309        audit_log: Arc::clone(&audit_log),
310        bypass_matcher,
311    });
312
313    // Spawn accept loop as a task within the current runtime.
314    // The caller MUST ensure this runtime is being driven (e.g., via
315    // a dedicated thread calling block_on or a multi-thread runtime).
316    tokio::spawn(accept_loop(listener, state, shutdown_rx));
317
318    Ok(ProxyHandle {
319        port,
320        token: session_token,
321        audit_log,
322        shutdown_tx,
323        loaded_routes,
324        no_proxy_hosts,
325    })
326}
327
328/// Accept loop: listen for connections until shutdown.
329async fn accept_loop(
330    listener: TcpListener,
331    state: Arc<ProxyState>,
332    mut shutdown_rx: watch::Receiver<bool>,
333) {
334    loop {
335        tokio::select! {
336            result = listener.accept() => {
337                match result {
338                    Ok((stream, addr)) => {
339                        // Connection limit enforcement
340                        let max = state.config.max_connections;
341                        if max > 0 {
342                            let current = state.active_connections.load(Ordering::Relaxed);
343                            if current >= max {
344                                warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
345                                // Drop the stream (connection refused)
346                                drop(stream);
347                                continue;
348                            }
349                        }
350                        state.active_connections.fetch_add(1, Ordering::Relaxed);
351
352                        debug!("Accepted connection from {}", addr);
353                        let state = Arc::clone(&state);
354                        tokio::spawn(async move {
355                            if let Err(e) = handle_connection(stream, &state).await {
356                                debug!("Connection handler error: {}", e);
357                            }
358                            state.active_connections.fetch_sub(1, Ordering::Relaxed);
359                        });
360                    }
361                    Err(e) => {
362                        warn!("Accept error: {}", e);
363                    }
364                }
365            }
366            _ = shutdown_rx.changed() => {
367                if *shutdown_rx.borrow() {
368                    info!("Proxy server shutting down");
369                    return;
370                }
371            }
372        }
373    }
374}
375
376/// Handle a single client connection.
377///
378/// Reads the first HTTP line to determine the proxy mode:
379/// - CONNECT method -> tunnel (Mode 1 or 3)
380/// - Other methods  -> reverse proxy (Mode 2)
381async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
382    // Read the first line and headers through a BufReader.
383    // We keep the BufReader alive until we've consumed the full header
384    // to prevent data loss (BufReader may read ahead into the body).
385    let mut buf_reader = BufReader::new(&mut stream);
386    let mut first_line = String::new();
387    buf_reader.read_line(&mut first_line).await?;
388
389    if first_line.is_empty() {
390        return Ok(()); // Client disconnected
391    }
392
393    // Read remaining headers (up to empty line), with size limit to prevent OOM.
394    let mut header_bytes = Vec::new();
395    loop {
396        let mut line = String::new();
397        let n = buf_reader.read_line(&mut line).await?;
398        if n == 0 || line.trim().is_empty() {
399            break;
400        }
401        header_bytes.extend_from_slice(line.as_bytes());
402        if header_bytes.len() > MAX_HEADER_SIZE {
403            drop(buf_reader);
404            let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
405            stream.write_all(response.as_bytes()).await?;
406            return Ok(());
407        }
408    }
409
410    // Extract any data buffered beyond headers before dropping BufReader.
411    // BufReader may have read ahead into the request body. We capture
412    // those bytes and pass them to the reverse proxy handler so no body
413    // data is lost. For CONNECT requests this is always empty (no body).
414    let buffered = buf_reader.buffer().to_vec();
415    drop(buf_reader);
416
417    let first_line = first_line.trim_end();
418
419    // Dispatch by method
420    if first_line.starts_with("CONNECT ") {
421        // Block CONNECT tunnels to route upstreams. These must go
422        // through the reverse proxy path so L7 path filtering and
423        // credential injection are enforced. A CONNECT tunnel would
424        // bypass both (raw TLS pipe, proxy never sees HTTP method/path).
425        if !state.route_store.is_empty() {
426            if let Some(authority) = first_line.split_whitespace().nth(1) {
427                // Normalise authority to host:port. Handle IPv6 brackets:
428                // "[::1]:443" already has port, "[::1]" needs default, "host:443" has port.
429                let host_port = if authority.starts_with('[') {
430                    // IPv6 literal
431                    if authority.contains("]:") {
432                        authority.to_lowercase()
433                    } else {
434                        format!("{}:443", authority.to_lowercase())
435                    }
436                } else if authority.contains(':') {
437                    authority.to_lowercase()
438                } else {
439                    format!("{}:443", authority.to_lowercase())
440                };
441                if state.route_store.is_route_upstream(&host_port) {
442                    let (host, port) = host_port
443                        .rsplit_once(':')
444                        .map(|(h, p)| (h, p.parse::<u16>().unwrap_or(443)))
445                        .unwrap_or((&host_port, 443));
446                    warn!(
447                        "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
448                        authority
449                    );
450                    audit::log_denied(
451                        Some(&state.audit_log),
452                        audit::ProxyMode::Connect,
453                        host,
454                        port,
455                        "route upstream: CONNECT bypasses L7 filtering",
456                    );
457                    let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
458                    stream.write_all(response.as_bytes()).await?;
459                    return Ok(());
460                }
461            }
462        }
463
464        // Check if external proxy is configured and host is not bypassed
465        let use_external = if let Some(ref ext_config) = state.config.external_proxy {
466            if state.bypass_matcher.is_empty() {
467                Some(ext_config)
468            } else {
469                // Parse host from CONNECT line to check bypass
470                let host = first_line
471                    .split_whitespace()
472                    .nth(1)
473                    .and_then(|authority| {
474                        authority
475                            .rsplit_once(':')
476                            .map(|(h, _)| h)
477                            .or(Some(authority))
478                    })
479                    .unwrap_or("");
480                if state.bypass_matcher.matches(host) {
481                    debug!("Bypassing external proxy for {}", host);
482                    None
483                } else {
484                    Some(ext_config)
485                }
486            }
487        } else {
488            None
489        };
490
491        if let Some(ext_config) = use_external {
492            external::handle_external_proxy(
493                first_line,
494                &mut stream,
495                &header_bytes,
496                &state.filter,
497                &state.session_token,
498                ext_config,
499                Some(&state.audit_log),
500            )
501            .await
502        } else if state.config.external_proxy.is_some() {
503            // Bypass route: enforce strict session token validation before
504            // routing direct. Without this, bypassed hosts would inherit
505            // connect::handle_connect()'s lenient auth (which tolerates
506            // missing Proxy-Authorization for Node.js undici compat).
507            token::validate_proxy_auth(&header_bytes, &state.session_token)?;
508            connect::handle_connect(
509                first_line,
510                &mut stream,
511                &state.filter,
512                &state.session_token,
513                &header_bytes,
514                Some(&state.audit_log),
515            )
516            .await
517        } else {
518            connect::handle_connect(
519                first_line,
520                &mut stream,
521                &state.filter,
522                &state.session_token,
523                &header_bytes,
524                Some(&state.audit_log),
525            )
526            .await
527        }
528    } else if !state.route_store.is_empty() {
529        // Non-CONNECT request with routes configured -> reverse proxy
530        let ctx = reverse::ReverseProxyCtx {
531            route_store: &state.route_store,
532            credential_store: &state.credential_store,
533            session_token: &state.session_token,
534            filter: &state.filter,
535            tls_connector: &state.tls_connector,
536            audit_log: Some(&state.audit_log),
537        };
538        reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
539    } else {
540        // No routes configured, reject non-CONNECT requests
541        let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
542        stream.write_all(response.as_bytes()).await?;
543        Ok(())
544    }
545}
546
547#[cfg(test)]
548#[allow(clippy::unwrap_used)]
549mod tests {
550    use super::*;
551
552    #[tokio::test]
553    async fn test_proxy_starts_and_binds() {
554        let config = ProxyConfig::default();
555        let handle = start(config).await.unwrap();
556
557        // Port should be non-zero (OS-assigned)
558        assert!(handle.port > 0);
559        // Token should be 64 hex chars
560        assert_eq!(handle.token.len(), 64);
561
562        // Shutdown
563        handle.shutdown();
564    }
565
566    #[tokio::test]
567    async fn test_proxy_env_vars() {
568        let config = ProxyConfig::default();
569        let handle = start(config).await.unwrap();
570
571        let vars = handle.env_vars();
572        let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
573        assert!(http_proxy.is_some());
574        assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
575
576        let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
577        assert!(token_var.is_some());
578        assert_eq!(token_var.unwrap().1.len(), 64);
579
580        let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
581        assert!(
582            node_proxy_flag.is_none(),
583            "proxy env should avoid Node-specific flags that can perturb non-Node runtimes"
584        );
585
586        handle.shutdown();
587    }
588
589    #[tokio::test]
590    async fn test_proxy_credential_env_vars() {
591        let config = ProxyConfig {
592            routes: vec![crate::config::RouteConfig {
593                prefix: "openai".to_string(),
594                upstream: "https://api.openai.com".to_string(),
595                credential_key: None,
596                inject_mode: crate::config::InjectMode::Header,
597                inject_header: "Authorization".to_string(),
598                credential_format: "Bearer {}".to_string(),
599                path_pattern: None,
600                path_replacement: None,
601                query_param_name: None,
602                env_var: None,
603                endpoint_rules: vec![],
604                tls_ca: None,
605            }],
606            ..Default::default()
607        };
608        let handle = start(config.clone()).await.unwrap();
609
610        let vars = handle.credential_env_vars(&config);
611        assert_eq!(vars.len(), 1);
612        assert_eq!(vars[0].0, "OPENAI_BASE_URL");
613        assert!(vars[0].1.contains("/openai"));
614
615        handle.shutdown();
616    }
617
618    #[test]
619    fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
620        // When env_var is None and credential_key is set, the env var name
621        // should be derived from uppercasing credential_key. This is the
622        // backward-compatible path for keyring-backed credentials.
623        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
624        let handle = ProxyHandle {
625            port: 12345,
626            token: Zeroizing::new("test_token".to_string()),
627            audit_log: audit::new_audit_log(),
628            shutdown_tx,
629            loaded_routes: ["openai".to_string()].into_iter().collect(),
630            no_proxy_hosts: Vec::new(),
631        };
632        let config = ProxyConfig {
633            routes: vec![crate::config::RouteConfig {
634                prefix: "openai".to_string(),
635                upstream: "https://api.openai.com".to_string(),
636                credential_key: Some("openai_api_key".to_string()),
637                inject_mode: crate::config::InjectMode::Header,
638                inject_header: "Authorization".to_string(),
639                credential_format: "Bearer {}".to_string(),
640                path_pattern: None,
641                path_replacement: None,
642                query_param_name: None,
643                env_var: None, // No explicit env_var — should fall back to uppercase
644                endpoint_rules: vec![],
645                tls_ca: None,
646            }],
647            ..Default::default()
648        };
649
650        let vars = handle.credential_env_vars(&config);
651        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
652
653        // Should derive OPENAI_API_KEY from uppercasing "openai_api_key"
654        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
655        assert!(
656            api_key_var.is_some(),
657            "Should derive env var name from credential_key.to_uppercase()"
658        );
659
660        let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
661        assert_eq!(val, "test_token");
662    }
663
664    #[test]
665    fn test_proxy_credential_env_vars_with_explicit_env_var() {
666        // When env_var is set on a route, it should be used instead of
667        // deriving from credential_key. This is essential for URI manager
668        // credential refs (e.g., op://, apple-password://)
669        // where uppercasing produces nonsensical env var names.
670        //
671        // We construct a ProxyHandle directly to test env var generation
672        // without starting a real proxy (which would try to load credentials).
673        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
674        let handle = ProxyHandle {
675            port: 12345,
676            token: Zeroizing::new("test_token".to_string()),
677            audit_log: audit::new_audit_log(),
678            shutdown_tx,
679            loaded_routes: ["openai".to_string()].into_iter().collect(),
680            no_proxy_hosts: Vec::new(),
681        };
682        let config = ProxyConfig {
683            routes: vec![crate::config::RouteConfig {
684                prefix: "openai".to_string(),
685                upstream: "https://api.openai.com".to_string(),
686                credential_key: Some("op://Development/OpenAI/credential".to_string()),
687                inject_mode: crate::config::InjectMode::Header,
688                inject_header: "Authorization".to_string(),
689                credential_format: "Bearer {}".to_string(),
690                path_pattern: None,
691                path_replacement: None,
692                query_param_name: None,
693                env_var: Some("OPENAI_API_KEY".to_string()),
694                endpoint_rules: vec![],
695                tls_ca: None,
696            }],
697            ..Default::default()
698        };
699
700        let vars = handle.credential_env_vars(&config);
701        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
702
703        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
704        assert!(
705            api_key_var.is_some(),
706            "Should use explicit env_var name, not derive from credential_key"
707        );
708
709        // Verify the value is the phantom token, not the real credential
710        let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
711        assert_eq!(val, "test_token");
712
713        // Verify no nonsensical OP:// env var was generated
714        let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
715        assert!(
716            bad_var.is_none(),
717            "Should not generate env var from op:// URI uppercase"
718        );
719    }
720
721    #[test]
722    fn test_proxy_credential_env_vars_skips_unloaded_routes() {
723        // When a credential is unavailable (e.g., GITHUB_TOKEN not set),
724        // the route should NOT inject a phantom token env var. Otherwise
725        // the phantom token shadows valid credentials from other sources
726        // like the system keyring. See: #234
727        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
728        let handle = ProxyHandle {
729            port: 12345,
730            token: Zeroizing::new("test_token".to_string()),
731            audit_log: audit::new_audit_log(),
732            shutdown_tx,
733            // Only "openai" was loaded; "github" credential was unavailable
734            loaded_routes: ["openai".to_string()].into_iter().collect(),
735            no_proxy_hosts: Vec::new(),
736        };
737        let config = ProxyConfig {
738            routes: vec![
739                crate::config::RouteConfig {
740                    prefix: "openai".to_string(),
741                    upstream: "https://api.openai.com".to_string(),
742                    credential_key: Some("openai_api_key".to_string()),
743                    inject_mode: crate::config::InjectMode::Header,
744                    inject_header: "Authorization".to_string(),
745                    credential_format: "Bearer {}".to_string(),
746                    path_pattern: None,
747                    path_replacement: None,
748                    query_param_name: None,
749                    env_var: None,
750                    endpoint_rules: vec![],
751                    tls_ca: None,
752                },
753                crate::config::RouteConfig {
754                    prefix: "github".to_string(),
755                    upstream: "https://api.github.com".to_string(),
756                    credential_key: Some("env://GITHUB_TOKEN".to_string()),
757                    inject_mode: crate::config::InjectMode::Header,
758                    inject_header: "Authorization".to_string(),
759                    credential_format: "token {}".to_string(),
760                    path_pattern: None,
761                    path_replacement: None,
762                    query_param_name: None,
763                    env_var: Some("GITHUB_TOKEN".to_string()),
764                    endpoint_rules: vec![],
765                    tls_ca: None,
766                },
767            ],
768            ..Default::default()
769        };
770
771        let vars = handle.credential_env_vars(&config);
772
773        // openai should have BASE_URL + API_KEY (credential loaded)
774        let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
775        assert!(openai_base.is_some(), "loaded route should have BASE_URL");
776        let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
777        assert!(openai_key.is_some(), "loaded route should have API key");
778
779        // github should have BASE_URL (always set for declared routes) but
780        // must NOT have GITHUB_TOKEN (credential was not loaded)
781        let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
782        assert!(
783            github_base.is_some(),
784            "declared route should still have BASE_URL"
785        );
786        let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
787        assert!(
788            github_token.is_none(),
789            "unloaded route must not inject phantom GITHUB_TOKEN"
790        );
791    }
792
793    #[test]
794    fn test_proxy_credential_env_vars_strips_slashes() {
795        // When prefix includes leading/trailing slashes, the env var name
796        // must not contain slashes and the URL must not double-slash.
797        // Regression test for user-reported bug where "/anthropic" produced
798        // "/ANTHROPIC_BASE_URL=http://127.0.0.1:PORT//anthropic".
799        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
800        let handle = ProxyHandle {
801            port: 58406,
802            token: Zeroizing::new("test_token".to_string()),
803            audit_log: audit::new_audit_log(),
804            shutdown_tx,
805            loaded_routes: std::collections::HashSet::new(),
806            no_proxy_hosts: Vec::new(),
807        };
808
809        // Test leading slash
810        let config = ProxyConfig {
811            routes: vec![crate::config::RouteConfig {
812                prefix: "/anthropic".to_string(),
813                upstream: "https://api.anthropic.com".to_string(),
814                credential_key: None,
815                inject_mode: crate::config::InjectMode::Header,
816                inject_header: "Authorization".to_string(),
817                credential_format: "Bearer {}".to_string(),
818                path_pattern: None,
819                path_replacement: None,
820                query_param_name: None,
821                env_var: None,
822                endpoint_rules: vec![],
823                tls_ca: None,
824            }],
825            ..Default::default()
826        };
827
828        let vars = handle.credential_env_vars(&config);
829        assert_eq!(vars.len(), 1);
830        assert_eq!(
831            vars[0].0, "ANTHROPIC_BASE_URL",
832            "env var name must not have leading slash"
833        );
834        assert_eq!(
835            vars[0].1, "http://127.0.0.1:58406/anthropic",
836            "URL must not have double slash"
837        );
838
839        // Test trailing slash
840        let config = ProxyConfig {
841            routes: vec![crate::config::RouteConfig {
842                prefix: "openai/".to_string(),
843                upstream: "https://api.openai.com".to_string(),
844                credential_key: None,
845                inject_mode: crate::config::InjectMode::Header,
846                inject_header: "Authorization".to_string(),
847                credential_format: "Bearer {}".to_string(),
848                path_pattern: None,
849                path_replacement: None,
850                query_param_name: None,
851                env_var: None,
852                endpoint_rules: vec![],
853                tls_ca: None,
854            }],
855            ..Default::default()
856        };
857
858        let vars = handle.credential_env_vars(&config);
859        assert_eq!(
860            vars[0].0, "OPENAI_BASE_URL",
861            "env var name must not have trailing slash"
862        );
863        assert_eq!(
864            vars[0].1, "http://127.0.0.1:58406/openai",
865            "URL must not have trailing slash in path"
866        );
867    }
868
869    #[test]
870    fn test_no_proxy_excludes_credential_upstreams() {
871        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
872        let handle = ProxyHandle {
873            port: 12345,
874            token: Zeroizing::new("test_token".to_string()),
875            audit_log: audit::new_audit_log(),
876            shutdown_tx,
877            loaded_routes: std::collections::HashSet::new(),
878            no_proxy_hosts: vec![
879                "nats.internal:4222".to_string(),
880                "opencode.internal:4096".to_string(),
881            ],
882        };
883
884        let vars = handle.env_vars();
885        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
886        assert!(
887            no_proxy.1.contains("nats.internal"),
888            "non-credential host should be in NO_PROXY"
889        );
890        assert!(
891            no_proxy.1.contains("opencode.internal"),
892            "non-credential host should be in NO_PROXY"
893        );
894        assert!(
895            no_proxy.1.contains("localhost"),
896            "localhost should always be in NO_PROXY"
897        );
898    }
899
900    #[test]
901    fn test_no_proxy_empty_when_no_non_credential_hosts() {
902        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
903        let handle = ProxyHandle {
904            port: 12345,
905            token: Zeroizing::new("test_token".to_string()),
906            audit_log: audit::new_audit_log(),
907            shutdown_tx,
908            loaded_routes: std::collections::HashSet::new(),
909            no_proxy_hosts: Vec::new(),
910        };
911
912        let vars = handle.env_vars();
913        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
914        assert_eq!(
915            no_proxy.1, "localhost,127.0.0.1",
916            "NO_PROXY should only contain loopback when no bypass hosts"
917        );
918    }
919}