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                // Skip URI-format keys (e.g. env://, op://, apple-password://) —
154                // uppercasing a URI produces a nonsensical env var name. These
155                // routes must declare an explicit env_var to get phantom token injection.
156                if !cred_key.contains("://") {
157                    let api_key_name = cred_key.to_uppercase();
158                    vars.push((api_key_name, self.token.to_string()));
159                }
160            }
161        }
162        vars
163    }
164}
165
166/// Shared state for the proxy server.
167struct ProxyState {
168    filter: ProxyFilter,
169    session_token: Zeroizing<String>,
170    /// Route-level configuration (upstream, L7 filtering, custom TLS CA) for all routes.
171    route_store: RouteStore,
172    /// Credential-specific configuration (inject mode, headers, secrets) for routes with credentials.
173    credential_store: CredentialStore,
174    config: ProxyConfig,
175    /// Shared TLS connector for upstream connections (reverse proxy mode).
176    /// Created once at startup to avoid rebuilding the root cert store per request.
177    tls_connector: tokio_rustls::TlsConnector,
178    /// Active connection count for connection limiting.
179    active_connections: AtomicUsize,
180    /// Shared network audit log for this proxy session.
181    audit_log: audit::SharedAuditLog,
182    /// Matcher for hosts that bypass the external proxy and route direct.
183    /// Built once at startup from `ExternalProxyConfig.bypass_hosts`.
184    bypass_matcher: external::BypassMatcher,
185}
186
187/// Start the proxy server.
188///
189/// Binds to `config.bind_addr:config.bind_port` (port 0 = OS-assigned),
190/// generates a session token, and begins accepting connections.
191///
192/// Returns a `ProxyHandle` with the assigned port and session token.
193/// The server runs until the handle is dropped or `shutdown()` is called.
194pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
195    // Generate session token
196    let session_token = token::generate_session_token()?;
197
198    // Bind listener
199    let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
200    let listener = TcpListener::bind(bind_addr)
201        .await
202        .map_err(|e| ProxyError::Bind {
203            addr: bind_addr.to_string(),
204            source: e,
205        })?;
206
207    let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
208        addr: bind_addr.to_string(),
209        source: e,
210    })?;
211    let port = local_addr.port();
212
213    info!("Proxy server listening on {}", local_addr);
214
215    // Load route-level configuration (upstream, L7 filtering, custom TLS CA)
216    // for ALL routes, regardless of credential presence.
217    let route_store = if config.routes.is_empty() {
218        RouteStore::empty()
219    } else {
220        RouteStore::load(&config.routes)?
221    };
222
223    // Load credentials for reverse proxy routes (only routes with credential_key)
224    let credential_store = if config.routes.is_empty() {
225        CredentialStore::empty()
226    } else {
227        CredentialStore::load(&config.routes)?
228    };
229    let loaded_routes = credential_store.loaded_prefixes();
230
231    // Build filter
232    let filter = if config.allowed_hosts.is_empty() {
233        ProxyFilter::allow_all()
234    } else {
235        ProxyFilter::new(&config.allowed_hosts)
236    };
237
238    // Build shared TLS connector (root cert store is expensive to construct).
239    // Use the ring provider explicitly to avoid ambiguity when multiple
240    // crypto providers are in the dependency tree.
241    let mut root_store = rustls::RootCertStore::empty();
242    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
243    let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
244        rustls::crypto::ring::default_provider(),
245    ))
246    .with_safe_default_protocol_versions()
247    .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
248    .with_root_certificates(root_store)
249    .with_no_client_auth();
250    let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
251
252    // Build bypass matcher from external proxy config (once, not per-request)
253    let bypass_matcher = config
254        .external_proxy
255        .as_ref()
256        .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
257        .unwrap_or_else(|| external::BypassMatcher::new(&[]));
258
259    // Shutdown channel
260    let (shutdown_tx, shutdown_rx) = watch::channel(false);
261    let audit_log = audit::new_audit_log();
262
263    // Compute NO_PROXY hosts: allowed_hosts minus route upstreams.
264    // Non-route hosts bypass the proxy (direct connection, still
265    // Landlock-enforced). Route upstreams must go through the proxy
266    // for L7 path filtering and/or credential injection.
267    //
268    // On macOS this MUST be empty: Seatbelt's ProxyOnly mode generates
269    // `(deny network*) (allow network-outbound (remote tcp "localhost:PORT"))`
270    // which blocks ALL direct outbound. Tools that respect NO_PROXY would
271    // attempt direct connections that the sandbox denies (DNS lookup fails).
272    // All traffic must route through the proxy on macOS. See #580.
273    let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
274        Vec::new()
275    } else {
276        let route_hosts = route_store.route_upstream_hosts();
277        config
278            .allowed_hosts
279            .iter()
280            .filter(|host| {
281                let normalised = {
282                    let h = host.to_lowercase();
283                    if h.starts_with('[') {
284                        // IPv6 literal: "[::1]:443" has port, "[::1]" needs default
285                        if h.contains("]:") {
286                            h
287                        } else {
288                            format!("{}:443", h)
289                        }
290                    } else if h.contains(':') {
291                        h
292                    } else {
293                        format!("{}:443", h)
294                    }
295                };
296                !route_hosts.contains(&normalised)
297            })
298            .cloned()
299            .collect()
300    };
301
302    if !no_proxy_hosts.is_empty() {
303        debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
304    }
305
306    let state = Arc::new(ProxyState {
307        filter,
308        session_token: session_token.clone(),
309        route_store,
310        credential_store,
311        config,
312        tls_connector,
313        active_connections: AtomicUsize::new(0),
314        audit_log: Arc::clone(&audit_log),
315        bypass_matcher,
316    });
317
318    // Spawn accept loop as a task within the current runtime.
319    // The caller MUST ensure this runtime is being driven (e.g., via
320    // a dedicated thread calling block_on or a multi-thread runtime).
321    tokio::spawn(accept_loop(listener, state, shutdown_rx));
322
323    Ok(ProxyHandle {
324        port,
325        token: session_token,
326        audit_log,
327        shutdown_tx,
328        loaded_routes,
329        no_proxy_hosts,
330    })
331}
332
333/// Accept loop: listen for connections until shutdown.
334async fn accept_loop(
335    listener: TcpListener,
336    state: Arc<ProxyState>,
337    mut shutdown_rx: watch::Receiver<bool>,
338) {
339    loop {
340        tokio::select! {
341            result = listener.accept() => {
342                match result {
343                    Ok((stream, addr)) => {
344                        // Connection limit enforcement
345                        let max = state.config.max_connections;
346                        if max > 0 {
347                            let current = state.active_connections.load(Ordering::Relaxed);
348                            if current >= max {
349                                warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
350                                // Drop the stream (connection refused)
351                                drop(stream);
352                                continue;
353                            }
354                        }
355                        state.active_connections.fetch_add(1, Ordering::Relaxed);
356
357                        debug!("Accepted connection from {}", addr);
358                        let state = Arc::clone(&state);
359                        tokio::spawn(async move {
360                            if let Err(e) = handle_connection(stream, &state).await {
361                                debug!("Connection handler error: {}", e);
362                            }
363                            state.active_connections.fetch_sub(1, Ordering::Relaxed);
364                        });
365                    }
366                    Err(e) => {
367                        warn!("Accept error: {}", e);
368                    }
369                }
370            }
371            _ = shutdown_rx.changed() => {
372                if *shutdown_rx.borrow() {
373                    info!("Proxy server shutting down");
374                    return;
375                }
376            }
377        }
378    }
379}
380
381/// Handle a single client connection.
382///
383/// Reads the first HTTP line to determine the proxy mode:
384/// - CONNECT method -> tunnel (Mode 1 or 3)
385/// - Other methods  -> reverse proxy (Mode 2)
386async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
387    // Read the first line and headers through a BufReader.
388    // We keep the BufReader alive until we've consumed the full header
389    // to prevent data loss (BufReader may read ahead into the body).
390    let mut buf_reader = BufReader::new(&mut stream);
391    let mut first_line = String::new();
392    buf_reader.read_line(&mut first_line).await?;
393
394    if first_line.is_empty() {
395        return Ok(()); // Client disconnected
396    }
397
398    // Read remaining headers (up to empty line), with size limit to prevent OOM.
399    let mut header_bytes = Vec::new();
400    loop {
401        let mut line = String::new();
402        let n = buf_reader.read_line(&mut line).await?;
403        if n == 0 || line.trim().is_empty() {
404            break;
405        }
406        header_bytes.extend_from_slice(line.as_bytes());
407        if header_bytes.len() > MAX_HEADER_SIZE {
408            drop(buf_reader);
409            let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
410            stream.write_all(response.as_bytes()).await?;
411            return Ok(());
412        }
413    }
414
415    // Extract any data buffered beyond headers before dropping BufReader.
416    // BufReader may have read ahead into the request body. We capture
417    // those bytes and pass them to the reverse proxy handler so no body
418    // data is lost. For CONNECT requests this is always empty (no body).
419    let buffered = buf_reader.buffer().to_vec();
420    drop(buf_reader);
421
422    let first_line = first_line.trim_end();
423
424    // Dispatch by method
425    if first_line.starts_with("CONNECT ") {
426        // Block CONNECT tunnels to route upstreams. These must go
427        // through the reverse proxy path so L7 path filtering and
428        // credential injection are enforced. A CONNECT tunnel would
429        // bypass both (raw TLS pipe, proxy never sees HTTP method/path).
430        if !state.route_store.is_empty() {
431            if let Some(authority) = first_line.split_whitespace().nth(1) {
432                // Normalise authority to host:port. Handle IPv6 brackets:
433                // "[::1]:443" already has port, "[::1]" needs default, "host:443" has port.
434                let host_port = if authority.starts_with('[') {
435                    // IPv6 literal
436                    if authority.contains("]:") {
437                        authority.to_lowercase()
438                    } else {
439                        format!("{}:443", authority.to_lowercase())
440                    }
441                } else if authority.contains(':') {
442                    authority.to_lowercase()
443                } else {
444                    format!("{}:443", authority.to_lowercase())
445                };
446                if state.route_store.is_route_upstream(&host_port) {
447                    let (host, port) = host_port
448                        .rsplit_once(':')
449                        .map(|(h, p)| (h, p.parse::<u16>().unwrap_or(443)))
450                        .unwrap_or((&host_port, 443));
451                    warn!(
452                        "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
453                        authority
454                    );
455                    audit::log_denied(
456                        Some(&state.audit_log),
457                        audit::ProxyMode::Connect,
458                        host,
459                        port,
460                        "route upstream: CONNECT bypasses L7 filtering",
461                    );
462                    let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
463                    stream.write_all(response.as_bytes()).await?;
464                    return Ok(());
465                }
466            }
467        }
468
469        // Check if external proxy is configured and host is not bypassed
470        let use_external = if let Some(ref ext_config) = state.config.external_proxy {
471            if state.bypass_matcher.is_empty() {
472                Some(ext_config)
473            } else {
474                // Parse host from CONNECT line to check bypass
475                let host = first_line
476                    .split_whitespace()
477                    .nth(1)
478                    .and_then(|authority| {
479                        authority
480                            .rsplit_once(':')
481                            .map(|(h, _)| h)
482                            .or(Some(authority))
483                    })
484                    .unwrap_or("");
485                if state.bypass_matcher.matches(host) {
486                    debug!("Bypassing external proxy for {}", host);
487                    None
488                } else {
489                    Some(ext_config)
490                }
491            }
492        } else {
493            None
494        };
495
496        if let Some(ext_config) = use_external {
497            external::handle_external_proxy(
498                first_line,
499                &mut stream,
500                &header_bytes,
501                &state.filter,
502                &state.session_token,
503                ext_config,
504                Some(&state.audit_log),
505            )
506            .await
507        } else if state.config.external_proxy.is_some() {
508            // Bypass route: enforce strict session token validation before
509            // routing direct. Without this, bypassed hosts would inherit
510            // connect::handle_connect()'s lenient auth (which tolerates
511            // missing Proxy-Authorization for Node.js undici compat).
512            token::validate_proxy_auth(&header_bytes, &state.session_token)?;
513            connect::handle_connect(
514                first_line,
515                &mut stream,
516                &state.filter,
517                &state.session_token,
518                &header_bytes,
519                Some(&state.audit_log),
520            )
521            .await
522        } else {
523            connect::handle_connect(
524                first_line,
525                &mut stream,
526                &state.filter,
527                &state.session_token,
528                &header_bytes,
529                Some(&state.audit_log),
530            )
531            .await
532        }
533    } else if !state.route_store.is_empty() {
534        // Non-CONNECT request with routes configured -> reverse proxy
535        let ctx = reverse::ReverseProxyCtx {
536            route_store: &state.route_store,
537            credential_store: &state.credential_store,
538            session_token: &state.session_token,
539            filter: &state.filter,
540            tls_connector: &state.tls_connector,
541            audit_log: Some(&state.audit_log),
542        };
543        reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
544    } else {
545        // No routes configured, reject non-CONNECT requests
546        let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
547        stream.write_all(response.as_bytes()).await?;
548        Ok(())
549    }
550}
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used)]
554mod tests {
555    use super::*;
556
557    #[tokio::test]
558    async fn test_proxy_starts_and_binds() {
559        let config = ProxyConfig::default();
560        let handle = start(config).await.unwrap();
561
562        // Port should be non-zero (OS-assigned)
563        assert!(handle.port > 0);
564        // Token should be 64 hex chars
565        assert_eq!(handle.token.len(), 64);
566
567        // Shutdown
568        handle.shutdown();
569    }
570
571    #[tokio::test]
572    async fn test_proxy_env_vars() {
573        let config = ProxyConfig::default();
574        let handle = start(config).await.unwrap();
575
576        let vars = handle.env_vars();
577        let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
578        assert!(http_proxy.is_some());
579        assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
580
581        let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
582        assert!(token_var.is_some());
583        assert_eq!(token_var.unwrap().1.len(), 64);
584
585        let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
586        assert!(
587            node_proxy_flag.is_none(),
588            "proxy env should avoid Node-specific flags that can perturb non-Node runtimes"
589        );
590
591        handle.shutdown();
592    }
593
594    #[tokio::test]
595    async fn test_proxy_credential_env_vars() {
596        let config = ProxyConfig {
597            routes: vec![crate::config::RouteConfig {
598                prefix: "openai".to_string(),
599                upstream: "https://api.openai.com".to_string(),
600                credential_key: None,
601                inject_mode: crate::config::InjectMode::Header,
602                inject_header: "Authorization".to_string(),
603                credential_format: "Bearer {}".to_string(),
604                path_pattern: None,
605                path_replacement: None,
606                query_param_name: None,
607                proxy: None,
608                env_var: None,
609                endpoint_rules: vec![],
610                tls_ca: None,
611                tls_client_cert: None,
612                tls_client_key: None,
613            }],
614            ..Default::default()
615        };
616        let handle = start(config.clone()).await.unwrap();
617
618        let vars = handle.credential_env_vars(&config);
619        assert_eq!(vars.len(), 1);
620        assert_eq!(vars[0].0, "OPENAI_BASE_URL");
621        assert!(vars[0].1.contains("/openai"));
622
623        handle.shutdown();
624    }
625
626    #[test]
627    fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
628        // When env_var is None and credential_key is set, the env var name
629        // should be derived from uppercasing credential_key. This is the
630        // backward-compatible path for keyring-backed credentials.
631        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
632        let handle = ProxyHandle {
633            port: 12345,
634            token: Zeroizing::new("test_token".to_string()),
635            audit_log: audit::new_audit_log(),
636            shutdown_tx,
637            loaded_routes: ["openai".to_string()].into_iter().collect(),
638            no_proxy_hosts: Vec::new(),
639        };
640        let config = ProxyConfig {
641            routes: vec![crate::config::RouteConfig {
642                prefix: "openai".to_string(),
643                upstream: "https://api.openai.com".to_string(),
644                credential_key: Some("openai_api_key".to_string()),
645                inject_mode: crate::config::InjectMode::Header,
646                inject_header: "Authorization".to_string(),
647                credential_format: "Bearer {}".to_string(),
648                path_pattern: None,
649                path_replacement: None,
650                query_param_name: None,
651                proxy: None,
652                env_var: None, // No explicit env_var — should fall back to uppercase
653                endpoint_rules: vec![],
654                tls_ca: None,
655                tls_client_cert: None,
656                tls_client_key: None,
657            }],
658            ..Default::default()
659        };
660
661        let vars = handle.credential_env_vars(&config);
662        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
663
664        // Should derive OPENAI_API_KEY from uppercasing "openai_api_key"
665        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
666        assert!(
667            api_key_var.is_some(),
668            "Should derive env var name from credential_key.to_uppercase()"
669        );
670
671        let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
672        assert_eq!(val, "test_token");
673    }
674
675    #[test]
676    fn test_proxy_credential_env_vars_with_explicit_env_var() {
677        // When env_var is set on a route, it should be used instead of
678        // deriving from credential_key. This is essential for URI manager
679        // credential refs (e.g., op://, apple-password://)
680        // where uppercasing produces nonsensical env var names.
681        //
682        // We construct a ProxyHandle directly to test env var generation
683        // without starting a real proxy (which would try to load credentials).
684        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
685        let handle = ProxyHandle {
686            port: 12345,
687            token: Zeroizing::new("test_token".to_string()),
688            audit_log: audit::new_audit_log(),
689            shutdown_tx,
690            loaded_routes: ["openai".to_string()].into_iter().collect(),
691            no_proxy_hosts: Vec::new(),
692        };
693        let config = ProxyConfig {
694            routes: vec![crate::config::RouteConfig {
695                prefix: "openai".to_string(),
696                upstream: "https://api.openai.com".to_string(),
697                credential_key: Some("op://Development/OpenAI/credential".to_string()),
698                inject_mode: crate::config::InjectMode::Header,
699                inject_header: "Authorization".to_string(),
700                credential_format: "Bearer {}".to_string(),
701                path_pattern: None,
702                path_replacement: None,
703                query_param_name: None,
704                proxy: None,
705                env_var: Some("OPENAI_API_KEY".to_string()),
706                endpoint_rules: vec![],
707                tls_ca: None,
708                tls_client_cert: None,
709                tls_client_key: None,
710            }],
711            ..Default::default()
712        };
713
714        let vars = handle.credential_env_vars(&config);
715        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
716
717        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
718        assert!(
719            api_key_var.is_some(),
720            "Should use explicit env_var name, not derive from credential_key"
721        );
722
723        // Verify the value is the phantom token, not the real credential
724        let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
725        assert_eq!(val, "test_token");
726
727        // Verify no nonsensical OP:// env var was generated
728        let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
729        assert!(
730            bad_var.is_none(),
731            "Should not generate env var from op:// URI uppercase"
732        );
733    }
734
735    #[test]
736    fn test_proxy_credential_env_vars_skips_unloaded_routes() {
737        // When a credential is unavailable (e.g., GITHUB_TOKEN not set),
738        // the route should NOT inject a phantom token env var. Otherwise
739        // the phantom token shadows valid credentials from other sources
740        // like the system keyring. See: #234
741        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
742        let handle = ProxyHandle {
743            port: 12345,
744            token: Zeroizing::new("test_token".to_string()),
745            audit_log: audit::new_audit_log(),
746            shutdown_tx,
747            // Only "openai" was loaded; "github" credential was unavailable
748            loaded_routes: ["openai".to_string()].into_iter().collect(),
749            no_proxy_hosts: Vec::new(),
750        };
751        let config = ProxyConfig {
752            routes: vec![
753                crate::config::RouteConfig {
754                    prefix: "openai".to_string(),
755                    upstream: "https://api.openai.com".to_string(),
756                    credential_key: Some("openai_api_key".to_string()),
757                    inject_mode: crate::config::InjectMode::Header,
758                    inject_header: "Authorization".to_string(),
759                    credential_format: "Bearer {}".to_string(),
760                    path_pattern: None,
761                    path_replacement: None,
762                    query_param_name: None,
763                    proxy: None,
764                    env_var: None,
765                    endpoint_rules: vec![],
766                    tls_ca: None,
767                    tls_client_cert: None,
768                    tls_client_key: None,
769                },
770                crate::config::RouteConfig {
771                    prefix: "github".to_string(),
772                    upstream: "https://api.github.com".to_string(),
773                    credential_key: Some("env://GITHUB_TOKEN".to_string()),
774                    inject_mode: crate::config::InjectMode::Header,
775                    inject_header: "Authorization".to_string(),
776                    credential_format: "token {}".to_string(),
777                    path_pattern: None,
778                    path_replacement: None,
779                    query_param_name: None,
780                    proxy: None,
781                    env_var: Some("GITHUB_TOKEN".to_string()),
782                    endpoint_rules: vec![],
783                    tls_ca: None,
784                    tls_client_cert: None,
785                    tls_client_key: None,
786                },
787            ],
788            ..Default::default()
789        };
790
791        let vars = handle.credential_env_vars(&config);
792
793        // openai should have BASE_URL + API_KEY (credential loaded)
794        let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
795        assert!(openai_base.is_some(), "loaded route should have BASE_URL");
796        let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
797        assert!(openai_key.is_some(), "loaded route should have API key");
798
799        // github should have BASE_URL (always set for declared routes) but
800        // must NOT have GITHUB_TOKEN (credential was not loaded)
801        let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
802        assert!(
803            github_base.is_some(),
804            "declared route should still have BASE_URL"
805        );
806        let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
807        assert!(
808            github_token.is_none(),
809            "unloaded route must not inject phantom GITHUB_TOKEN"
810        );
811    }
812
813    #[test]
814    fn test_proxy_credential_env_vars_strips_slashes() {
815        // When prefix includes leading/trailing slashes, the env var name
816        // must not contain slashes and the URL must not double-slash.
817        // Regression test for user-reported bug where "/anthropic" produced
818        // "/ANTHROPIC_BASE_URL=http://127.0.0.1:PORT//anthropic".
819        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
820        let handle = ProxyHandle {
821            port: 58406,
822            token: Zeroizing::new("test_token".to_string()),
823            audit_log: audit::new_audit_log(),
824            shutdown_tx,
825            loaded_routes: std::collections::HashSet::new(),
826            no_proxy_hosts: Vec::new(),
827        };
828
829        // Test leading slash
830        let config = ProxyConfig {
831            routes: vec![crate::config::RouteConfig {
832                prefix: "/anthropic".to_string(),
833                upstream: "https://api.anthropic.com".to_string(),
834                credential_key: None,
835                inject_mode: crate::config::InjectMode::Header,
836                inject_header: "Authorization".to_string(),
837                credential_format: "Bearer {}".to_string(),
838                path_pattern: None,
839                path_replacement: None,
840                query_param_name: None,
841                proxy: None,
842                env_var: None,
843                endpoint_rules: vec![],
844                tls_ca: None,
845                tls_client_cert: None,
846                tls_client_key: None,
847            }],
848            ..Default::default()
849        };
850
851        let vars = handle.credential_env_vars(&config);
852        assert_eq!(vars.len(), 1);
853        assert_eq!(
854            vars[0].0, "ANTHROPIC_BASE_URL",
855            "env var name must not have leading slash"
856        );
857        assert_eq!(
858            vars[0].1, "http://127.0.0.1:58406/anthropic",
859            "URL must not have double slash"
860        );
861
862        // Test trailing slash
863        let config = ProxyConfig {
864            routes: vec![crate::config::RouteConfig {
865                prefix: "openai/".to_string(),
866                upstream: "https://api.openai.com".to_string(),
867                credential_key: None,
868                inject_mode: crate::config::InjectMode::Header,
869                inject_header: "Authorization".to_string(),
870                credential_format: "Bearer {}".to_string(),
871                path_pattern: None,
872                path_replacement: None,
873                query_param_name: None,
874                proxy: None,
875                env_var: None,
876                endpoint_rules: vec![],
877                tls_ca: None,
878                tls_client_cert: None,
879                tls_client_key: None,
880            }],
881            ..Default::default()
882        };
883
884        let vars = handle.credential_env_vars(&config);
885        assert_eq!(
886            vars[0].0, "OPENAI_BASE_URL",
887            "env var name must not have trailing slash"
888        );
889        assert_eq!(
890            vars[0].1, "http://127.0.0.1:58406/openai",
891            "URL must not have trailing slash in path"
892        );
893    }
894
895    #[test]
896    fn test_anthropic_credential_phantom_token_regression() {
897        // Regression test for issue #624: the built-in anthropic credential
898        // entry had no env_var or credential_key, so ANTHROPIC_API_KEY was
899        // never set to the phantom token. Only ANTHROPIC_BASE_URL was injected,
900        // leaving the sandbox to send the host's real key directly.
901        //
902        // Pre-fix state: route in loaded_routes but no env_var / credential_key
903        // => ANTHROPIC_API_KEY must NOT appear (demonstrates the bug).
904        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
905        let handle_no_env_var = ProxyHandle {
906            port: 12345,
907            token: Zeroizing::new("phantom".to_string()),
908            audit_log: audit::new_audit_log(),
909            shutdown_tx: shutdown_tx.clone(),
910            loaded_routes: ["anthropic".to_string()].into_iter().collect(),
911            no_proxy_hosts: Vec::new(),
912        };
913        let config_no_env_var = ProxyConfig {
914            routes: vec![crate::config::RouteConfig {
915                prefix: "anthropic".to_string(),
916                upstream: "https://api.anthropic.com".to_string(),
917                credential_key: None,
918                inject_mode: crate::config::InjectMode::Header,
919                inject_header: "x-api-key".to_string(),
920                credential_format: "{}".to_string(),
921                path_pattern: None,
922                path_replacement: None,
923                query_param_name: None,
924                proxy: None,
925                env_var: None,
926                endpoint_rules: vec![],
927                tls_ca: None,
928                tls_client_cert: None,
929                tls_client_key: None,
930            }],
931            ..Default::default()
932        };
933        let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
934        assert!(
935            vars_no_env_var.iter().all(|(k, _)| k != "ANTHROPIC_API_KEY"),
936            "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
937        );
938
939        // Post-fix state: route has env_var = "ANTHROPIC_API_KEY"
940        // => ANTHROPIC_API_KEY must be set to the phantom token.
941        let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
942        let handle_fixed = ProxyHandle {
943            port: 12345,
944            token: Zeroizing::new("phantom".to_string()),
945            audit_log: audit::new_audit_log(),
946            shutdown_tx: shutdown_tx2,
947            loaded_routes: ["anthropic".to_string()].into_iter().collect(),
948            no_proxy_hosts: Vec::new(),
949        };
950        let config_fixed = ProxyConfig {
951            routes: vec![crate::config::RouteConfig {
952                prefix: "anthropic".to_string(),
953                upstream: "https://api.anthropic.com".to_string(),
954                credential_key: Some("ANTHROPIC_API_KEY".to_string()),
955                inject_mode: crate::config::InjectMode::Header,
956                inject_header: "x-api-key".to_string(),
957                credential_format: "{}".to_string(),
958                path_pattern: None,
959                path_replacement: None,
960                query_param_name: None,
961                proxy: None,
962                env_var: Some("ANTHROPIC_API_KEY".to_string()),
963                endpoint_rules: vec![],
964                tls_ca: None,
965                tls_client_cert: None,
966                tls_client_key: None,
967            }],
968            ..Default::default()
969        };
970        let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
971        let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
972        assert!(
973            api_key_var.is_some(),
974            "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
975        );
976        assert_eq!(api_key_var.unwrap().1, "phantom");
977    }
978
979    #[test]
980    fn test_no_proxy_excludes_credential_upstreams() {
981        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
982        let handle = ProxyHandle {
983            port: 12345,
984            token: Zeroizing::new("test_token".to_string()),
985            audit_log: audit::new_audit_log(),
986            shutdown_tx,
987            loaded_routes: std::collections::HashSet::new(),
988            no_proxy_hosts: vec![
989                "nats.internal:4222".to_string(),
990                "opencode.internal:4096".to_string(),
991            ],
992        };
993
994        let vars = handle.env_vars();
995        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
996        assert!(
997            no_proxy.1.contains("nats.internal"),
998            "non-credential host should be in NO_PROXY"
999        );
1000        assert!(
1001            no_proxy.1.contains("opencode.internal"),
1002            "non-credential host should be in NO_PROXY"
1003        );
1004        assert!(
1005            no_proxy.1.contains("localhost"),
1006            "localhost should always be in NO_PROXY"
1007        );
1008    }
1009
1010    #[test]
1011    fn test_no_proxy_empty_when_no_non_credential_hosts() {
1012        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1013        let handle = ProxyHandle {
1014            port: 12345,
1015            token: Zeroizing::new("test_token".to_string()),
1016            audit_log: audit::new_audit_log(),
1017            shutdown_tx,
1018            loaded_routes: std::collections::HashSet::new(),
1019            no_proxy_hosts: Vec::new(),
1020        };
1021
1022        let vars = handle.env_vars();
1023        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1024        assert_eq!(
1025            no_proxy.1, "localhost,127.0.0.1",
1026            "NO_PROXY should only contain loopback when no bypass hosts"
1027        );
1028    }
1029}