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 if !cred_key.contains("://") {
157 let api_key_name = cred_key.to_uppercase();
158 vars.push((api_key_name, self.token.to_string()));
159 }
160 }
161 }
162 vars
163 }
164}
165
166struct ProxyState {
168 filter: ProxyFilter,
169 session_token: Zeroizing<String>,
170 route_store: RouteStore,
172 credential_store: CredentialStore,
174 config: ProxyConfig,
175 tls_connector: tokio_rustls::TlsConnector,
178 active_connections: AtomicUsize,
180 audit_log: audit::SharedAuditLog,
182 bypass_matcher: external::BypassMatcher,
185}
186
187pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
195 let session_token = token::generate_session_token()?;
197
198 let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
200 let listener = TcpListener::bind(bind_addr)
201 .await
202 .map_err(|e| ProxyError::Bind {
203 addr: bind_addr.to_string(),
204 source: e,
205 })?;
206
207 let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
208 addr: bind_addr.to_string(),
209 source: e,
210 })?;
211 let port = local_addr.port();
212
213 info!("Proxy server listening on {}", local_addr);
214
215 let route_store = if config.routes.is_empty() {
218 RouteStore::empty()
219 } else {
220 RouteStore::load(&config.routes)?
221 };
222
223 let credential_store = if config.routes.is_empty() {
225 CredentialStore::empty()
226 } else {
227 CredentialStore::load(&config.routes)?
228 };
229 let loaded_routes = credential_store.loaded_prefixes();
230
231 let filter = if config.allowed_hosts.is_empty() {
233 ProxyFilter::allow_all()
234 } else {
235 ProxyFilter::new(&config.allowed_hosts)
236 };
237
238 let mut root_store = rustls::RootCertStore::empty();
242 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
243 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
244 rustls::crypto::ring::default_provider(),
245 ))
246 .with_safe_default_protocol_versions()
247 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
248 .with_root_certificates(root_store)
249 .with_no_client_auth();
250 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
251
252 let bypass_matcher = config
254 .external_proxy
255 .as_ref()
256 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
257 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
258
259 let (shutdown_tx, shutdown_rx) = watch::channel(false);
261 let audit_log = audit::new_audit_log();
262
263 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
274 Vec::new()
275 } else {
276 let route_hosts = route_store.route_upstream_hosts();
277 config
278 .allowed_hosts
279 .iter()
280 .filter(|host| {
281 let normalised = {
282 let h = host.to_lowercase();
283 if h.starts_with('[') {
284 if h.contains("]:") {
286 h
287 } else {
288 format!("{}:443", h)
289 }
290 } else if h.contains(':') {
291 h
292 } else {
293 format!("{}:443", h)
294 }
295 };
296 !route_hosts.contains(&normalised)
297 })
298 .cloned()
299 .collect()
300 };
301
302 if !no_proxy_hosts.is_empty() {
303 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
304 }
305
306 let state = Arc::new(ProxyState {
307 filter,
308 session_token: session_token.clone(),
309 route_store,
310 credential_store,
311 config,
312 tls_connector,
313 active_connections: AtomicUsize::new(0),
314 audit_log: Arc::clone(&audit_log),
315 bypass_matcher,
316 });
317
318 tokio::spawn(accept_loop(listener, state, shutdown_rx));
322
323 Ok(ProxyHandle {
324 port,
325 token: session_token,
326 audit_log,
327 shutdown_tx,
328 loaded_routes,
329 no_proxy_hosts,
330 })
331}
332
333async fn accept_loop(
335 listener: TcpListener,
336 state: Arc<ProxyState>,
337 mut shutdown_rx: watch::Receiver<bool>,
338) {
339 loop {
340 tokio::select! {
341 result = listener.accept() => {
342 match result {
343 Ok((stream, addr)) => {
344 let max = state.config.max_connections;
346 if max > 0 {
347 let current = state.active_connections.load(Ordering::Relaxed);
348 if current >= max {
349 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
350 drop(stream);
352 continue;
353 }
354 }
355 state.active_connections.fetch_add(1, Ordering::Relaxed);
356
357 debug!("Accepted connection from {}", addr);
358 let state = Arc::clone(&state);
359 tokio::spawn(async move {
360 if let Err(e) = handle_connection(stream, &state).await {
361 debug!("Connection handler error: {}", e);
362 }
363 state.active_connections.fetch_sub(1, Ordering::Relaxed);
364 });
365 }
366 Err(e) => {
367 warn!("Accept error: {}", e);
368 }
369 }
370 }
371 _ = shutdown_rx.changed() => {
372 if *shutdown_rx.borrow() {
373 info!("Proxy server shutting down");
374 return;
375 }
376 }
377 }
378 }
379}
380
381async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
387 let mut buf_reader = BufReader::new(&mut stream);
391 let mut first_line = String::new();
392 buf_reader.read_line(&mut first_line).await?;
393
394 if first_line.is_empty() {
395 return Ok(()); }
397
398 let mut header_bytes = Vec::new();
400 loop {
401 let mut line = String::new();
402 let n = buf_reader.read_line(&mut line).await?;
403 if n == 0 || line.trim().is_empty() {
404 break;
405 }
406 header_bytes.extend_from_slice(line.as_bytes());
407 if header_bytes.len() > MAX_HEADER_SIZE {
408 drop(buf_reader);
409 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
410 stream.write_all(response.as_bytes()).await?;
411 return Ok(());
412 }
413 }
414
415 let buffered = buf_reader.buffer().to_vec();
420 drop(buf_reader);
421
422 let first_line = first_line.trim_end();
423
424 if first_line.starts_with("CONNECT ") {
426 if !state.route_store.is_empty() {
431 if let Some(authority) = first_line.split_whitespace().nth(1) {
432 let host_port = if authority.starts_with('[') {
435 if authority.contains("]:") {
437 authority.to_lowercase()
438 } else {
439 format!("{}:443", authority.to_lowercase())
440 }
441 } else if authority.contains(':') {
442 authority.to_lowercase()
443 } else {
444 format!("{}:443", authority.to_lowercase())
445 };
446 if state.route_store.is_route_upstream(&host_port) {
447 let (host, port) = host_port
448 .rsplit_once(':')
449 .map(|(h, p)| (h, p.parse::<u16>().unwrap_or(443)))
450 .unwrap_or((&host_port, 443));
451 warn!(
452 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
453 authority
454 );
455 audit::log_denied(
456 Some(&state.audit_log),
457 audit::ProxyMode::Connect,
458 host,
459 port,
460 "route upstream: CONNECT bypasses L7 filtering",
461 );
462 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
463 stream.write_all(response.as_bytes()).await?;
464 return Ok(());
465 }
466 }
467 }
468
469 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
471 if state.bypass_matcher.is_empty() {
472 Some(ext_config)
473 } else {
474 let host = first_line
476 .split_whitespace()
477 .nth(1)
478 .and_then(|authority| {
479 authority
480 .rsplit_once(':')
481 .map(|(h, _)| h)
482 .or(Some(authority))
483 })
484 .unwrap_or("");
485 if state.bypass_matcher.matches(host) {
486 debug!("Bypassing external proxy for {}", host);
487 None
488 } else {
489 Some(ext_config)
490 }
491 }
492 } else {
493 None
494 };
495
496 if let Some(ext_config) = use_external {
497 external::handle_external_proxy(
498 first_line,
499 &mut stream,
500 &header_bytes,
501 &state.filter,
502 &state.session_token,
503 ext_config,
504 Some(&state.audit_log),
505 )
506 .await
507 } else if state.config.external_proxy.is_some() {
508 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
513 connect::handle_connect(
514 first_line,
515 &mut stream,
516 &state.filter,
517 &state.session_token,
518 &header_bytes,
519 Some(&state.audit_log),
520 )
521 .await
522 } else {
523 connect::handle_connect(
524 first_line,
525 &mut stream,
526 &state.filter,
527 &state.session_token,
528 &header_bytes,
529 Some(&state.audit_log),
530 )
531 .await
532 }
533 } else if !state.route_store.is_empty() {
534 let ctx = reverse::ReverseProxyCtx {
536 route_store: &state.route_store,
537 credential_store: &state.credential_store,
538 session_token: &state.session_token,
539 filter: &state.filter,
540 tls_connector: &state.tls_connector,
541 audit_log: Some(&state.audit_log),
542 };
543 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
544 } else {
545 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
547 stream.write_all(response.as_bytes()).await?;
548 Ok(())
549 }
550}
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used)]
554mod tests {
555 use super::*;
556
557 #[tokio::test]
558 async fn test_proxy_starts_and_binds() {
559 let config = ProxyConfig::default();
560 let handle = start(config).await.unwrap();
561
562 assert!(handle.port > 0);
564 assert_eq!(handle.token.len(), 64);
566
567 handle.shutdown();
569 }
570
571 #[tokio::test]
572 async fn test_proxy_env_vars() {
573 let config = ProxyConfig::default();
574 let handle = start(config).await.unwrap();
575
576 let vars = handle.env_vars();
577 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
578 assert!(http_proxy.is_some());
579 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
580
581 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
582 assert!(token_var.is_some());
583 assert_eq!(token_var.unwrap().1.len(), 64);
584
585 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
586 assert!(
587 node_proxy_flag.is_none(),
588 "proxy env should avoid Node-specific flags that can perturb non-Node runtimes"
589 );
590
591 handle.shutdown();
592 }
593
594 #[tokio::test]
595 async fn test_proxy_credential_env_vars() {
596 let config = ProxyConfig {
597 routes: vec![crate::config::RouteConfig {
598 prefix: "openai".to_string(),
599 upstream: "https://api.openai.com".to_string(),
600 credential_key: None,
601 inject_mode: crate::config::InjectMode::Header,
602 inject_header: "Authorization".to_string(),
603 credential_format: "Bearer {}".to_string(),
604 path_pattern: None,
605 path_replacement: None,
606 query_param_name: None,
607 proxy: None,
608 env_var: None,
609 endpoint_rules: vec![],
610 tls_ca: None,
611 tls_client_cert: None,
612 tls_client_key: None,
613 }],
614 ..Default::default()
615 };
616 let handle = start(config.clone()).await.unwrap();
617
618 let vars = handle.credential_env_vars(&config);
619 assert_eq!(vars.len(), 1);
620 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
621 assert!(vars[0].1.contains("/openai"));
622
623 handle.shutdown();
624 }
625
626 #[test]
627 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
628 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
632 let handle = ProxyHandle {
633 port: 12345,
634 token: Zeroizing::new("test_token".to_string()),
635 audit_log: audit::new_audit_log(),
636 shutdown_tx,
637 loaded_routes: ["openai".to_string()].into_iter().collect(),
638 no_proxy_hosts: Vec::new(),
639 };
640 let config = ProxyConfig {
641 routes: vec![crate::config::RouteConfig {
642 prefix: "openai".to_string(),
643 upstream: "https://api.openai.com".to_string(),
644 credential_key: Some("openai_api_key".to_string()),
645 inject_mode: crate::config::InjectMode::Header,
646 inject_header: "Authorization".to_string(),
647 credential_format: "Bearer {}".to_string(),
648 path_pattern: None,
649 path_replacement: None,
650 query_param_name: None,
651 proxy: None,
652 env_var: None, endpoint_rules: vec![],
654 tls_ca: None,
655 tls_client_cert: None,
656 tls_client_key: None,
657 }],
658 ..Default::default()
659 };
660
661 let vars = handle.credential_env_vars(&config);
662 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
666 assert!(
667 api_key_var.is_some(),
668 "Should derive env var name from credential_key.to_uppercase()"
669 );
670
671 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
672 assert_eq!(val, "test_token");
673 }
674
675 #[test]
676 fn test_proxy_credential_env_vars_with_explicit_env_var() {
677 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
685 let handle = ProxyHandle {
686 port: 12345,
687 token: Zeroizing::new("test_token".to_string()),
688 audit_log: audit::new_audit_log(),
689 shutdown_tx,
690 loaded_routes: ["openai".to_string()].into_iter().collect(),
691 no_proxy_hosts: Vec::new(),
692 };
693 let config = ProxyConfig {
694 routes: vec![crate::config::RouteConfig {
695 prefix: "openai".to_string(),
696 upstream: "https://api.openai.com".to_string(),
697 credential_key: Some("op://Development/OpenAI/credential".to_string()),
698 inject_mode: crate::config::InjectMode::Header,
699 inject_header: "Authorization".to_string(),
700 credential_format: "Bearer {}".to_string(),
701 path_pattern: None,
702 path_replacement: None,
703 query_param_name: None,
704 proxy: None,
705 env_var: Some("OPENAI_API_KEY".to_string()),
706 endpoint_rules: vec![],
707 tls_ca: None,
708 tls_client_cert: None,
709 tls_client_key: None,
710 }],
711 ..Default::default()
712 };
713
714 let vars = handle.credential_env_vars(&config);
715 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
718 assert!(
719 api_key_var.is_some(),
720 "Should use explicit env_var name, not derive from credential_key"
721 );
722
723 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
725 assert_eq!(val, "test_token");
726
727 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
729 assert!(
730 bad_var.is_none(),
731 "Should not generate env var from op:// URI uppercase"
732 );
733 }
734
735 #[test]
736 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
737 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
742 let handle = ProxyHandle {
743 port: 12345,
744 token: Zeroizing::new("test_token".to_string()),
745 audit_log: audit::new_audit_log(),
746 shutdown_tx,
747 loaded_routes: ["openai".to_string()].into_iter().collect(),
749 no_proxy_hosts: Vec::new(),
750 };
751 let config = ProxyConfig {
752 routes: vec![
753 crate::config::RouteConfig {
754 prefix: "openai".to_string(),
755 upstream: "https://api.openai.com".to_string(),
756 credential_key: Some("openai_api_key".to_string()),
757 inject_mode: crate::config::InjectMode::Header,
758 inject_header: "Authorization".to_string(),
759 credential_format: "Bearer {}".to_string(),
760 path_pattern: None,
761 path_replacement: None,
762 query_param_name: None,
763 proxy: None,
764 env_var: None,
765 endpoint_rules: vec![],
766 tls_ca: None,
767 tls_client_cert: None,
768 tls_client_key: None,
769 },
770 crate::config::RouteConfig {
771 prefix: "github".to_string(),
772 upstream: "https://api.github.com".to_string(),
773 credential_key: Some("env://GITHUB_TOKEN".to_string()),
774 inject_mode: crate::config::InjectMode::Header,
775 inject_header: "Authorization".to_string(),
776 credential_format: "token {}".to_string(),
777 path_pattern: None,
778 path_replacement: None,
779 query_param_name: None,
780 proxy: None,
781 env_var: Some("GITHUB_TOKEN".to_string()),
782 endpoint_rules: vec![],
783 tls_ca: None,
784 tls_client_cert: None,
785 tls_client_key: None,
786 },
787 ],
788 ..Default::default()
789 };
790
791 let vars = handle.credential_env_vars(&config);
792
793 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
795 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
796 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
797 assert!(openai_key.is_some(), "loaded route should have API key");
798
799 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
802 assert!(
803 github_base.is_some(),
804 "declared route should still have BASE_URL"
805 );
806 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
807 assert!(
808 github_token.is_none(),
809 "unloaded route must not inject phantom GITHUB_TOKEN"
810 );
811 }
812
813 #[test]
814 fn test_proxy_credential_env_vars_strips_slashes() {
815 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
820 let handle = ProxyHandle {
821 port: 58406,
822 token: Zeroizing::new("test_token".to_string()),
823 audit_log: audit::new_audit_log(),
824 shutdown_tx,
825 loaded_routes: std::collections::HashSet::new(),
826 no_proxy_hosts: Vec::new(),
827 };
828
829 let config = ProxyConfig {
831 routes: vec![crate::config::RouteConfig {
832 prefix: "/anthropic".to_string(),
833 upstream: "https://api.anthropic.com".to_string(),
834 credential_key: None,
835 inject_mode: crate::config::InjectMode::Header,
836 inject_header: "Authorization".to_string(),
837 credential_format: "Bearer {}".to_string(),
838 path_pattern: None,
839 path_replacement: None,
840 query_param_name: None,
841 proxy: None,
842 env_var: None,
843 endpoint_rules: vec![],
844 tls_ca: None,
845 tls_client_cert: None,
846 tls_client_key: None,
847 }],
848 ..Default::default()
849 };
850
851 let vars = handle.credential_env_vars(&config);
852 assert_eq!(vars.len(), 1);
853 assert_eq!(
854 vars[0].0, "ANTHROPIC_BASE_URL",
855 "env var name must not have leading slash"
856 );
857 assert_eq!(
858 vars[0].1, "http://127.0.0.1:58406/anthropic",
859 "URL must not have double slash"
860 );
861
862 let config = ProxyConfig {
864 routes: vec![crate::config::RouteConfig {
865 prefix: "openai/".to_string(),
866 upstream: "https://api.openai.com".to_string(),
867 credential_key: None,
868 inject_mode: crate::config::InjectMode::Header,
869 inject_header: "Authorization".to_string(),
870 credential_format: "Bearer {}".to_string(),
871 path_pattern: None,
872 path_replacement: None,
873 query_param_name: None,
874 proxy: None,
875 env_var: None,
876 endpoint_rules: vec![],
877 tls_ca: None,
878 tls_client_cert: None,
879 tls_client_key: None,
880 }],
881 ..Default::default()
882 };
883
884 let vars = handle.credential_env_vars(&config);
885 assert_eq!(
886 vars[0].0, "OPENAI_BASE_URL",
887 "env var name must not have trailing slash"
888 );
889 assert_eq!(
890 vars[0].1, "http://127.0.0.1:58406/openai",
891 "URL must not have trailing slash in path"
892 );
893 }
894
895 #[test]
896 fn test_anthropic_credential_phantom_token_regression() {
897 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
905 let handle_no_env_var = ProxyHandle {
906 port: 12345,
907 token: Zeroizing::new("phantom".to_string()),
908 audit_log: audit::new_audit_log(),
909 shutdown_tx: shutdown_tx.clone(),
910 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
911 no_proxy_hosts: Vec::new(),
912 };
913 let config_no_env_var = ProxyConfig {
914 routes: vec![crate::config::RouteConfig {
915 prefix: "anthropic".to_string(),
916 upstream: "https://api.anthropic.com".to_string(),
917 credential_key: None,
918 inject_mode: crate::config::InjectMode::Header,
919 inject_header: "x-api-key".to_string(),
920 credential_format: "{}".to_string(),
921 path_pattern: None,
922 path_replacement: None,
923 query_param_name: None,
924 proxy: None,
925 env_var: None,
926 endpoint_rules: vec![],
927 tls_ca: None,
928 tls_client_cert: None,
929 tls_client_key: None,
930 }],
931 ..Default::default()
932 };
933 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
934 assert!(
935 vars_no_env_var.iter().all(|(k, _)| k != "ANTHROPIC_API_KEY"),
936 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
937 );
938
939 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
942 let handle_fixed = ProxyHandle {
943 port: 12345,
944 token: Zeroizing::new("phantom".to_string()),
945 audit_log: audit::new_audit_log(),
946 shutdown_tx: shutdown_tx2,
947 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
948 no_proxy_hosts: Vec::new(),
949 };
950 let config_fixed = ProxyConfig {
951 routes: vec![crate::config::RouteConfig {
952 prefix: "anthropic".to_string(),
953 upstream: "https://api.anthropic.com".to_string(),
954 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
955 inject_mode: crate::config::InjectMode::Header,
956 inject_header: "x-api-key".to_string(),
957 credential_format: "{}".to_string(),
958 path_pattern: None,
959 path_replacement: None,
960 query_param_name: None,
961 proxy: None,
962 env_var: Some("ANTHROPIC_API_KEY".to_string()),
963 endpoint_rules: vec![],
964 tls_ca: None,
965 tls_client_cert: None,
966 tls_client_key: None,
967 }],
968 ..Default::default()
969 };
970 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
971 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
972 assert!(
973 api_key_var.is_some(),
974 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
975 );
976 assert_eq!(api_key_var.unwrap().1, "phantom");
977 }
978
979 #[test]
980 fn test_no_proxy_excludes_credential_upstreams() {
981 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
982 let handle = ProxyHandle {
983 port: 12345,
984 token: Zeroizing::new("test_token".to_string()),
985 audit_log: audit::new_audit_log(),
986 shutdown_tx,
987 loaded_routes: std::collections::HashSet::new(),
988 no_proxy_hosts: vec![
989 "nats.internal:4222".to_string(),
990 "opencode.internal:4096".to_string(),
991 ],
992 };
993
994 let vars = handle.env_vars();
995 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
996 assert!(
997 no_proxy.1.contains("nats.internal"),
998 "non-credential host should be in NO_PROXY"
999 );
1000 assert!(
1001 no_proxy.1.contains("opencode.internal"),
1002 "non-credential host should be in NO_PROXY"
1003 );
1004 assert!(
1005 no_proxy.1.contains("localhost"),
1006 "localhost should always be in NO_PROXY"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1012 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1013 let handle = ProxyHandle {
1014 port: 12345,
1015 token: Zeroizing::new("test_token".to_string()),
1016 audit_log: audit::new_audit_log(),
1017 shutdown_tx,
1018 loaded_routes: std::collections::HashSet::new(),
1019 no_proxy_hosts: Vec::new(),
1020 };
1021
1022 let vars = handle.env_vars();
1023 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1024 assert_eq!(
1025 no_proxy.1, "localhost,127.0.0.1",
1026 "NO_PROXY should only contain loopback when no bypass hosts"
1027 );
1028 }
1029}