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 tls_client_cert: None,
606 tls_client_key: None,
607 }],
608 ..Default::default()
609 };
610 let handle = start(config.clone()).await.unwrap();
611
612 let vars = handle.credential_env_vars(&config);
613 assert_eq!(vars.len(), 1);
614 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
615 assert!(vars[0].1.contains("/openai"));
616
617 handle.shutdown();
618 }
619
620 #[test]
621 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
622 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
626 let handle = ProxyHandle {
627 port: 12345,
628 token: Zeroizing::new("test_token".to_string()),
629 audit_log: audit::new_audit_log(),
630 shutdown_tx,
631 loaded_routes: ["openai".to_string()].into_iter().collect(),
632 no_proxy_hosts: Vec::new(),
633 };
634 let config = ProxyConfig {
635 routes: vec![crate::config::RouteConfig {
636 prefix: "openai".to_string(),
637 upstream: "https://api.openai.com".to_string(),
638 credential_key: Some("openai_api_key".to_string()),
639 inject_mode: crate::config::InjectMode::Header,
640 inject_header: "Authorization".to_string(),
641 credential_format: "Bearer {}".to_string(),
642 path_pattern: None,
643 path_replacement: None,
644 query_param_name: None,
645 env_var: None, endpoint_rules: vec![],
647 tls_ca: None,
648 tls_client_cert: None,
649 tls_client_key: None,
650 }],
651 ..Default::default()
652 };
653
654 let vars = handle.credential_env_vars(&config);
655 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
659 assert!(
660 api_key_var.is_some(),
661 "Should derive env var name from credential_key.to_uppercase()"
662 );
663
664 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
665 assert_eq!(val, "test_token");
666 }
667
668 #[test]
669 fn test_proxy_credential_env_vars_with_explicit_env_var() {
670 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
678 let handle = ProxyHandle {
679 port: 12345,
680 token: Zeroizing::new("test_token".to_string()),
681 audit_log: audit::new_audit_log(),
682 shutdown_tx,
683 loaded_routes: ["openai".to_string()].into_iter().collect(),
684 no_proxy_hosts: Vec::new(),
685 };
686 let config = ProxyConfig {
687 routes: vec![crate::config::RouteConfig {
688 prefix: "openai".to_string(),
689 upstream: "https://api.openai.com".to_string(),
690 credential_key: Some("op://Development/OpenAI/credential".to_string()),
691 inject_mode: crate::config::InjectMode::Header,
692 inject_header: "Authorization".to_string(),
693 credential_format: "Bearer {}".to_string(),
694 path_pattern: None,
695 path_replacement: None,
696 query_param_name: None,
697 env_var: Some("OPENAI_API_KEY".to_string()),
698 endpoint_rules: vec![],
699 tls_ca: None,
700 tls_client_cert: None,
701 tls_client_key: None,
702 }],
703 ..Default::default()
704 };
705
706 let vars = handle.credential_env_vars(&config);
707 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
710 assert!(
711 api_key_var.is_some(),
712 "Should use explicit env_var name, not derive from credential_key"
713 );
714
715 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
717 assert_eq!(val, "test_token");
718
719 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
721 assert!(
722 bad_var.is_none(),
723 "Should not generate env var from op:// URI uppercase"
724 );
725 }
726
727 #[test]
728 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
729 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
734 let handle = ProxyHandle {
735 port: 12345,
736 token: Zeroizing::new("test_token".to_string()),
737 audit_log: audit::new_audit_log(),
738 shutdown_tx,
739 loaded_routes: ["openai".to_string()].into_iter().collect(),
741 no_proxy_hosts: Vec::new(),
742 };
743 let config = ProxyConfig {
744 routes: vec![
745 crate::config::RouteConfig {
746 prefix: "openai".to_string(),
747 upstream: "https://api.openai.com".to_string(),
748 credential_key: Some("openai_api_key".to_string()),
749 inject_mode: crate::config::InjectMode::Header,
750 inject_header: "Authorization".to_string(),
751 credential_format: "Bearer {}".to_string(),
752 path_pattern: None,
753 path_replacement: None,
754 query_param_name: None,
755 env_var: None,
756 endpoint_rules: vec![],
757 tls_ca: None,
758 tls_client_cert: None,
759 tls_client_key: None,
760 },
761 crate::config::RouteConfig {
762 prefix: "github".to_string(),
763 upstream: "https://api.github.com".to_string(),
764 credential_key: Some("env://GITHUB_TOKEN".to_string()),
765 inject_mode: crate::config::InjectMode::Header,
766 inject_header: "Authorization".to_string(),
767 credential_format: "token {}".to_string(),
768 path_pattern: None,
769 path_replacement: None,
770 query_param_name: None,
771 env_var: Some("GITHUB_TOKEN".to_string()),
772 endpoint_rules: vec![],
773 tls_ca: None,
774 tls_client_cert: None,
775 tls_client_key: None,
776 },
777 ],
778 ..Default::default()
779 };
780
781 let vars = handle.credential_env_vars(&config);
782
783 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
785 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
786 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
787 assert!(openai_key.is_some(), "loaded route should have API key");
788
789 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
792 assert!(
793 github_base.is_some(),
794 "declared route should still have BASE_URL"
795 );
796 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
797 assert!(
798 github_token.is_none(),
799 "unloaded route must not inject phantom GITHUB_TOKEN"
800 );
801 }
802
803 #[test]
804 fn test_proxy_credential_env_vars_strips_slashes() {
805 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
810 let handle = ProxyHandle {
811 port: 58406,
812 token: Zeroizing::new("test_token".to_string()),
813 audit_log: audit::new_audit_log(),
814 shutdown_tx,
815 loaded_routes: std::collections::HashSet::new(),
816 no_proxy_hosts: Vec::new(),
817 };
818
819 let config = ProxyConfig {
821 routes: vec![crate::config::RouteConfig {
822 prefix: "/anthropic".to_string(),
823 upstream: "https://api.anthropic.com".to_string(),
824 credential_key: None,
825 inject_mode: crate::config::InjectMode::Header,
826 inject_header: "Authorization".to_string(),
827 credential_format: "Bearer {}".to_string(),
828 path_pattern: None,
829 path_replacement: None,
830 query_param_name: None,
831 env_var: None,
832 endpoint_rules: vec![],
833 tls_ca: None,
834 tls_client_cert: None,
835 tls_client_key: None,
836 }],
837 ..Default::default()
838 };
839
840 let vars = handle.credential_env_vars(&config);
841 assert_eq!(vars.len(), 1);
842 assert_eq!(
843 vars[0].0, "ANTHROPIC_BASE_URL",
844 "env var name must not have leading slash"
845 );
846 assert_eq!(
847 vars[0].1, "http://127.0.0.1:58406/anthropic",
848 "URL must not have double slash"
849 );
850
851 let config = ProxyConfig {
853 routes: vec![crate::config::RouteConfig {
854 prefix: "openai/".to_string(),
855 upstream: "https://api.openai.com".to_string(),
856 credential_key: None,
857 inject_mode: crate::config::InjectMode::Header,
858 inject_header: "Authorization".to_string(),
859 credential_format: "Bearer {}".to_string(),
860 path_pattern: None,
861 path_replacement: None,
862 query_param_name: None,
863 env_var: None,
864 endpoint_rules: vec![],
865 tls_ca: None,
866 tls_client_cert: None,
867 tls_client_key: None,
868 }],
869 ..Default::default()
870 };
871
872 let vars = handle.credential_env_vars(&config);
873 assert_eq!(
874 vars[0].0, "OPENAI_BASE_URL",
875 "env var name must not have trailing slash"
876 );
877 assert_eq!(
878 vars[0].1, "http://127.0.0.1:58406/openai",
879 "URL must not have trailing slash in path"
880 );
881 }
882
883 #[test]
884 fn test_no_proxy_excludes_credential_upstreams() {
885 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
886 let handle = ProxyHandle {
887 port: 12345,
888 token: Zeroizing::new("test_token".to_string()),
889 audit_log: audit::new_audit_log(),
890 shutdown_tx,
891 loaded_routes: std::collections::HashSet::new(),
892 no_proxy_hosts: vec![
893 "nats.internal:4222".to_string(),
894 "opencode.internal:4096".to_string(),
895 ],
896 };
897
898 let vars = handle.env_vars();
899 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
900 assert!(
901 no_proxy.1.contains("nats.internal"),
902 "non-credential host should be in NO_PROXY"
903 );
904 assert!(
905 no_proxy.1.contains("opencode.internal"),
906 "non-credential host should be in NO_PROXY"
907 );
908 assert!(
909 no_proxy.1.contains("localhost"),
910 "localhost should always be in NO_PROXY"
911 );
912 }
913
914 #[test]
915 fn test_no_proxy_empty_when_no_non_credential_hosts() {
916 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
917 let handle = ProxyHandle {
918 port: 12345,
919 token: Zeroizing::new("test_token".to_string()),
920 audit_log: audit::new_audit_log(),
921 shutdown_tx,
922 loaded_routes: std::collections::HashSet::new(),
923 no_proxy_hosts: Vec::new(),
924 };
925
926 let vars = handle.env_vars();
927 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
928 assert_eq!(
929 no_proxy.1, "localhost,127.0.0.1",
930 "NO_PROXY should only contain loopback when no bypass hosts"
931 );
932 }
933}