1use crate::audit;
11use crate::config::ProxyConfig;
12use crate::connect;
13use crate::credential::CredentialStore;
14use crate::error::{ProxyError, Result};
15use crate::external;
16use crate::filter::ProxyFilter;
17use crate::reverse;
18use crate::route::RouteStore;
19use crate::tls_intercept::{self, CertCache, EphemeralCa};
20use crate::token;
21use std::net::SocketAddr;
22use std::path::PathBuf;
23use std::sync::atomic::{AtomicUsize, Ordering};
24use std::sync::Arc;
25use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
26use tokio::net::TcpListener;
27use tokio::sync::watch;
28use tracing::{debug, info, warn};
29use zeroize::Zeroizing;
30
31const MAX_HEADER_SIZE: usize = 64 * 1024;
34
35pub struct ProxyHandle {
40 pub port: u16,
42 pub token: Zeroizing<String>,
44 audit_log: audit::SharedAuditLog,
46 shutdown_tx: watch::Sender<bool>,
48 loaded_routes: std::collections::HashSet<String>,
52 no_proxy_hosts: Vec<String>,
55 intercept_ca_path: Option<PathBuf>,
61}
62
63impl ProxyHandle {
64 pub fn shutdown(&self) {
66 let _ = self.shutdown_tx.send(true);
67 }
68
69 #[must_use]
71 pub fn drain_audit_events(&self) -> Vec<nono::undo::NetworkAuditEvent> {
72 audit::drain_audit_events(&self.audit_log)
73 }
74
75 #[must_use]
86 pub fn intercept_ca_path(&self) -> Option<&std::path::Path> {
87 self.intercept_ca_path.as_deref()
88 }
89
90 #[must_use]
102 pub fn route_diagnostics(&self, config: &ProxyConfig) -> Vec<(String, String)> {
103 let mut rows = Vec::with_capacity(config.routes.len());
104 for route in &config.routes {
105 let prefix = route.prefix.trim_matches('/').to_string();
106 let cred_summary = if let Some(ref key) = route.credential_key {
107 let resolved = self.loaded_routes.contains(&prefix);
108 if resolved {
109 format!("creds: {} ✓", key)
110 } else {
111 format!("creds: {} ✗ (not found)", key)
112 }
113 } else if route.oauth2.is_some() {
114 let resolved = self.loaded_routes.contains(&prefix);
115 if resolved {
116 "creds: oauth2 ✓".to_string()
117 } else {
118 "creds: oauth2 ✗ (token exchange failed)".to_string()
119 }
120 } else {
121 "creds: none".to_string()
122 };
123
124 let intercept_summary = if self.intercept_ca_path.is_some()
125 && (route.credential_key.is_some()
126 || route.oauth2.is_some()
127 || !route.endpoint_rules.is_empty())
128 {
129 "intercept: on"
130 } else {
131 "intercept: off"
132 };
133
134 let rules_summary = format!("endpoint_rules: {}", route.endpoint_rules.len());
135 let summary = format!(
136 "→ {} | {} | {} | {}",
137 route.upstream, cred_summary, intercept_summary, rules_summary
138 );
139 rows.push((prefix, summary));
140 }
141 rows
142 }
143
144 #[must_use]
157 pub fn env_vars(&self) -> Vec<(String, String)> {
158 let proxy_url = format!("http://nono:{}@127.0.0.1:{}", &*self.token, self.port);
159
160 let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
164 for host in &self.no_proxy_hosts {
165 let hostname = if host.contains("]:") {
168 host.rsplit_once("]:")
170 .map(|(h, _)| format!("{}]", h))
171 .unwrap_or_else(|| host.clone())
172 } else {
173 host.rsplit_once(':')
174 .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h.to_string()))
175 .unwrap_or_else(|| host.clone())
176 };
177 if !no_proxy_parts.contains(&hostname.to_string()) {
178 no_proxy_parts.push(hostname.to_string());
179 }
180 }
181 let no_proxy = no_proxy_parts.join(",");
182
183 let mut vars = vec![
184 ("HTTP_PROXY".to_string(), proxy_url.clone()),
185 ("HTTPS_PROXY".to_string(), proxy_url.clone()),
186 ("NO_PROXY".to_string(), no_proxy.clone()),
187 ("NONO_PROXY_TOKEN".to_string(), self.token.to_string()),
188 ];
189
190 vars.push(("http_proxy".to_string(), proxy_url.clone()));
192 vars.push(("https_proxy".to_string(), proxy_url));
193 vars.push(("no_proxy".to_string(), no_proxy));
194
195 vars.push(("NODE_USE_ENV_PROXY".to_string(), "1".to_string()));
202
203 if let Some(path) = self.intercept_ca_path.as_deref() {
217 let path_str = path.to_string_lossy().to_string();
218 vars.push(("SSL_CERT_FILE".to_string(), path_str.clone()));
219 vars.push(("REQUESTS_CA_BUNDLE".to_string(), path_str.clone()));
220 vars.push(("NODE_EXTRA_CA_CERTS".to_string(), path_str.clone()));
221 vars.push(("CURL_CA_BUNDLE".to_string(), path_str.clone()));
222 vars.push(("GIT_SSL_CAINFO".to_string(), path_str));
223 }
224
225 vars
226 }
227
228 #[must_use]
237 pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
238 let mut vars = Vec::new();
239 for route in &config.routes {
240 let prefix = route.prefix.trim_matches('/');
245
246 let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
248 let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
249 vars.push((base_url_name, url));
250
251 if !self.loaded_routes.contains(prefix) {
256 continue;
257 }
258
259 if let Some(ref env_var) = route.env_var {
263 vars.push((env_var.clone(), self.token.to_string()));
264 } else if let Some(ref cred_key) = route.credential_key {
265 if !cred_key.contains("://") {
269 let api_key_name = cred_key.to_uppercase();
270 vars.push((api_key_name, self.token.to_string()));
271 }
272 }
273 }
274 vars
275 }
276}
277
278impl Drop for ProxyHandle {
279 fn drop(&mut self) {
290 if let Some(path) = self.intercept_ca_path.take() {
291 let _ = std::fs::remove_file(&path);
292 if let Some(parent) = path.parent() {
297 let _ = std::fs::remove_dir(parent);
298 }
299 }
300 }
301}
302
303struct ProxyState {
305 filter: ProxyFilter,
306 session_token: Zeroizing<String>,
307 route_store: RouteStore,
309 credential_store: CredentialStore,
311 config: ProxyConfig,
312 tls_connector: tokio_rustls::TlsConnector,
315 active_connections: AtomicUsize,
317 audit_log: audit::SharedAuditLog,
319 bypass_matcher: external::BypassMatcher,
322 cert_cache: Option<Arc<CertCache>>,
327}
328
329pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
337 let session_token = token::generate_session_token()?;
339
340 let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
342 let listener = TcpListener::bind(bind_addr)
343 .await
344 .map_err(|e| ProxyError::Bind {
345 addr: bind_addr.to_string(),
346 source: e,
347 })?;
348
349 let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
350 addr: bind_addr.to_string(),
351 source: e,
352 })?;
353 let port = local_addr.port();
354
355 info!("Proxy server listening on {}", local_addr);
356
357 let route_store = if config.routes.is_empty() {
360 RouteStore::empty()
361 } else {
362 RouteStore::load(&config.routes)?
363 };
364 let mut root_store = rustls::RootCertStore::empty();
370 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
371 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
372 rustls::crypto::ring::default_provider(),
373 ))
374 .with_safe_default_protocol_versions()
375 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
376 .with_root_certificates(root_store)
377 .with_no_client_auth();
378 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
379
380 let credential_store = if config.routes.is_empty() {
382 CredentialStore::empty()
383 } else {
384 CredentialStore::load(&config.routes, &tls_connector)?
385 };
386 let loaded_routes = credential_store.loaded_prefixes();
387
388 let filter = if config.allowed_hosts.is_empty() {
390 ProxyFilter::allow_all()
391 } else {
392 ProxyFilter::new(&config.allowed_hosts)
393 };
394
395 let bypass_matcher = config
397 .external_proxy
398 .as_ref()
399 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
400 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
401
402 let (shutdown_tx, shutdown_rx) = watch::channel(false);
404 let audit_log = audit::new_audit_log();
405
406 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
418 Vec::new()
419 } else {
420 let route_hosts = route_store.route_upstream_hosts();
421 config
422 .allowed_hosts
423 .iter()
424 .filter(|host| {
425 let normalised = {
426 let h = host.to_lowercase();
427 if h.starts_with('[') {
428 if h.contains("]:") {
430 h
431 } else {
432 format!("{}:443", h)
433 }
434 } else if h.contains(':') {
435 h
436 } else {
437 format!("{}:443", h)
438 }
439 };
440 if route_hosts.contains(&normalised) {
441 return false;
442 }
443 let port = normalised
446 .rsplit_once(':')
447 .and_then(|(_, p)| p.parse::<u16>().ok())
448 .unwrap_or(443);
449 config.direct_connect_ports.contains(&port)
450 })
451 .cloned()
452 .collect()
453 };
454
455 if !no_proxy_hosts.is_empty() {
456 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
457 }
458
459 let any_intercept_route = route_store
465 .route_upstream_hosts()
466 .iter()
467 .any(|hp| route_store.has_intercept_route(hp));
468 let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
469 (Some(dir), true) => {
470 let intercept_route_count = route_store
471 .route_upstream_hosts()
472 .iter()
473 .filter(|hp| route_store.has_intercept_route(hp))
474 .count();
475 match EphemeralCa::generate().and_then(|ca| {
476 let ca = Arc::new(ca);
477 let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
478 let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
479 dir,
480 filename: "intercept-ca.pem",
481 parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
482 ephemeral_ca_pem: ca.cert_pem(),
483 })?;
484 Ok((cache, path))
485 }) {
486 Ok((cache, path)) => {
487 info!(
488 "TLS interception active for {} route(s); trust bundle at {}",
489 intercept_route_count,
490 path.display()
491 );
492 (Some(cache), Some(path))
493 }
494 Err(e) => {
495 warn!(
496 "TLS interception setup failed for {} route(s): {}. \
497 Continuing with interception disabled; reverse-proxy routes remain available.",
498 intercept_route_count, e
499 );
500 (None, None)
501 }
502 }
503 }
504 (Some(_), false) => {
505 debug!(
506 "TLS interception requested but no configured route requires L7 visibility; \
507 skipping CA generation"
508 );
509 (None, None)
510 }
511 (None, _) => (None, None),
512 };
513
514 let state = Arc::new(ProxyState {
515 filter,
516 session_token: session_token.clone(),
517 route_store,
518 credential_store,
519 config,
520 tls_connector,
521 active_connections: AtomicUsize::new(0),
522 audit_log: Arc::clone(&audit_log),
523 bypass_matcher,
524 cert_cache,
525 });
526
527 tokio::spawn(accept_loop(listener, state, shutdown_rx));
531
532 Ok(ProxyHandle {
533 port,
534 token: session_token,
535 audit_log,
536 shutdown_tx,
537 loaded_routes,
538 no_proxy_hosts,
539 intercept_ca_path,
540 })
541}
542
543async fn accept_loop(
545 listener: TcpListener,
546 state: Arc<ProxyState>,
547 mut shutdown_rx: watch::Receiver<bool>,
548) {
549 loop {
550 tokio::select! {
551 result = listener.accept() => {
552 match result {
553 Ok((stream, addr)) => {
554 let max = state.config.max_connections;
556 if max > 0 {
557 let current = state.active_connections.load(Ordering::Relaxed);
558 if current >= max {
559 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
560 drop(stream);
562 continue;
563 }
564 }
565 state.active_connections.fetch_add(1, Ordering::Relaxed);
566
567 debug!("Accepted connection from {}", addr);
568 let state = Arc::clone(&state);
569 tokio::spawn(async move {
570 if let Err(e) = handle_connection(stream, &state).await {
571 debug!("Connection handler error: {}", e);
572 }
573 state.active_connections.fetch_sub(1, Ordering::Relaxed);
574 });
575 }
576 Err(e) => {
577 warn!("Accept error: {}", e);
578 }
579 }
580 }
581 _ = shutdown_rx.changed() => {
582 if *shutdown_rx.borrow() {
583 info!("Proxy server shutting down");
584 return;
585 }
586 }
587 }
588 }
589}
590
591async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
597 let mut buf_reader = BufReader::new(&mut stream);
601 let mut first_line = String::new();
602 buf_reader.read_line(&mut first_line).await?;
603
604 if first_line.is_empty() {
605 return Ok(()); }
607
608 let mut header_bytes = Vec::new();
610 loop {
611 let mut line = String::new();
612 let n = buf_reader.read_line(&mut line).await?;
613 if n == 0 || line.trim().is_empty() {
614 break;
615 }
616 header_bytes.extend_from_slice(line.as_bytes());
617 if header_bytes.len() > MAX_HEADER_SIZE {
618 drop(buf_reader);
619 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
620 stream.write_all(response.as_bytes()).await?;
621 return Ok(());
622 }
623 }
624
625 let buffered = buf_reader.buffer().to_vec();
630 drop(buf_reader);
631
632 let first_line = first_line.trim_end();
633
634 if first_line.starts_with("CONNECT ") {
636 if !state.route_store.is_empty() {
652 if let Some(authority) = first_line.split_whitespace().nth(1) {
653 let host_port = if authority.starts_with('[') {
656 if authority.contains("]:") {
657 authority.to_lowercase()
658 } else {
659 format!("{}:443", authority.to_lowercase())
660 }
661 } else if authority.contains(':') {
662 authority.to_lowercase()
663 } else {
664 format!("{}:443", authority.to_lowercase())
665 };
666
667 if state.route_store.is_route_upstream(&host_port) {
668 let route_id = state
669 .route_store
670 .lookup_by_upstream(&host_port)
671 .map(|(prefix, _)| prefix);
672 let (host, port) = host_port
673 .rsplit_once(':')
674 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
675 .unwrap_or_else(|| (host_port.clone(), 443));
676
677 let intercept_eligible = state.route_store.has_intercept_route(&host_port);
678
679 match (intercept_eligible, state.cert_cache.as_ref()) {
680 (true, Some(cache)) => {
682 if let Err(e) =
687 token::validate_proxy_auth(&header_bytes, &state.session_token)
688 {
689 debug!(
690 "tls_intercept: rejecting CONNECT to {}:{} — {}",
691 host, port, e
692 );
693 audit::log_denied(
694 Some(&state.audit_log),
695 audit::ProxyMode::ConnectIntercept,
696 &audit::EventContext {
697 route_id,
698 auth_mechanism: Some(
699 nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
700 ),
701 auth_outcome: Some(
702 nono::undo::NetworkAuditAuthOutcome::Failed,
703 ),
704 denial_category: Some(
705 nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
706 ),
707 ..audit::EventContext::default()
708 },
709 &host,
710 port,
711 "proxy auth missing or invalid",
712 );
713 let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
714 stream.write_all(response.as_bytes()).await?;
715 return Ok(());
716 }
717
718 let ctx = tls_intercept::InterceptCtx {
719 route_id,
720 host: &host,
721 port,
722 route_store: &state.route_store,
723 credential_store: &state.credential_store,
724 session_token: &state.session_token,
725 cert_cache: Arc::clone(cache),
726 tls_connector: &state.tls_connector,
727 filter: &state.filter,
728 audit_log: Some(&state.audit_log),
729 };
730 return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
731 }
732 _ => {
736 debug!(
737 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
738 authority
739 );
740 audit::log_denied(
741 Some(&state.audit_log),
742 audit::ProxyMode::Connect,
743 &audit::EventContext {
744 route_id,
745 denial_category: Some(
746 nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
747 ),
748 ..audit::EventContext::default()
749 },
750 &host,
751 port,
752 "route upstream: CONNECT bypasses L7 filtering",
753 );
754 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
755 stream.write_all(response.as_bytes()).await?;
756 return Ok(());
757 }
758 }
759 }
760 }
761 }
762
763 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
765 if state.bypass_matcher.is_empty() {
766 Some(ext_config)
767 } else {
768 let host = first_line
770 .split_whitespace()
771 .nth(1)
772 .and_then(|authority| {
773 authority
774 .rsplit_once(':')
775 .map(|(h, _)| h)
776 .or(Some(authority))
777 })
778 .unwrap_or("");
779 if state.bypass_matcher.matches(host) {
780 debug!("Bypassing external proxy for {}", host);
781 None
782 } else {
783 Some(ext_config)
784 }
785 }
786 } else {
787 None
788 };
789
790 if let Some(ext_config) = use_external {
791 external::handle_external_proxy(
792 first_line,
793 &mut stream,
794 &header_bytes,
795 &state.filter,
796 &state.session_token,
797 ext_config,
798 Some(&state.audit_log),
799 )
800 .await
801 } else if state.config.external_proxy.is_some() {
802 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
807 connect::handle_connect(
808 first_line,
809 &mut stream,
810 &state.filter,
811 &state.session_token,
812 &header_bytes,
813 Some(&state.audit_log),
814 )
815 .await
816 } else {
817 connect::handle_connect(
818 first_line,
819 &mut stream,
820 &state.filter,
821 &state.session_token,
822 &header_bytes,
823 Some(&state.audit_log),
824 )
825 .await
826 }
827 } else if !state.route_store.is_empty() {
828 let ctx = reverse::ReverseProxyCtx {
830 route_store: &state.route_store,
831 credential_store: &state.credential_store,
832 session_token: &state.session_token,
833 filter: &state.filter,
834 tls_connector: &state.tls_connector,
835 audit_log: Some(&state.audit_log),
836 };
837 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
838 } else {
839 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
841 stream.write_all(response.as_bytes()).await?;
842 Ok(())
843 }
844}
845
846#[cfg(test)]
847#[allow(clippy::unwrap_used)]
848mod tests {
849 use super::*;
850
851 #[tokio::test]
852 async fn test_proxy_starts_and_binds() {
853 let config = ProxyConfig::default();
854 let handle = start(config).await.unwrap();
855
856 assert!(handle.port > 0);
858 assert_eq!(handle.token.len(), 64);
860
861 handle.shutdown();
863 }
864
865 #[tokio::test]
873 async fn test_intercept_lifecycle_end_to_end() {
874 let dir = tempfile::tempdir().unwrap();
875 let ca_path_clone;
876
877 {
878 let config = ProxyConfig {
879 routes: vec![crate::config::RouteConfig {
880 prefix: "openai".to_string(),
881 upstream: "https://api.openai.com".to_string(),
882 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
883 inject_mode: Default::default(),
884 inject_header: "Authorization".to_string(),
885 credential_format: "Bearer {}".to_string(),
886 path_pattern: None,
887 path_replacement: None,
888 query_param_name: None,
889 proxy: None,
890 env_var: None,
891 endpoint_rules: vec![],
892 tls_ca: None,
893 tls_client_cert: None,
894 tls_client_key: None,
895 oauth2: None,
896 }],
897 intercept_ca_dir: Some(dir.path().to_path_buf()),
898 ..Default::default()
899 };
900 let handle = start(config).await.unwrap();
901 assert!(
902 handle.intercept_ca_path().is_some(),
903 "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
904 );
905 ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
906 assert!(
907 ca_path_clone.exists(),
908 "bundle file should have been written"
909 );
910
911 let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
912 assert!(
913 contents.contains("BEGIN CERTIFICATE"),
914 "bundle should contain at least one PEM block"
915 );
916
917 let vars = handle.env_vars();
919 let ssl = vars
920 .iter()
921 .find(|(k, _)| k == "SSL_CERT_FILE")
922 .expect("SSL_CERT_FILE should be set when intercept active");
923 assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
924 assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
925 assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
926 assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
927
928 handle.shutdown();
929 }
930 assert!(
932 !ca_path_clone.exists(),
933 "bundle should be removed when ProxyHandle drops"
934 );
935 }
936
937 #[tokio::test]
940 async fn test_intercept_skipped_for_purely_declarative_routes() {
941 let dir = tempfile::tempdir().unwrap();
942 let config = ProxyConfig {
943 routes: vec![crate::config::RouteConfig {
944 prefix: "alias".to_string(),
945 upstream: "https://aliased.example.com".to_string(),
946 credential_key: None,
947 inject_mode: Default::default(),
948 inject_header: "Authorization".to_string(),
949 credential_format: "Bearer {}".to_string(),
950 path_pattern: None,
951 path_replacement: None,
952 query_param_name: None,
953 proxy: None,
954 env_var: None,
955 endpoint_rules: vec![],
956 tls_ca: None,
957 tls_client_cert: None,
958 tls_client_key: None,
959 oauth2: None,
960 }],
961 intercept_ca_dir: Some(dir.path().to_path_buf()),
962 ..Default::default()
963 };
964 let handle = start(config).await.unwrap();
965 assert!(
966 handle.intercept_ca_path().is_none(),
967 "no L7-bearing route → no CA should be generated"
968 );
969 let vars = handle.env_vars();
970 assert!(
971 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
972 "trust env vars must not be set when intercept inactive"
973 );
974 handle.shutdown();
975 }
976
977 #[tokio::test]
982 async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
983 let missing_dir = tempfile::tempdir()
984 .unwrap()
985 .path()
986 .join("missing")
987 .join("intercept");
988 let config = ProxyConfig {
989 routes: vec![crate::config::RouteConfig {
990 prefix: "openai".to_string(),
991 upstream: "https://api.openai.com".to_string(),
992 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
993 inject_mode: Default::default(),
994 inject_header: "Authorization".to_string(),
995 credential_format: "Bearer {}".to_string(),
996 path_pattern: None,
997 path_replacement: None,
998 query_param_name: None,
999 proxy: None,
1000 env_var: None,
1001 endpoint_rules: vec![],
1002 tls_ca: None,
1003 tls_client_cert: None,
1004 tls_client_key: None,
1005 oauth2: None,
1006 }],
1007 intercept_ca_dir: Some(missing_dir),
1008 ..Default::default()
1009 };
1010 let handle = start(config.clone()).await.unwrap();
1011 assert!(
1012 handle.intercept_ca_path().is_none(),
1013 "intercept setup failure should disable interception instead of aborting startup"
1014 );
1015 let vars = handle.env_vars();
1016 assert!(
1017 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1018 "trust env vars must not be set when interception setup fails"
1019 );
1020 let route_vars = handle.credential_env_vars(&config);
1021 assert!(
1022 route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1023 "reverse-proxy route env vars should still be emitted"
1024 );
1025 handle.shutdown();
1026 }
1027
1028 #[tokio::test]
1031 async fn test_route_diagnostics_summarises_each_route() {
1032 let dir = tempfile::tempdir().unwrap();
1033 let config = ProxyConfig {
1034 routes: vec![
1035 crate::config::RouteConfig {
1036 prefix: "openai".to_string(),
1037 upstream: "https://api.openai.com".to_string(),
1038 credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1039 inject_mode: Default::default(),
1040 inject_header: "Authorization".to_string(),
1041 credential_format: "Bearer {}".to_string(),
1042 path_pattern: None,
1043 path_replacement: None,
1044 query_param_name: None,
1045 proxy: None,
1046 env_var: None,
1047 endpoint_rules: vec![],
1048 tls_ca: None,
1049 tls_client_cert: None,
1050 tls_client_key: None,
1051 oauth2: None,
1052 },
1053 crate::config::RouteConfig {
1054 prefix: "alias".to_string(),
1055 upstream: "https://aliased.example.com".to_string(),
1056 credential_key: None,
1057 inject_mode: Default::default(),
1058 inject_header: "Authorization".to_string(),
1059 credential_format: "Bearer {}".to_string(),
1060 path_pattern: None,
1061 path_replacement: None,
1062 query_param_name: None,
1063 proxy: None,
1064 env_var: None,
1065 endpoint_rules: vec![],
1066 tls_ca: None,
1067 tls_client_cert: None,
1068 tls_client_key: None,
1069 oauth2: None,
1070 },
1071 ],
1072 intercept_ca_dir: Some(dir.path().to_path_buf()),
1073 ..Default::default()
1074 };
1075 let handle = start(config.clone()).await.unwrap();
1076 let rows = handle.route_diagnostics(&config);
1077 assert_eq!(rows.len(), 2);
1078
1079 let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1080 assert!(openai.1.contains("api.openai.com"));
1081 assert!(openai.1.contains("intercept: on"));
1082 assert!(
1083 openai.1.contains("✗") || openai.1.contains("not found"),
1084 "missing credential should show ✗, got: {}",
1085 openai.1
1086 );
1087
1088 let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1089 assert!(alias.1.contains("creds: none"));
1090 assert!(alias.1.contains("intercept: off"));
1091
1092 handle.shutdown();
1093 }
1094
1095 #[tokio::test]
1096 async fn test_proxy_env_vars() {
1097 let config = ProxyConfig::default();
1098 let handle = start(config).await.unwrap();
1099
1100 let vars = handle.env_vars();
1101 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1102 assert!(http_proxy.is_some());
1103 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1104
1105 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1106 assert!(token_var.is_some());
1107 assert_eq!(token_var.unwrap().1.len(), 64);
1108
1109 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1110 assert!(
1111 node_proxy_flag.is_some(),
1112 "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1113 );
1114 assert_eq!(
1115 node_proxy_flag.unwrap().1,
1116 "1",
1117 "NODE_USE_ENV_PROXY must be '1'"
1118 );
1119
1120 handle.shutdown();
1121 }
1122
1123 #[tokio::test]
1124 async fn test_proxy_credential_env_vars() {
1125 let config = ProxyConfig {
1126 routes: vec![crate::config::RouteConfig {
1127 prefix: "openai".to_string(),
1128 upstream: "https://api.openai.com".to_string(),
1129 credential_key: None,
1130 inject_mode: crate::config::InjectMode::Header,
1131 inject_header: "Authorization".to_string(),
1132 credential_format: "Bearer {}".to_string(),
1133 path_pattern: None,
1134 path_replacement: None,
1135 query_param_name: None,
1136 proxy: None,
1137 env_var: None,
1138 endpoint_rules: vec![],
1139 tls_ca: None,
1140 tls_client_cert: None,
1141 tls_client_key: None,
1142 oauth2: None,
1143 }],
1144 ..Default::default()
1145 };
1146 let handle = start(config.clone()).await.unwrap();
1147
1148 let vars = handle.credential_env_vars(&config);
1149 assert_eq!(vars.len(), 1);
1150 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1151 assert!(vars[0].1.contains("/openai"));
1152
1153 handle.shutdown();
1154 }
1155
1156 #[test]
1157 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1158 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1162 let handle = ProxyHandle {
1163 port: 12345,
1164 token: Zeroizing::new("test_token".to_string()),
1165 audit_log: audit::new_audit_log(),
1166 shutdown_tx,
1167 loaded_routes: ["openai".to_string()].into_iter().collect(),
1168 no_proxy_hosts: Vec::new(),
1169 intercept_ca_path: None,
1170 };
1171 let config = ProxyConfig {
1172 routes: vec![crate::config::RouteConfig {
1173 prefix: "openai".to_string(),
1174 upstream: "https://api.openai.com".to_string(),
1175 credential_key: Some("openai_api_key".to_string()),
1176 inject_mode: crate::config::InjectMode::Header,
1177 inject_header: "Authorization".to_string(),
1178 credential_format: "Bearer {}".to_string(),
1179 path_pattern: None,
1180 path_replacement: None,
1181 query_param_name: None,
1182 proxy: None,
1183 env_var: None, endpoint_rules: vec![],
1185 tls_ca: None,
1186 tls_client_cert: None,
1187 tls_client_key: None,
1188 oauth2: None,
1189 }],
1190 ..Default::default()
1191 };
1192
1193 let vars = handle.credential_env_vars(&config);
1194 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1198 assert!(
1199 api_key_var.is_some(),
1200 "Should derive env var name from credential_key.to_uppercase()"
1201 );
1202
1203 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1204 assert_eq!(val, "test_token");
1205 }
1206
1207 #[test]
1208 fn test_proxy_credential_env_vars_with_explicit_env_var() {
1209 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1217 let handle = ProxyHandle {
1218 port: 12345,
1219 token: Zeroizing::new("test_token".to_string()),
1220 audit_log: audit::new_audit_log(),
1221 shutdown_tx,
1222 loaded_routes: ["openai".to_string()].into_iter().collect(),
1223 no_proxy_hosts: Vec::new(),
1224 intercept_ca_path: None,
1225 };
1226 let config = ProxyConfig {
1227 routes: vec![crate::config::RouteConfig {
1228 prefix: "openai".to_string(),
1229 upstream: "https://api.openai.com".to_string(),
1230 credential_key: Some("op://Development/OpenAI/credential".to_string()),
1231 inject_mode: crate::config::InjectMode::Header,
1232 inject_header: "Authorization".to_string(),
1233 credential_format: "Bearer {}".to_string(),
1234 path_pattern: None,
1235 path_replacement: None,
1236 query_param_name: None,
1237 proxy: None,
1238 env_var: Some("OPENAI_API_KEY".to_string()),
1239 endpoint_rules: vec![],
1240 tls_ca: None,
1241 tls_client_cert: None,
1242 tls_client_key: None,
1243 oauth2: None,
1244 }],
1245 ..Default::default()
1246 };
1247
1248 let vars = handle.credential_env_vars(&config);
1249 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1252 assert!(
1253 api_key_var.is_some(),
1254 "Should use explicit env_var name, not derive from credential_key"
1255 );
1256
1257 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1259 assert_eq!(val, "test_token");
1260
1261 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1263 assert!(
1264 bad_var.is_none(),
1265 "Should not generate env var from op:// URI uppercase"
1266 );
1267 }
1268
1269 #[test]
1270 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1271 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1276 let handle = ProxyHandle {
1277 port: 12345,
1278 token: Zeroizing::new("test_token".to_string()),
1279 audit_log: audit::new_audit_log(),
1280 shutdown_tx,
1281 loaded_routes: ["openai".to_string()].into_iter().collect(),
1283 no_proxy_hosts: Vec::new(),
1284 intercept_ca_path: None,
1285 };
1286 let config = ProxyConfig {
1287 routes: vec![
1288 crate::config::RouteConfig {
1289 prefix: "openai".to_string(),
1290 upstream: "https://api.openai.com".to_string(),
1291 credential_key: Some("openai_api_key".to_string()),
1292 inject_mode: crate::config::InjectMode::Header,
1293 inject_header: "Authorization".to_string(),
1294 credential_format: "Bearer {}".to_string(),
1295 path_pattern: None,
1296 path_replacement: None,
1297 query_param_name: None,
1298 proxy: None,
1299 env_var: None,
1300 endpoint_rules: vec![],
1301 tls_ca: None,
1302 tls_client_cert: None,
1303 tls_client_key: None,
1304 oauth2: None,
1305 },
1306 crate::config::RouteConfig {
1307 prefix: "github".to_string(),
1308 upstream: "https://api.github.com".to_string(),
1309 credential_key: Some("env://GITHUB_TOKEN".to_string()),
1310 inject_mode: crate::config::InjectMode::Header,
1311 inject_header: "Authorization".to_string(),
1312 credential_format: "token {}".to_string(),
1313 path_pattern: None,
1314 path_replacement: None,
1315 query_param_name: None,
1316 proxy: None,
1317 env_var: Some("GITHUB_TOKEN".to_string()),
1318 endpoint_rules: vec![],
1319 tls_ca: None,
1320 tls_client_cert: None,
1321 tls_client_key: None,
1322 oauth2: None,
1323 },
1324 ],
1325 ..Default::default()
1326 };
1327
1328 let vars = handle.credential_env_vars(&config);
1329
1330 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1332 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1333 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1334 assert!(openai_key.is_some(), "loaded route should have API key");
1335
1336 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1339 assert!(
1340 github_base.is_some(),
1341 "declared route should still have BASE_URL"
1342 );
1343 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1344 assert!(
1345 github_token.is_none(),
1346 "unloaded route must not inject phantom GITHUB_TOKEN"
1347 );
1348 }
1349
1350 #[test]
1351 fn test_proxy_credential_env_vars_strips_slashes() {
1352 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1357 let handle = ProxyHandle {
1358 port: 58406,
1359 token: Zeroizing::new("test_token".to_string()),
1360 audit_log: audit::new_audit_log(),
1361 shutdown_tx,
1362 loaded_routes: std::collections::HashSet::new(),
1363 no_proxy_hosts: Vec::new(),
1364 intercept_ca_path: None,
1365 };
1366
1367 let config = ProxyConfig {
1369 routes: vec![crate::config::RouteConfig {
1370 prefix: "/anthropic".to_string(),
1371 upstream: "https://api.anthropic.com".to_string(),
1372 credential_key: None,
1373 inject_mode: crate::config::InjectMode::Header,
1374 inject_header: "Authorization".to_string(),
1375 credential_format: "Bearer {}".to_string(),
1376 path_pattern: None,
1377 path_replacement: None,
1378 query_param_name: None,
1379 proxy: None,
1380 env_var: None,
1381 endpoint_rules: vec![],
1382 tls_ca: None,
1383 tls_client_cert: None,
1384 tls_client_key: None,
1385 oauth2: None,
1386 }],
1387 ..Default::default()
1388 };
1389
1390 let vars = handle.credential_env_vars(&config);
1391 assert_eq!(vars.len(), 1);
1392 assert_eq!(
1393 vars[0].0, "ANTHROPIC_BASE_URL",
1394 "env var name must not have leading slash"
1395 );
1396 assert_eq!(
1397 vars[0].1, "http://127.0.0.1:58406/anthropic",
1398 "URL must not have double slash"
1399 );
1400
1401 let config = ProxyConfig {
1403 routes: vec![crate::config::RouteConfig {
1404 prefix: "openai/".to_string(),
1405 upstream: "https://api.openai.com".to_string(),
1406 credential_key: None,
1407 inject_mode: crate::config::InjectMode::Header,
1408 inject_header: "Authorization".to_string(),
1409 credential_format: "Bearer {}".to_string(),
1410 path_pattern: None,
1411 path_replacement: None,
1412 query_param_name: None,
1413 proxy: None,
1414 env_var: None,
1415 endpoint_rules: vec![],
1416 tls_ca: None,
1417 tls_client_cert: None,
1418 tls_client_key: None,
1419 oauth2: None,
1420 }],
1421 ..Default::default()
1422 };
1423
1424 let vars = handle.credential_env_vars(&config);
1425 assert_eq!(
1426 vars[0].0, "OPENAI_BASE_URL",
1427 "env var name must not have trailing slash"
1428 );
1429 assert_eq!(
1430 vars[0].1, "http://127.0.0.1:58406/openai",
1431 "URL must not have trailing slash in path"
1432 );
1433 }
1434
1435 #[test]
1436 fn test_anthropic_credential_phantom_token_regression() {
1437 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1445 let handle_no_env_var = ProxyHandle {
1446 port: 12345,
1447 token: Zeroizing::new("phantom".to_string()),
1448 audit_log: audit::new_audit_log(),
1449 shutdown_tx: shutdown_tx.clone(),
1450 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1451 no_proxy_hosts: Vec::new(),
1452 intercept_ca_path: None,
1453 };
1454 let config_no_env_var = ProxyConfig {
1455 routes: vec![crate::config::RouteConfig {
1456 prefix: "anthropic".to_string(),
1457 upstream: "https://api.anthropic.com".to_string(),
1458 credential_key: None,
1459 inject_mode: crate::config::InjectMode::Header,
1460 inject_header: "x-api-key".to_string(),
1461 credential_format: "{}".to_string(),
1462 path_pattern: None,
1463 path_replacement: None,
1464 query_param_name: None,
1465 proxy: None,
1466 env_var: None,
1467 endpoint_rules: vec![],
1468 tls_ca: None,
1469 tls_client_cert: None,
1470 tls_client_key: None,
1471 oauth2: None,
1472 }],
1473 ..Default::default()
1474 };
1475 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1476 assert!(
1477 vars_no_env_var.iter().all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1478 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1479 );
1480
1481 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1484 let handle_fixed = ProxyHandle {
1485 port: 12345,
1486 token: Zeroizing::new("phantom".to_string()),
1487 audit_log: audit::new_audit_log(),
1488 shutdown_tx: shutdown_tx2,
1489 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1490 no_proxy_hosts: Vec::new(),
1491 intercept_ca_path: None,
1492 };
1493 let config_fixed = ProxyConfig {
1494 routes: vec![crate::config::RouteConfig {
1495 prefix: "anthropic".to_string(),
1496 upstream: "https://api.anthropic.com".to_string(),
1497 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1498 inject_mode: crate::config::InjectMode::Header,
1499 inject_header: "x-api-key".to_string(),
1500 credential_format: "{}".to_string(),
1501 path_pattern: None,
1502 path_replacement: None,
1503 query_param_name: None,
1504 proxy: None,
1505 env_var: Some("ANTHROPIC_API_KEY".to_string()),
1506 endpoint_rules: vec![],
1507 tls_ca: None,
1508 tls_client_cert: None,
1509 tls_client_key: None,
1510 oauth2: None,
1511 }],
1512 ..Default::default()
1513 };
1514 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1515 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1516 assert!(
1517 api_key_var.is_some(),
1518 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1519 );
1520 assert_eq!(api_key_var.unwrap().1, "phantom");
1521 }
1522
1523 #[test]
1524 fn test_no_proxy_excludes_credential_upstreams() {
1525 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1526 let handle = ProxyHandle {
1527 port: 12345,
1528 token: Zeroizing::new("test_token".to_string()),
1529 audit_log: audit::new_audit_log(),
1530 shutdown_tx,
1531 loaded_routes: std::collections::HashSet::new(),
1532 no_proxy_hosts: vec![
1533 "nats.internal:4222".to_string(),
1534 "opencode.internal:4096".to_string(),
1535 ],
1536 intercept_ca_path: None,
1537 };
1538
1539 let vars = handle.env_vars();
1540 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1541 assert!(
1542 no_proxy.1.contains("nats.internal"),
1543 "non-credential host should be in NO_PROXY"
1544 );
1545 assert!(
1546 no_proxy.1.contains("opencode.internal"),
1547 "non-credential host should be in NO_PROXY"
1548 );
1549 assert!(
1550 no_proxy.1.contains("localhost"),
1551 "localhost should always be in NO_PROXY"
1552 );
1553 }
1554
1555 #[test]
1556 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1557 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1558 let handle = ProxyHandle {
1559 port: 12345,
1560 token: Zeroizing::new("test_token".to_string()),
1561 audit_log: audit::new_audit_log(),
1562 shutdown_tx,
1563 loaded_routes: std::collections::HashSet::new(),
1564 no_proxy_hosts: Vec::new(),
1565 intercept_ca_path: None,
1566 };
1567
1568 let vars = handle.env_vars();
1569 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1570 assert_eq!(
1571 no_proxy.1, "localhost,127.0.0.1",
1572 "NO_PROXY should only contain loopback when no bypass hosts"
1573 );
1574 }
1575
1576 #[tokio::test]
1577 async fn test_no_proxy_empty_without_direct_connect_ports() {
1578 let config = ProxyConfig {
1582 allowed_hosts: vec!["github.com".to_string()],
1583 ..Default::default()
1584 };
1585 let handle = start(config).await.unwrap();
1586
1587 let vars = handle.env_vars();
1588 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1589 assert_eq!(
1590 no_proxy.1, "localhost,127.0.0.1",
1591 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1592 );
1593
1594 handle.shutdown();
1595 }
1596
1597 #[cfg(not(target_os = "macos"))]
1598 #[tokio::test]
1599 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1600 let config = ProxyConfig {
1604 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1605 direct_connect_ports: vec![443],
1606 ..Default::default()
1607 };
1608 let handle = start(config).await.unwrap();
1609
1610 let vars = handle.env_vars();
1611 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1612 assert!(
1613 no_proxy.1.contains("github.com"),
1614 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1615 );
1616 assert!(
1617 !no_proxy.1.contains("server.internal"),
1618 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1619 );
1620
1621 handle.shutdown();
1622 }
1623}