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 let mut root_store = rustls::RootCertStore::empty();
228 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
229 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
230 rustls::crypto::ring::default_provider(),
231 ))
232 .with_safe_default_protocol_versions()
233 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
234 .with_root_certificates(root_store)
235 .with_no_client_auth();
236 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
237
238 let credential_store = if config.routes.is_empty() {
240 CredentialStore::empty()
241 } else {
242 CredentialStore::load(&config.routes, &tls_connector)?
243 };
244 let loaded_routes = credential_store.loaded_prefixes();
245
246 let filter = if config.allowed_hosts.is_empty() {
248 ProxyFilter::allow_all()
249 } else {
250 ProxyFilter::new(&config.allowed_hosts)
251 };
252
253 let bypass_matcher = config
255 .external_proxy
256 .as_ref()
257 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
258 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
259
260 let (shutdown_tx, shutdown_rx) = watch::channel(false);
262 let audit_log = audit::new_audit_log();
263
264 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
276 Vec::new()
277 } else {
278 let route_hosts = route_store.route_upstream_hosts();
279 config
280 .allowed_hosts
281 .iter()
282 .filter(|host| {
283 let normalised = {
284 let h = host.to_lowercase();
285 if h.starts_with('[') {
286 if h.contains("]:") {
288 h
289 } else {
290 format!("{}:443", h)
291 }
292 } else if h.contains(':') {
293 h
294 } else {
295 format!("{}:443", h)
296 }
297 };
298 if route_hosts.contains(&normalised) {
299 return false;
300 }
301 let port = normalised
304 .rsplit_once(':')
305 .and_then(|(_, p)| p.parse::<u16>().ok())
306 .unwrap_or(443);
307 config.direct_connect_ports.contains(&port)
308 })
309 .cloned()
310 .collect()
311 };
312
313 if !no_proxy_hosts.is_empty() {
314 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
315 }
316
317 let state = Arc::new(ProxyState {
318 filter,
319 session_token: session_token.clone(),
320 route_store,
321 credential_store,
322 config,
323 tls_connector,
324 active_connections: AtomicUsize::new(0),
325 audit_log: Arc::clone(&audit_log),
326 bypass_matcher,
327 });
328
329 tokio::spawn(accept_loop(listener, state, shutdown_rx));
333
334 Ok(ProxyHandle {
335 port,
336 token: session_token,
337 audit_log,
338 shutdown_tx,
339 loaded_routes,
340 no_proxy_hosts,
341 })
342}
343
344async fn accept_loop(
346 listener: TcpListener,
347 state: Arc<ProxyState>,
348 mut shutdown_rx: watch::Receiver<bool>,
349) {
350 loop {
351 tokio::select! {
352 result = listener.accept() => {
353 match result {
354 Ok((stream, addr)) => {
355 let max = state.config.max_connections;
357 if max > 0 {
358 let current = state.active_connections.load(Ordering::Relaxed);
359 if current >= max {
360 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
361 drop(stream);
363 continue;
364 }
365 }
366 state.active_connections.fetch_add(1, Ordering::Relaxed);
367
368 debug!("Accepted connection from {}", addr);
369 let state = Arc::clone(&state);
370 tokio::spawn(async move {
371 if let Err(e) = handle_connection(stream, &state).await {
372 debug!("Connection handler error: {}", e);
373 }
374 state.active_connections.fetch_sub(1, Ordering::Relaxed);
375 });
376 }
377 Err(e) => {
378 warn!("Accept error: {}", e);
379 }
380 }
381 }
382 _ = shutdown_rx.changed() => {
383 if *shutdown_rx.borrow() {
384 info!("Proxy server shutting down");
385 return;
386 }
387 }
388 }
389 }
390}
391
392async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
398 let mut buf_reader = BufReader::new(&mut stream);
402 let mut first_line = String::new();
403 buf_reader.read_line(&mut first_line).await?;
404
405 if first_line.is_empty() {
406 return Ok(()); }
408
409 let mut header_bytes = Vec::new();
411 loop {
412 let mut line = String::new();
413 let n = buf_reader.read_line(&mut line).await?;
414 if n == 0 || line.trim().is_empty() {
415 break;
416 }
417 header_bytes.extend_from_slice(line.as_bytes());
418 if header_bytes.len() > MAX_HEADER_SIZE {
419 drop(buf_reader);
420 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
421 stream.write_all(response.as_bytes()).await?;
422 return Ok(());
423 }
424 }
425
426 let buffered = buf_reader.buffer().to_vec();
431 drop(buf_reader);
432
433 let first_line = first_line.trim_end();
434
435 if first_line.starts_with("CONNECT ") {
437 if !state.route_store.is_empty() {
442 if let Some(authority) = first_line.split_whitespace().nth(1) {
443 let host_port = if authority.starts_with('[') {
446 if authority.contains("]:") {
448 authority.to_lowercase()
449 } else {
450 format!("{}:443", authority.to_lowercase())
451 }
452 } else if authority.contains(':') {
453 authority.to_lowercase()
454 } else {
455 format!("{}:443", authority.to_lowercase())
456 };
457 if state.route_store.is_route_upstream(&host_port) {
458 let (host, port) = host_port
459 .rsplit_once(':')
460 .map(|(h, p)| (h, p.parse::<u16>().unwrap_or(443)))
461 .unwrap_or((&host_port, 443));
462 debug!(
463 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
464 authority
465 );
466 audit::log_denied(
467 Some(&state.audit_log),
468 audit::ProxyMode::Connect,
469 host,
470 port,
471 "route upstream: CONNECT bypasses L7 filtering",
472 );
473 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
474 stream.write_all(response.as_bytes()).await?;
475 return Ok(());
476 }
477 }
478 }
479
480 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
482 if state.bypass_matcher.is_empty() {
483 Some(ext_config)
484 } else {
485 let host = first_line
487 .split_whitespace()
488 .nth(1)
489 .and_then(|authority| {
490 authority
491 .rsplit_once(':')
492 .map(|(h, _)| h)
493 .or(Some(authority))
494 })
495 .unwrap_or("");
496 if state.bypass_matcher.matches(host) {
497 debug!("Bypassing external proxy for {}", host);
498 None
499 } else {
500 Some(ext_config)
501 }
502 }
503 } else {
504 None
505 };
506
507 if let Some(ext_config) = use_external {
508 external::handle_external_proxy(
509 first_line,
510 &mut stream,
511 &header_bytes,
512 &state.filter,
513 &state.session_token,
514 ext_config,
515 Some(&state.audit_log),
516 )
517 .await
518 } else if state.config.external_proxy.is_some() {
519 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
524 connect::handle_connect(
525 first_line,
526 &mut stream,
527 &state.filter,
528 &state.session_token,
529 &header_bytes,
530 Some(&state.audit_log),
531 )
532 .await
533 } else {
534 connect::handle_connect(
535 first_line,
536 &mut stream,
537 &state.filter,
538 &state.session_token,
539 &header_bytes,
540 Some(&state.audit_log),
541 )
542 .await
543 }
544 } else if !state.route_store.is_empty() {
545 let ctx = reverse::ReverseProxyCtx {
547 route_store: &state.route_store,
548 credential_store: &state.credential_store,
549 session_token: &state.session_token,
550 filter: &state.filter,
551 tls_connector: &state.tls_connector,
552 audit_log: Some(&state.audit_log),
553 };
554 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
555 } else {
556 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
558 stream.write_all(response.as_bytes()).await?;
559 Ok(())
560 }
561}
562
563#[cfg(test)]
564#[allow(clippy::unwrap_used)]
565mod tests {
566 use super::*;
567
568 #[tokio::test]
569 async fn test_proxy_starts_and_binds() {
570 let config = ProxyConfig::default();
571 let handle = start(config).await.unwrap();
572
573 assert!(handle.port > 0);
575 assert_eq!(handle.token.len(), 64);
577
578 handle.shutdown();
580 }
581
582 #[tokio::test]
583 async fn test_proxy_env_vars() {
584 let config = ProxyConfig::default();
585 let handle = start(config).await.unwrap();
586
587 let vars = handle.env_vars();
588 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
589 assert!(http_proxy.is_some());
590 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
591
592 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
593 assert!(token_var.is_some());
594 assert_eq!(token_var.unwrap().1.len(), 64);
595
596 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
597 assert!(
598 node_proxy_flag.is_none(),
599 "proxy env should avoid Node-specific flags that can perturb non-Node runtimes"
600 );
601
602 handle.shutdown();
603 }
604
605 #[tokio::test]
606 async fn test_proxy_credential_env_vars() {
607 let config = ProxyConfig {
608 routes: vec![crate::config::RouteConfig {
609 prefix: "openai".to_string(),
610 upstream: "https://api.openai.com".to_string(),
611 credential_key: None,
612 inject_mode: crate::config::InjectMode::Header,
613 inject_header: "Authorization".to_string(),
614 credential_format: "Bearer {}".to_string(),
615 path_pattern: None,
616 path_replacement: None,
617 query_param_name: None,
618 proxy: None,
619 env_var: None,
620 endpoint_rules: vec![],
621 tls_ca: None,
622 tls_client_cert: None,
623 tls_client_key: None,
624 oauth2: None,
625 }],
626 ..Default::default()
627 };
628 let handle = start(config.clone()).await.unwrap();
629
630 let vars = handle.credential_env_vars(&config);
631 assert_eq!(vars.len(), 1);
632 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
633 assert!(vars[0].1.contains("/openai"));
634
635 handle.shutdown();
636 }
637
638 #[test]
639 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
640 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
644 let handle = ProxyHandle {
645 port: 12345,
646 token: Zeroizing::new("test_token".to_string()),
647 audit_log: audit::new_audit_log(),
648 shutdown_tx,
649 loaded_routes: ["openai".to_string()].into_iter().collect(),
650 no_proxy_hosts: Vec::new(),
651 };
652 let config = ProxyConfig {
653 routes: vec![crate::config::RouteConfig {
654 prefix: "openai".to_string(),
655 upstream: "https://api.openai.com".to_string(),
656 credential_key: Some("openai_api_key".to_string()),
657 inject_mode: crate::config::InjectMode::Header,
658 inject_header: "Authorization".to_string(),
659 credential_format: "Bearer {}".to_string(),
660 path_pattern: None,
661 path_replacement: None,
662 query_param_name: None,
663 proxy: None,
664 env_var: None, endpoint_rules: vec![],
666 tls_ca: None,
667 tls_client_cert: None,
668 tls_client_key: None,
669 oauth2: None,
670 }],
671 ..Default::default()
672 };
673
674 let vars = handle.credential_env_vars(&config);
675 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
679 assert!(
680 api_key_var.is_some(),
681 "Should derive env var name from credential_key.to_uppercase()"
682 );
683
684 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
685 assert_eq!(val, "test_token");
686 }
687
688 #[test]
689 fn test_proxy_credential_env_vars_with_explicit_env_var() {
690 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
698 let handle = ProxyHandle {
699 port: 12345,
700 token: Zeroizing::new("test_token".to_string()),
701 audit_log: audit::new_audit_log(),
702 shutdown_tx,
703 loaded_routes: ["openai".to_string()].into_iter().collect(),
704 no_proxy_hosts: Vec::new(),
705 };
706 let config = ProxyConfig {
707 routes: vec![crate::config::RouteConfig {
708 prefix: "openai".to_string(),
709 upstream: "https://api.openai.com".to_string(),
710 credential_key: Some("op://Development/OpenAI/credential".to_string()),
711 inject_mode: crate::config::InjectMode::Header,
712 inject_header: "Authorization".to_string(),
713 credential_format: "Bearer {}".to_string(),
714 path_pattern: None,
715 path_replacement: None,
716 query_param_name: None,
717 proxy: None,
718 env_var: Some("OPENAI_API_KEY".to_string()),
719 endpoint_rules: vec![],
720 tls_ca: None,
721 tls_client_cert: None,
722 tls_client_key: None,
723 oauth2: None,
724 }],
725 ..Default::default()
726 };
727
728 let vars = handle.credential_env_vars(&config);
729 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
732 assert!(
733 api_key_var.is_some(),
734 "Should use explicit env_var name, not derive from credential_key"
735 );
736
737 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
739 assert_eq!(val, "test_token");
740
741 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
743 assert!(
744 bad_var.is_none(),
745 "Should not generate env var from op:// URI uppercase"
746 );
747 }
748
749 #[test]
750 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
751 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
756 let handle = ProxyHandle {
757 port: 12345,
758 token: Zeroizing::new("test_token".to_string()),
759 audit_log: audit::new_audit_log(),
760 shutdown_tx,
761 loaded_routes: ["openai".to_string()].into_iter().collect(),
763 no_proxy_hosts: Vec::new(),
764 };
765 let config = ProxyConfig {
766 routes: vec![
767 crate::config::RouteConfig {
768 prefix: "openai".to_string(),
769 upstream: "https://api.openai.com".to_string(),
770 credential_key: Some("openai_api_key".to_string()),
771 inject_mode: crate::config::InjectMode::Header,
772 inject_header: "Authorization".to_string(),
773 credential_format: "Bearer {}".to_string(),
774 path_pattern: None,
775 path_replacement: None,
776 query_param_name: None,
777 proxy: None,
778 env_var: None,
779 endpoint_rules: vec![],
780 tls_ca: None,
781 tls_client_cert: None,
782 tls_client_key: None,
783 oauth2: None,
784 },
785 crate::config::RouteConfig {
786 prefix: "github".to_string(),
787 upstream: "https://api.github.com".to_string(),
788 credential_key: Some("env://GITHUB_TOKEN".to_string()),
789 inject_mode: crate::config::InjectMode::Header,
790 inject_header: "Authorization".to_string(),
791 credential_format: "token {}".to_string(),
792 path_pattern: None,
793 path_replacement: None,
794 query_param_name: None,
795 proxy: None,
796 env_var: Some("GITHUB_TOKEN".to_string()),
797 endpoint_rules: vec![],
798 tls_ca: None,
799 tls_client_cert: None,
800 tls_client_key: None,
801 oauth2: None,
802 },
803 ],
804 ..Default::default()
805 };
806
807 let vars = handle.credential_env_vars(&config);
808
809 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
811 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
812 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
813 assert!(openai_key.is_some(), "loaded route should have API key");
814
815 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
818 assert!(
819 github_base.is_some(),
820 "declared route should still have BASE_URL"
821 );
822 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
823 assert!(
824 github_token.is_none(),
825 "unloaded route must not inject phantom GITHUB_TOKEN"
826 );
827 }
828
829 #[test]
830 fn test_proxy_credential_env_vars_strips_slashes() {
831 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
836 let handle = ProxyHandle {
837 port: 58406,
838 token: Zeroizing::new("test_token".to_string()),
839 audit_log: audit::new_audit_log(),
840 shutdown_tx,
841 loaded_routes: std::collections::HashSet::new(),
842 no_proxy_hosts: Vec::new(),
843 };
844
845 let config = ProxyConfig {
847 routes: vec![crate::config::RouteConfig {
848 prefix: "/anthropic".to_string(),
849 upstream: "https://api.anthropic.com".to_string(),
850 credential_key: None,
851 inject_mode: crate::config::InjectMode::Header,
852 inject_header: "Authorization".to_string(),
853 credential_format: "Bearer {}".to_string(),
854 path_pattern: None,
855 path_replacement: None,
856 query_param_name: None,
857 proxy: None,
858 env_var: None,
859 endpoint_rules: vec![],
860 tls_ca: None,
861 tls_client_cert: None,
862 tls_client_key: None,
863 oauth2: None,
864 }],
865 ..Default::default()
866 };
867
868 let vars = handle.credential_env_vars(&config);
869 assert_eq!(vars.len(), 1);
870 assert_eq!(
871 vars[0].0, "ANTHROPIC_BASE_URL",
872 "env var name must not have leading slash"
873 );
874 assert_eq!(
875 vars[0].1, "http://127.0.0.1:58406/anthropic",
876 "URL must not have double slash"
877 );
878
879 let config = ProxyConfig {
881 routes: vec![crate::config::RouteConfig {
882 prefix: "openai/".to_string(),
883 upstream: "https://api.openai.com".to_string(),
884 credential_key: None,
885 inject_mode: crate::config::InjectMode::Header,
886 inject_header: "Authorization".to_string(),
887 credential_format: "Bearer {}".to_string(),
888 path_pattern: None,
889 path_replacement: None,
890 query_param_name: None,
891 proxy: None,
892 env_var: None,
893 endpoint_rules: vec![],
894 tls_ca: None,
895 tls_client_cert: None,
896 tls_client_key: None,
897 oauth2: None,
898 }],
899 ..Default::default()
900 };
901
902 let vars = handle.credential_env_vars(&config);
903 assert_eq!(
904 vars[0].0, "OPENAI_BASE_URL",
905 "env var name must not have trailing slash"
906 );
907 assert_eq!(
908 vars[0].1, "http://127.0.0.1:58406/openai",
909 "URL must not have trailing slash in path"
910 );
911 }
912
913 #[test]
914 fn test_anthropic_credential_phantom_token_regression() {
915 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
923 let handle_no_env_var = ProxyHandle {
924 port: 12345,
925 token: Zeroizing::new("phantom".to_string()),
926 audit_log: audit::new_audit_log(),
927 shutdown_tx: shutdown_tx.clone(),
928 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
929 no_proxy_hosts: Vec::new(),
930 };
931 let config_no_env_var = ProxyConfig {
932 routes: vec![crate::config::RouteConfig {
933 prefix: "anthropic".to_string(),
934 upstream: "https://api.anthropic.com".to_string(),
935 credential_key: None,
936 inject_mode: crate::config::InjectMode::Header,
937 inject_header: "x-api-key".to_string(),
938 credential_format: "{}".to_string(),
939 path_pattern: None,
940 path_replacement: None,
941 query_param_name: None,
942 proxy: None,
943 env_var: None,
944 endpoint_rules: vec![],
945 tls_ca: None,
946 tls_client_cert: None,
947 tls_client_key: None,
948 oauth2: None,
949 }],
950 ..Default::default()
951 };
952 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
953 assert!(
954 vars_no_env_var.iter().all(|(k, _)| k != "ANTHROPIC_API_KEY"),
955 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
956 );
957
958 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
961 let handle_fixed = ProxyHandle {
962 port: 12345,
963 token: Zeroizing::new("phantom".to_string()),
964 audit_log: audit::new_audit_log(),
965 shutdown_tx: shutdown_tx2,
966 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
967 no_proxy_hosts: Vec::new(),
968 };
969 let config_fixed = ProxyConfig {
970 routes: vec![crate::config::RouteConfig {
971 prefix: "anthropic".to_string(),
972 upstream: "https://api.anthropic.com".to_string(),
973 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
974 inject_mode: crate::config::InjectMode::Header,
975 inject_header: "x-api-key".to_string(),
976 credential_format: "{}".to_string(),
977 path_pattern: None,
978 path_replacement: None,
979 query_param_name: None,
980 proxy: None,
981 env_var: Some("ANTHROPIC_API_KEY".to_string()),
982 endpoint_rules: vec![],
983 tls_ca: None,
984 tls_client_cert: None,
985 tls_client_key: None,
986 oauth2: None,
987 }],
988 ..Default::default()
989 };
990 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
991 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
992 assert!(
993 api_key_var.is_some(),
994 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
995 );
996 assert_eq!(api_key_var.unwrap().1, "phantom");
997 }
998
999 #[test]
1000 fn test_no_proxy_excludes_credential_upstreams() {
1001 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1002 let handle = ProxyHandle {
1003 port: 12345,
1004 token: Zeroizing::new("test_token".to_string()),
1005 audit_log: audit::new_audit_log(),
1006 shutdown_tx,
1007 loaded_routes: std::collections::HashSet::new(),
1008 no_proxy_hosts: vec![
1009 "nats.internal:4222".to_string(),
1010 "opencode.internal:4096".to_string(),
1011 ],
1012 };
1013
1014 let vars = handle.env_vars();
1015 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1016 assert!(
1017 no_proxy.1.contains("nats.internal"),
1018 "non-credential host should be in NO_PROXY"
1019 );
1020 assert!(
1021 no_proxy.1.contains("opencode.internal"),
1022 "non-credential host should be in NO_PROXY"
1023 );
1024 assert!(
1025 no_proxy.1.contains("localhost"),
1026 "localhost should always be in NO_PROXY"
1027 );
1028 }
1029
1030 #[test]
1031 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1032 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1033 let handle = ProxyHandle {
1034 port: 12345,
1035 token: Zeroizing::new("test_token".to_string()),
1036 audit_log: audit::new_audit_log(),
1037 shutdown_tx,
1038 loaded_routes: std::collections::HashSet::new(),
1039 no_proxy_hosts: Vec::new(),
1040 };
1041
1042 let vars = handle.env_vars();
1043 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1044 assert_eq!(
1045 no_proxy.1, "localhost,127.0.0.1",
1046 "NO_PROXY should only contain loopback when no bypass hosts"
1047 );
1048 }
1049
1050 #[tokio::test]
1051 async fn test_no_proxy_empty_without_direct_connect_ports() {
1052 let config = ProxyConfig {
1056 allowed_hosts: vec!["github.com".to_string()],
1057 ..Default::default()
1058 };
1059 let handle = start(config).await.unwrap();
1060
1061 let vars = handle.env_vars();
1062 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1063 assert_eq!(
1064 no_proxy.1, "localhost,127.0.0.1",
1065 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1066 );
1067
1068 handle.shutdown();
1069 }
1070
1071 #[cfg(not(target_os = "macos"))]
1072 #[tokio::test]
1073 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1074 let config = ProxyConfig {
1078 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1079 direct_connect_ports: vec![443],
1080 ..Default::default()
1081 };
1082 let handle = start(config).await.unwrap();
1083
1084 let vars = handle.env_vars();
1085 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1086 assert!(
1087 no_proxy.1.contains("github.com"),
1088 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1089 );
1090 assert!(
1091 !no_proxy.1.contains("server.internal"),
1092 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1093 );
1094
1095 handle.shutdown();
1096 }
1097}