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