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::Arc;
24use std::sync::atomic::{AtomicUsize, Ordering};
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. Strict mode treats an empty allowlist as deny-all.
405    let filter = if config.strict_filter {
406        ProxyFilter::new_strict(&config.allowed_hosts)
407    } else if config.allowed_hosts.is_empty() {
408        ProxyFilter::allow_all()
409    } else {
410        ProxyFilter::new(&config.allowed_hosts)
411    };
412
413    // Build bypass matcher from external proxy config (once, not per-request)
414    let bypass_matcher = config
415        .external_proxy
416        .as_ref()
417        .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
418        .unwrap_or_else(|| external::BypassMatcher::new(&[]));
419
420    // Shutdown channel
421    let (shutdown_tx, shutdown_rx) = watch::channel(false);
422    let audit_log = audit::new_audit_log();
423
424    // Compute NO_PROXY hosts: allowed_hosts that can be reached via
425    // direct TCP connections (i.e. their port is in direct_connect_ports).
426    // Hosts without a direct TCP grant MUST go through the proxy —
427    // adding them to NO_PROXY would cause clients to attempt direct
428    // connections that the sandbox (Landlock / Seatbelt) denies.
429    //
430    // Route upstreams are always excluded so their traffic goes through
431    // the proxy for L7 path filtering and/or credential injection.
432    //
433    // On macOS this MUST be empty regardless: Seatbelt's ProxyOnly mode
434    // blocks ALL direct outbound. See #580.
435    let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
436        Vec::new()
437    } else {
438        let route_hosts = route_store.route_upstream_hosts();
439        config
440            .allowed_hosts
441            .iter()
442            .filter(|host| {
443                let normalised = {
444                    let h = host.to_lowercase();
445                    if h.starts_with('[') {
446                        // IPv6 literal: "[::1]:443" has port, "[::1]" needs default
447                        if h.contains("]:") {
448                            h
449                        } else {
450                            format!("{}:443", h)
451                        }
452                    } else if h.contains(':') {
453                        h
454                    } else {
455                        format!("{}:443", h)
456                    }
457                };
458                if route_hosts.contains(&normalised) {
459                    return false;
460                }
461                // Only bypass the proxy if the sandbox grants direct
462                // TCP on this host's port (via --allow-connect-port).
463                let port = normalised
464                    .rsplit_once(':')
465                    .and_then(|(_, p)| p.parse::<u16>().ok())
466                    .unwrap_or(443);
467                config.direct_connect_ports.contains(&port)
468            })
469            .cloned()
470            .collect()
471    };
472
473    if !no_proxy_hosts.is_empty() {
474        debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
475    }
476
477    // Initialise TLS interception if a directory was supplied AND at least
478    // one configured route actually requires L7 visibility. Routes are
479    // checked here (rather than relying solely on the CLI's decision) so a
480    // misconfigured `intercept_ca_dir` without intercept-bearing routes
481    // doesn't generate a useless CA on disk.
482    let any_intercept_route = route_store
483        .route_upstream_hosts()
484        .iter()
485        .any(|hp| route_store.has_intercept_route(hp));
486    let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
487        (Some(dir), true) => {
488            let intercept_route_count = route_store
489                .route_upstream_hosts()
490                .iter()
491                .filter(|hp| route_store.has_intercept_route(hp))
492                .count();
493            let ca_result = if let Some(ref preloaded) = config.preloaded_ca {
494                EphemeralCa::from_existing(&preloaded.key_der, &preloaded.cert_pem)
495            } else {
496                let validity = config
497                    .ca_validity
498                    .unwrap_or(crate::tls_intercept::ca::CA_VALIDITY_DEFAULT);
499                EphemeralCa::generate_with_cn("nono-session-ca", validity)
500            };
501            match ca_result.and_then(|ca| {
502                let ca = Arc::new(ca);
503                let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
504                let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
505                    dir,
506                    filename: "intercept-ca.pem",
507                    parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
508                    ephemeral_ca_pem: ca.cert_pem(),
509                })?;
510                Ok((cache, path))
511            }) {
512                Ok((cache, path)) => {
513                    info!(
514                        "TLS interception active for {} route(s); trust bundle at {}",
515                        intercept_route_count,
516                        path.display()
517                    );
518                    (Some(cache), Some(path))
519                }
520                Err(e) => {
521                    warn!(
522                        "TLS interception setup failed for {} route(s): {}. \
523                         Continuing with interception disabled; reverse-proxy routes remain available.",
524                        intercept_route_count, e
525                    );
526                    (None, None)
527                }
528            }
529        }
530        (Some(_), false) => {
531            debug!(
532                "TLS interception requested but no configured route requires L7 visibility; \
533                 skipping CA generation"
534            );
535            (None, None)
536        }
537        (None, _) => (None, None),
538    };
539
540    let state = Arc::new(ProxyState {
541        filter,
542        session_token: session_token.clone(),
543        route_store,
544        credential_store,
545        config,
546        tls_connector,
547        active_connections: AtomicUsize::new(0),
548        audit_log: Arc::clone(&audit_log),
549        bypass_matcher,
550        cert_cache,
551    });
552
553    // Spawn accept loop as a task within the current runtime.
554    // The caller MUST ensure this runtime is being driven (e.g., via
555    // a dedicated thread calling block_on or a multi-thread runtime).
556    tokio::spawn(accept_loop(listener, state, shutdown_rx));
557
558    Ok(ProxyHandle {
559        port,
560        token: session_token,
561        audit_log,
562        shutdown_tx,
563        loaded_routes,
564        no_proxy_hosts,
565        intercept_ca_path,
566    })
567}
568
569/// Accept loop: listen for connections until shutdown.
570async fn accept_loop(
571    listener: TcpListener,
572    state: Arc<ProxyState>,
573    mut shutdown_rx: watch::Receiver<bool>,
574) {
575    loop {
576        tokio::select! {
577            result = listener.accept() => {
578                match result {
579                    Ok((stream, addr)) => {
580                        // Connection limit enforcement
581                        let max = state.config.max_connections;
582                        if max > 0 {
583                            let current = state.active_connections.load(Ordering::Relaxed);
584                            if current >= max {
585                                warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
586                                // Drop the stream (connection refused)
587                                drop(stream);
588                                continue;
589                            }
590                        }
591                        state.active_connections.fetch_add(1, Ordering::Relaxed);
592
593                        debug!("Accepted connection from {}", addr);
594                        let state = Arc::clone(&state);
595                        tokio::spawn(async move {
596                            if let Err(e) = handle_connection(stream, &state).await {
597                                debug!("Connection handler error: {}", e);
598                            }
599                            state.active_connections.fetch_sub(1, Ordering::Relaxed);
600                        });
601                    }
602                    Err(e) => {
603                        warn!("Accept error: {}", e);
604                    }
605                }
606            }
607            _ = shutdown_rx.changed() => {
608                if *shutdown_rx.borrow() {
609                    info!("Proxy server shutting down");
610                    return;
611                }
612            }
613        }
614    }
615}
616
617/// Normalise a CONNECT authority to lowercase `host:port`, defaulting the port
618/// to 443 when absent. Handles IPv6 brackets: `[::1]:443` already has a port,
619/// `[::1]` needs the default, `host:443` has a port.
620fn normalize_authority(authority: &str) -> String {
621    if authority.starts_with('[') {
622        if authority.contains("]:") {
623            authority.to_lowercase()
624        } else {
625            format!("{}:443", authority.to_lowercase())
626        }
627    } else if authority.contains(':') {
628        authority.to_lowercase()
629    } else {
630        format!("{}:443", authority.to_lowercase())
631    }
632}
633
634/// Handle a single client connection.
635///
636/// Reads the first HTTP line to determine the proxy mode:
637/// - CONNECT method -> tunnel (Mode 1 or 3)
638/// - Other methods  -> reverse proxy (Mode 2)
639async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
640    // Read the first line and headers through a BufReader.
641    // We keep the BufReader alive until we've consumed the full header
642    // to prevent data loss (BufReader may read ahead into the body).
643    let mut buf_reader = BufReader::new(&mut stream);
644    let mut first_line = String::new();
645    buf_reader.read_line(&mut first_line).await?;
646
647    if first_line.is_empty() {
648        return Ok(()); // Client disconnected
649    }
650
651    // Read remaining headers (up to empty line), with size limit to prevent OOM.
652    let mut header_bytes = Vec::new();
653    loop {
654        let mut line = String::new();
655        let n = buf_reader.read_line(&mut line).await?;
656        if n == 0 || line.trim().is_empty() {
657            break;
658        }
659        header_bytes.extend_from_slice(line.as_bytes());
660        if header_bytes.len() > MAX_HEADER_SIZE {
661            drop(buf_reader);
662            let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
663            stream.write_all(response.as_bytes()).await?;
664            return Ok(());
665        }
666    }
667
668    // Extract any data buffered beyond headers before dropping BufReader.
669    // BufReader may have read ahead into the request body. We capture
670    // those bytes and pass them to the reverse proxy handler so no body
671    // data is lost. For CONNECT requests this is always empty (no body).
672    let buffered = buf_reader.buffer().to_vec();
673    drop(buf_reader);
674
675    let first_line = first_line.trim_end();
676
677    // Dispatch by method
678    if first_line.starts_with("CONNECT ") {
679        // CONNECT requests targeting a configured route's upstream get
680        // special handling. There are three sub-cases:
681        //
682        // 1. Route requires L7 visibility (`endpoint_rules`, `credential_key`,
683        //    or `oauth2`) AND TLS interception is configured: terminate TLS
684        //    locally so credential injection / endpoint filtering can run.
685        // 2. Route requires L7 visibility but interception is *not* configured:
686        //    fall back to the existing 403 — the agent must use the reverse
687        //    proxy path. Without interception we can't enforce L7 over CONNECT.
688        // 3. Route exists but is purely declarative (no L7 requirements):
689        //    keep the existing 403 — the route exists to provide a `*_BASE_URL`
690        //    env var, and CONNECT would bypass that intent.
691        //
692        // Anything else (host not matching any route) falls through to the
693        // existing transparent-tunnel / external-proxy paths.
694        if !state.route_store.is_empty()
695            && let Some(authority) = first_line.split_whitespace().nth(1)
696        {
697            let host_port = normalize_authority(authority);
698
699            if state.route_store.is_route_upstream(&host_port) {
700                let route_id = state
701                    .route_store
702                    .lookup_by_upstream(&host_port)
703                    .map(|(prefix, _)| prefix);
704                let (host, port) = host_port
705                    .rsplit_once(':')
706                    .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
707                    .unwrap_or_else(|| (host_port.clone(), 443));
708
709                let intercept_eligible = state.route_store.has_intercept_route(&host_port);
710
711                match (intercept_eligible, state.cert_cache.as_ref()) {
712                    // Case 1: intercept-eligible route + cert cache available.
713                    (true, Some(cache)) => {
714                        // Strict OUTER auth: intercept is a privileged op
715                        // (we mint a leaf cert and decrypt traffic), so
716                        // unlike the lenient transparent-tunnel path we
717                        // require Proxy-Authorization here.
718                        // Reactive proxy auth (RFC 7235 / RFC 9110 §15.5.8): a
719                        // client may send the first CONNECT without credentials,
720                        // receive the 407 challenge, then retry the CONNECT with
721                        // Proxy-Authorization on the SAME connection. Keep the
722                        // connection open across the 407 and re-read the retried
723                        // request head rather than dropping the socket — closing
724                        // it breaks reactive clients (Apache HttpClient, Java's
725                        // HttpClient, Maven's native resolver).
726                        let mut current_headers = header_bytes;
727                        loop {
728                            match token::validate_proxy_auth(&current_headers, &state.session_token)
729                            {
730                                Ok(()) => break,
731                                Err(e) => {
732                                    debug!(
733                                        "tls_intercept: CONNECT to {}:{} missing/invalid proxy auth — {}",
734                                        host, port, e
735                                    );
736                                    audit::log_denied(
737                                        Some(&state.audit_log),
738                                        audit::ProxyMode::ConnectIntercept,
739                                        &audit::EventContext {
740                                            route_id,
741                                            auth_mechanism: Some(
742                                                nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
743                                            ),
744                                            auth_outcome: Some(
745                                                nono::undo::NetworkAuditAuthOutcome::Failed,
746                                            ),
747                                            denial_category: Some(
748                                                nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
749                                            ),
750                                            ..audit::EventContext::default()
751                                        },
752                                        &host,
753                                        port,
754                                        "proxy auth missing or invalid",
755                                    );
756                                    let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
757                                    stream.write_all(response.as_bytes()).await?;
758
759                                    // Read the client's retried request head on
760                                    // the same connection.
761                                    let mut buf_reader = BufReader::new(&mut stream);
762                                    let mut retry_line = String::new();
763                                    buf_reader.read_line(&mut retry_line).await?;
764                                    if retry_line.is_empty() {
765                                        return Ok(()); // client disconnected
766                                    }
767                                    let mut retry_headers = Vec::new();
768                                    loop {
769                                        let mut line = String::new();
770                                        let n = buf_reader.read_line(&mut line).await?;
771                                        if n == 0 || line.trim().is_empty() {
772                                            break;
773                                        }
774                                        retry_headers.extend_from_slice(line.as_bytes());
775                                        if retry_headers.len() > MAX_HEADER_SIZE {
776                                            drop(buf_reader);
777                                            let too_large = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
778                                            stream.write_all(too_large.as_bytes()).await?;
779                                            return Ok(());
780                                        }
781                                    }
782                                    drop(buf_reader);
783
784                                    // host/port/route are reused from the first
785                                    // CONNECT, so the retry must target the same
786                                    // authority; anything else (or a non-CONNECT
787                                    // request) would desync routing.
788                                    let same_authority = retry_line
789                                        .trim_end()
790                                        .strip_prefix("CONNECT ")
791                                        .and_then(|rest| rest.split_whitespace().next())
792                                        .map(normalize_authority)
793                                        .as_deref()
794                                        == Some(host_port.as_str());
795                                    if !same_authority {
796                                        return Ok(());
797                                    }
798                                    current_headers = retry_headers;
799                                }
800                            }
801                        }
802
803                        let ctx = tls_intercept::InterceptCtx {
804                            route_id,
805                            host: &host,
806                            port,
807                            route_store: &state.route_store,
808                            credential_store: &state.credential_store,
809                            session_token: &state.session_token,
810                            cert_cache: Arc::clone(cache),
811                            tls_connector: &state.tls_connector,
812                            filter: &state.filter,
813                            audit_log: Some(&state.audit_log),
814                        };
815                        return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
816                    }
817                    // Case 2 & 3: route exists but interception is unavailable
818                    // or the route is purely declarative — keep the existing
819                    // 403 to force SDK cooperation with the reverse-proxy path.
820                    _ => {
821                        debug!(
822                            "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
823                            authority
824                        );
825                        audit::log_denied(
826                            Some(&state.audit_log),
827                            audit::ProxyMode::Connect,
828                            &audit::EventContext {
829                                route_id,
830                                denial_category: Some(
831                                    nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
832                                ),
833                                ..audit::EventContext::default()
834                            },
835                            &host,
836                            port,
837                            "route upstream: CONNECT bypasses L7 filtering",
838                        );
839                        let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
840                        stream.write_all(response.as_bytes()).await?;
841                        return Ok(());
842                    }
843                }
844            }
845        }
846
847        // Check if external proxy is configured and host is not bypassed
848        let use_external = if let Some(ref ext_config) = state.config.external_proxy {
849            if state.bypass_matcher.is_empty() {
850                Some(ext_config)
851            } else {
852                // Parse host from CONNECT line to check bypass
853                let host = first_line
854                    .split_whitespace()
855                    .nth(1)
856                    .and_then(|authority| {
857                        authority
858                            .rsplit_once(':')
859                            .map(|(h, _)| h)
860                            .or(Some(authority))
861                    })
862                    .unwrap_or("");
863                if state.bypass_matcher.matches(host) {
864                    debug!("Bypassing external proxy for {}", host);
865                    None
866                } else {
867                    Some(ext_config)
868                }
869            }
870        } else {
871            None
872        };
873
874        if let Some(ext_config) = use_external {
875            external::handle_external_proxy(
876                first_line,
877                &mut stream,
878                &header_bytes,
879                &state.filter,
880                &state.session_token,
881                ext_config,
882                Some(&state.audit_log),
883            )
884            .await
885        } else if state.config.external_proxy.is_some() {
886            // Bypass route: enforce strict session token validation before
887            // routing direct. Without this, bypassed hosts would inherit
888            // connect::handle_connect()'s lenient auth (which tolerates
889            // missing Proxy-Authorization for Node.js undici compat).
890            token::validate_proxy_auth(&header_bytes, &state.session_token)?;
891            connect::handle_connect(
892                first_line,
893                &mut stream,
894                &state.filter,
895                &state.session_token,
896                &header_bytes,
897                Some(&state.audit_log),
898            )
899            .await
900        } else {
901            connect::handle_connect(
902                first_line,
903                &mut stream,
904                &state.filter,
905                &state.session_token,
906                &header_bytes,
907                Some(&state.audit_log),
908            )
909            .await
910        }
911    } else if !state.route_store.is_empty() {
912        // Non-CONNECT request with routes configured -> reverse proxy
913        let ctx = reverse::ReverseProxyCtx {
914            route_store: &state.route_store,
915            credential_store: &state.credential_store,
916            session_token: &state.session_token,
917            filter: &state.filter,
918            tls_connector: &state.tls_connector,
919            audit_log: Some(&state.audit_log),
920        };
921        reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
922    } else {
923        // No routes configured, reject non-CONNECT requests
924        let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
925        stream.write_all(response.as_bytes()).await?;
926        Ok(())
927    }
928}
929
930#[cfg(test)]
931#[allow(clippy::unwrap_used)]
932mod tests {
933    use super::*;
934
935    #[test]
936    fn normalize_authority_normalises_case_and_default_port() {
937        assert_eq!(normalize_authority("API.OpenAI.com"), "api.openai.com:443");
938        assert_eq!(
939            normalize_authority("api.openai.com:443"),
940            "api.openai.com:443"
941        );
942        assert_eq!(
943            normalize_authority("api.openai.com:8443"),
944            "api.openai.com:8443"
945        );
946        assert_eq!(normalize_authority("[::1]"), "[::1]:443");
947        assert_eq!(normalize_authority("[::1]:8443"), "[::1]:8443");
948        // case- and port-insensitive equality is the point of the retry guard
949        assert_eq!(
950            normalize_authority("API.OPENAI.COM:443"),
951            normalize_authority("api.openai.com")
952        );
953    }
954
955    #[tokio::test]
956    async fn test_proxy_starts_and_binds() {
957        let config = ProxyConfig::default();
958        let handle = start(config).await.unwrap();
959
960        // Port should be non-zero (OS-assigned)
961        assert!(handle.port > 0);
962        // Token should be 64 hex chars
963        assert_eq!(handle.token.len(), 64);
964
965        // Shutdown
966        handle.shutdown();
967    }
968
969    /// End-to-end smoke test: when `intercept_ca_dir` is set AND a route
970    /// requires L7 visibility, the proxy:
971    /// 1. generates an ephemeral CA;
972    /// 2. writes a trust bundle file with at least the ephemeral cert + system roots;
973    /// 3. exposes the path via `intercept_ca_path()`;
974    /// 4. emits trust env vars (`SSL_CERT_FILE` etc.) pointing at it;
975    /// 5. cleans the file on `Drop`.
976    #[tokio::test]
977    async fn test_intercept_lifecycle_end_to_end() {
978        let dir = tempfile::tempdir().unwrap();
979        let ca_path_clone;
980
981        {
982            let config = ProxyConfig {
983                routes: vec![crate::config::RouteConfig {
984                    prefix: "openai".to_string(),
985                    upstream: "https://api.openai.com".to_string(),
986                    credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
987                    inject_mode: Default::default(),
988                    inject_header: "Authorization".to_string(),
989                    credential_format: Some("Bearer {}".to_string()),
990                    path_pattern: None,
991                    path_replacement: None,
992                    query_param_name: None,
993                    proxy: None,
994                    env_var: None,
995                    endpoint_rules: vec![],
996                    tls_ca: None,
997                    tls_client_cert: None,
998                    tls_client_key: None,
999                    oauth2: None,
1000                }],
1001                intercept_ca_dir: Some(dir.path().to_path_buf()),
1002                ..Default::default()
1003            };
1004            let handle = start(config).await.unwrap();
1005            assert!(
1006                handle.intercept_ca_path().is_some(),
1007                "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
1008            );
1009            ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
1010            assert!(
1011                ca_path_clone.exists(),
1012                "bundle file should have been written"
1013            );
1014
1015            let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
1016            assert!(
1017                contents.contains("BEGIN CERTIFICATE"),
1018                "bundle should contain at least one PEM block"
1019            );
1020
1021            // Trust env vars should reference the bundle.
1022            let vars = handle.env_vars();
1023            let ssl = vars
1024                .iter()
1025                .find(|(k, _)| k == "SSL_CERT_FILE")
1026                .expect("SSL_CERT_FILE should be set when intercept active");
1027            assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
1028            assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
1029            assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
1030            assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
1031
1032            handle.shutdown();
1033        }
1034        // After `handle` is dropped, the bundle file should be gone.
1035        assert!(
1036            !ca_path_clone.exists(),
1037            "bundle should be removed when ProxyHandle drops"
1038        );
1039    }
1040
1041    /// When `intercept_ca_dir` is set but no route requires L7 visibility,
1042    /// the proxy should NOT generate a CA (it would just be wasted material).
1043    #[tokio::test]
1044    async fn test_intercept_skipped_for_purely_declarative_routes() {
1045        let dir = tempfile::tempdir().unwrap();
1046        let config = ProxyConfig {
1047            routes: vec![crate::config::RouteConfig {
1048                prefix: "alias".to_string(),
1049                upstream: "https://aliased.example.com".to_string(),
1050                credential_key: None,
1051                inject_mode: Default::default(),
1052                inject_header: "Authorization".to_string(),
1053                credential_format: Some("Bearer {}".to_string()),
1054                path_pattern: None,
1055                path_replacement: None,
1056                query_param_name: None,
1057                proxy: None,
1058                env_var: None,
1059                endpoint_rules: vec![],
1060                tls_ca: None,
1061                tls_client_cert: None,
1062                tls_client_key: None,
1063                oauth2: None,
1064            }],
1065            intercept_ca_dir: Some(dir.path().to_path_buf()),
1066            ..Default::default()
1067        };
1068        let handle = start(config).await.unwrap();
1069        assert!(
1070            handle.intercept_ca_path().is_none(),
1071            "no L7-bearing route → no CA should be generated"
1072        );
1073        let vars = handle.env_vars();
1074        assert!(
1075            vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1076            "trust env vars must not be set when intercept inactive"
1077        );
1078        handle.shutdown();
1079    }
1080
1081    /// Intercept setup failures must not abort proxy startup for reverse-proxy
1082    /// routes. We degrade to "intercept off" so credential routes still work,
1083    /// while CONNECT interception remains unavailable and will keep its
1084    /// existing deny behaviour.
1085    #[tokio::test]
1086    async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
1087        let missing_dir = tempfile::tempdir()
1088            .unwrap()
1089            .path()
1090            .join("missing")
1091            .join("intercept");
1092        let config = ProxyConfig {
1093            routes: vec![crate::config::RouteConfig {
1094                prefix: "openai".to_string(),
1095                upstream: "https://api.openai.com".to_string(),
1096                credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1097                inject_mode: Default::default(),
1098                inject_header: "Authorization".to_string(),
1099                credential_format: Some("Bearer {}".to_string()),
1100                path_pattern: None,
1101                path_replacement: None,
1102                query_param_name: None,
1103                proxy: None,
1104                env_var: None,
1105                endpoint_rules: vec![],
1106                tls_ca: None,
1107                tls_client_cert: None,
1108                tls_client_key: None,
1109                oauth2: None,
1110            }],
1111            intercept_ca_dir: Some(missing_dir),
1112            ..Default::default()
1113        };
1114        let handle = start(config.clone()).await.unwrap();
1115        assert!(
1116            handle.intercept_ca_path().is_none(),
1117            "intercept setup failure should disable interception instead of aborting startup"
1118        );
1119        let vars = handle.env_vars();
1120        assert!(
1121            vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1122            "trust env vars must not be set when interception setup fails"
1123        );
1124        let route_vars = handle.credential_env_vars(&config);
1125        assert!(
1126            route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1127            "reverse-proxy route env vars should still be emitted"
1128        );
1129        handle.shutdown();
1130    }
1131
1132    /// `route_diagnostics()` returns one row per route summarising
1133    /// upstream, credential resolution, intercept on/off, and rule count.
1134    #[tokio::test]
1135    async fn test_route_diagnostics_summarises_each_route() {
1136        let dir = tempfile::tempdir().unwrap();
1137        let config = ProxyConfig {
1138            routes: vec![
1139                crate::config::RouteConfig {
1140                    prefix: "openai".to_string(),
1141                    upstream: "https://api.openai.com".to_string(),
1142                    credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1143                    inject_mode: Default::default(),
1144                    inject_header: "Authorization".to_string(),
1145                    credential_format: Some("Bearer {}".to_string()),
1146                    path_pattern: None,
1147                    path_replacement: None,
1148                    query_param_name: None,
1149                    proxy: None,
1150                    env_var: None,
1151                    endpoint_rules: vec![],
1152                    tls_ca: None,
1153                    tls_client_cert: None,
1154                    tls_client_key: None,
1155                    oauth2: None,
1156                },
1157                crate::config::RouteConfig {
1158                    prefix: "alias".to_string(),
1159                    upstream: "https://aliased.example.com".to_string(),
1160                    credential_key: None,
1161                    inject_mode: Default::default(),
1162                    inject_header: "Authorization".to_string(),
1163                    credential_format: Some("Bearer {}".to_string()),
1164                    path_pattern: None,
1165                    path_replacement: None,
1166                    query_param_name: None,
1167                    proxy: None,
1168                    env_var: None,
1169                    endpoint_rules: vec![],
1170                    tls_ca: None,
1171                    tls_client_cert: None,
1172                    tls_client_key: None,
1173                    oauth2: None,
1174                },
1175            ],
1176            intercept_ca_dir: Some(dir.path().to_path_buf()),
1177            ..Default::default()
1178        };
1179        let handle = start(config.clone()).await.unwrap();
1180        let rows = handle.route_diagnostics(&config);
1181        assert_eq!(rows.len(), 2);
1182
1183        let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1184        assert!(openai.1.contains("api.openai.com"));
1185        assert!(openai.1.contains("intercept: on"));
1186        assert!(
1187            openai.1.contains("✗") || openai.1.contains("not found"),
1188            "missing credential should show ✗, got: {}",
1189            openai.1
1190        );
1191
1192        let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1193        assert!(alias.1.contains("creds: none"));
1194        assert!(alias.1.contains("intercept: off"));
1195
1196        handle.shutdown();
1197    }
1198
1199    #[tokio::test]
1200    async fn test_proxy_env_vars() {
1201        let config = ProxyConfig::default();
1202        let handle = start(config).await.unwrap();
1203
1204        let vars = handle.env_vars();
1205        let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1206        assert!(http_proxy.is_some());
1207        assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1208
1209        let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1210        assert!(token_var.is_some());
1211        assert_eq!(token_var.unwrap().1.len(), 64);
1212
1213        let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1214        assert!(
1215            node_proxy_flag.is_some(),
1216            "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1217        );
1218        assert_eq!(
1219            node_proxy_flag.unwrap().1,
1220            "1",
1221            "NODE_USE_ENV_PROXY must be '1'"
1222        );
1223
1224        handle.shutdown();
1225    }
1226
1227    #[tokio::test]
1228    async fn test_proxy_credential_env_vars() {
1229        let config = ProxyConfig {
1230            routes: vec![crate::config::RouteConfig {
1231                prefix: "openai".to_string(),
1232                upstream: "https://api.openai.com".to_string(),
1233                credential_key: None,
1234                inject_mode: crate::config::InjectMode::Header,
1235                inject_header: "Authorization".to_string(),
1236                credential_format: Some("Bearer {}".to_string()),
1237                path_pattern: None,
1238                path_replacement: None,
1239                query_param_name: None,
1240                proxy: None,
1241                env_var: None,
1242                endpoint_rules: vec![],
1243                tls_ca: None,
1244                tls_client_cert: None,
1245                tls_client_key: None,
1246                oauth2: None,
1247            }],
1248            ..Default::default()
1249        };
1250        let handle = start(config.clone()).await.unwrap();
1251
1252        let vars = handle.credential_env_vars(&config);
1253        assert_eq!(vars.len(), 1);
1254        assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1255        assert!(vars[0].1.contains("/openai"));
1256
1257        handle.shutdown();
1258    }
1259
1260    #[test]
1261    fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1262        // When env_var is None and credential_key is set, the env var name
1263        // should be derived from uppercasing credential_key. This is the
1264        // backward-compatible path for keyring-backed credentials.
1265        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1266        let handle = ProxyHandle {
1267            port: 12345,
1268            token: Zeroizing::new("test_token".to_string()),
1269            audit_log: audit::new_audit_log(),
1270            shutdown_tx,
1271            loaded_routes: ["openai".to_string()].into_iter().collect(),
1272            no_proxy_hosts: Vec::new(),
1273            intercept_ca_path: None,
1274        };
1275        let config = ProxyConfig {
1276            routes: vec![crate::config::RouteConfig {
1277                prefix: "openai".to_string(),
1278                upstream: "https://api.openai.com".to_string(),
1279                credential_key: Some("openai_api_key".to_string()),
1280                inject_mode: crate::config::InjectMode::Header,
1281                inject_header: "Authorization".to_string(),
1282                credential_format: Some("Bearer {}".to_string()),
1283                path_pattern: None,
1284                path_replacement: None,
1285                query_param_name: None,
1286                proxy: None,
1287                env_var: None, // No explicit env_var — should fall back to uppercase
1288                endpoint_rules: vec![],
1289                tls_ca: None,
1290                tls_client_cert: None,
1291                tls_client_key: None,
1292                oauth2: None,
1293            }],
1294            ..Default::default()
1295        };
1296
1297        let vars = handle.credential_env_vars(&config);
1298        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
1299
1300        // Should derive OPENAI_API_KEY from uppercasing "openai_api_key"
1301        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1302        assert!(
1303            api_key_var.is_some(),
1304            "Should derive env var name from credential_key.to_uppercase()"
1305        );
1306
1307        let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1308        assert_eq!(val, "test_token");
1309    }
1310
1311    #[test]
1312    fn test_proxy_credential_env_vars_with_explicit_env_var() {
1313        // When env_var is set on a route, it should be used instead of
1314        // deriving from credential_key. This is essential for URI manager
1315        // credential refs (e.g., op://, apple-password://)
1316        // where uppercasing produces nonsensical env var names.
1317        //
1318        // We construct a ProxyHandle directly to test env var generation
1319        // without starting a real proxy (which would try to load credentials).
1320        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1321        let handle = ProxyHandle {
1322            port: 12345,
1323            token: Zeroizing::new("test_token".to_string()),
1324            audit_log: audit::new_audit_log(),
1325            shutdown_tx,
1326            loaded_routes: ["openai".to_string()].into_iter().collect(),
1327            no_proxy_hosts: Vec::new(),
1328            intercept_ca_path: None,
1329        };
1330        let config = ProxyConfig {
1331            routes: vec![crate::config::RouteConfig {
1332                prefix: "openai".to_string(),
1333                upstream: "https://api.openai.com".to_string(),
1334                credential_key: Some("op://Development/OpenAI/credential".to_string()),
1335                inject_mode: crate::config::InjectMode::Header,
1336                inject_header: "Authorization".to_string(),
1337                credential_format: Some("Bearer {}".to_string()),
1338                path_pattern: None,
1339                path_replacement: None,
1340                query_param_name: None,
1341                proxy: None,
1342                env_var: Some("OPENAI_API_KEY".to_string()),
1343                endpoint_rules: vec![],
1344                tls_ca: None,
1345                tls_client_cert: None,
1346                tls_client_key: None,
1347                oauth2: None,
1348            }],
1349            ..Default::default()
1350        };
1351
1352        let vars = handle.credential_env_vars(&config);
1353        assert_eq!(vars.len(), 2); // BASE_URL + API_KEY
1354
1355        let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1356        assert!(
1357            api_key_var.is_some(),
1358            "Should use explicit env_var name, not derive from credential_key"
1359        );
1360
1361        // Verify the value is the phantom token, not the real credential
1362        let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1363        assert_eq!(val, "test_token");
1364
1365        // Verify no nonsensical OP:// env var was generated
1366        let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1367        assert!(
1368            bad_var.is_none(),
1369            "Should not generate env var from op:// URI uppercase"
1370        );
1371    }
1372
1373    #[test]
1374    fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1375        // When a credential is unavailable (e.g., GITHUB_TOKEN not set),
1376        // the route should NOT inject a phantom token env var. Otherwise
1377        // the phantom token shadows valid credentials from other sources
1378        // like the system keyring. See: #234
1379        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1380        let handle = ProxyHandle {
1381            port: 12345,
1382            token: Zeroizing::new("test_token".to_string()),
1383            audit_log: audit::new_audit_log(),
1384            shutdown_tx,
1385            // Only "openai" was loaded; "github" credential was unavailable
1386            loaded_routes: ["openai".to_string()].into_iter().collect(),
1387            no_proxy_hosts: Vec::new(),
1388            intercept_ca_path: None,
1389        };
1390        let config = ProxyConfig {
1391            routes: vec![
1392                crate::config::RouteConfig {
1393                    prefix: "openai".to_string(),
1394                    upstream: "https://api.openai.com".to_string(),
1395                    credential_key: Some("openai_api_key".to_string()),
1396                    inject_mode: crate::config::InjectMode::Header,
1397                    inject_header: "Authorization".to_string(),
1398                    credential_format: Some("Bearer {}".to_string()),
1399                    path_pattern: None,
1400                    path_replacement: None,
1401                    query_param_name: None,
1402                    proxy: None,
1403                    env_var: None,
1404                    endpoint_rules: vec![],
1405                    tls_ca: None,
1406                    tls_client_cert: None,
1407                    tls_client_key: None,
1408                    oauth2: None,
1409                },
1410                crate::config::RouteConfig {
1411                    prefix: "github".to_string(),
1412                    upstream: "https://api.github.com".to_string(),
1413                    credential_key: Some("env://GITHUB_TOKEN".to_string()),
1414                    inject_mode: crate::config::InjectMode::Header,
1415                    inject_header: "Authorization".to_string(),
1416                    credential_format: Some("token {}".to_string()),
1417                    path_pattern: None,
1418                    path_replacement: None,
1419                    query_param_name: None,
1420                    proxy: None,
1421                    env_var: Some("GITHUB_TOKEN".to_string()),
1422                    endpoint_rules: vec![],
1423                    tls_ca: None,
1424                    tls_client_cert: None,
1425                    tls_client_key: None,
1426                    oauth2: None,
1427                },
1428            ],
1429            ..Default::default()
1430        };
1431
1432        let vars = handle.credential_env_vars(&config);
1433
1434        // openai should have BASE_URL + API_KEY (credential loaded)
1435        let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1436        assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1437        let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1438        assert!(openai_key.is_some(), "loaded route should have API key");
1439
1440        // github should have BASE_URL (always set for declared routes) but
1441        // must NOT have GITHUB_TOKEN (credential was not loaded)
1442        let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1443        assert!(
1444            github_base.is_some(),
1445            "declared route should still have BASE_URL"
1446        );
1447        let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1448        assert!(
1449            github_token.is_none(),
1450            "unloaded route must not inject phantom GITHUB_TOKEN"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_proxy_credential_env_vars_strips_slashes() {
1456        // When prefix includes leading/trailing slashes, the env var name
1457        // must not contain slashes and the URL must not double-slash.
1458        // Regression test for user-reported bug where "/anthropic" produced
1459        // "/ANTHROPIC_BASE_URL=http://127.0.0.1:PORT//anthropic".
1460        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1461        let handle = ProxyHandle {
1462            port: 58406,
1463            token: Zeroizing::new("test_token".to_string()),
1464            audit_log: audit::new_audit_log(),
1465            shutdown_tx,
1466            loaded_routes: std::collections::HashSet::new(),
1467            no_proxy_hosts: Vec::new(),
1468            intercept_ca_path: None,
1469        };
1470
1471        // Test leading slash
1472        let config = ProxyConfig {
1473            routes: vec![crate::config::RouteConfig {
1474                prefix: "/anthropic".to_string(),
1475                upstream: "https://api.anthropic.com".to_string(),
1476                credential_key: None,
1477                inject_mode: crate::config::InjectMode::Header,
1478                inject_header: "Authorization".to_string(),
1479                credential_format: Some("Bearer {}".to_string()),
1480                path_pattern: None,
1481                path_replacement: None,
1482                query_param_name: None,
1483                proxy: None,
1484                env_var: None,
1485                endpoint_rules: vec![],
1486                tls_ca: None,
1487                tls_client_cert: None,
1488                tls_client_key: None,
1489                oauth2: None,
1490            }],
1491            ..Default::default()
1492        };
1493
1494        let vars = handle.credential_env_vars(&config);
1495        assert_eq!(vars.len(), 1);
1496        assert_eq!(
1497            vars[0].0, "ANTHROPIC_BASE_URL",
1498            "env var name must not have leading slash"
1499        );
1500        assert_eq!(
1501            vars[0].1, "http://127.0.0.1:58406/anthropic",
1502            "URL must not have double slash"
1503        );
1504
1505        // Test trailing slash
1506        let config = ProxyConfig {
1507            routes: vec![crate::config::RouteConfig {
1508                prefix: "openai/".to_string(),
1509                upstream: "https://api.openai.com".to_string(),
1510                credential_key: None,
1511                inject_mode: crate::config::InjectMode::Header,
1512                inject_header: "Authorization".to_string(),
1513                credential_format: Some("Bearer {}".to_string()),
1514                path_pattern: None,
1515                path_replacement: None,
1516                query_param_name: None,
1517                proxy: None,
1518                env_var: None,
1519                endpoint_rules: vec![],
1520                tls_ca: None,
1521                tls_client_cert: None,
1522                tls_client_key: None,
1523                oauth2: None,
1524            }],
1525            ..Default::default()
1526        };
1527
1528        let vars = handle.credential_env_vars(&config);
1529        assert_eq!(
1530            vars[0].0, "OPENAI_BASE_URL",
1531            "env var name must not have trailing slash"
1532        );
1533        assert_eq!(
1534            vars[0].1, "http://127.0.0.1:58406/openai",
1535            "URL must not have trailing slash in path"
1536        );
1537    }
1538
1539    #[test]
1540    fn test_anthropic_credential_phantom_token_regression() {
1541        // Regression test for issue #624: the built-in anthropic credential
1542        // entry had no env_var or credential_key, so ANTHROPIC_API_KEY was
1543        // never set to the phantom token. Only ANTHROPIC_BASE_URL was injected,
1544        // leaving the sandbox to send the host's real key directly.
1545        //
1546        // Pre-fix state: route in loaded_routes but no env_var / credential_key
1547        // => ANTHROPIC_API_KEY must NOT appear (demonstrates the bug).
1548        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1549        let handle_no_env_var = ProxyHandle {
1550            port: 12345,
1551            token: Zeroizing::new("phantom".to_string()),
1552            audit_log: audit::new_audit_log(),
1553            shutdown_tx: shutdown_tx.clone(),
1554            loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1555            no_proxy_hosts: Vec::new(),
1556            intercept_ca_path: None,
1557        };
1558        let config_no_env_var = ProxyConfig {
1559            routes: vec![crate::config::RouteConfig {
1560                prefix: "anthropic".to_string(),
1561                upstream: "https://api.anthropic.com".to_string(),
1562                credential_key: None,
1563                inject_mode: crate::config::InjectMode::Header,
1564                inject_header: "x-api-key".to_string(),
1565                credential_format: Some("{}".to_string()),
1566                path_pattern: None,
1567                path_replacement: None,
1568                query_param_name: None,
1569                proxy: None,
1570                env_var: None,
1571                endpoint_rules: vec![],
1572                tls_ca: None,
1573                tls_client_cert: None,
1574                tls_client_key: None,
1575                oauth2: None,
1576            }],
1577            ..Default::default()
1578        };
1579        let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1580        assert!(
1581            vars_no_env_var
1582                .iter()
1583                .all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1584            "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1585        );
1586
1587        // Post-fix state: route has env_var = "ANTHROPIC_API_KEY"
1588        // => ANTHROPIC_API_KEY must be set to the phantom token.
1589        let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1590        let handle_fixed = ProxyHandle {
1591            port: 12345,
1592            token: Zeroizing::new("phantom".to_string()),
1593            audit_log: audit::new_audit_log(),
1594            shutdown_tx: shutdown_tx2,
1595            loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1596            no_proxy_hosts: Vec::new(),
1597            intercept_ca_path: None,
1598        };
1599        let config_fixed = ProxyConfig {
1600            routes: vec![crate::config::RouteConfig {
1601                prefix: "anthropic".to_string(),
1602                upstream: "https://api.anthropic.com".to_string(),
1603                credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1604                inject_mode: crate::config::InjectMode::Header,
1605                inject_header: "x-api-key".to_string(),
1606                credential_format: Some("{}".to_string()),
1607                path_pattern: None,
1608                path_replacement: None,
1609                query_param_name: None,
1610                proxy: None,
1611                env_var: Some("ANTHROPIC_API_KEY".to_string()),
1612                endpoint_rules: vec![],
1613                tls_ca: None,
1614                tls_client_cert: None,
1615                tls_client_key: None,
1616                oauth2: None,
1617            }],
1618            ..Default::default()
1619        };
1620        let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1621        let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1622        assert!(
1623            api_key_var.is_some(),
1624            "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1625        );
1626        assert_eq!(api_key_var.unwrap().1, "phantom");
1627    }
1628
1629    #[test]
1630    fn test_no_proxy_excludes_credential_upstreams() {
1631        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1632        let handle = ProxyHandle {
1633            port: 12345,
1634            token: Zeroizing::new("test_token".to_string()),
1635            audit_log: audit::new_audit_log(),
1636            shutdown_tx,
1637            loaded_routes: std::collections::HashSet::new(),
1638            no_proxy_hosts: vec![
1639                "nats.internal:4222".to_string(),
1640                "opencode.internal:4096".to_string(),
1641            ],
1642            intercept_ca_path: None,
1643        };
1644
1645        let vars = handle.env_vars();
1646        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1647        assert!(
1648            no_proxy.1.contains("nats.internal"),
1649            "non-credential host should be in NO_PROXY"
1650        );
1651        assert!(
1652            no_proxy.1.contains("opencode.internal"),
1653            "non-credential host should be in NO_PROXY"
1654        );
1655        assert!(
1656            no_proxy.1.contains("localhost"),
1657            "localhost should always be in NO_PROXY"
1658        );
1659    }
1660
1661    #[test]
1662    fn test_no_proxy_empty_when_no_non_credential_hosts() {
1663        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1664        let handle = ProxyHandle {
1665            port: 12345,
1666            token: Zeroizing::new("test_token".to_string()),
1667            audit_log: audit::new_audit_log(),
1668            shutdown_tx,
1669            loaded_routes: std::collections::HashSet::new(),
1670            no_proxy_hosts: Vec::new(),
1671            intercept_ca_path: None,
1672        };
1673
1674        let vars = handle.env_vars();
1675        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1676        assert_eq!(
1677            no_proxy.1, "localhost,127.0.0.1",
1678            "NO_PROXY should only contain loopback when no bypass hosts"
1679        );
1680    }
1681
1682    #[tokio::test]
1683    async fn test_no_proxy_empty_without_direct_connect_ports() {
1684        // When direct_connect_ports is empty (no --allow-connect-port),
1685        // allowed_hosts should NOT appear in NO_PROXY because the sandbox
1686        // blocks direct TCP and clients would fail to connect. See #760.
1687        let config = ProxyConfig {
1688            allowed_hosts: vec!["github.com".to_string()],
1689            ..Default::default()
1690        };
1691        let handle = start(config).await.unwrap();
1692
1693        let vars = handle.env_vars();
1694        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1695        assert_eq!(
1696            no_proxy.1, "localhost,127.0.0.1",
1697            "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1698        );
1699
1700        handle.shutdown();
1701    }
1702
1703    #[cfg(not(target_os = "macos"))]
1704    #[tokio::test]
1705    async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1706        // When direct_connect_ports includes port 443, allowed_hosts on
1707        // that port SHOULD appear in NO_PROXY (direct TCP is permitted).
1708        // macOS always returns empty NO_PROXY (Seatbelt blocks all direct outbound).
1709        let config = ProxyConfig {
1710            allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1711            direct_connect_ports: vec![443],
1712            ..Default::default()
1713        };
1714        let handle = start(config).await.unwrap();
1715
1716        let vars = handle.env_vars();
1717        let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1718        assert!(
1719            no_proxy.1.contains("github.com"),
1720            "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1721        );
1722        assert!(
1723            !no_proxy.1.contains("server.internal"),
1724            "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1725        );
1726
1727        handle.shutdown();
1728    }
1729
1730    /// Regression test: when `strict_filter` is true and `allowed_hosts` is
1731    /// empty, the proxy must deny CONNECT instead of falling back to allow-all.
1732    #[tokio::test]
1733    async fn test_strict_filter_with_empty_allowlist_denies_connect() {
1734        use tokio::io::AsyncReadExt;
1735        use tokio::net::TcpStream;
1736
1737        let config = ProxyConfig {
1738            strict_filter: true,
1739            allowed_hosts: Vec::new(),
1740            ..ProxyConfig::default()
1741        };
1742        let handle = start(config).await.unwrap();
1743        let addr = format!("127.0.0.1:{}", handle.port);
1744
1745        let mut stream = TcpStream::connect(&addr).await.unwrap();
1746        let request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
1747        tokio::io::AsyncWriteExt::write_all(&mut stream, request)
1748            .await
1749            .unwrap();
1750
1751        let mut response = Vec::new();
1752        stream.read_to_end(&mut response).await.unwrap();
1753        let response_str = String::from_utf8_lossy(&response);
1754        assert!(
1755            response_str.starts_with("HTTP/1.1 403"),
1756            "strict filter with empty allowlist must deny CONNECT, got: {}",
1757            response_str
1758        );
1759
1760        let events = handle.drain_audit_events();
1761        assert!(
1762            events
1763                .iter()
1764                .any(|e| e.decision == nono::undo::NetworkAuditDecision::Deny
1765                    && e.target == "example.com"),
1766            "expected a Deny audit event for example.com, got: {:?}",
1767            events
1768        );
1769
1770        handle.shutdown();
1771    }
1772
1773    /// Regression test for reactive proxy auth on the intercept CONNECT path.
1774    /// After a 407 the proxy must keep the connection open and answer the
1775    /// client's credentialed retry on the same socket, rather than closing it
1776    /// (which breaks reactive clients such as Apache HttpClient / Maven's
1777    /// native resolver).
1778    #[tokio::test]
1779    async fn reactive_proxy_auth_retry_answered_after_407() {
1780        use base64::Engine;
1781        use std::time::Duration;
1782        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1783        use tokio::net::TcpStream;
1784
1785        let dir = tempfile::tempdir().unwrap();
1786        let config = ProxyConfig {
1787            routes: vec![crate::config::RouteConfig {
1788                prefix: "openai".to_string(),
1789                upstream: "https://api.openai.com".to_string(),
1790                credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1791                inject_mode: Default::default(),
1792                inject_header: "Authorization".to_string(),
1793                credential_format: Some("Bearer {}".to_string()),
1794                path_pattern: None,
1795                path_replacement: None,
1796                query_param_name: None,
1797                proxy: None,
1798                env_var: None,
1799                endpoint_rules: vec![],
1800                tls_ca: None,
1801                tls_client_cert: None,
1802                tls_client_key: None,
1803                oauth2: None,
1804            }],
1805            intercept_ca_dir: Some(dir.path().to_path_buf()),
1806            ..Default::default()
1807        };
1808        let handle = start(config).await.unwrap();
1809        assert!(
1810            handle.intercept_ca_path().is_some(),
1811            "precondition: interception must be active so the 407 path is reached"
1812        );
1813        let port = handle.port;
1814        let token = handle.token.to_string();
1815
1816        let mut sock = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
1817
1818        // 1) Unauthenticated CONNECT -> expect a 407 challenge.
1819        sock.write_all(b"CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\n\r\n")
1820            .await
1821            .unwrap();
1822        sock.flush().await.unwrap();
1823
1824        let mut buf = [0u8; 4096];
1825        let n = sock.read(&mut buf).await.unwrap();
1826        let response = String::from_utf8_lossy(&buf[..n]);
1827        assert!(
1828            response.starts_with("HTTP/1.1 407 "),
1829            "expected 407 challenge, got: {:?}",
1830            response
1831        );
1832
1833        // 2) Reactive retry WITH valid credentials on the SAME socket.
1834        let creds = base64::engine::general_purpose::STANDARD.encode(format!("nono:{}", token));
1835        let retry = format!(
1836            "CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\nProxy-Authorization: Basic {}\r\n\r\n",
1837            creds
1838        );
1839        sock.write_all(retry.as_bytes()).await.unwrap();
1840        sock.flush().await.unwrap();
1841
1842        // 3) The proxy must answer the retried CONNECT on the same socket
1843        //    instead of returning EOF. (The upstream connect to api.openai.com
1844        //    may fail in the test env, so we require a response, not a 200.)
1845        let mut retry_buf = [0u8; 4096];
1846        let read_result =
1847            tokio::time::timeout(Duration::from_secs(5), sock.read(&mut retry_buf)).await;
1848        match read_result {
1849            Ok(Ok(0)) => panic!(
1850                "regression: proxy closed the socket after the 407 instead of \
1851                 answering the reactive retry"
1852            ),
1853            Ok(Ok(_)) => {} // answered -> reactive auth handled
1854            Ok(Err(e)) => panic!("retry read errored: {e}"),
1855            Err(_) => panic!("retry read timed out — proxy did not answer the retry"),
1856        }
1857
1858        handle.shutdown();
1859    }
1860}