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::token;
20use std::net::SocketAddr;
21use std::sync::atomic::{AtomicUsize, Ordering};
22use std::sync::Arc;
23use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
24use tokio::net::TcpListener;
25use tokio::sync::watch;
26use tracing::{debug, info, warn};
27use zeroize::Zeroizing;
28
29const MAX_HEADER_SIZE: usize = 64 * 1024;
32
33pub struct ProxyHandle {
38 pub port: u16,
40 pub token: Zeroizing<String>,
42 audit_log: audit::SharedAuditLog,
44 shutdown_tx: watch::Sender<bool>,
46 loaded_routes: std::collections::HashSet<String>,
50 no_proxy_hosts: Vec<String>,
53}
54
55impl ProxyHandle {
56 pub fn shutdown(&self) {
58 let _ = self.shutdown_tx.send(true);
59 }
60
61 #[must_use]
63 pub fn drain_audit_events(&self) -> Vec<nono::undo::NetworkAuditEvent> {
64 audit::drain_audit_events(&self.audit_log)
65 }
66
67 #[must_use]
75 pub fn env_vars(&self) -> Vec<(String, String)> {
76 let proxy_url = format!("http://nono:{}@127.0.0.1:{}", &*self.token, self.port);
77
78 let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
82 for host in &self.no_proxy_hosts {
83 let hostname = if host.contains("]:") {
86 host.rsplit_once("]:")
88 .map(|(h, _)| format!("{}]", h))
89 .unwrap_or_else(|| host.clone())
90 } else {
91 host.rsplit_once(':')
92 .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h.to_string()))
93 .unwrap_or_else(|| host.clone())
94 };
95 if !no_proxy_parts.contains(&hostname.to_string()) {
96 no_proxy_parts.push(hostname.to_string());
97 }
98 }
99 let no_proxy = no_proxy_parts.join(",");
100
101 let mut vars = vec![
102 ("HTTP_PROXY".to_string(), proxy_url.clone()),
103 ("HTTPS_PROXY".to_string(), proxy_url.clone()),
104 ("NO_PROXY".to_string(), no_proxy.clone()),
105 ("NONO_PROXY_TOKEN".to_string(), self.token.to_string()),
106 ];
107
108 vars.push(("http_proxy".to_string(), proxy_url.clone()));
110 vars.push(("https_proxy".to_string(), proxy_url));
111 vars.push(("no_proxy".to_string(), no_proxy));
112
113 vars
114 }
115
116 #[must_use]
125 pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
126 let mut vars = Vec::new();
127 for route in &config.routes {
128 let prefix = route.prefix.trim_matches('/');
133
134 let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
136 let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
137 vars.push((base_url_name, url));
138
139 if !self.loaded_routes.contains(prefix) {
144 continue;
145 }
146
147 if let Some(ref env_var) = route.env_var {
151 vars.push((env_var.clone(), self.token.to_string()));
152 } else if let Some(ref cred_key) = route.credential_key {
153 let api_key_name = cred_key.to_uppercase();
154 vars.push((api_key_name, self.token.to_string()));
155 }
156 }
157 vars
158 }
159}
160
161struct ProxyState {
163 filter: ProxyFilter,
164 session_token: Zeroizing<String>,
165 route_store: RouteStore,
167 credential_store: CredentialStore,
169 config: ProxyConfig,
170 tls_connector: tokio_rustls::TlsConnector,
173 active_connections: AtomicUsize,
175 audit_log: audit::SharedAuditLog,
177 bypass_matcher: external::BypassMatcher,
180}
181
182pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
190 let session_token = token::generate_session_token()?;
192
193 let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
195 let listener = TcpListener::bind(bind_addr)
196 .await
197 .map_err(|e| ProxyError::Bind {
198 addr: bind_addr.to_string(),
199 source: e,
200 })?;
201
202 let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
203 addr: bind_addr.to_string(),
204 source: e,
205 })?;
206 let port = local_addr.port();
207
208 info!("Proxy server listening on {}", local_addr);
209
210 let route_store = if config.routes.is_empty() {
213 RouteStore::empty()
214 } else {
215 RouteStore::load(&config.routes)?
216 };
217
218 let credential_store = if config.routes.is_empty() {
220 CredentialStore::empty()
221 } else {
222 CredentialStore::load(&config.routes)?
223 };
224 let loaded_routes = credential_store.loaded_prefixes();
225
226 let filter = if config.allowed_hosts.is_empty() {
228 ProxyFilter::allow_all()
229 } else {
230 ProxyFilter::new(&config.allowed_hosts)
231 };
232
233 let mut root_store = rustls::RootCertStore::empty();
237 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
238 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
239 rustls::crypto::ring::default_provider(),
240 ))
241 .with_safe_default_protocol_versions()
242 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
243 .with_root_certificates(root_store)
244 .with_no_client_auth();
245 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
246
247 let bypass_matcher = config
249 .external_proxy
250 .as_ref()
251 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
252 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
253
254 let (shutdown_tx, shutdown_rx) = watch::channel(false);
256 let audit_log = audit::new_audit_log();
257
258 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
269 Vec::new()
270 } else {
271 let route_hosts = route_store.route_upstream_hosts();
272 config
273 .allowed_hosts
274 .iter()
275 .filter(|host| {
276 let normalised = {
277 let h = host.to_lowercase();
278 if h.starts_with('[') {
279 if h.contains("]:") {
281 h
282 } else {
283 format!("{}:443", h)
284 }
285 } else if h.contains(':') {
286 h
287 } else {
288 format!("{}:443", h)
289 }
290 };
291 !route_hosts.contains(&normalised)
292 })
293 .cloned()
294 .collect()
295 };
296
297 if !no_proxy_hosts.is_empty() {
298 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
299 }
300
301 let state = Arc::new(ProxyState {
302 filter,
303 session_token: session_token.clone(),
304 route_store,
305 credential_store,
306 config,
307 tls_connector,
308 active_connections: AtomicUsize::new(0),
309 audit_log: Arc::clone(&audit_log),
310 bypass_matcher,
311 });
312
313 tokio::spawn(accept_loop(listener, state, shutdown_rx));
317
318 Ok(ProxyHandle {
319 port,
320 token: session_token,
321 audit_log,
322 shutdown_tx,
323 loaded_routes,
324 no_proxy_hosts,
325 })
326}
327
328async fn accept_loop(
330 listener: TcpListener,
331 state: Arc<ProxyState>,
332 mut shutdown_rx: watch::Receiver<bool>,
333) {
334 loop {
335 tokio::select! {
336 result = listener.accept() => {
337 match result {
338 Ok((stream, addr)) => {
339 let max = state.config.max_connections;
341 if max > 0 {
342 let current = state.active_connections.load(Ordering::Relaxed);
343 if current >= max {
344 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
345 drop(stream);
347 continue;
348 }
349 }
350 state.active_connections.fetch_add(1, Ordering::Relaxed);
351
352 debug!("Accepted connection from {}", addr);
353 let state = Arc::clone(&state);
354 tokio::spawn(async move {
355 if let Err(e) = handle_connection(stream, &state).await {
356 debug!("Connection handler error: {}", e);
357 }
358 state.active_connections.fetch_sub(1, Ordering::Relaxed);
359 });
360 }
361 Err(e) => {
362 warn!("Accept error: {}", e);
363 }
364 }
365 }
366 _ = shutdown_rx.changed() => {
367 if *shutdown_rx.borrow() {
368 info!("Proxy server shutting down");
369 return;
370 }
371 }
372 }
373 }
374}
375
376async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
382 let mut buf_reader = BufReader::new(&mut stream);
386 let mut first_line = String::new();
387 buf_reader.read_line(&mut first_line).await?;
388
389 if first_line.is_empty() {
390 return Ok(()); }
392
393 let mut header_bytes = Vec::new();
395 loop {
396 let mut line = String::new();
397 let n = buf_reader.read_line(&mut line).await?;
398 if n == 0 || line.trim().is_empty() {
399 break;
400 }
401 header_bytes.extend_from_slice(line.as_bytes());
402 if header_bytes.len() > MAX_HEADER_SIZE {
403 drop(buf_reader);
404 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
405 stream.write_all(response.as_bytes()).await?;
406 return Ok(());
407 }
408 }
409
410 let buffered = buf_reader.buffer().to_vec();
415 drop(buf_reader);
416
417 let first_line = first_line.trim_end();
418
419 if first_line.starts_with("CONNECT ") {
421 if !state.route_store.is_empty() {
426 if let Some(authority) = first_line.split_whitespace().nth(1) {
427 let host_port = if authority.starts_with('[') {
430 if authority.contains("]:") {
432 authority.to_lowercase()
433 } else {
434 format!("{}:443", authority.to_lowercase())
435 }
436 } else if authority.contains(':') {
437 authority.to_lowercase()
438 } else {
439 format!("{}:443", authority.to_lowercase())
440 };
441 if state.route_store.is_route_upstream(&host_port) {
442 let (host, port) = host_port
443 .rsplit_once(':')
444 .map(|(h, p)| (h, p.parse::<u16>().unwrap_or(443)))
445 .unwrap_or((&host_port, 443));
446 warn!(
447 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
448 authority
449 );
450 audit::log_denied(
451 Some(&state.audit_log),
452 audit::ProxyMode::Connect,
453 host,
454 port,
455 "route upstream: CONNECT bypasses L7 filtering",
456 );
457 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
458 stream.write_all(response.as_bytes()).await?;
459 return Ok(());
460 }
461 }
462 }
463
464 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
466 if state.bypass_matcher.is_empty() {
467 Some(ext_config)
468 } else {
469 let host = first_line
471 .split_whitespace()
472 .nth(1)
473 .and_then(|authority| {
474 authority
475 .rsplit_once(':')
476 .map(|(h, _)| h)
477 .or(Some(authority))
478 })
479 .unwrap_or("");
480 if state.bypass_matcher.matches(host) {
481 debug!("Bypassing external proxy for {}", host);
482 None
483 } else {
484 Some(ext_config)
485 }
486 }
487 } else {
488 None
489 };
490
491 if let Some(ext_config) = use_external {
492 external::handle_external_proxy(
493 first_line,
494 &mut stream,
495 &header_bytes,
496 &state.filter,
497 &state.session_token,
498 ext_config,
499 Some(&state.audit_log),
500 )
501 .await
502 } else if state.config.external_proxy.is_some() {
503 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
508 connect::handle_connect(
509 first_line,
510 &mut stream,
511 &state.filter,
512 &state.session_token,
513 &header_bytes,
514 Some(&state.audit_log),
515 )
516 .await
517 } else {
518 connect::handle_connect(
519 first_line,
520 &mut stream,
521 &state.filter,
522 &state.session_token,
523 &header_bytes,
524 Some(&state.audit_log),
525 )
526 .await
527 }
528 } else if !state.route_store.is_empty() {
529 let ctx = reverse::ReverseProxyCtx {
531 route_store: &state.route_store,
532 credential_store: &state.credential_store,
533 session_token: &state.session_token,
534 filter: &state.filter,
535 tls_connector: &state.tls_connector,
536 audit_log: Some(&state.audit_log),
537 };
538 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
539 } else {
540 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
542 stream.write_all(response.as_bytes()).await?;
543 Ok(())
544 }
545}
546
547#[cfg(test)]
548#[allow(clippy::unwrap_used)]
549mod tests {
550 use super::*;
551
552 #[tokio::test]
553 async fn test_proxy_starts_and_binds() {
554 let config = ProxyConfig::default();
555 let handle = start(config).await.unwrap();
556
557 assert!(handle.port > 0);
559 assert_eq!(handle.token.len(), 64);
561
562 handle.shutdown();
564 }
565
566 #[tokio::test]
567 async fn test_proxy_env_vars() {
568 let config = ProxyConfig::default();
569 let handle = start(config).await.unwrap();
570
571 let vars = handle.env_vars();
572 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
573 assert!(http_proxy.is_some());
574 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
575
576 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
577 assert!(token_var.is_some());
578 assert_eq!(token_var.unwrap().1.len(), 64);
579
580 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
581 assert!(
582 node_proxy_flag.is_none(),
583 "proxy env should avoid Node-specific flags that can perturb non-Node runtimes"
584 );
585
586 handle.shutdown();
587 }
588
589 #[tokio::test]
590 async fn test_proxy_credential_env_vars() {
591 let config = ProxyConfig {
592 routes: vec![crate::config::RouteConfig {
593 prefix: "openai".to_string(),
594 upstream: "https://api.openai.com".to_string(),
595 credential_key: None,
596 inject_mode: crate::config::InjectMode::Header,
597 inject_header: "Authorization".to_string(),
598 credential_format: "Bearer {}".to_string(),
599 path_pattern: None,
600 path_replacement: None,
601 query_param_name: None,
602 env_var: None,
603 endpoint_rules: vec![],
604 tls_ca: None,
605 }],
606 ..Default::default()
607 };
608 let handle = start(config.clone()).await.unwrap();
609
610 let vars = handle.credential_env_vars(&config);
611 assert_eq!(vars.len(), 1);
612 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
613 assert!(vars[0].1.contains("/openai"));
614
615 handle.shutdown();
616 }
617
618 #[test]
619 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
620 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
624 let handle = ProxyHandle {
625 port: 12345,
626 token: Zeroizing::new("test_token".to_string()),
627 audit_log: audit::new_audit_log(),
628 shutdown_tx,
629 loaded_routes: ["openai".to_string()].into_iter().collect(),
630 no_proxy_hosts: Vec::new(),
631 };
632 let config = ProxyConfig {
633 routes: vec![crate::config::RouteConfig {
634 prefix: "openai".to_string(),
635 upstream: "https://api.openai.com".to_string(),
636 credential_key: Some("openai_api_key".to_string()),
637 inject_mode: crate::config::InjectMode::Header,
638 inject_header: "Authorization".to_string(),
639 credential_format: "Bearer {}".to_string(),
640 path_pattern: None,
641 path_replacement: None,
642 query_param_name: None,
643 env_var: None, endpoint_rules: vec![],
645 tls_ca: None,
646 }],
647 ..Default::default()
648 };
649
650 let vars = handle.credential_env_vars(&config);
651 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
655 assert!(
656 api_key_var.is_some(),
657 "Should derive env var name from credential_key.to_uppercase()"
658 );
659
660 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
661 assert_eq!(val, "test_token");
662 }
663
664 #[test]
665 fn test_proxy_credential_env_vars_with_explicit_env_var() {
666 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
674 let handle = ProxyHandle {
675 port: 12345,
676 token: Zeroizing::new("test_token".to_string()),
677 audit_log: audit::new_audit_log(),
678 shutdown_tx,
679 loaded_routes: ["openai".to_string()].into_iter().collect(),
680 no_proxy_hosts: Vec::new(),
681 };
682 let config = ProxyConfig {
683 routes: vec![crate::config::RouteConfig {
684 prefix: "openai".to_string(),
685 upstream: "https://api.openai.com".to_string(),
686 credential_key: Some("op://Development/OpenAI/credential".to_string()),
687 inject_mode: crate::config::InjectMode::Header,
688 inject_header: "Authorization".to_string(),
689 credential_format: "Bearer {}".to_string(),
690 path_pattern: None,
691 path_replacement: None,
692 query_param_name: None,
693 env_var: Some("OPENAI_API_KEY".to_string()),
694 endpoint_rules: vec![],
695 tls_ca: None,
696 }],
697 ..Default::default()
698 };
699
700 let vars = handle.credential_env_vars(&config);
701 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
704 assert!(
705 api_key_var.is_some(),
706 "Should use explicit env_var name, not derive from credential_key"
707 );
708
709 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
711 assert_eq!(val, "test_token");
712
713 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
715 assert!(
716 bad_var.is_none(),
717 "Should not generate env var from op:// URI uppercase"
718 );
719 }
720
721 #[test]
722 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
723 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
728 let handle = ProxyHandle {
729 port: 12345,
730 token: Zeroizing::new("test_token".to_string()),
731 audit_log: audit::new_audit_log(),
732 shutdown_tx,
733 loaded_routes: ["openai".to_string()].into_iter().collect(),
735 no_proxy_hosts: Vec::new(),
736 };
737 let config = ProxyConfig {
738 routes: vec![
739 crate::config::RouteConfig {
740 prefix: "openai".to_string(),
741 upstream: "https://api.openai.com".to_string(),
742 credential_key: Some("openai_api_key".to_string()),
743 inject_mode: crate::config::InjectMode::Header,
744 inject_header: "Authorization".to_string(),
745 credential_format: "Bearer {}".to_string(),
746 path_pattern: None,
747 path_replacement: None,
748 query_param_name: None,
749 env_var: None,
750 endpoint_rules: vec![],
751 tls_ca: None,
752 },
753 crate::config::RouteConfig {
754 prefix: "github".to_string(),
755 upstream: "https://api.github.com".to_string(),
756 credential_key: Some("env://GITHUB_TOKEN".to_string()),
757 inject_mode: crate::config::InjectMode::Header,
758 inject_header: "Authorization".to_string(),
759 credential_format: "token {}".to_string(),
760 path_pattern: None,
761 path_replacement: None,
762 query_param_name: None,
763 env_var: Some("GITHUB_TOKEN".to_string()),
764 endpoint_rules: vec![],
765 tls_ca: None,
766 },
767 ],
768 ..Default::default()
769 };
770
771 let vars = handle.credential_env_vars(&config);
772
773 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
775 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
776 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
777 assert!(openai_key.is_some(), "loaded route should have API key");
778
779 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
782 assert!(
783 github_base.is_some(),
784 "declared route should still have BASE_URL"
785 );
786 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
787 assert!(
788 github_token.is_none(),
789 "unloaded route must not inject phantom GITHUB_TOKEN"
790 );
791 }
792
793 #[test]
794 fn test_proxy_credential_env_vars_strips_slashes() {
795 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
800 let handle = ProxyHandle {
801 port: 58406,
802 token: Zeroizing::new("test_token".to_string()),
803 audit_log: audit::new_audit_log(),
804 shutdown_tx,
805 loaded_routes: std::collections::HashSet::new(),
806 no_proxy_hosts: Vec::new(),
807 };
808
809 let config = ProxyConfig {
811 routes: vec![crate::config::RouteConfig {
812 prefix: "/anthropic".to_string(),
813 upstream: "https://api.anthropic.com".to_string(),
814 credential_key: None,
815 inject_mode: crate::config::InjectMode::Header,
816 inject_header: "Authorization".to_string(),
817 credential_format: "Bearer {}".to_string(),
818 path_pattern: None,
819 path_replacement: None,
820 query_param_name: None,
821 env_var: None,
822 endpoint_rules: vec![],
823 tls_ca: None,
824 }],
825 ..Default::default()
826 };
827
828 let vars = handle.credential_env_vars(&config);
829 assert_eq!(vars.len(), 1);
830 assert_eq!(
831 vars[0].0, "ANTHROPIC_BASE_URL",
832 "env var name must not have leading slash"
833 );
834 assert_eq!(
835 vars[0].1, "http://127.0.0.1:58406/anthropic",
836 "URL must not have double slash"
837 );
838
839 let config = ProxyConfig {
841 routes: vec![crate::config::RouteConfig {
842 prefix: "openai/".to_string(),
843 upstream: "https://api.openai.com".to_string(),
844 credential_key: None,
845 inject_mode: crate::config::InjectMode::Header,
846 inject_header: "Authorization".to_string(),
847 credential_format: "Bearer {}".to_string(),
848 path_pattern: None,
849 path_replacement: None,
850 query_param_name: None,
851 env_var: None,
852 endpoint_rules: vec![],
853 tls_ca: None,
854 }],
855 ..Default::default()
856 };
857
858 let vars = handle.credential_env_vars(&config);
859 assert_eq!(
860 vars[0].0, "OPENAI_BASE_URL",
861 "env var name must not have trailing slash"
862 );
863 assert_eq!(
864 vars[0].1, "http://127.0.0.1:58406/openai",
865 "URL must not have trailing slash in path"
866 );
867 }
868
869 #[test]
870 fn test_no_proxy_excludes_credential_upstreams() {
871 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
872 let handle = ProxyHandle {
873 port: 12345,
874 token: Zeroizing::new("test_token".to_string()),
875 audit_log: audit::new_audit_log(),
876 shutdown_tx,
877 loaded_routes: std::collections::HashSet::new(),
878 no_proxy_hosts: vec![
879 "nats.internal:4222".to_string(),
880 "opencode.internal:4096".to_string(),
881 ],
882 };
883
884 let vars = handle.env_vars();
885 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
886 assert!(
887 no_proxy.1.contains("nats.internal"),
888 "non-credential host should be in NO_PROXY"
889 );
890 assert!(
891 no_proxy.1.contains("opencode.internal"),
892 "non-credential host should be in NO_PROXY"
893 );
894 assert!(
895 no_proxy.1.contains("localhost"),
896 "localhost should always be in NO_PROXY"
897 );
898 }
899
900 #[test]
901 fn test_no_proxy_empty_when_no_non_credential_hosts() {
902 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
903 let handle = ProxyHandle {
904 port: 12345,
905 token: Zeroizing::new("test_token".to_string()),
906 audit_log: audit::new_audit_log(),
907 shutdown_tx,
908 loaded_routes: std::collections::HashSet::new(),
909 no_proxy_hosts: Vec::new(),
910 };
911
912 let vars = handle.env_vars();
913 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
914 assert_eq!(
915 no_proxy.1, "localhost,127.0.0.1",
916 "NO_PROXY should only contain loopback when no bypass hosts"
917 );
918 }
919}