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::tls_intercept::{self, CertCache, EphemeralCa};
20use crate::token;
21use std::net::SocketAddr;
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::Arc;
25use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
26use tokio::net::TcpListener;
27use tokio::sync::watch;
28use tracing::{debug, info, warn};
29use zeroize::Zeroizing;
30
31/// Maximum total size of HTTP headers (64 KiB). Prevents OOM from
32/// malicious clients sending unbounded header data.
33const MAX_HEADER_SIZE: usize = 64 * 1024;
34
35/// Handle returned when the proxy server starts.
36///
37/// Contains the assigned port, session token, and a shutdown channel.
38/// Drop the handle or send to `shutdown_tx` to stop the proxy.
39pub struct ProxyHandle {
40    /// The actual port the proxy is listening on
41    pub port: u16,
42    /// Session token for client authentication
43    pub token: Zeroizing<String>,
44    /// Shared in-memory network audit log
45    audit_log: audit::SharedAuditLog,
46    /// Send `true` to trigger graceful shutdown
47    shutdown_tx: watch::Sender<bool>,
48    /// Route prefixes that have credentials actually loaded.
49    /// Routes whose credentials were unavailable are excluded so we
50    /// don't inject phantom tokens that shadow valid external credentials.
51    loaded_routes: std::collections::HashSet<String>,
52    /// Non-credential allowed hosts that should bypass the proxy (NO_PROXY).
53    /// Computed at startup: `allowed_hosts` minus credential upstream hosts.
54    no_proxy_hosts: Vec<String>,
55    /// Path to the TLS-intercept trust bundle written at startup, when
56    /// interception is active. The CLI passes this path to the sandboxed
57    /// child via env vars (`SSL_CERT_FILE` etc.) and grants a Landlock /
58    /// Seatbelt read capability on it. `None` when interception is not
59    /// configured (no `intercept_ca_dir`) or no route requires L7 visibility.
60    intercept_ca_path: Option<PathBuf>,
61}
62
63impl ProxyHandle {
64    /// Signal the proxy to shut down gracefully.
65    pub fn shutdown(&self) {
66        let _ = self.shutdown_tx.send(true);
67    }
68
69    /// Drain and return collected network audit events.
70    #[must_use]
71    pub fn drain_audit_events(&self) -> Vec<nono::undo::NetworkAuditEvent> {
72        audit::drain_audit_events(&self.audit_log)
73    }
74
75    /// Path to the TLS-intercept trust bundle, when interception is active.
76    ///
77    /// The CLI uses this to:
78    /// * point `SSL_CERT_FILE` / `REQUESTS_CA_BUNDLE` / `NODE_EXTRA_CA_CERTS`
79    ///   / `CURL_CA_BUNDLE` at the file in the child env;
80    /// * grant the sandboxed child a Landlock / Seatbelt read capability
81    ///   on the file before applying the sandbox.
82    ///
83    /// `None` when interception is not configured (no `intercept_ca_dir`
84    /// in `ProxyConfig`) or when no configured route requires L7 visibility.
85    #[must_use]
86    pub fn intercept_ca_path(&self) -> Option<&std::path::Path> {
87        self.intercept_ca_path.as_deref()
88    }
89
90    /// One-line-per-route diagnostic summary suitable for surfacing at
91    /// session start. Returns `(prefix, summary)` pairs.
92    ///
93    /// Each summary names: upstream URL, credential resolution status
94    /// (✓ / ✗ + source label), TLS-intercept on/off, and `endpoint_rules`
95    /// count. Designed to make silent credential-resolution failures
96    /// noisy by default, addressing the common "I created the keychain
97    /// entry but the warn at debug level got missed" footgun.
98    ///
99    /// `config` is the same `ProxyConfig` that was passed to `start()`;
100    /// the handle doesn't keep a copy, so the CLI passes it back in.
101    #[must_use]
102    pub fn route_diagnostics(&self, config: &ProxyConfig) -> Vec<(String, String)> {
103        let mut rows = Vec::with_capacity(config.routes.len());
104        for route in &config.routes {
105            let prefix = route.prefix.trim_matches('/').to_string();
106            let cred_summary = if let Some(ref key) = route.credential_key {
107                let resolved = self.loaded_routes.contains(&prefix);
108                if resolved {
109                    format!("creds: {} ✓", key)
110                } else {
111                    format!("creds: {} ✗ (not found)", key)
112                }
113            } else if route.oauth2.is_some() {
114                let resolved = self.loaded_routes.contains(&prefix);
115                if resolved {
116                    "creds: oauth2 ✓".to_string()
117                } else {
118                    "creds: oauth2 ✗ (token exchange failed)".to_string()
119                }
120            } else {
121                "creds: none".to_string()
122            };
123
124            let intercept_summary = if self.intercept_ca_path.is_some()
125                && (route.credential_key.is_some()
126                    || route.oauth2.is_some()
127                    || !route.endpoint_rules.is_empty())
128            {
129                "intercept: on"
130            } else {
131                "intercept: off"
132            };
133
134            let rules_summary = format!("endpoint_rules: {}", route.endpoint_rules.len());
135            let summary = format!(
136                "→ {} | {} | {} | {}",
137                route.upstream, cred_summary, intercept_summary, rules_summary
138            );
139            rows.push((prefix, summary));
140        }
141        rows
142    }
143
144    /// Environment variables to inject into the child process.
145    ///
146    /// The proxy URL includes `nono:<token>@` userinfo so that standard HTTP
147    /// clients (curl, Python requests, etc.) automatically send
148    /// `Proxy-Authorization: Basic ...` on every request. The raw token is
149    /// also provided via `NONO_PROXY_TOKEN` for nono-aware clients that
150    /// prefer Bearer auth.
151    ///
152    /// When TLS interception is active (`intercept_ca_path()` is `Some`),
153    /// the standard runtime CA-trust env vars are also set so the agent
154    /// trusts the proxy's ephemeral CA when minted leaf certs are
155    /// presented during interception.
156    #[must_use]
157    pub fn env_vars(&self) -> Vec<(String, String)> {
158        let proxy_url = format!("http://nono:{}@127.0.0.1:{}", &*self.token, self.port);
159
160        // Build NO_PROXY: always include loopback, plus non-credential
161        // allowed hosts. Credential upstreams are excluded so their traffic
162        // goes through the reverse proxy for L7 filtering + injection.
163        let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
164        for host in &self.no_proxy_hosts {
165            // Strip port for NO_PROXY (most HTTP clients match on hostname).
166            // Handle IPv6 brackets: "[::1]:443" → "[::1]", "host:443" → "host"
167            let hostname = if host.contains("]:") {
168                // IPv6 with port: split at "]:port"
169                host.rsplit_once("]:")
170                    .map(|(h, _)| format!("{}]", h))
171                    .unwrap_or_else(|| host.clone())
172            } else {
173                host.rsplit_once(':')
174                    .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h.to_string()))
175                    .unwrap_or_else(|| host.clone())
176            };
177            if !no_proxy_parts.contains(&hostname.to_string()) {
178                no_proxy_parts.push(hostname.to_string());
179            }
180        }
181        let no_proxy = no_proxy_parts.join(",");
182
183        let mut vars = vec![
184            ("HTTP_PROXY".to_string(), proxy_url.clone()),
185            ("HTTPS_PROXY".to_string(), proxy_url.clone()),
186            ("NO_PROXY".to_string(), no_proxy.clone()),
187            ("NONO_PROXY_TOKEN".to_string(), self.token.to_string()),
188        ];
189
190        // Lowercase variants for compatibility
191        vars.push(("http_proxy".to_string(), proxy_url.clone()));
192        vars.push(("https_proxy".to_string(), proxy_url));
193        vars.push(("no_proxy".to_string(), no_proxy));
194
195        // Node.js 20.6+ needs an explicit hint to use HTTPS_PROXY for built-in
196        // fetch(). Without it, Node-based clients can bypass the proxy and hit
197        // the sandboxed network directly.
198        // NODE_USE_ENV_PROXY tells Node's built-in fetch() to read HTTPS_PROXY
199        // from the environment.
200        // Harmless to non-Node runtimes — they ignore unknown env vars.
201        vars.push(("NODE_USE_ENV_PROXY".to_string(), "1".to_string()));
202
203        // TLS-intercept trust injection. The bundle file at this path
204        // contains the parent's `SSL_CERT_FILE` (if any) + the host's
205        // system trust store + the ephemeral session CA, so standard
206        // runtimes see a superset of the trust they had before nono.
207        //
208        // Replacement semantics (swap out the default store entirely):
209        //   SSL_CERT_FILE, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO
210        // Additive semantics (default + this file):
211        //   NODE_EXTRA_CA_CERTS
212        //
213        // Pointing all five at the same bundle is safe: Node sees system
214        // roots twice (harmless), and all other runtimes get the union of
215        // trust they need.
216        if let Some(path) = self.intercept_ca_path.as_deref() {
217            let path_str = path.to_string_lossy().to_string();
218            vars.push(("SSL_CERT_FILE".to_string(), path_str.clone()));
219            vars.push(("REQUESTS_CA_BUNDLE".to_string(), path_str.clone()));
220            vars.push(("NODE_EXTRA_CA_CERTS".to_string(), path_str.clone()));
221            vars.push(("CURL_CA_BUNDLE".to_string(), path_str.clone()));
222            vars.push(("GIT_SSL_CAINFO".to_string(), path_str));
223        }
224
225        vars
226    }
227
228    /// Environment variables for reverse proxy credential routes.
229    ///
230    /// Returns two types of env vars per route:
231    /// 1. SDK base URL overrides (e.g., `OPENAI_BASE_URL=http://127.0.0.1:PORT/openai`)
232    /// 2. SDK API key vars set to the session token (e.g., `OPENAI_API_KEY=<token>`)
233    ///
234    /// The SDK sends the session token as its "API key" (phantom token pattern).
235    /// The proxy validates this token and swaps it for the real credential.
236    #[must_use]
237    pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
238        let mut vars = Vec::new();
239        for route in &config.routes {
240            // Strip any leading or trailing '/' from the prefix — prefix should
241            // be a bare service name (e.g., "anthropic"), not a URL path.
242            // Defensively handle both forms to prevent malformed env var names
243            // and double-slashed URLs.
244            let prefix = route.prefix.trim_matches('/');
245
246            // Base URL override (e.g., OPENAI_BASE_URL)
247            let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
248            let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
249            vars.push((base_url_name, url));
250
251            // Only inject phantom token env vars for routes whose credentials
252            // were actually loaded. If a credential was unavailable (e.g.,
253            // GITHUB_TOKEN env var not set), injecting a phantom token would
254            // shadow valid credentials from other sources (keyring, gh auth).
255            if !self.loaded_routes.contains(prefix) {
256                continue;
257            }
258
259            // API key set to session token (phantom token pattern).
260            // Use explicit env_var if set (required for URI manager refs), otherwise
261            // fall back to uppercasing the credential_key (e.g., "openai_api_key" -> "OPENAI_API_KEY").
262            if let Some(ref env_var) = route.env_var {
263                vars.push((env_var.clone(), self.token.to_string()));
264            } else if let Some(ref cred_key) = route.credential_key {
265                // Skip URI-format keys (e.g. env://, op://, apple-password://) —
266                // uppercasing a URI produces a nonsensical env var name. These
267                // routes must declare an explicit env_var to get phantom token injection.
268                if !cred_key.contains("://") {
269                    let api_key_name = cred_key.to_uppercase();
270                    vars.push((api_key_name, self.token.to_string()));
271                }
272            }
273        }
274        vars
275    }
276}
277
278impl Drop for ProxyHandle {
279    /// Best-effort cleanup of the TLS-intercept trust bundle on shutdown.
280    ///
281    /// The CA private key was never persisted to disk (it lives only in a
282    /// `Zeroizing<Vec<u8>>` inside the running proxy task and is zeroized
283    /// when that task drops). Here we remove the public certificate file
284    /// so the next session doesn't inherit a stale bundle path.
285    ///
286    /// Errors are intentionally swallowed — `Drop` has no good way to
287    /// surface them, and the file may already be gone if the user invoked
288    /// `shutdown()` from another path.
289    fn drop(&mut self) {
290        if let Some(path) = self.intercept_ca_path.take() {
291            let _ = std::fs::remove_file(&path);
292            // If the parent dir is now empty (we may have been the only
293            // tenant in `~/.nono/sessions/<id>/`), tidy up. A non-empty
294            // dir simply fails the rmdir and leaves unrelated contents
295            // in place — exactly what we want.
296            if let Some(parent) = path.parent() {
297                let _ = std::fs::remove_dir(parent);
298            }
299        }
300    }
301}
302
303/// Shared state for the proxy server.
304struct ProxyState {
305    filter: ProxyFilter,
306    session_token: Zeroizing<String>,
307    /// Route-level configuration (upstream, L7 filtering, custom TLS CA) for all routes.
308    route_store: RouteStore,
309    /// Credential-specific configuration (inject mode, headers, secrets) for routes with credentials.
310    credential_store: CredentialStore,
311    config: ProxyConfig,
312    /// Shared TLS connector for upstream connections (reverse proxy mode).
313    /// Created once at startup to avoid rebuilding the root cert store per request.
314    tls_connector: tokio_rustls::TlsConnector,
315    /// Active connection count for connection limiting.
316    active_connections: AtomicUsize,
317    /// Shared network audit log for this proxy session.
318    audit_log: audit::SharedAuditLog,
319    /// Matcher for hosts that bypass the external proxy and route direct.
320    /// Built once at startup from `ExternalProxyConfig.bypass_hosts`.
321    bypass_matcher: external::BypassMatcher,
322    /// Per-hostname leaf-certificate cache backed by the session ephemeral
323    /// CA, when TLS interception is active. `None` disables the intercept
324    /// CONNECT branch (CONNECTs fall through to the existing 403/tunnel
325    /// dispatch even for routes that would otherwise require L7).
326    cert_cache: Option<Arc<CertCache>>,
327}
328
329/// Start the proxy server.
330///
331/// Binds to `config.bind_addr:config.bind_port` (port 0 = OS-assigned),
332/// generates a session token, and begins accepting connections.
333///
334/// Returns a `ProxyHandle` with the assigned port and session token.
335/// The server runs until the handle is dropped or `shutdown()` is called.
336pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
337    // Generate session token
338    let session_token = token::generate_session_token()?;
339
340    // Bind listener
341    let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
342    let listener = TcpListener::bind(bind_addr)
343        .await
344        .map_err(|e| ProxyError::Bind {
345            addr: bind_addr.to_string(),
346            source: e,
347        })?;
348
349    let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
350        addr: bind_addr.to_string(),
351        source: e,
352    })?;
353    let port = local_addr.port();
354
355    info!("Proxy server listening on {}", local_addr);
356
357    // Load route-level configuration (upstream, L7 filtering, custom TLS CA)
358    // for ALL routes, regardless of credential presence.
359    let route_store = if config.routes.is_empty() {
360        RouteStore::empty()
361    } else {
362        RouteStore::load(&config.routes)?
363    };
364    // Build shared TLS connector (root cert store is expensive to construct).
365    // Use the ring provider explicitly to avoid ambiguity when multiple
366    // crypto providers are in the dependency tree.
367    // Must be created before CredentialStore::load() because OAuth2 token
368    // exchange needs TLS.
369    let mut root_store = rustls::RootCertStore::empty();
370    root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
371    let native = rustls_native_certs::load_native_certs();
372    if !native.errors.is_empty() {
373        debug!(
374            "failed to load {} native cert(s); continuing with webpki roots + any that succeeded",
375            native.errors.len()
376        );
377    }
378    let native_count = native.certs.len();
379    for cert in native.certs {
380        if let Err(e) = root_store.add(cert) {
381            debug!("skipping unparseable native cert: {e}");
382        }
383    }
384    if native_count > 0 {
385        debug!("added {native_count} native system CA(s) to upstream trust store");
386    }
387    let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
388        rustls::crypto::ring::default_provider(),
389    ))
390    .with_safe_default_protocol_versions()
391    .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
392    .with_root_certificates(root_store)
393    .with_no_client_auth();
394    let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
395
396    // Load credentials for reverse proxy routes (static keystore + OAuth2)
397    let credential_store = if config.routes.is_empty() {
398        CredentialStore::empty()
399    } else {
400        CredentialStore::load(&config.routes, &tls_connector)?
401    };
402    let loaded_routes = credential_store.loaded_prefixes();
403
404    // Build filter
405    let filter = if config.allowed_hosts.is_empty() {
406        ProxyFilter::allow_all()
407    } else {
408        ProxyFilter::new(&config.allowed_hosts)
409    };
410
411    // Build bypass matcher from external proxy config (once, not per-request)
412    let bypass_matcher = config
413        .external_proxy
414        .as_ref()
415        .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
416        .unwrap_or_else(|| external::BypassMatcher::new(&[]));
417
418    // Shutdown channel
419    let (shutdown_tx, shutdown_rx) = watch::channel(false);
420    let audit_log = audit::new_audit_log();
421
422    // Compute NO_PROXY hosts: allowed_hosts that can be reached via
423    // direct TCP connections (i.e. their port is in direct_connect_ports).
424    // Hosts without a direct TCP grant MUST go through the proxy —
425    // adding them to NO_PROXY would cause clients to attempt direct
426    // connections that the sandbox (Landlock / Seatbelt) denies.
427    //
428    // Route upstreams are always excluded so their traffic goes through
429    // the proxy for L7 path filtering and/or credential injection.
430    //
431    // On macOS this MUST be empty regardless: Seatbelt's ProxyOnly mode
432    // blocks ALL direct outbound. See #580.
433    let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
434        Vec::new()
435    } else {
436        let route_hosts = route_store.route_upstream_hosts();
437        config
438            .allowed_hosts
439            .iter()
440            .filter(|host| {
441                let normalised = {
442                    let h = host.to_lowercase();
443                    if h.starts_with('[') {
444                        // IPv6 literal: "[::1]:443" has port, "[::1]" needs default
445                        if h.contains("]:") {
446                            h
447                        } else {
448                            format!("{}:443", h)
449                        }
450                    } else if h.contains(':') {
451                        h
452                    } else {
453                        format!("{}:443", h)
454                    }
455                };
456                if route_hosts.contains(&normalised) {
457                    return false;
458                }
459                // Only bypass the proxy if the sandbox grants direct
460                // TCP on this host's port (via --allow-connect-port).
461                let port = normalised
462                    .rsplit_once(':')
463                    .and_then(|(_, p)| p.parse::<u16>().ok())
464                    .unwrap_or(443);
465                config.direct_connect_ports.contains(&port)
466            })
467            .cloned()
468            .collect()
469    };
470
471    if !no_proxy_hosts.is_empty() {
472        debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
473    }
474
475    // Initialise TLS interception if a directory was supplied AND at least
476    // one configured route actually requires L7 visibility. Routes are
477    // checked here (rather than relying solely on the CLI's decision) so a
478    // misconfigured `intercept_ca_dir` without intercept-bearing routes
479    // doesn't generate a useless CA on disk.
480    let any_intercept_route = route_store
481        .route_upstream_hosts()
482        .iter()
483        .any(|hp| route_store.has_intercept_route(hp));
484    let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
485        (Some(dir), true) => {
486            let intercept_route_count = route_store
487                .route_upstream_hosts()
488                .iter()
489                .filter(|hp| route_store.has_intercept_route(hp))
490                .count();
491            match EphemeralCa::generate().and_then(|ca| {
492                let ca = Arc::new(ca);
493                let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
494                let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
495                    dir,
496                    filename: "intercept-ca.pem",
497                    parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
498                    ephemeral_ca_pem: ca.cert_pem(),
499                })?;
500                Ok((cache, path))
501            }) {
502                Ok((cache, path)) => {
503                    info!(
504                        "TLS interception active for {} route(s); trust bundle at {}",
505                        intercept_route_count,
506                        path.display()
507                    );
508                    (Some(cache), Some(path))
509                }
510                Err(e) => {
511                    warn!(
512                        "TLS interception setup failed for {} route(s): {}. \
513                         Continuing with interception disabled; reverse-proxy routes remain available.",
514                        intercept_route_count, e
515                    );
516                    (None, None)
517                }
518            }
519        }
520        (Some(_), false) => {
521            debug!(
522                "TLS interception requested but no configured route requires L7 visibility; \
523                 skipping CA generation"
524            );
525            (None, None)
526        }
527        (None, _) => (None, None),
528    };
529
530    let state = Arc::new(ProxyState {
531        filter,
532        session_token: session_token.clone(),
533        route_store,
534        credential_store,
535        config,
536        tls_connector,
537        active_connections: AtomicUsize::new(0),
538        audit_log: Arc::clone(&audit_log),
539        bypass_matcher,
540        cert_cache,
541    });
542
543    // Spawn accept loop as a task within the current runtime.
544    // The caller MUST ensure this runtime is being driven (e.g., via
545    // a dedicated thread calling block_on or a multi-thread runtime).
546    tokio::spawn(accept_loop(listener, state, shutdown_rx));
547
548    Ok(ProxyHandle {
549        port,
550        token: session_token,
551        audit_log,
552        shutdown_tx,
553        loaded_routes,
554        no_proxy_hosts,
555        intercept_ca_path,
556    })
557}
558
559/// Accept loop: listen for connections until shutdown.
560async fn accept_loop(
561    listener: TcpListener,
562    state: Arc<ProxyState>,
563    mut shutdown_rx: watch::Receiver<bool>,
564) {
565    loop {
566        tokio::select! {
567            result = listener.accept() => {
568                match result {
569                    Ok((stream, addr)) => {
570                        // Connection limit enforcement
571                        let max = state.config.max_connections;
572                        if max > 0 {
573                            let current = state.active_connections.load(Ordering::Relaxed);
574                            if current >= max {
575                                warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
576                                // Drop the stream (connection refused)
577                                drop(stream);
578                                continue;
579                            }
580                        }
581                        state.active_connections.fetch_add(1, Ordering::Relaxed);
582
583                        debug!("Accepted connection from {}", addr);
584                        let state = Arc::clone(&state);
585                        tokio::spawn(async move {
586                            if let Err(e) = handle_connection(stream, &state).await {
587                                debug!("Connection handler error: {}", e);
588                            }
589                            state.active_connections.fetch_sub(1, Ordering::Relaxed);
590                        });
591                    }
592                    Err(e) => {
593                        warn!("Accept error: {}", e);
594                    }
595                }
596            }
597            _ = shutdown_rx.changed() => {
598                if *shutdown_rx.borrow() {
599                    info!("Proxy server shutting down");
600                    return;
601                }
602            }
603        }
604    }
605}
606
607/// Handle a single client connection.
608///
609/// Reads the first HTTP line to determine the proxy mode:
610/// - CONNECT method -> tunnel (Mode 1 or 3)
611/// - Other methods  -> reverse proxy (Mode 2)
612async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
613    // Read the first line and headers through a BufReader.
614    // We keep the BufReader alive until we've consumed the full header
615    // to prevent data loss (BufReader may read ahead into the body).
616    let mut buf_reader = BufReader::new(&mut stream);
617    let mut first_line = String::new();
618    buf_reader.read_line(&mut first_line).await?;
619
620    if first_line.is_empty() {
621        return Ok(()); // Client disconnected
622    }
623
624    // Read remaining headers (up to empty line), with size limit to prevent OOM.
625    let mut header_bytes = Vec::new();
626    loop {
627        let mut line = String::new();
628        let n = buf_reader.read_line(&mut line).await?;
629        if n == 0 || line.trim().is_empty() {
630            break;
631        }
632        header_bytes.extend_from_slice(line.as_bytes());
633        if header_bytes.len() > MAX_HEADER_SIZE {
634            drop(buf_reader);
635            let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
636            stream.write_all(response.as_bytes()).await?;
637            return Ok(());
638        }
639    }
640
641    // Extract any data buffered beyond headers before dropping BufReader.
642    // BufReader may have read ahead into the request body. We capture
643    // those bytes and pass them to the reverse proxy handler so no body
644    // data is lost. For CONNECT requests this is always empty (no body).
645    let buffered = buf_reader.buffer().to_vec();
646    drop(buf_reader);
647
648    let first_line = first_line.trim_end();
649
650    // Dispatch by method
651    if first_line.starts_with("CONNECT ") {
652        // CONNECT requests targeting a configured route's upstream get
653        // special handling. There are three sub-cases:
654        //
655        // 1. Route requires L7 visibility (`endpoint_rules`, `credential_key`,
656        //    or `oauth2`) AND TLS interception is configured: terminate TLS
657        //    locally so credential injection / endpoint filtering can run.
658        // 2. Route requires L7 visibility but interception is *not* configured:
659        //    fall back to the existing 403 — the agent must use the reverse
660        //    proxy path. Without interception we can't enforce L7 over CONNECT.
661        // 3. Route exists but is purely declarative (no L7 requirements):
662        //    keep the existing 403 — the route exists to provide a `*_BASE_URL`
663        //    env var, and CONNECT would bypass that intent.
664        //
665        // Anything else (host not matching any route) falls through to the
666        // existing transparent-tunnel / external-proxy paths.
667        if !state.route_store.is_empty() {
668            if let Some(authority) = first_line.split_whitespace().nth(1) {
669                // Normalise authority to host:port. Handle IPv6 brackets:
670                // "[::1]:443" already has port, "[::1]" needs default, "host:443" has port.
671                let host_port = if authority.starts_with('[') {
672                    if authority.contains("]:") {
673                        authority.to_lowercase()
674                    } else {
675                        format!("{}:443", authority.to_lowercase())
676                    }
677                } else if authority.contains(':') {
678                    authority.to_lowercase()
679                } else {
680                    format!("{}:443", authority.to_lowercase())
681                };
682
683                if state.route_store.is_route_upstream(&host_port) {
684                    let route_id = state
685                        .route_store
686                        .lookup_by_upstream(&host_port)
687                        .map(|(prefix, _)| prefix);
688                    let (host, port) = host_port
689                        .rsplit_once(':')
690                        .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
691                        .unwrap_or_else(|| (host_port.clone(), 443));
692
693                    let intercept_eligible = state.route_store.has_intercept_route(&host_port);
694
695                    match (intercept_eligible, state.cert_cache.as_ref()) {
696                        // Case 1: intercept-eligible route + cert cache available.
697                        (true, Some(cache)) => {
698                            // Strict OUTER auth: intercept is a privileged op
699                            // (we mint a leaf cert and decrypt traffic), so
700                            // unlike the lenient transparent-tunnel path we
701                            // require Proxy-Authorization here.
702                            if let Err(e) =
703                                token::validate_proxy_auth(&header_bytes, &state.session_token)
704                            {
705                                debug!(
706                                    "tls_intercept: rejecting CONNECT to {}:{} — {}",
707                                    host, port, e
708                                );
709                                audit::log_denied(
710                                    Some(&state.audit_log),
711                                    audit::ProxyMode::ConnectIntercept,
712                                    &audit::EventContext {
713                                        route_id,
714                                        auth_mechanism: Some(
715                                            nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
716                                        ),
717                                        auth_outcome: Some(
718                                            nono::undo::NetworkAuditAuthOutcome::Failed,
719                                        ),
720                                        denial_category: Some(
721                                            nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
722                                        ),
723                                        ..audit::EventContext::default()
724                                    },
725                                    &host,
726                                    port,
727                                    "proxy auth missing or invalid",
728                                );
729                                let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
730                                stream.write_all(response.as_bytes()).await?;
731                                return Ok(());
732                            }
733
734                            let ctx = tls_intercept::InterceptCtx {
735                                route_id,
736                                host: &host,
737                                port,
738                                route_store: &state.route_store,
739                                credential_store: &state.credential_store,
740                                session_token: &state.session_token,
741                                cert_cache: Arc::clone(cache),
742                                tls_connector: &state.tls_connector,
743                                filter: &state.filter,
744                                audit_log: Some(&state.audit_log),
745                            };
746                            return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
747                        }
748                        // Case 2 & 3: route exists but interception is unavailable
749                        // or the route is purely declarative — keep the existing
750                        // 403 to force SDK cooperation with the reverse-proxy path.
751                        _ => {
752                            debug!(
753                                "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
754                                authority
755                            );
756                            audit::log_denied(
757                                Some(&state.audit_log),
758                                audit::ProxyMode::Connect,
759                                &audit::EventContext {
760                                    route_id,
761                                    denial_category: Some(
762                                        nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
763                                    ),
764                                    ..audit::EventContext::default()
765                                },
766                                &host,
767                                port,
768                                "route upstream: CONNECT bypasses L7 filtering",
769                            );
770                            let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
771                            stream.write_all(response.as_bytes()).await?;
772                            return Ok(());
773                        }
774                    }
775                }
776            }
777        }
778
779        // Check if external proxy is configured and host is not bypassed
780        let use_external = if let Some(ref ext_config) = state.config.external_proxy {
781            if state.bypass_matcher.is_empty() {
782                Some(ext_config)
783            } else {
784                // Parse host from CONNECT line to check bypass
785                let host = first_line
786                    .split_whitespace()
787                    .nth(1)
788                    .and_then(|authority| {
789                        authority
790                            .rsplit_once(':')
791                            .map(|(h, _)| h)
792                            .or(Some(authority))
793                    })
794                    .unwrap_or("");
795                if state.bypass_matcher.matches(host) {
796                    debug!("Bypassing external proxy for {}", host);
797                    None
798                } else {
799                    Some(ext_config)
800                }
801            }
802        } else {
803            None
804        };
805
806        if let Some(ext_config) = use_external {
807            external::handle_external_proxy(
808                first_line,
809                &mut stream,
810                &header_bytes,
811                &state.filter,
812                &state.session_token,
813                ext_config,
814                Some(&state.audit_log),
815            )
816            .await
817        } else if state.config.external_proxy.is_some() {
818            // Bypass route: enforce strict session token validation before
819            // routing direct. Without this, bypassed hosts would inherit
820            // connect::handle_connect()'s lenient auth (which tolerates
821            // missing Proxy-Authorization for Node.js undici compat).
822            token::validate_proxy_auth(&header_bytes, &state.session_token)?;
823            connect::handle_connect(
824                first_line,
825                &mut stream,
826                &state.filter,
827                &state.session_token,
828                &header_bytes,
829                Some(&state.audit_log),
830            )
831            .await
832        } else {
833            connect::handle_connect(
834                first_line,
835                &mut stream,
836                &state.filter,
837                &state.session_token,
838                &header_bytes,
839                Some(&state.audit_log),
840            )
841            .await
842        }
843    } else if !state.route_store.is_empty() {
844        // Non-CONNECT request with routes configured -> reverse proxy
845        let ctx = reverse::ReverseProxyCtx {
846            route_store: &state.route_store,
847            credential_store: &state.credential_store,
848            session_token: &state.session_token,
849            filter: &state.filter,
850            tls_connector: &state.tls_connector,
851            audit_log: Some(&state.audit_log),
852        };
853        reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
854    } else {
855        // No routes configured, reject non-CONNECT requests
856        let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
857        stream.write_all(response.as_bytes()).await?;
858        Ok(())
859    }
860}
861
862#[cfg(test)]
863#[allow(clippy::unwrap_used)]
864mod tests {
865    use super::*;
866
867    #[tokio::test]
868    async fn test_proxy_starts_and_binds() {
869        let config = ProxyConfig::default();
870        let handle = start(config).await.unwrap();
871
872        // Port should be non-zero (OS-assigned)
873        assert!(handle.port > 0);
874        // Token should be 64 hex chars
875        assert_eq!(handle.token.len(), 64);
876
877        // Shutdown
878        handle.shutdown();
879    }
880
881    /// End-to-end smoke test: when `intercept_ca_dir` is set AND a route
882    /// requires L7 visibility, the proxy:
883    /// 1. generates an ephemeral CA;
884    /// 2. writes a trust bundle file with at least the ephemeral cert + system roots;
885    /// 3. exposes the path via `intercept_ca_path()`;
886    /// 4. emits trust env vars (`SSL_CERT_FILE` etc.) pointing at it;
887    /// 5. cleans the file on `Drop`.
888    #[tokio::test]
889    async fn test_intercept_lifecycle_end_to_end() {
890        let dir = tempfile::tempdir().unwrap();
891        let ca_path_clone;
892
893        {
894            let config = ProxyConfig {
895                routes: vec![crate::config::RouteConfig {
896                    prefix: "openai".to_string(),
897                    upstream: "https://api.openai.com".to_string(),
898                    credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
899                    inject_mode: Default::default(),
900                    inject_header: "Authorization".to_string(),
901                    credential_format: "Bearer {}".to_string(),
902                    path_pattern: None,
903                    path_replacement: None,
904                    query_param_name: None,
905                    proxy: None,
906                    env_var: None,
907                    endpoint_rules: vec![],
908                    tls_ca: None,
909                    tls_client_cert: None,
910                    tls_client_key: None,
911                    oauth2: None,
912                }],
913                intercept_ca_dir: Some(dir.path().to_path_buf()),
914                ..Default::default()
915            };
916            let handle = start(config).await.unwrap();
917            assert!(
918                handle.intercept_ca_path().is_some(),
919                "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
920            );
921            ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
922            assert!(
923                ca_path_clone.exists(),
924                "bundle file should have been written"
925            );
926
927            let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
928            assert!(
929                contents.contains("BEGIN CERTIFICATE"),
930                "bundle should contain at least one PEM block"
931            );
932
933            // Trust env vars should reference the bundle.
934            let vars = handle.env_vars();
935            let ssl = vars
936                .iter()
937                .find(|(k, _)| k == "SSL_CERT_FILE")
938                .expect("SSL_CERT_FILE should be set when intercept active");
939            assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
940            assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
941            assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
942            assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
943
944            handle.shutdown();
945        }
946        // After `handle` is dropped, the bundle file should be gone.
947        assert!(
948            !ca_path_clone.exists(),
949            "bundle should be removed when ProxyHandle drops"
950        );
951    }
952
953    /// When `intercept_ca_dir` is set but no route requires L7 visibility,
954    /// the proxy should NOT generate a CA (it would just be wasted material).
955    #[tokio::test]
956    async fn test_intercept_skipped_for_purely_declarative_routes() {
957        let dir = tempfile::tempdir().unwrap();
958        let config = ProxyConfig {
959            routes: vec![crate::config::RouteConfig {
960                prefix: "alias".to_string(),
961                upstream: "https://aliased.example.com".to_string(),
962                credential_key: None,
963                inject_mode: Default::default(),
964                inject_header: "Authorization".to_string(),
965                credential_format: "Bearer {}".to_string(),
966                path_pattern: None,
967                path_replacement: None,
968                query_param_name: None,
969                proxy: None,
970                env_var: None,
971                endpoint_rules: vec![],
972                tls_ca: None,
973                tls_client_cert: None,
974                tls_client_key: None,
975                oauth2: None,
976            }],
977            intercept_ca_dir: Some(dir.path().to_path_buf()),
978            ..Default::default()
979        };
980        let handle = start(config).await.unwrap();
981        assert!(
982            handle.intercept_ca_path().is_none(),
983            "no L7-bearing route → no CA should be generated"
984        );
985        let vars = handle.env_vars();
986        assert!(
987            vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
988            "trust env vars must not be set when intercept inactive"
989        );
990        handle.shutdown();
991    }
992
993    /// Intercept setup failures must not abort proxy startup for reverse-proxy
994    /// routes. We degrade to "intercept off" so credential routes still work,
995    /// while CONNECT interception remains unavailable and will keep its
996    /// existing deny behaviour.
997    #[tokio::test]
998    async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
999        let missing_dir = tempfile::tempdir()
1000            .unwrap()
1001            .path()
1002            .join("missing")
1003            .join("intercept");
1004        let config = ProxyConfig {
1005            routes: vec![crate::config::RouteConfig {
1006                prefix: "openai".to_string(),
1007                upstream: "https://api.openai.com".to_string(),
1008                credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1009                inject_mode: Default::default(),
1010                inject_header: "Authorization".to_string(),
1011                credential_format: "Bearer {}".to_string(),
1012                path_pattern: None,
1013                path_replacement: None,
1014                query_param_name: None,
1015                proxy: None,
1016                env_var: None,
1017                endpoint_rules: vec![],
1018                tls_ca: None,
1019                tls_client_cert: None,
1020                tls_client_key: None,
1021                oauth2: None,
1022            }],
1023            intercept_ca_dir: Some(missing_dir),
1024            ..Default::default()
1025        };
1026        let handle = start(config.clone()).await.unwrap();
1027        assert!(
1028            handle.intercept_ca_path().is_none(),
1029            "intercept setup failure should disable interception instead of aborting startup"
1030        );
1031        let vars = handle.env_vars();
1032        assert!(
1033            vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1034            "trust env vars must not be set when interception setup fails"
1035        );
1036        let route_vars = handle.credential_env_vars(&config);
1037        assert!(
1038            route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1039            "reverse-proxy route env vars should still be emitted"
1040        );
1041        handle.shutdown();
1042    }
1043
1044    /// `route_diagnostics()` returns one row per route summarising
1045    /// upstream, credential resolution, intercept on/off, and rule count.
1046    #[tokio::test]
1047    async fn test_route_diagnostics_summarises_each_route() {
1048        let dir = tempfile::tempdir().unwrap();
1049        let config = ProxyConfig {
1050            routes: vec![
1051                crate::config::RouteConfig {
1052                    prefix: "openai".to_string(),
1053                    upstream: "https://api.openai.com".to_string(),
1054                    credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1055                    inject_mode: Default::default(),
1056                    inject_header: "Authorization".to_string(),
1057                    credential_format: "Bearer {}".to_string(),
1058                    path_pattern: None,
1059                    path_replacement: None,
1060                    query_param_name: None,
1061                    proxy: None,
1062                    env_var: None,
1063                    endpoint_rules: vec![],
1064                    tls_ca: None,
1065                    tls_client_cert: None,
1066                    tls_client_key: None,
1067                    oauth2: None,
1068                },
1069                crate::config::RouteConfig {
1070                    prefix: "alias".to_string(),
1071                    upstream: "https://aliased.example.com".to_string(),
1072                    credential_key: None,
1073                    inject_mode: Default::default(),
1074                    inject_header: "Authorization".to_string(),
1075                    credential_format: "Bearer {}".to_string(),
1076                    path_pattern: None,
1077                    path_replacement: None,
1078                    query_param_name: None,
1079                    proxy: None,
1080                    env_var: None,
1081                    endpoint_rules: vec![],
1082                    tls_ca: None,
1083                    tls_client_cert: None,
1084                    tls_client_key: None,
1085                    oauth2: None,
1086                },
1087            ],
1088            intercept_ca_dir: Some(dir.path().to_path_buf()),
1089            ..Default::default()
1090        };
1091        let handle = start(config.clone()).await.unwrap();
1092        let rows = handle.route_diagnostics(&config);
1093        assert_eq!(rows.len(), 2);
1094
1095        let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1096        assert!(openai.1.contains("api.openai.com"));
1097        assert!(openai.1.contains("intercept: on"));
1098        assert!(
1099            openai.1.contains("✗") || openai.1.contains("not found"),
1100            "missing credential should show ✗, got: {}",
1101            openai.1
1102        );
1103
1104        let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1105        assert!(alias.1.contains("creds: none"));
1106        assert!(alias.1.contains("intercept: off"));
1107
1108        handle.shutdown();
1109    }
1110
1111    #[tokio::test]
1112    async fn test_proxy_env_vars() {
1113        let config = ProxyConfig::default();
1114        let handle = start(config).await.unwrap();
1115
1116        let vars = handle.env_vars();
1117        let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1118        assert!(http_proxy.is_some());
1119        assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1120
1121        let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1122        assert!(token_var.is_some());
1123        assert_eq!(token_var.unwrap().1.len(), 64);
1124
1125        let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1126        assert!(
1127            node_proxy_flag.is_some(),
1128            "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1129        );
1130        assert_eq!(
1131            node_proxy_flag.unwrap().1,
1132            "1",
1133            "NODE_USE_ENV_PROXY must be '1'"
1134        );
1135
1136        handle.shutdown();
1137    }
1138
1139    #[tokio::test]
1140    async fn test_proxy_credential_env_vars() {
1141        let config = ProxyConfig {
1142            routes: vec![crate::config::RouteConfig {
1143                prefix: "openai".to_string(),
1144                upstream: "https://api.openai.com".to_string(),
1145                credential_key: None,
1146                inject_mode: crate::config::InjectMode::Header,
1147                inject_header: "Authorization".to_string(),
1148                credential_format: "Bearer {}".to_string(),
1149                path_pattern: None,
1150                path_replacement: None,
1151                query_param_name: None,
1152                proxy: None,
1153                env_var: None,
1154                endpoint_rules: vec![],
1155                tls_ca: None,
1156                tls_client_cert: None,
1157                tls_client_key: None,
1158                oauth2: None,
1159            }],
1160            ..Default::default()
1161        };
1162        let handle = start(config.clone()).await.unwrap();
1163
1164        let vars = handle.credential_env_vars(&config);
1165        assert_eq!(vars.len(), 1);
1166        assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1167        assert!(vars[0].1.contains("/openai"));
1168
1169        handle.shutdown();
1170    }
1171
1172    #[test]
1173    fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1174        // When env_var is None and credential_key is set, the env var name
1175        // should be derived from uppercasing credential_key. This is the
1176        // backward-compatible path for keyring-backed credentials.
1177        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1178        let handle = ProxyHandle {
1179            port: 12345,
1180            token: Zeroizing::new("test_token".to_string()),
1181            audit_log: audit::new_audit_log(),
1182            shutdown_tx,
1183            loaded_routes: ["openai".to_string()].into_iter().collect(),
1184            no_proxy_hosts: Vec::new(),
1185            intercept_ca_path: None,
1186        };
1187        let config = ProxyConfig {
1188            routes: vec![crate::config::RouteConfig {
1189                prefix: "openai".to_string(),
1190                upstream: "https://api.openai.com".to_string(),
1191                credential_key: Some("openai_api_key".to_string()),
1192                inject_mode: crate::config::InjectMode::Header,
1193                inject_header: "Authorization".to_string(),
1194                credential_format: "Bearer {}".to_string(),
1195                path_pattern: None,
1196                path_replacement: None,
1197                query_param_name: None,
1198                proxy: None,
1199                env_var: None, // No explicit env_var — should fall back to uppercase
1200                endpoint_rules: vec![],
1201                tls_ca: None,
1202                tls_client_cert: None,
1203                tls_client_key: None,
1204                oauth2: None,
1205            }],
1206            ..Default::default()
1207        };
1208
1209        let vars = handle.credential_env_vars(&config);
1210        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
1211
1212        // Should derive OPENAI_API_KEY from uppercasing "openai_api_key"
1213        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1214        assert!(
1215            api_key_var.is_some(),
1216            "Should derive env var name from credential_key.to_uppercase()"
1217        );
1218
1219        let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1220        assert_eq!(val, "test_token");
1221    }
1222
1223    #[test]
1224    fn test_proxy_credential_env_vars_with_explicit_env_var() {
1225        // When env_var is set on a route, it should be used instead of
1226        // deriving from credential_key. This is essential for URI manager
1227        // credential refs (e.g., op://, apple-password://)
1228        // where uppercasing produces nonsensical env var names.
1229        //
1230        // We construct a ProxyHandle directly to test env var generation
1231        // without starting a real proxy (which would try to load credentials).
1232        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1233        let handle = ProxyHandle {
1234            port: 12345,
1235            token: Zeroizing::new("test_token".to_string()),
1236            audit_log: audit::new_audit_log(),
1237            shutdown_tx,
1238            loaded_routes: ["openai".to_string()].into_iter().collect(),
1239            no_proxy_hosts: Vec::new(),
1240            intercept_ca_path: None,
1241        };
1242        let config = ProxyConfig {
1243            routes: vec![crate::config::RouteConfig {
1244                prefix: "openai".to_string(),
1245                upstream: "https://api.openai.com".to_string(),
1246                credential_key: Some("op://Development/OpenAI/credential".to_string()),
1247                inject_mode: crate::config::InjectMode::Header,
1248                inject_header: "Authorization".to_string(),
1249                credential_format: "Bearer {}".to_string(),
1250                path_pattern: None,
1251                path_replacement: None,
1252                query_param_name: None,
1253                proxy: None,
1254                env_var: Some("OPENAI_API_KEY".to_string()),
1255                endpoint_rules: vec![],
1256                tls_ca: None,
1257                tls_client_cert: None,
1258                tls_client_key: None,
1259                oauth2: None,
1260            }],
1261            ..Default::default()
1262        };
1263
1264        let vars = handle.credential_env_vars(&config);
1265        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
1266
1267        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1268        assert!(
1269            api_key_var.is_some(),
1270            "Should use explicit env_var name, not derive from credential_key"
1271        );
1272
1273        // Verify the value is the phantom token, not the real credential
1274        let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1275        assert_eq!(val, "test_token");
1276
1277        // Verify no nonsensical OP:// env var was generated
1278        let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1279        assert!(
1280            bad_var.is_none(),
1281            "Should not generate env var from op:// URI uppercase"
1282        );
1283    }
1284
1285    #[test]
1286    fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1287        // When a credential is unavailable (e.g., GITHUB_TOKEN not set),
1288        // the route should NOT inject a phantom token env var. Otherwise
1289        // the phantom token shadows valid credentials from other sources
1290        // like the system keyring. See: #234
1291        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1292        let handle = ProxyHandle {
1293            port: 12345,
1294            token: Zeroizing::new("test_token".to_string()),
1295            audit_log: audit::new_audit_log(),
1296            shutdown_tx,
1297            // Only "openai" was loaded; "github" credential was unavailable
1298            loaded_routes: ["openai".to_string()].into_iter().collect(),
1299            no_proxy_hosts: Vec::new(),
1300            intercept_ca_path: None,
1301        };
1302        let config = ProxyConfig {
1303            routes: vec![
1304                crate::config::RouteConfig {
1305                    prefix: "openai".to_string(),
1306                    upstream: "https://api.openai.com".to_string(),
1307                    credential_key: Some("openai_api_key".to_string()),
1308                    inject_mode: crate::config::InjectMode::Header,
1309                    inject_header: "Authorization".to_string(),
1310                    credential_format: "Bearer {}".to_string(),
1311                    path_pattern: None,
1312                    path_replacement: None,
1313                    query_param_name: None,
1314                    proxy: None,
1315                    env_var: None,
1316                    endpoint_rules: vec![],
1317                    tls_ca: None,
1318                    tls_client_cert: None,
1319                    tls_client_key: None,
1320                    oauth2: None,
1321                },
1322                crate::config::RouteConfig {
1323                    prefix: "github".to_string(),
1324                    upstream: "https://api.github.com".to_string(),
1325                    credential_key: Some("env://GITHUB_TOKEN".to_string()),
1326                    inject_mode: crate::config::InjectMode::Header,
1327                    inject_header: "Authorization".to_string(),
1328                    credential_format: "token {}".to_string(),
1329                    path_pattern: None,
1330                    path_replacement: None,
1331                    query_param_name: None,
1332                    proxy: None,
1333                    env_var: Some("GITHUB_TOKEN".to_string()),
1334                    endpoint_rules: vec![],
1335                    tls_ca: None,
1336                    tls_client_cert: None,
1337                    tls_client_key: None,
1338                    oauth2: None,
1339                },
1340            ],
1341            ..Default::default()
1342        };
1343
1344        let vars = handle.credential_env_vars(&config);
1345
1346        // openai should have BASE_URL + API_KEY (credential loaded)
1347        let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1348        assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1349        let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1350        assert!(openai_key.is_some(), "loaded route should have API key");
1351
1352        // github should have BASE_URL (always set for declared routes) but
1353        // must NOT have GITHUB_TOKEN (credential was not loaded)
1354        let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1355        assert!(
1356            github_base.is_some(),
1357            "declared route should still have BASE_URL"
1358        );
1359        let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1360        assert!(
1361            github_token.is_none(),
1362            "unloaded route must not inject phantom GITHUB_TOKEN"
1363        );
1364    }
1365
1366    #[test]
1367    fn test_proxy_credential_env_vars_strips_slashes() {
1368        // When prefix includes leading/trailing slashes, the env var name
1369        // must not contain slashes and the URL must not double-slash.
1370        // Regression test for user-reported bug where "/anthropic" produced
1371        // "/ANTHROPIC_BASE_URL=http://127.0.0.1:PORT//anthropic".
1372        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1373        let handle = ProxyHandle {
1374            port: 58406,
1375            token: Zeroizing::new("test_token".to_string()),
1376            audit_log: audit::new_audit_log(),
1377            shutdown_tx,
1378            loaded_routes: std::collections::HashSet::new(),
1379            no_proxy_hosts: Vec::new(),
1380            intercept_ca_path: None,
1381        };
1382
1383        // Test leading slash
1384        let config = ProxyConfig {
1385            routes: vec![crate::config::RouteConfig {
1386                prefix: "/anthropic".to_string(),
1387                upstream: "https://api.anthropic.com".to_string(),
1388                credential_key: None,
1389                inject_mode: crate::config::InjectMode::Header,
1390                inject_header: "Authorization".to_string(),
1391                credential_format: "Bearer {}".to_string(),
1392                path_pattern: None,
1393                path_replacement: None,
1394                query_param_name: None,
1395                proxy: None,
1396                env_var: None,
1397                endpoint_rules: vec![],
1398                tls_ca: None,
1399                tls_client_cert: None,
1400                tls_client_key: None,
1401                oauth2: None,
1402            }],
1403            ..Default::default()
1404        };
1405
1406        let vars = handle.credential_env_vars(&config);
1407        assert_eq!(vars.len(), 1);
1408        assert_eq!(
1409            vars[0].0, "ANTHROPIC_BASE_URL",
1410            "env var name must not have leading slash"
1411        );
1412        assert_eq!(
1413            vars[0].1, "http://127.0.0.1:58406/anthropic",
1414            "URL must not have double slash"
1415        );
1416
1417        // Test trailing slash
1418        let config = ProxyConfig {
1419            routes: vec![crate::config::RouteConfig {
1420                prefix: "openai/".to_string(),
1421                upstream: "https://api.openai.com".to_string(),
1422                credential_key: None,
1423                inject_mode: crate::config::InjectMode::Header,
1424                inject_header: "Authorization".to_string(),
1425                credential_format: "Bearer {}".to_string(),
1426                path_pattern: None,
1427                path_replacement: None,
1428                query_param_name: None,
1429                proxy: None,
1430                env_var: None,
1431                endpoint_rules: vec![],
1432                tls_ca: None,
1433                tls_client_cert: None,
1434                tls_client_key: None,
1435                oauth2: None,
1436            }],
1437            ..Default::default()
1438        };
1439
1440        let vars = handle.credential_env_vars(&config);
1441        assert_eq!(
1442            vars[0].0, "OPENAI_BASE_URL",
1443            "env var name must not have trailing slash"
1444        );
1445        assert_eq!(
1446            vars[0].1, "http://127.0.0.1:58406/openai",
1447            "URL must not have trailing slash in path"
1448        );
1449    }
1450
1451    #[test]
1452    fn test_anthropic_credential_phantom_token_regression() {
1453        // Regression test for issue #624: the built-in anthropic credential
1454        // entry had no env_var or credential_key, so ANTHROPIC_API_KEY was
1455        // never set to the phantom token. Only ANTHROPIC_BASE_URL was injected,
1456        // leaving the sandbox to send the host's real key directly.
1457        //
1458        // Pre-fix state: route in loaded_routes but no env_var / credential_key
1459        // => ANTHROPIC_API_KEY must NOT appear (demonstrates the bug).
1460        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1461        let handle_no_env_var = ProxyHandle {
1462            port: 12345,
1463            token: Zeroizing::new("phantom".to_string()),
1464            audit_log: audit::new_audit_log(),
1465            shutdown_tx: shutdown_tx.clone(),
1466            loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1467            no_proxy_hosts: Vec::new(),
1468            intercept_ca_path: None,
1469        };
1470        let config_no_env_var = ProxyConfig {
1471            routes: vec![crate::config::RouteConfig {
1472                prefix: "anthropic".to_string(),
1473                upstream: "https://api.anthropic.com".to_string(),
1474                credential_key: None,
1475                inject_mode: crate::config::InjectMode::Header,
1476                inject_header: "x-api-key".to_string(),
1477                credential_format: "{}".to_string(),
1478                path_pattern: None,
1479                path_replacement: None,
1480                query_param_name: None,
1481                proxy: None,
1482                env_var: None,
1483                endpoint_rules: vec![],
1484                tls_ca: None,
1485                tls_client_cert: None,
1486                tls_client_key: None,
1487                oauth2: None,
1488            }],
1489            ..Default::default()
1490        };
1491        let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1492        assert!(
1493            vars_no_env_var.iter().all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1494            "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1495        );
1496
1497        // Post-fix state: route has env_var = "ANTHROPIC_API_KEY"
1498        // => ANTHROPIC_API_KEY must be set to the phantom token.
1499        let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1500        let handle_fixed = ProxyHandle {
1501            port: 12345,
1502            token: Zeroizing::new("phantom".to_string()),
1503            audit_log: audit::new_audit_log(),
1504            shutdown_tx: shutdown_tx2,
1505            loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1506            no_proxy_hosts: Vec::new(),
1507            intercept_ca_path: None,
1508        };
1509        let config_fixed = ProxyConfig {
1510            routes: vec![crate::config::RouteConfig {
1511                prefix: "anthropic".to_string(),
1512                upstream: "https://api.anthropic.com".to_string(),
1513                credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1514                inject_mode: crate::config::InjectMode::Header,
1515                inject_header: "x-api-key".to_string(),
1516                credential_format: "{}".to_string(),
1517                path_pattern: None,
1518                path_replacement: None,
1519                query_param_name: None,
1520                proxy: None,
1521                env_var: Some("ANTHROPIC_API_KEY".to_string()),
1522                endpoint_rules: vec![],
1523                tls_ca: None,
1524                tls_client_cert: None,
1525                tls_client_key: None,
1526                oauth2: None,
1527            }],
1528            ..Default::default()
1529        };
1530        let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1531        let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1532        assert!(
1533            api_key_var.is_some(),
1534            "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1535        );
1536        assert_eq!(api_key_var.unwrap().1, "phantom");
1537    }
1538
1539    #[test]
1540    fn test_no_proxy_excludes_credential_upstreams() {
1541        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1542        let handle = ProxyHandle {
1543            port: 12345,
1544            token: Zeroizing::new("test_token".to_string()),
1545            audit_log: audit::new_audit_log(),
1546            shutdown_tx,
1547            loaded_routes: std::collections::HashSet::new(),
1548            no_proxy_hosts: vec![
1549                "nats.internal:4222".to_string(),
1550                "opencode.internal:4096".to_string(),
1551            ],
1552            intercept_ca_path: None,
1553        };
1554
1555        let vars = handle.env_vars();
1556        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1557        assert!(
1558            no_proxy.1.contains("nats.internal"),
1559            "non-credential host should be in NO_PROXY"
1560        );
1561        assert!(
1562            no_proxy.1.contains("opencode.internal"),
1563            "non-credential host should be in NO_PROXY"
1564        );
1565        assert!(
1566            no_proxy.1.contains("localhost"),
1567            "localhost should always be in NO_PROXY"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_no_proxy_empty_when_no_non_credential_hosts() {
1573        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1574        let handle = ProxyHandle {
1575            port: 12345,
1576            token: Zeroizing::new("test_token".to_string()),
1577            audit_log: audit::new_audit_log(),
1578            shutdown_tx,
1579            loaded_routes: std::collections::HashSet::new(),
1580            no_proxy_hosts: Vec::new(),
1581            intercept_ca_path: None,
1582        };
1583
1584        let vars = handle.env_vars();
1585        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1586        assert_eq!(
1587            no_proxy.1, "localhost,127.0.0.1",
1588            "NO_PROXY should only contain loopback when no bypass hosts"
1589        );
1590    }
1591
1592    #[tokio::test]
1593    async fn test_no_proxy_empty_without_direct_connect_ports() {
1594        // When direct_connect_ports is empty (no --allow-connect-port),
1595        // allowed_hosts should NOT appear in NO_PROXY because the sandbox
1596        // blocks direct TCP and clients would fail to connect. See #760.
1597        let config = ProxyConfig {
1598            allowed_hosts: vec!["github.com".to_string()],
1599            ..Default::default()
1600        };
1601        let handle = start(config).await.unwrap();
1602
1603        let vars = handle.env_vars();
1604        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1605        assert_eq!(
1606            no_proxy.1, "localhost,127.0.0.1",
1607            "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1608        );
1609
1610        handle.shutdown();
1611    }
1612
1613    #[cfg(not(target_os = "macos"))]
1614    #[tokio::test]
1615    async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1616        // When direct_connect_ports includes port 443, allowed_hosts on
1617        // that port SHOULD appear in NO_PROXY (direct TCP is permitted).
1618        // macOS always returns empty NO_PROXY (Seatbelt blocks all direct outbound).
1619        let config = ProxyConfig {
1620            allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1621            direct_connect_ports: vec![443],
1622            ..Default::default()
1623        };
1624        let handle = start(config).await.unwrap();
1625
1626        let vars = handle.env_vars();
1627        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1628        assert!(
1629            no_proxy.1.contains("github.com"),
1630            "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1631        );
1632        assert!(
1633            !no_proxy.1.contains("server.internal"),
1634            "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1635        );
1636
1637        handle.shutdown();
1638    }
1639}