1use crate::audit;
11use crate::config::ProxyConfig;
12use crate::connect;
13use crate::credential::CredentialStore;
14use crate::error::{ProxyError, Result};
15use crate::external;
16use crate::filter::ProxyFilter;
17use crate::reverse;
18use crate::route::RouteStore;
19use crate::tls_intercept::{self, CertCache, EphemeralCa};
20use crate::token;
21use std::net::SocketAddr;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::sync::atomic::{AtomicUsize, Ordering};
25use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
26use tokio::net::TcpListener;
27use tokio::sync::watch;
28use tracing::{debug, info, warn};
29use zeroize::Zeroizing;
30
31const MAX_HEADER_SIZE: usize = 64 * 1024;
34
35pub struct ProxyHandle {
40 pub port: u16,
42 pub token: Zeroizing<String>,
44 audit_log: audit::SharedAuditLog,
46 shutdown_tx: watch::Sender<bool>,
48 loaded_routes: std::collections::HashSet<String>,
52 no_proxy_hosts: Vec<String>,
55 intercept_ca_path: Option<PathBuf>,
61}
62
63impl ProxyHandle {
64 pub fn shutdown(&self) {
66 let _ = self.shutdown_tx.send(true);
67 }
68
69 #[must_use]
71 pub fn drain_audit_events(&self) -> Vec<nono::undo::NetworkAuditEvent> {
72 audit::drain_audit_events(&self.audit_log)
73 }
74
75 #[must_use]
86 pub fn intercept_ca_path(&self) -> Option<&std::path::Path> {
87 self.intercept_ca_path.as_deref()
88 }
89
90 #[must_use]
102 pub fn route_diagnostics(&self, config: &ProxyConfig) -> Vec<(String, String)> {
103 let mut rows = Vec::with_capacity(config.routes.len());
104 for route in &config.routes {
105 let prefix = route.prefix.trim_matches('/').to_string();
106 let cred_summary = if let Some(ref key) = route.credential_key {
107 let resolved = self.loaded_routes.contains(&prefix);
108 if resolved {
109 format!("creds: {} ✓", key)
110 } else {
111 format!("creds: {} ✗ (not found)", key)
112 }
113 } else if route.oauth2.is_some() {
114 let resolved = self.loaded_routes.contains(&prefix);
115 if resolved {
116 "creds: oauth2 ✓".to_string()
117 } else {
118 "creds: oauth2 ✗ (token exchange failed)".to_string()
119 }
120 } else {
121 "creds: none".to_string()
122 };
123
124 let intercept_summary = if self.intercept_ca_path.is_some()
125 && (route.credential_key.is_some()
126 || route.oauth2.is_some()
127 || !route.endpoint_rules.is_empty())
128 {
129 "intercept: on"
130 } else {
131 "intercept: off"
132 };
133
134 let rules_summary = format!("endpoint_rules: {}", route.endpoint_rules.len());
135 let summary = format!(
136 "→ {} | {} | {} | {}",
137 route.upstream, cred_summary, intercept_summary, rules_summary
138 );
139 rows.push((prefix, summary));
140 }
141 rows
142 }
143
144 #[must_use]
157 pub fn env_vars(&self) -> Vec<(String, String)> {
158 let proxy_url = format!("http://nono:{}@127.0.0.1:{}", &*self.token, self.port);
159
160 let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
164 for host in &self.no_proxy_hosts {
165 let hostname = if host.contains("]:") {
168 host.rsplit_once("]:")
170 .map(|(h, _)| format!("{}]", h))
171 .unwrap_or_else(|| host.clone())
172 } else {
173 host.rsplit_once(':')
174 .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h.to_string()))
175 .unwrap_or_else(|| host.clone())
176 };
177 if !no_proxy_parts.contains(&hostname.to_string()) {
178 no_proxy_parts.push(hostname.to_string());
179 }
180 }
181 let no_proxy = no_proxy_parts.join(",");
182
183 let mut vars = vec![
184 ("HTTP_PROXY".to_string(), proxy_url.clone()),
185 ("HTTPS_PROXY".to_string(), proxy_url.clone()),
186 ("NO_PROXY".to_string(), no_proxy.clone()),
187 ("NONO_PROXY_TOKEN".to_string(), self.token.to_string()),
188 ];
189
190 vars.push(("http_proxy".to_string(), proxy_url.clone()));
192 vars.push(("https_proxy".to_string(), proxy_url));
193 vars.push(("no_proxy".to_string(), no_proxy));
194
195 vars.push(("NODE_USE_ENV_PROXY".to_string(), "1".to_string()));
202
203 if let Some(path) = self.intercept_ca_path.as_deref() {
217 let path_str = path.to_string_lossy().to_string();
218 vars.push(("SSL_CERT_FILE".to_string(), path_str.clone()));
219 vars.push(("REQUESTS_CA_BUNDLE".to_string(), path_str.clone()));
220 vars.push(("NODE_EXTRA_CA_CERTS".to_string(), path_str.clone()));
221 vars.push(("CURL_CA_BUNDLE".to_string(), path_str.clone()));
222 vars.push(("GIT_SSL_CAINFO".to_string(), path_str));
223 }
224
225 vars
226 }
227
228 #[must_use]
237 pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
238 let mut vars = Vec::new();
239 for route in &config.routes {
240 let prefix = route.prefix.trim_matches('/');
245
246 let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
248 let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
249 vars.push((base_url_name, url));
250
251 if !self.loaded_routes.contains(prefix) {
256 continue;
257 }
258
259 if let Some(ref env_var) = route.env_var {
263 vars.push((env_var.clone(), self.token.to_string()));
264 } else if let Some(ref cred_key) = route.credential_key {
265 if !cred_key.contains("://") {
269 let api_key_name = cred_key.to_uppercase();
270 vars.push((api_key_name, self.token.to_string()));
271 }
272 }
273 }
274 vars
275 }
276}
277
278impl Drop for ProxyHandle {
279 fn drop(&mut self) {
290 if let Some(path) = self.intercept_ca_path.take() {
291 let _ = std::fs::remove_file(&path);
292 if let Some(parent) = path.parent() {
297 let _ = std::fs::remove_dir(parent);
298 }
299 }
300 }
301}
302
303struct ProxyState {
305 filter: ProxyFilter,
306 session_token: Zeroizing<String>,
307 route_store: RouteStore,
309 credential_store: CredentialStore,
311 config: ProxyConfig,
312 tls_connector: tokio_rustls::TlsConnector,
315 active_connections: AtomicUsize,
317 audit_log: audit::SharedAuditLog,
319 bypass_matcher: external::BypassMatcher,
322 cert_cache: Option<Arc<CertCache>>,
327}
328
329pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
337 let session_token = token::generate_session_token()?;
339
340 let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
342 let listener = TcpListener::bind(bind_addr)
343 .await
344 .map_err(|e| ProxyError::Bind {
345 addr: bind_addr.to_string(),
346 source: e,
347 })?;
348
349 let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
350 addr: bind_addr.to_string(),
351 source: e,
352 })?;
353 let port = local_addr.port();
354
355 info!("Proxy server listening on {}", local_addr);
356
357 let route_store = if config.routes.is_empty() {
360 RouteStore::empty()
361 } else {
362 RouteStore::load(&config.routes)?
363 };
364 let mut root_store = rustls::RootCertStore::empty();
370 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
371 let native = rustls_native_certs::load_native_certs();
372 if !native.errors.is_empty() {
373 debug!(
374 "failed to load {} native cert(s); continuing with webpki roots + any that succeeded",
375 native.errors.len()
376 );
377 }
378 let native_count = native.certs.len();
379 for cert in native.certs {
380 if let Err(e) = root_store.add(cert) {
381 debug!("skipping unparseable native cert: {e}");
382 }
383 }
384 if native_count > 0 {
385 debug!("added {native_count} native system CA(s) to upstream trust store");
386 }
387 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
388 rustls::crypto::ring::default_provider(),
389 ))
390 .with_safe_default_protocol_versions()
391 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
392 .with_root_certificates(root_store)
393 .with_no_client_auth();
394 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
395
396 let credential_store = if config.routes.is_empty() {
398 CredentialStore::empty()
399 } else {
400 CredentialStore::load(&config.routes, &tls_connector)?
401 };
402 let loaded_routes = credential_store.loaded_prefixes();
403
404 let filter = if config.strict_filter {
406 ProxyFilter::new_strict(&config.allowed_hosts)
407 } else if config.allowed_hosts.is_empty() {
408 ProxyFilter::allow_all()
409 } else {
410 ProxyFilter::new(&config.allowed_hosts)
411 };
412
413 let bypass_matcher = config
415 .external_proxy
416 .as_ref()
417 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
418 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
419
420 let (shutdown_tx, shutdown_rx) = watch::channel(false);
422 let audit_log = audit::new_audit_log();
423
424 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
436 Vec::new()
437 } else {
438 let route_hosts = route_store.route_upstream_hosts();
439 config
440 .allowed_hosts
441 .iter()
442 .filter(|host| {
443 let normalised = {
444 let h = host.to_lowercase();
445 if h.starts_with('[') {
446 if h.contains("]:") {
448 h
449 } else {
450 format!("{}:443", h)
451 }
452 } else if h.contains(':') {
453 h
454 } else {
455 format!("{}:443", h)
456 }
457 };
458 if route_hosts.contains(&normalised) {
459 return false;
460 }
461 let port = normalised
464 .rsplit_once(':')
465 .and_then(|(_, p)| p.parse::<u16>().ok())
466 .unwrap_or(443);
467 config.direct_connect_ports.contains(&port)
468 })
469 .cloned()
470 .collect()
471 };
472
473 if !no_proxy_hosts.is_empty() {
474 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
475 }
476
477 let any_intercept_route = route_store
483 .route_upstream_hosts()
484 .iter()
485 .any(|hp| route_store.has_intercept_route(hp));
486 let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
487 (Some(dir), true) => {
488 let intercept_route_count = route_store
489 .route_upstream_hosts()
490 .iter()
491 .filter(|hp| route_store.has_intercept_route(hp))
492 .count();
493 let ca_result = if let Some(ref preloaded) = config.preloaded_ca {
494 EphemeralCa::from_existing(&preloaded.key_der, &preloaded.cert_pem)
495 } else {
496 let validity = config
497 .ca_validity
498 .unwrap_or(crate::tls_intercept::ca::CA_VALIDITY_DEFAULT);
499 EphemeralCa::generate_with_cn("nono-session-ca", validity)
500 };
501 match ca_result.and_then(|ca| {
502 let ca = Arc::new(ca);
503 let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
504 let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
505 dir,
506 filename: "intercept-ca.pem",
507 parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
508 ephemeral_ca_pem: ca.cert_pem(),
509 })?;
510 Ok((cache, path))
511 }) {
512 Ok((cache, path)) => {
513 info!(
514 "TLS interception active for {} route(s); trust bundle at {}",
515 intercept_route_count,
516 path.display()
517 );
518 (Some(cache), Some(path))
519 }
520 Err(e) => {
521 warn!(
522 "TLS interception setup failed for {} route(s): {}. \
523 Continuing with interception disabled; reverse-proxy routes remain available.",
524 intercept_route_count, e
525 );
526 (None, None)
527 }
528 }
529 }
530 (Some(_), false) => {
531 debug!(
532 "TLS interception requested but no configured route requires L7 visibility; \
533 skipping CA generation"
534 );
535 (None, None)
536 }
537 (None, _) => (None, None),
538 };
539
540 let state = Arc::new(ProxyState {
541 filter,
542 session_token: session_token.clone(),
543 route_store,
544 credential_store,
545 config,
546 tls_connector,
547 active_connections: AtomicUsize::new(0),
548 audit_log: Arc::clone(&audit_log),
549 bypass_matcher,
550 cert_cache,
551 });
552
553 tokio::spawn(accept_loop(listener, state, shutdown_rx));
557
558 Ok(ProxyHandle {
559 port,
560 token: session_token,
561 audit_log,
562 shutdown_tx,
563 loaded_routes,
564 no_proxy_hosts,
565 intercept_ca_path,
566 })
567}
568
569async fn accept_loop(
571 listener: TcpListener,
572 state: Arc<ProxyState>,
573 mut shutdown_rx: watch::Receiver<bool>,
574) {
575 loop {
576 tokio::select! {
577 result = listener.accept() => {
578 match result {
579 Ok((stream, addr)) => {
580 let max = state.config.max_connections;
582 if max > 0 {
583 let current = state.active_connections.load(Ordering::Relaxed);
584 if current >= max {
585 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
586 drop(stream);
588 continue;
589 }
590 }
591 state.active_connections.fetch_add(1, Ordering::Relaxed);
592
593 debug!("Accepted connection from {}", addr);
594 let state = Arc::clone(&state);
595 tokio::spawn(async move {
596 if let Err(e) = handle_connection(stream, &state).await {
597 debug!("Connection handler error: {}", e);
598 }
599 state.active_connections.fetch_sub(1, Ordering::Relaxed);
600 });
601 }
602 Err(e) => {
603 warn!("Accept error: {}", e);
604 }
605 }
606 }
607 _ = shutdown_rx.changed() => {
608 if *shutdown_rx.borrow() {
609 info!("Proxy server shutting down");
610 return;
611 }
612 }
613 }
614 }
615}
616
617fn normalize_authority(authority: &str) -> String {
621 if authority.starts_with('[') {
622 if authority.contains("]:") {
623 authority.to_lowercase()
624 } else {
625 format!("{}:443", authority.to_lowercase())
626 }
627 } else if authority.contains(':') {
628 authority.to_lowercase()
629 } else {
630 format!("{}:443", authority.to_lowercase())
631 }
632}
633
634async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
640 let mut buf_reader = BufReader::new(&mut stream);
644 let mut first_line = String::new();
645 buf_reader.read_line(&mut first_line).await?;
646
647 if first_line.is_empty() {
648 return Ok(()); }
650
651 let mut header_bytes = Vec::new();
653 loop {
654 let mut line = String::new();
655 let n = buf_reader.read_line(&mut line).await?;
656 if n == 0 || line.trim().is_empty() {
657 break;
658 }
659 header_bytes.extend_from_slice(line.as_bytes());
660 if header_bytes.len() > MAX_HEADER_SIZE {
661 drop(buf_reader);
662 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
663 stream.write_all(response.as_bytes()).await?;
664 return Ok(());
665 }
666 }
667
668 let buffered = buf_reader.buffer().to_vec();
673 drop(buf_reader);
674
675 let first_line = first_line.trim_end();
676
677 if first_line.starts_with("CONNECT ") {
679 if !state.route_store.is_empty()
695 && let Some(authority) = first_line.split_whitespace().nth(1)
696 {
697 let host_port = normalize_authority(authority);
698
699 if state.route_store.is_route_upstream(&host_port) {
700 let route_id = state
701 .route_store
702 .lookup_by_upstream(&host_port)
703 .map(|(prefix, _)| prefix);
704 let (host, port) = host_port
705 .rsplit_once(':')
706 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
707 .unwrap_or_else(|| (host_port.clone(), 443));
708
709 let intercept_eligible = state.route_store.has_intercept_route(&host_port);
710
711 match (intercept_eligible, state.cert_cache.as_ref()) {
712 (true, Some(cache)) => {
714 let mut current_headers = header_bytes;
727 loop {
728 match token::validate_proxy_auth(¤t_headers, &state.session_token)
729 {
730 Ok(()) => break,
731 Err(e) => {
732 debug!(
733 "tls_intercept: CONNECT to {}:{} missing/invalid proxy auth — {}",
734 host, port, e
735 );
736 audit::log_denied(
737 Some(&state.audit_log),
738 audit::ProxyMode::ConnectIntercept,
739 &audit::EventContext {
740 route_id,
741 auth_mechanism: Some(
742 nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
743 ),
744 auth_outcome: Some(
745 nono::undo::NetworkAuditAuthOutcome::Failed,
746 ),
747 denial_category: Some(
748 nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
749 ),
750 ..audit::EventContext::default()
751 },
752 &host,
753 port,
754 "proxy auth missing or invalid",
755 );
756 let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
757 stream.write_all(response.as_bytes()).await?;
758
759 let mut buf_reader = BufReader::new(&mut stream);
762 let mut retry_line = String::new();
763 buf_reader.read_line(&mut retry_line).await?;
764 if retry_line.is_empty() {
765 return Ok(()); }
767 let mut retry_headers = Vec::new();
768 loop {
769 let mut line = String::new();
770 let n = buf_reader.read_line(&mut line).await?;
771 if n == 0 || line.trim().is_empty() {
772 break;
773 }
774 retry_headers.extend_from_slice(line.as_bytes());
775 if retry_headers.len() > MAX_HEADER_SIZE {
776 drop(buf_reader);
777 let too_large = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
778 stream.write_all(too_large.as_bytes()).await?;
779 return Ok(());
780 }
781 }
782 drop(buf_reader);
783
784 let same_authority = retry_line
789 .trim_end()
790 .strip_prefix("CONNECT ")
791 .and_then(|rest| rest.split_whitespace().next())
792 .map(normalize_authority)
793 .as_deref()
794 == Some(host_port.as_str());
795 if !same_authority {
796 return Ok(());
797 }
798 current_headers = retry_headers;
799 }
800 }
801 }
802
803 let ctx = tls_intercept::InterceptCtx {
804 route_id,
805 host: &host,
806 port,
807 route_store: &state.route_store,
808 credential_store: &state.credential_store,
809 session_token: &state.session_token,
810 cert_cache: Arc::clone(cache),
811 tls_connector: &state.tls_connector,
812 filter: &state.filter,
813 audit_log: Some(&state.audit_log),
814 };
815 return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
816 }
817 _ => {
821 debug!(
822 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
823 authority
824 );
825 audit::log_denied(
826 Some(&state.audit_log),
827 audit::ProxyMode::Connect,
828 &audit::EventContext {
829 route_id,
830 denial_category: Some(
831 nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
832 ),
833 ..audit::EventContext::default()
834 },
835 &host,
836 port,
837 "route upstream: CONNECT bypasses L7 filtering",
838 );
839 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
840 stream.write_all(response.as_bytes()).await?;
841 return Ok(());
842 }
843 }
844 }
845 }
846
847 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
849 if state.bypass_matcher.is_empty() {
850 Some(ext_config)
851 } else {
852 let host = first_line
854 .split_whitespace()
855 .nth(1)
856 .and_then(|authority| {
857 authority
858 .rsplit_once(':')
859 .map(|(h, _)| h)
860 .or(Some(authority))
861 })
862 .unwrap_or("");
863 if state.bypass_matcher.matches(host) {
864 debug!("Bypassing external proxy for {}", host);
865 None
866 } else {
867 Some(ext_config)
868 }
869 }
870 } else {
871 None
872 };
873
874 if let Some(ext_config) = use_external {
875 external::handle_external_proxy(
876 first_line,
877 &mut stream,
878 &header_bytes,
879 &state.filter,
880 &state.session_token,
881 ext_config,
882 Some(&state.audit_log),
883 )
884 .await
885 } else if state.config.external_proxy.is_some() {
886 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
891 connect::handle_connect(
892 first_line,
893 &mut stream,
894 &state.filter,
895 &state.session_token,
896 &header_bytes,
897 Some(&state.audit_log),
898 )
899 .await
900 } else {
901 connect::handle_connect(
902 first_line,
903 &mut stream,
904 &state.filter,
905 &state.session_token,
906 &header_bytes,
907 Some(&state.audit_log),
908 )
909 .await
910 }
911 } else if !state.route_store.is_empty() {
912 let ctx = reverse::ReverseProxyCtx {
914 route_store: &state.route_store,
915 credential_store: &state.credential_store,
916 session_token: &state.session_token,
917 filter: &state.filter,
918 tls_connector: &state.tls_connector,
919 audit_log: Some(&state.audit_log),
920 };
921 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
922 } else {
923 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
925 stream.write_all(response.as_bytes()).await?;
926 Ok(())
927 }
928}
929
930#[cfg(test)]
931#[allow(clippy::unwrap_used)]
932mod tests {
933 use super::*;
934
935 #[test]
936 fn normalize_authority_normalises_case_and_default_port() {
937 assert_eq!(normalize_authority("API.OpenAI.com"), "api.openai.com:443");
938 assert_eq!(
939 normalize_authority("api.openai.com:443"),
940 "api.openai.com:443"
941 );
942 assert_eq!(
943 normalize_authority("api.openai.com:8443"),
944 "api.openai.com:8443"
945 );
946 assert_eq!(normalize_authority("[::1]"), "[::1]:443");
947 assert_eq!(normalize_authority("[::1]:8443"), "[::1]:8443");
948 assert_eq!(
950 normalize_authority("API.OPENAI.COM:443"),
951 normalize_authority("api.openai.com")
952 );
953 }
954
955 #[tokio::test]
956 async fn test_proxy_starts_and_binds() {
957 let config = ProxyConfig::default();
958 let handle = start(config).await.unwrap();
959
960 assert!(handle.port > 0);
962 assert_eq!(handle.token.len(), 64);
964
965 handle.shutdown();
967 }
968
969 #[tokio::test]
977 async fn test_intercept_lifecycle_end_to_end() {
978 let dir = tempfile::tempdir().unwrap();
979 let ca_path_clone;
980
981 {
982 let config = ProxyConfig {
983 routes: vec![crate::config::RouteConfig {
984 prefix: "openai".to_string(),
985 upstream: "https://api.openai.com".to_string(),
986 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
987 inject_mode: Default::default(),
988 inject_header: "Authorization".to_string(),
989 credential_format: Some("Bearer {}".to_string()),
990 path_pattern: None,
991 path_replacement: None,
992 query_param_name: None,
993 proxy: None,
994 env_var: None,
995 endpoint_rules: vec![],
996 tls_ca: None,
997 tls_client_cert: None,
998 tls_client_key: None,
999 oauth2: None,
1000 }],
1001 intercept_ca_dir: Some(dir.path().to_path_buf()),
1002 ..Default::default()
1003 };
1004 let handle = start(config).await.unwrap();
1005 assert!(
1006 handle.intercept_ca_path().is_some(),
1007 "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
1008 );
1009 ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
1010 assert!(
1011 ca_path_clone.exists(),
1012 "bundle file should have been written"
1013 );
1014
1015 let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
1016 assert!(
1017 contents.contains("BEGIN CERTIFICATE"),
1018 "bundle should contain at least one PEM block"
1019 );
1020
1021 let vars = handle.env_vars();
1023 let ssl = vars
1024 .iter()
1025 .find(|(k, _)| k == "SSL_CERT_FILE")
1026 .expect("SSL_CERT_FILE should be set when intercept active");
1027 assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
1028 assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
1029 assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
1030 assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
1031
1032 handle.shutdown();
1033 }
1034 assert!(
1036 !ca_path_clone.exists(),
1037 "bundle should be removed when ProxyHandle drops"
1038 );
1039 }
1040
1041 #[tokio::test]
1044 async fn test_intercept_skipped_for_purely_declarative_routes() {
1045 let dir = tempfile::tempdir().unwrap();
1046 let config = ProxyConfig {
1047 routes: vec![crate::config::RouteConfig {
1048 prefix: "alias".to_string(),
1049 upstream: "https://aliased.example.com".to_string(),
1050 credential_key: None,
1051 inject_mode: Default::default(),
1052 inject_header: "Authorization".to_string(),
1053 credential_format: Some("Bearer {}".to_string()),
1054 path_pattern: None,
1055 path_replacement: None,
1056 query_param_name: None,
1057 proxy: None,
1058 env_var: None,
1059 endpoint_rules: vec![],
1060 tls_ca: None,
1061 tls_client_cert: None,
1062 tls_client_key: None,
1063 oauth2: None,
1064 }],
1065 intercept_ca_dir: Some(dir.path().to_path_buf()),
1066 ..Default::default()
1067 };
1068 let handle = start(config).await.unwrap();
1069 assert!(
1070 handle.intercept_ca_path().is_none(),
1071 "no L7-bearing route → no CA should be generated"
1072 );
1073 let vars = handle.env_vars();
1074 assert!(
1075 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1076 "trust env vars must not be set when intercept inactive"
1077 );
1078 handle.shutdown();
1079 }
1080
1081 #[tokio::test]
1086 async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
1087 let missing_dir = tempfile::tempdir()
1088 .unwrap()
1089 .path()
1090 .join("missing")
1091 .join("intercept");
1092 let config = ProxyConfig {
1093 routes: vec![crate::config::RouteConfig {
1094 prefix: "openai".to_string(),
1095 upstream: "https://api.openai.com".to_string(),
1096 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1097 inject_mode: Default::default(),
1098 inject_header: "Authorization".to_string(),
1099 credential_format: Some("Bearer {}".to_string()),
1100 path_pattern: None,
1101 path_replacement: None,
1102 query_param_name: None,
1103 proxy: None,
1104 env_var: None,
1105 endpoint_rules: vec![],
1106 tls_ca: None,
1107 tls_client_cert: None,
1108 tls_client_key: None,
1109 oauth2: None,
1110 }],
1111 intercept_ca_dir: Some(missing_dir),
1112 ..Default::default()
1113 };
1114 let handle = start(config.clone()).await.unwrap();
1115 assert!(
1116 handle.intercept_ca_path().is_none(),
1117 "intercept setup failure should disable interception instead of aborting startup"
1118 );
1119 let vars = handle.env_vars();
1120 assert!(
1121 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1122 "trust env vars must not be set when interception setup fails"
1123 );
1124 let route_vars = handle.credential_env_vars(&config);
1125 assert!(
1126 route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1127 "reverse-proxy route env vars should still be emitted"
1128 );
1129 handle.shutdown();
1130 }
1131
1132 #[tokio::test]
1135 async fn test_route_diagnostics_summarises_each_route() {
1136 let dir = tempfile::tempdir().unwrap();
1137 let config = ProxyConfig {
1138 routes: vec![
1139 crate::config::RouteConfig {
1140 prefix: "openai".to_string(),
1141 upstream: "https://api.openai.com".to_string(),
1142 credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1143 inject_mode: Default::default(),
1144 inject_header: "Authorization".to_string(),
1145 credential_format: Some("Bearer {}".to_string()),
1146 path_pattern: None,
1147 path_replacement: None,
1148 query_param_name: None,
1149 proxy: None,
1150 env_var: None,
1151 endpoint_rules: vec![],
1152 tls_ca: None,
1153 tls_client_cert: None,
1154 tls_client_key: None,
1155 oauth2: None,
1156 },
1157 crate::config::RouteConfig {
1158 prefix: "alias".to_string(),
1159 upstream: "https://aliased.example.com".to_string(),
1160 credential_key: None,
1161 inject_mode: Default::default(),
1162 inject_header: "Authorization".to_string(),
1163 credential_format: Some("Bearer {}".to_string()),
1164 path_pattern: None,
1165 path_replacement: None,
1166 query_param_name: None,
1167 proxy: None,
1168 env_var: None,
1169 endpoint_rules: vec![],
1170 tls_ca: None,
1171 tls_client_cert: None,
1172 tls_client_key: None,
1173 oauth2: None,
1174 },
1175 ],
1176 intercept_ca_dir: Some(dir.path().to_path_buf()),
1177 ..Default::default()
1178 };
1179 let handle = start(config.clone()).await.unwrap();
1180 let rows = handle.route_diagnostics(&config);
1181 assert_eq!(rows.len(), 2);
1182
1183 let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1184 assert!(openai.1.contains("api.openai.com"));
1185 assert!(openai.1.contains("intercept: on"));
1186 assert!(
1187 openai.1.contains("✗") || openai.1.contains("not found"),
1188 "missing credential should show ✗, got: {}",
1189 openai.1
1190 );
1191
1192 let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1193 assert!(alias.1.contains("creds: none"));
1194 assert!(alias.1.contains("intercept: off"));
1195
1196 handle.shutdown();
1197 }
1198
1199 #[tokio::test]
1200 async fn test_proxy_env_vars() {
1201 let config = ProxyConfig::default();
1202 let handle = start(config).await.unwrap();
1203
1204 let vars = handle.env_vars();
1205 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1206 assert!(http_proxy.is_some());
1207 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1208
1209 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1210 assert!(token_var.is_some());
1211 assert_eq!(token_var.unwrap().1.len(), 64);
1212
1213 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1214 assert!(
1215 node_proxy_flag.is_some(),
1216 "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1217 );
1218 assert_eq!(
1219 node_proxy_flag.unwrap().1,
1220 "1",
1221 "NODE_USE_ENV_PROXY must be '1'"
1222 );
1223
1224 handle.shutdown();
1225 }
1226
1227 #[tokio::test]
1228 async fn test_proxy_credential_env_vars() {
1229 let config = ProxyConfig {
1230 routes: vec![crate::config::RouteConfig {
1231 prefix: "openai".to_string(),
1232 upstream: "https://api.openai.com".to_string(),
1233 credential_key: None,
1234 inject_mode: crate::config::InjectMode::Header,
1235 inject_header: "Authorization".to_string(),
1236 credential_format: Some("Bearer {}".to_string()),
1237 path_pattern: None,
1238 path_replacement: None,
1239 query_param_name: None,
1240 proxy: None,
1241 env_var: None,
1242 endpoint_rules: vec![],
1243 tls_ca: None,
1244 tls_client_cert: None,
1245 tls_client_key: None,
1246 oauth2: None,
1247 }],
1248 ..Default::default()
1249 };
1250 let handle = start(config.clone()).await.unwrap();
1251
1252 let vars = handle.credential_env_vars(&config);
1253 assert_eq!(vars.len(), 1);
1254 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1255 assert!(vars[0].1.contains("/openai"));
1256
1257 handle.shutdown();
1258 }
1259
1260 #[test]
1261 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1262 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1266 let handle = ProxyHandle {
1267 port: 12345,
1268 token: Zeroizing::new("test_token".to_string()),
1269 audit_log: audit::new_audit_log(),
1270 shutdown_tx,
1271 loaded_routes: ["openai".to_string()].into_iter().collect(),
1272 no_proxy_hosts: Vec::new(),
1273 intercept_ca_path: None,
1274 };
1275 let config = ProxyConfig {
1276 routes: vec![crate::config::RouteConfig {
1277 prefix: "openai".to_string(),
1278 upstream: "https://api.openai.com".to_string(),
1279 credential_key: Some("openai_api_key".to_string()),
1280 inject_mode: crate::config::InjectMode::Header,
1281 inject_header: "Authorization".to_string(),
1282 credential_format: Some("Bearer {}".to_string()),
1283 path_pattern: None,
1284 path_replacement: None,
1285 query_param_name: None,
1286 proxy: None,
1287 env_var: None, endpoint_rules: vec![],
1289 tls_ca: None,
1290 tls_client_cert: None,
1291 tls_client_key: None,
1292 oauth2: None,
1293 }],
1294 ..Default::default()
1295 };
1296
1297 let vars = handle.credential_env_vars(&config);
1298 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1302 assert!(
1303 api_key_var.is_some(),
1304 "Should derive env var name from credential_key.to_uppercase()"
1305 );
1306
1307 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1308 assert_eq!(val, "test_token");
1309 }
1310
1311 #[test]
1312 fn test_proxy_credential_env_vars_with_explicit_env_var() {
1313 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1321 let handle = ProxyHandle {
1322 port: 12345,
1323 token: Zeroizing::new("test_token".to_string()),
1324 audit_log: audit::new_audit_log(),
1325 shutdown_tx,
1326 loaded_routes: ["openai".to_string()].into_iter().collect(),
1327 no_proxy_hosts: Vec::new(),
1328 intercept_ca_path: None,
1329 };
1330 let config = ProxyConfig {
1331 routes: vec![crate::config::RouteConfig {
1332 prefix: "openai".to_string(),
1333 upstream: "https://api.openai.com".to_string(),
1334 credential_key: Some("op://Development/OpenAI/credential".to_string()),
1335 inject_mode: crate::config::InjectMode::Header,
1336 inject_header: "Authorization".to_string(),
1337 credential_format: Some("Bearer {}".to_string()),
1338 path_pattern: None,
1339 path_replacement: None,
1340 query_param_name: None,
1341 proxy: None,
1342 env_var: Some("OPENAI_API_KEY".to_string()),
1343 endpoint_rules: vec![],
1344 tls_ca: None,
1345 tls_client_cert: None,
1346 tls_client_key: None,
1347 oauth2: None,
1348 }],
1349 ..Default::default()
1350 };
1351
1352 let vars = handle.credential_env_vars(&config);
1353 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1356 assert!(
1357 api_key_var.is_some(),
1358 "Should use explicit env_var name, not derive from credential_key"
1359 );
1360
1361 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1363 assert_eq!(val, "test_token");
1364
1365 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1367 assert!(
1368 bad_var.is_none(),
1369 "Should not generate env var from op:// URI uppercase"
1370 );
1371 }
1372
1373 #[test]
1374 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1375 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1380 let handle = ProxyHandle {
1381 port: 12345,
1382 token: Zeroizing::new("test_token".to_string()),
1383 audit_log: audit::new_audit_log(),
1384 shutdown_tx,
1385 loaded_routes: ["openai".to_string()].into_iter().collect(),
1387 no_proxy_hosts: Vec::new(),
1388 intercept_ca_path: None,
1389 };
1390 let config = ProxyConfig {
1391 routes: vec![
1392 crate::config::RouteConfig {
1393 prefix: "openai".to_string(),
1394 upstream: "https://api.openai.com".to_string(),
1395 credential_key: Some("openai_api_key".to_string()),
1396 inject_mode: crate::config::InjectMode::Header,
1397 inject_header: "Authorization".to_string(),
1398 credential_format: Some("Bearer {}".to_string()),
1399 path_pattern: None,
1400 path_replacement: None,
1401 query_param_name: None,
1402 proxy: None,
1403 env_var: None,
1404 endpoint_rules: vec![],
1405 tls_ca: None,
1406 tls_client_cert: None,
1407 tls_client_key: None,
1408 oauth2: None,
1409 },
1410 crate::config::RouteConfig {
1411 prefix: "github".to_string(),
1412 upstream: "https://api.github.com".to_string(),
1413 credential_key: Some("env://GITHUB_TOKEN".to_string()),
1414 inject_mode: crate::config::InjectMode::Header,
1415 inject_header: "Authorization".to_string(),
1416 credential_format: Some("token {}".to_string()),
1417 path_pattern: None,
1418 path_replacement: None,
1419 query_param_name: None,
1420 proxy: None,
1421 env_var: Some("GITHUB_TOKEN".to_string()),
1422 endpoint_rules: vec![],
1423 tls_ca: None,
1424 tls_client_cert: None,
1425 tls_client_key: None,
1426 oauth2: None,
1427 },
1428 ],
1429 ..Default::default()
1430 };
1431
1432 let vars = handle.credential_env_vars(&config);
1433
1434 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1436 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1437 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1438 assert!(openai_key.is_some(), "loaded route should have API key");
1439
1440 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1443 assert!(
1444 github_base.is_some(),
1445 "declared route should still have BASE_URL"
1446 );
1447 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1448 assert!(
1449 github_token.is_none(),
1450 "unloaded route must not inject phantom GITHUB_TOKEN"
1451 );
1452 }
1453
1454 #[test]
1455 fn test_proxy_credential_env_vars_strips_slashes() {
1456 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1461 let handle = ProxyHandle {
1462 port: 58406,
1463 token: Zeroizing::new("test_token".to_string()),
1464 audit_log: audit::new_audit_log(),
1465 shutdown_tx,
1466 loaded_routes: std::collections::HashSet::new(),
1467 no_proxy_hosts: Vec::new(),
1468 intercept_ca_path: None,
1469 };
1470
1471 let config = ProxyConfig {
1473 routes: vec![crate::config::RouteConfig {
1474 prefix: "/anthropic".to_string(),
1475 upstream: "https://api.anthropic.com".to_string(),
1476 credential_key: None,
1477 inject_mode: crate::config::InjectMode::Header,
1478 inject_header: "Authorization".to_string(),
1479 credential_format: Some("Bearer {}".to_string()),
1480 path_pattern: None,
1481 path_replacement: None,
1482 query_param_name: None,
1483 proxy: None,
1484 env_var: None,
1485 endpoint_rules: vec![],
1486 tls_ca: None,
1487 tls_client_cert: None,
1488 tls_client_key: None,
1489 oauth2: None,
1490 }],
1491 ..Default::default()
1492 };
1493
1494 let vars = handle.credential_env_vars(&config);
1495 assert_eq!(vars.len(), 1);
1496 assert_eq!(
1497 vars[0].0, "ANTHROPIC_BASE_URL",
1498 "env var name must not have leading slash"
1499 );
1500 assert_eq!(
1501 vars[0].1, "http://127.0.0.1:58406/anthropic",
1502 "URL must not have double slash"
1503 );
1504
1505 let config = ProxyConfig {
1507 routes: vec![crate::config::RouteConfig {
1508 prefix: "openai/".to_string(),
1509 upstream: "https://api.openai.com".to_string(),
1510 credential_key: None,
1511 inject_mode: crate::config::InjectMode::Header,
1512 inject_header: "Authorization".to_string(),
1513 credential_format: Some("Bearer {}".to_string()),
1514 path_pattern: None,
1515 path_replacement: None,
1516 query_param_name: None,
1517 proxy: None,
1518 env_var: None,
1519 endpoint_rules: vec![],
1520 tls_ca: None,
1521 tls_client_cert: None,
1522 tls_client_key: None,
1523 oauth2: None,
1524 }],
1525 ..Default::default()
1526 };
1527
1528 let vars = handle.credential_env_vars(&config);
1529 assert_eq!(
1530 vars[0].0, "OPENAI_BASE_URL",
1531 "env var name must not have trailing slash"
1532 );
1533 assert_eq!(
1534 vars[0].1, "http://127.0.0.1:58406/openai",
1535 "URL must not have trailing slash in path"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_anthropic_credential_phantom_token_regression() {
1541 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1549 let handle_no_env_var = ProxyHandle {
1550 port: 12345,
1551 token: Zeroizing::new("phantom".to_string()),
1552 audit_log: audit::new_audit_log(),
1553 shutdown_tx: shutdown_tx.clone(),
1554 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1555 no_proxy_hosts: Vec::new(),
1556 intercept_ca_path: None,
1557 };
1558 let config_no_env_var = ProxyConfig {
1559 routes: vec![crate::config::RouteConfig {
1560 prefix: "anthropic".to_string(),
1561 upstream: "https://api.anthropic.com".to_string(),
1562 credential_key: None,
1563 inject_mode: crate::config::InjectMode::Header,
1564 inject_header: "x-api-key".to_string(),
1565 credential_format: Some("{}".to_string()),
1566 path_pattern: None,
1567 path_replacement: None,
1568 query_param_name: None,
1569 proxy: None,
1570 env_var: None,
1571 endpoint_rules: vec![],
1572 tls_ca: None,
1573 tls_client_cert: None,
1574 tls_client_key: None,
1575 oauth2: None,
1576 }],
1577 ..Default::default()
1578 };
1579 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1580 assert!(
1581 vars_no_env_var
1582 .iter()
1583 .all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1584 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1585 );
1586
1587 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1590 let handle_fixed = ProxyHandle {
1591 port: 12345,
1592 token: Zeroizing::new("phantom".to_string()),
1593 audit_log: audit::new_audit_log(),
1594 shutdown_tx: shutdown_tx2,
1595 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1596 no_proxy_hosts: Vec::new(),
1597 intercept_ca_path: None,
1598 };
1599 let config_fixed = ProxyConfig {
1600 routes: vec![crate::config::RouteConfig {
1601 prefix: "anthropic".to_string(),
1602 upstream: "https://api.anthropic.com".to_string(),
1603 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1604 inject_mode: crate::config::InjectMode::Header,
1605 inject_header: "x-api-key".to_string(),
1606 credential_format: Some("{}".to_string()),
1607 path_pattern: None,
1608 path_replacement: None,
1609 query_param_name: None,
1610 proxy: None,
1611 env_var: Some("ANTHROPIC_API_KEY".to_string()),
1612 endpoint_rules: vec![],
1613 tls_ca: None,
1614 tls_client_cert: None,
1615 tls_client_key: None,
1616 oauth2: None,
1617 }],
1618 ..Default::default()
1619 };
1620 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1621 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1622 assert!(
1623 api_key_var.is_some(),
1624 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1625 );
1626 assert_eq!(api_key_var.unwrap().1, "phantom");
1627 }
1628
1629 #[test]
1630 fn test_no_proxy_excludes_credential_upstreams() {
1631 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1632 let handle = ProxyHandle {
1633 port: 12345,
1634 token: Zeroizing::new("test_token".to_string()),
1635 audit_log: audit::new_audit_log(),
1636 shutdown_tx,
1637 loaded_routes: std::collections::HashSet::new(),
1638 no_proxy_hosts: vec![
1639 "nats.internal:4222".to_string(),
1640 "opencode.internal:4096".to_string(),
1641 ],
1642 intercept_ca_path: None,
1643 };
1644
1645 let vars = handle.env_vars();
1646 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1647 assert!(
1648 no_proxy.1.contains("nats.internal"),
1649 "non-credential host should be in NO_PROXY"
1650 );
1651 assert!(
1652 no_proxy.1.contains("opencode.internal"),
1653 "non-credential host should be in NO_PROXY"
1654 );
1655 assert!(
1656 no_proxy.1.contains("localhost"),
1657 "localhost should always be in NO_PROXY"
1658 );
1659 }
1660
1661 #[test]
1662 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1663 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1664 let handle = ProxyHandle {
1665 port: 12345,
1666 token: Zeroizing::new("test_token".to_string()),
1667 audit_log: audit::new_audit_log(),
1668 shutdown_tx,
1669 loaded_routes: std::collections::HashSet::new(),
1670 no_proxy_hosts: Vec::new(),
1671 intercept_ca_path: None,
1672 };
1673
1674 let vars = handle.env_vars();
1675 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1676 assert_eq!(
1677 no_proxy.1, "localhost,127.0.0.1",
1678 "NO_PROXY should only contain loopback when no bypass hosts"
1679 );
1680 }
1681
1682 #[tokio::test]
1683 async fn test_no_proxy_empty_without_direct_connect_ports() {
1684 let config = ProxyConfig {
1688 allowed_hosts: vec!["github.com".to_string()],
1689 ..Default::default()
1690 };
1691 let handle = start(config).await.unwrap();
1692
1693 let vars = handle.env_vars();
1694 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1695 assert_eq!(
1696 no_proxy.1, "localhost,127.0.0.1",
1697 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1698 );
1699
1700 handle.shutdown();
1701 }
1702
1703 #[cfg(not(target_os = "macos"))]
1704 #[tokio::test]
1705 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1706 let config = ProxyConfig {
1710 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1711 direct_connect_ports: vec![443],
1712 ..Default::default()
1713 };
1714 let handle = start(config).await.unwrap();
1715
1716 let vars = handle.env_vars();
1717 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1718 assert!(
1719 no_proxy.1.contains("github.com"),
1720 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1721 );
1722 assert!(
1723 !no_proxy.1.contains("server.internal"),
1724 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1725 );
1726
1727 handle.shutdown();
1728 }
1729
1730 #[tokio::test]
1733 async fn test_strict_filter_with_empty_allowlist_denies_connect() {
1734 use tokio::io::AsyncReadExt;
1735 use tokio::net::TcpStream;
1736
1737 let config = ProxyConfig {
1738 strict_filter: true,
1739 allowed_hosts: Vec::new(),
1740 ..ProxyConfig::default()
1741 };
1742 let handle = start(config).await.unwrap();
1743 let addr = format!("127.0.0.1:{}", handle.port);
1744
1745 let mut stream = TcpStream::connect(&addr).await.unwrap();
1746 let request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
1747 tokio::io::AsyncWriteExt::write_all(&mut stream, request)
1748 .await
1749 .unwrap();
1750
1751 let mut response = Vec::new();
1752 stream.read_to_end(&mut response).await.unwrap();
1753 let response_str = String::from_utf8_lossy(&response);
1754 assert!(
1755 response_str.starts_with("HTTP/1.1 403"),
1756 "strict filter with empty allowlist must deny CONNECT, got: {}",
1757 response_str
1758 );
1759
1760 let events = handle.drain_audit_events();
1761 assert!(
1762 events
1763 .iter()
1764 .any(|e| e.decision == nono::undo::NetworkAuditDecision::Deny
1765 && e.target == "example.com"),
1766 "expected a Deny audit event for example.com, got: {:?}",
1767 events
1768 );
1769
1770 handle.shutdown();
1771 }
1772
1773 #[tokio::test]
1779 async fn reactive_proxy_auth_retry_answered_after_407() {
1780 use base64::Engine;
1781 use std::time::Duration;
1782 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1783 use tokio::net::TcpStream;
1784
1785 let dir = tempfile::tempdir().unwrap();
1786 let config = ProxyConfig {
1787 routes: vec![crate::config::RouteConfig {
1788 prefix: "openai".to_string(),
1789 upstream: "https://api.openai.com".to_string(),
1790 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1791 inject_mode: Default::default(),
1792 inject_header: "Authorization".to_string(),
1793 credential_format: Some("Bearer {}".to_string()),
1794 path_pattern: None,
1795 path_replacement: None,
1796 query_param_name: None,
1797 proxy: None,
1798 env_var: None,
1799 endpoint_rules: vec![],
1800 tls_ca: None,
1801 tls_client_cert: None,
1802 tls_client_key: None,
1803 oauth2: None,
1804 }],
1805 intercept_ca_dir: Some(dir.path().to_path_buf()),
1806 ..Default::default()
1807 };
1808 let handle = start(config).await.unwrap();
1809 assert!(
1810 handle.intercept_ca_path().is_some(),
1811 "precondition: interception must be active so the 407 path is reached"
1812 );
1813 let port = handle.port;
1814 let token = handle.token.to_string();
1815
1816 let mut sock = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
1817
1818 sock.write_all(b"CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\n\r\n")
1820 .await
1821 .unwrap();
1822 sock.flush().await.unwrap();
1823
1824 let mut buf = [0u8; 4096];
1825 let n = sock.read(&mut buf).await.unwrap();
1826 let response = String::from_utf8_lossy(&buf[..n]);
1827 assert!(
1828 response.starts_with("HTTP/1.1 407 "),
1829 "expected 407 challenge, got: {:?}",
1830 response
1831 );
1832
1833 let creds = base64::engine::general_purpose::STANDARD.encode(format!("nono:{}", token));
1835 let retry = format!(
1836 "CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\nProxy-Authorization: Basic {}\r\n\r\n",
1837 creds
1838 );
1839 sock.write_all(retry.as_bytes()).await.unwrap();
1840 sock.flush().await.unwrap();
1841
1842 let mut retry_buf = [0u8; 4096];
1846 let read_result =
1847 tokio::time::timeout(Duration::from_secs(5), sock.read(&mut retry_buf)).await;
1848 match read_result {
1849 Ok(Ok(0)) => panic!(
1850 "regression: proxy closed the socket after the 407 instead of \
1851 answering the reactive retry"
1852 ),
1853 Ok(Ok(_)) => {} Ok(Err(e)) => panic!("retry read errored: {e}"),
1855 Err(_) => panic!("retry read timed out — proxy did not answer the retry"),
1856 }
1857
1858 handle.shutdown();
1859 }
1860}