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