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