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