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 if let Some(path) = self.intercept_ca_path.as_deref() {
209 let path_str = path.to_string_lossy().to_string();
210 vars.push(("SSL_CERT_FILE".to_string(), path_str.clone()));
211 vars.push(("REQUESTS_CA_BUNDLE".to_string(), path_str.clone()));
212 vars.push(("NODE_EXTRA_CA_CERTS".to_string(), path_str.clone()));
213 vars.push(("CURL_CA_BUNDLE".to_string(), path_str.clone()));
214 vars.push(("GIT_SSL_CAINFO".to_string(), path_str));
215 }
216
217 vars
218 }
219
220 #[must_use]
229 pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
230 let mut vars = Vec::new();
231 for route in &config.routes {
232 let prefix = route.prefix.trim_matches('/');
237
238 let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
240 let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
241 vars.push((base_url_name, url));
242
243 if !self.loaded_routes.contains(prefix) {
248 continue;
249 }
250
251 if let Some(ref env_var) = route.env_var {
255 vars.push((env_var.clone(), self.token.to_string()));
256 } else if let Some(ref cred_key) = route.credential_key {
257 if !cred_key.contains("://") {
261 let api_key_name = cred_key.to_uppercase();
262 vars.push((api_key_name, self.token.to_string()));
263 }
264 }
265 }
266 vars
267 }
268}
269
270impl Drop for ProxyHandle {
271 fn drop(&mut self) {
282 if let Some(path) = self.intercept_ca_path.take() {
283 let _ = std::fs::remove_file(&path);
284 if let Some(parent) = path.parent() {
289 let _ = std::fs::remove_dir(parent);
290 }
291 }
292 }
293}
294
295struct ProxyState {
297 filter: ProxyFilter,
298 session_token: Zeroizing<String>,
299 route_store: RouteStore,
301 credential_store: CredentialStore,
303 config: ProxyConfig,
304 tls_connector: tokio_rustls::TlsConnector,
307 active_connections: AtomicUsize,
309 audit_log: audit::SharedAuditLog,
311 bypass_matcher: external::BypassMatcher,
314 cert_cache: Option<Arc<CertCache>>,
319}
320
321pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
329 let session_token = token::generate_session_token()?;
331
332 let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
334 let listener = TcpListener::bind(bind_addr)
335 .await
336 .map_err(|e| ProxyError::Bind {
337 addr: bind_addr.to_string(),
338 source: e,
339 })?;
340
341 let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
342 addr: bind_addr.to_string(),
343 source: e,
344 })?;
345 let port = local_addr.port();
346
347 info!("Proxy server listening on {}", local_addr);
348
349 let route_store = if config.routes.is_empty() {
352 RouteStore::empty()
353 } else {
354 RouteStore::load(&config.routes)?
355 };
356 let mut root_store = rustls::RootCertStore::empty();
362 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
363 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
364 rustls::crypto::ring::default_provider(),
365 ))
366 .with_safe_default_protocol_versions()
367 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
368 .with_root_certificates(root_store)
369 .with_no_client_auth();
370 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
371
372 let credential_store = if config.routes.is_empty() {
374 CredentialStore::empty()
375 } else {
376 CredentialStore::load(&config.routes, &tls_connector)?
377 };
378 let loaded_routes = credential_store.loaded_prefixes();
379
380 let filter = if config.allowed_hosts.is_empty() {
382 ProxyFilter::allow_all()
383 } else {
384 ProxyFilter::new(&config.allowed_hosts)
385 };
386
387 let bypass_matcher = config
389 .external_proxy
390 .as_ref()
391 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
392 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
393
394 let (shutdown_tx, shutdown_rx) = watch::channel(false);
396 let audit_log = audit::new_audit_log();
397
398 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
410 Vec::new()
411 } else {
412 let route_hosts = route_store.route_upstream_hosts();
413 config
414 .allowed_hosts
415 .iter()
416 .filter(|host| {
417 let normalised = {
418 let h = host.to_lowercase();
419 if h.starts_with('[') {
420 if h.contains("]:") {
422 h
423 } else {
424 format!("{}:443", h)
425 }
426 } else if h.contains(':') {
427 h
428 } else {
429 format!("{}:443", h)
430 }
431 };
432 if route_hosts.contains(&normalised) {
433 return false;
434 }
435 let port = normalised
438 .rsplit_once(':')
439 .and_then(|(_, p)| p.parse::<u16>().ok())
440 .unwrap_or(443);
441 config.direct_connect_ports.contains(&port)
442 })
443 .cloned()
444 .collect()
445 };
446
447 if !no_proxy_hosts.is_empty() {
448 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
449 }
450
451 let any_intercept_route = route_store
457 .route_upstream_hosts()
458 .iter()
459 .any(|hp| route_store.has_intercept_route(hp));
460 let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
461 (Some(dir), true) => {
462 let intercept_route_count = route_store
463 .route_upstream_hosts()
464 .iter()
465 .filter(|hp| route_store.has_intercept_route(hp))
466 .count();
467 match EphemeralCa::generate().and_then(|ca| {
468 let ca = Arc::new(ca);
469 let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
470 let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
471 dir,
472 filename: "intercept-ca.pem",
473 parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
474 ephemeral_ca_pem: ca.cert_pem(),
475 })?;
476 Ok((cache, path))
477 }) {
478 Ok((cache, path)) => {
479 info!(
480 "TLS interception active for {} route(s); trust bundle at {}",
481 intercept_route_count,
482 path.display()
483 );
484 (Some(cache), Some(path))
485 }
486 Err(e) => {
487 warn!(
488 "TLS interception setup failed for {} route(s): {}. \
489 Continuing with interception disabled; reverse-proxy routes remain available.",
490 intercept_route_count, e
491 );
492 (None, None)
493 }
494 }
495 }
496 (Some(_), false) => {
497 debug!(
498 "TLS interception requested but no configured route requires L7 visibility; \
499 skipping CA generation"
500 );
501 (None, None)
502 }
503 (None, _) => (None, None),
504 };
505
506 let state = Arc::new(ProxyState {
507 filter,
508 session_token: session_token.clone(),
509 route_store,
510 credential_store,
511 config,
512 tls_connector,
513 active_connections: AtomicUsize::new(0),
514 audit_log: Arc::clone(&audit_log),
515 bypass_matcher,
516 cert_cache,
517 });
518
519 tokio::spawn(accept_loop(listener, state, shutdown_rx));
523
524 Ok(ProxyHandle {
525 port,
526 token: session_token,
527 audit_log,
528 shutdown_tx,
529 loaded_routes,
530 no_proxy_hosts,
531 intercept_ca_path,
532 })
533}
534
535async fn accept_loop(
537 listener: TcpListener,
538 state: Arc<ProxyState>,
539 mut shutdown_rx: watch::Receiver<bool>,
540) {
541 loop {
542 tokio::select! {
543 result = listener.accept() => {
544 match result {
545 Ok((stream, addr)) => {
546 let max = state.config.max_connections;
548 if max > 0 {
549 let current = state.active_connections.load(Ordering::Relaxed);
550 if current >= max {
551 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
552 drop(stream);
554 continue;
555 }
556 }
557 state.active_connections.fetch_add(1, Ordering::Relaxed);
558
559 debug!("Accepted connection from {}", addr);
560 let state = Arc::clone(&state);
561 tokio::spawn(async move {
562 if let Err(e) = handle_connection(stream, &state).await {
563 debug!("Connection handler error: {}", e);
564 }
565 state.active_connections.fetch_sub(1, Ordering::Relaxed);
566 });
567 }
568 Err(e) => {
569 warn!("Accept error: {}", e);
570 }
571 }
572 }
573 _ = shutdown_rx.changed() => {
574 if *shutdown_rx.borrow() {
575 info!("Proxy server shutting down");
576 return;
577 }
578 }
579 }
580 }
581}
582
583async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
589 let mut buf_reader = BufReader::new(&mut stream);
593 let mut first_line = String::new();
594 buf_reader.read_line(&mut first_line).await?;
595
596 if first_line.is_empty() {
597 return Ok(()); }
599
600 let mut header_bytes = Vec::new();
602 loop {
603 let mut line = String::new();
604 let n = buf_reader.read_line(&mut line).await?;
605 if n == 0 || line.trim().is_empty() {
606 break;
607 }
608 header_bytes.extend_from_slice(line.as_bytes());
609 if header_bytes.len() > MAX_HEADER_SIZE {
610 drop(buf_reader);
611 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
612 stream.write_all(response.as_bytes()).await?;
613 return Ok(());
614 }
615 }
616
617 let buffered = buf_reader.buffer().to_vec();
622 drop(buf_reader);
623
624 let first_line = first_line.trim_end();
625
626 if first_line.starts_with("CONNECT ") {
628 if !state.route_store.is_empty() {
644 if let Some(authority) = first_line.split_whitespace().nth(1) {
645 let host_port = if authority.starts_with('[') {
648 if authority.contains("]:") {
649 authority.to_lowercase()
650 } else {
651 format!("{}:443", authority.to_lowercase())
652 }
653 } else if authority.contains(':') {
654 authority.to_lowercase()
655 } else {
656 format!("{}:443", authority.to_lowercase())
657 };
658
659 if state.route_store.is_route_upstream(&host_port) {
660 let route_id = state
661 .route_store
662 .lookup_by_upstream(&host_port)
663 .map(|(prefix, _)| prefix);
664 let (host, port) = host_port
665 .rsplit_once(':')
666 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
667 .unwrap_or_else(|| (host_port.clone(), 443));
668
669 let intercept_eligible = state.route_store.has_intercept_route(&host_port);
670
671 match (intercept_eligible, state.cert_cache.as_ref()) {
672 (true, Some(cache)) => {
674 if let Err(e) =
679 token::validate_proxy_auth(&header_bytes, &state.session_token)
680 {
681 debug!(
682 "tls_intercept: rejecting CONNECT to {}:{} — {}",
683 host, port, e
684 );
685 audit::log_denied(
686 Some(&state.audit_log),
687 audit::ProxyMode::ConnectIntercept,
688 &audit::EventContext {
689 route_id,
690 auth_mechanism: Some(
691 nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
692 ),
693 auth_outcome: Some(
694 nono::undo::NetworkAuditAuthOutcome::Failed,
695 ),
696 denial_category: Some(
697 nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
698 ),
699 ..audit::EventContext::default()
700 },
701 &host,
702 port,
703 "proxy auth missing or invalid",
704 );
705 let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
706 stream.write_all(response.as_bytes()).await?;
707 return Ok(());
708 }
709
710 let ctx = tls_intercept::InterceptCtx {
711 route_id,
712 host: &host,
713 port,
714 route_store: &state.route_store,
715 credential_store: &state.credential_store,
716 session_token: &state.session_token,
717 cert_cache: Arc::clone(cache),
718 tls_connector: &state.tls_connector,
719 filter: &state.filter,
720 audit_log: Some(&state.audit_log),
721 };
722 return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
723 }
724 _ => {
728 debug!(
729 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
730 authority
731 );
732 audit::log_denied(
733 Some(&state.audit_log),
734 audit::ProxyMode::Connect,
735 &audit::EventContext {
736 route_id,
737 denial_category: Some(
738 nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
739 ),
740 ..audit::EventContext::default()
741 },
742 &host,
743 port,
744 "route upstream: CONNECT bypasses L7 filtering",
745 );
746 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
747 stream.write_all(response.as_bytes()).await?;
748 return Ok(());
749 }
750 }
751 }
752 }
753 }
754
755 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
757 if state.bypass_matcher.is_empty() {
758 Some(ext_config)
759 } else {
760 let host = first_line
762 .split_whitespace()
763 .nth(1)
764 .and_then(|authority| {
765 authority
766 .rsplit_once(':')
767 .map(|(h, _)| h)
768 .or(Some(authority))
769 })
770 .unwrap_or("");
771 if state.bypass_matcher.matches(host) {
772 debug!("Bypassing external proxy for {}", host);
773 None
774 } else {
775 Some(ext_config)
776 }
777 }
778 } else {
779 None
780 };
781
782 if let Some(ext_config) = use_external {
783 external::handle_external_proxy(
784 first_line,
785 &mut stream,
786 &header_bytes,
787 &state.filter,
788 &state.session_token,
789 ext_config,
790 Some(&state.audit_log),
791 )
792 .await
793 } else if state.config.external_proxy.is_some() {
794 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
799 connect::handle_connect(
800 first_line,
801 &mut stream,
802 &state.filter,
803 &state.session_token,
804 &header_bytes,
805 Some(&state.audit_log),
806 )
807 .await
808 } else {
809 connect::handle_connect(
810 first_line,
811 &mut stream,
812 &state.filter,
813 &state.session_token,
814 &header_bytes,
815 Some(&state.audit_log),
816 )
817 .await
818 }
819 } else if !state.route_store.is_empty() {
820 let ctx = reverse::ReverseProxyCtx {
822 route_store: &state.route_store,
823 credential_store: &state.credential_store,
824 session_token: &state.session_token,
825 filter: &state.filter,
826 tls_connector: &state.tls_connector,
827 audit_log: Some(&state.audit_log),
828 };
829 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
830 } else {
831 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
833 stream.write_all(response.as_bytes()).await?;
834 Ok(())
835 }
836}
837
838#[cfg(test)]
839#[allow(clippy::unwrap_used)]
840mod tests {
841 use super::*;
842
843 #[tokio::test]
844 async fn test_proxy_starts_and_binds() {
845 let config = ProxyConfig::default();
846 let handle = start(config).await.unwrap();
847
848 assert!(handle.port > 0);
850 assert_eq!(handle.token.len(), 64);
852
853 handle.shutdown();
855 }
856
857 #[tokio::test]
865 async fn test_intercept_lifecycle_end_to_end() {
866 let dir = tempfile::tempdir().unwrap();
867 let ca_path_clone;
868
869 {
870 let config = ProxyConfig {
871 routes: vec![crate::config::RouteConfig {
872 prefix: "openai".to_string(),
873 upstream: "https://api.openai.com".to_string(),
874 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
875 inject_mode: Default::default(),
876 inject_header: "Authorization".to_string(),
877 credential_format: "Bearer {}".to_string(),
878 path_pattern: None,
879 path_replacement: None,
880 query_param_name: None,
881 proxy: None,
882 env_var: None,
883 endpoint_rules: vec![],
884 tls_ca: None,
885 tls_client_cert: None,
886 tls_client_key: None,
887 oauth2: None,
888 }],
889 intercept_ca_dir: Some(dir.path().to_path_buf()),
890 ..Default::default()
891 };
892 let handle = start(config).await.unwrap();
893 assert!(
894 handle.intercept_ca_path().is_some(),
895 "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
896 );
897 ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
898 assert!(
899 ca_path_clone.exists(),
900 "bundle file should have been written"
901 );
902
903 let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
904 assert!(
905 contents.contains("BEGIN CERTIFICATE"),
906 "bundle should contain at least one PEM block"
907 );
908
909 let vars = handle.env_vars();
911 let ssl = vars
912 .iter()
913 .find(|(k, _)| k == "SSL_CERT_FILE")
914 .expect("SSL_CERT_FILE should be set when intercept active");
915 assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
916 assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
917 assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
918 assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
919
920 handle.shutdown();
921 }
922 assert!(
924 !ca_path_clone.exists(),
925 "bundle should be removed when ProxyHandle drops"
926 );
927 }
928
929 #[tokio::test]
932 async fn test_intercept_skipped_for_purely_declarative_routes() {
933 let dir = tempfile::tempdir().unwrap();
934 let config = ProxyConfig {
935 routes: vec![crate::config::RouteConfig {
936 prefix: "alias".to_string(),
937 upstream: "https://aliased.example.com".to_string(),
938 credential_key: None,
939 inject_mode: Default::default(),
940 inject_header: "Authorization".to_string(),
941 credential_format: "Bearer {}".to_string(),
942 path_pattern: None,
943 path_replacement: None,
944 query_param_name: None,
945 proxy: None,
946 env_var: None,
947 endpoint_rules: vec![],
948 tls_ca: None,
949 tls_client_cert: None,
950 tls_client_key: None,
951 oauth2: None,
952 }],
953 intercept_ca_dir: Some(dir.path().to_path_buf()),
954 ..Default::default()
955 };
956 let handle = start(config).await.unwrap();
957 assert!(
958 handle.intercept_ca_path().is_none(),
959 "no L7-bearing route → no CA should be generated"
960 );
961 let vars = handle.env_vars();
962 assert!(
963 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
964 "trust env vars must not be set when intercept inactive"
965 );
966 handle.shutdown();
967 }
968
969 #[tokio::test]
974 async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
975 let missing_dir = tempfile::tempdir()
976 .unwrap()
977 .path()
978 .join("missing")
979 .join("intercept");
980 let config = ProxyConfig {
981 routes: vec![crate::config::RouteConfig {
982 prefix: "openai".to_string(),
983 upstream: "https://api.openai.com".to_string(),
984 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
985 inject_mode: Default::default(),
986 inject_header: "Authorization".to_string(),
987 credential_format: "Bearer {}".to_string(),
988 path_pattern: None,
989 path_replacement: None,
990 query_param_name: None,
991 proxy: None,
992 env_var: None,
993 endpoint_rules: vec![],
994 tls_ca: None,
995 tls_client_cert: None,
996 tls_client_key: None,
997 oauth2: None,
998 }],
999 intercept_ca_dir: Some(missing_dir),
1000 ..Default::default()
1001 };
1002 let handle = start(config.clone()).await.unwrap();
1003 assert!(
1004 handle.intercept_ca_path().is_none(),
1005 "intercept setup failure should disable interception instead of aborting startup"
1006 );
1007 let vars = handle.env_vars();
1008 assert!(
1009 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1010 "trust env vars must not be set when interception setup fails"
1011 );
1012 let route_vars = handle.credential_env_vars(&config);
1013 assert!(
1014 route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1015 "reverse-proxy route env vars should still be emitted"
1016 );
1017 handle.shutdown();
1018 }
1019
1020 #[tokio::test]
1023 async fn test_route_diagnostics_summarises_each_route() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let config = ProxyConfig {
1026 routes: vec![
1027 crate::config::RouteConfig {
1028 prefix: "openai".to_string(),
1029 upstream: "https://api.openai.com".to_string(),
1030 credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1031 inject_mode: Default::default(),
1032 inject_header: "Authorization".to_string(),
1033 credential_format: "Bearer {}".to_string(),
1034 path_pattern: None,
1035 path_replacement: None,
1036 query_param_name: None,
1037 proxy: None,
1038 env_var: None,
1039 endpoint_rules: vec![],
1040 tls_ca: None,
1041 tls_client_cert: None,
1042 tls_client_key: None,
1043 oauth2: None,
1044 },
1045 crate::config::RouteConfig {
1046 prefix: "alias".to_string(),
1047 upstream: "https://aliased.example.com".to_string(),
1048 credential_key: None,
1049 inject_mode: Default::default(),
1050 inject_header: "Authorization".to_string(),
1051 credential_format: "Bearer {}".to_string(),
1052 path_pattern: None,
1053 path_replacement: None,
1054 query_param_name: None,
1055 proxy: None,
1056 env_var: None,
1057 endpoint_rules: vec![],
1058 tls_ca: None,
1059 tls_client_cert: None,
1060 tls_client_key: None,
1061 oauth2: None,
1062 },
1063 ],
1064 intercept_ca_dir: Some(dir.path().to_path_buf()),
1065 ..Default::default()
1066 };
1067 let handle = start(config.clone()).await.unwrap();
1068 let rows = handle.route_diagnostics(&config);
1069 assert_eq!(rows.len(), 2);
1070
1071 let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1072 assert!(openai.1.contains("api.openai.com"));
1073 assert!(openai.1.contains("intercept: on"));
1074 assert!(
1075 openai.1.contains("✗") || openai.1.contains("not found"),
1076 "missing credential should show ✗, got: {}",
1077 openai.1
1078 );
1079
1080 let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1081 assert!(alias.1.contains("creds: none"));
1082 assert!(alias.1.contains("intercept: off"));
1083
1084 handle.shutdown();
1085 }
1086
1087 #[tokio::test]
1088 async fn test_proxy_env_vars() {
1089 let config = ProxyConfig::default();
1090 let handle = start(config).await.unwrap();
1091
1092 let vars = handle.env_vars();
1093 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1094 assert!(http_proxy.is_some());
1095 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1096
1097 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1098 assert!(token_var.is_some());
1099 assert_eq!(token_var.unwrap().1.len(), 64);
1100
1101 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1102 assert!(
1103 node_proxy_flag.is_none(),
1104 "proxy env should avoid Node-specific flags that can perturb non-Node runtimes"
1105 );
1106
1107 handle.shutdown();
1108 }
1109
1110 #[tokio::test]
1111 async fn test_proxy_credential_env_vars() {
1112 let config = ProxyConfig {
1113 routes: vec![crate::config::RouteConfig {
1114 prefix: "openai".to_string(),
1115 upstream: "https://api.openai.com".to_string(),
1116 credential_key: None,
1117 inject_mode: crate::config::InjectMode::Header,
1118 inject_header: "Authorization".to_string(),
1119 credential_format: "Bearer {}".to_string(),
1120 path_pattern: None,
1121 path_replacement: None,
1122 query_param_name: None,
1123 proxy: None,
1124 env_var: None,
1125 endpoint_rules: vec![],
1126 tls_ca: None,
1127 tls_client_cert: None,
1128 tls_client_key: None,
1129 oauth2: None,
1130 }],
1131 ..Default::default()
1132 };
1133 let handle = start(config.clone()).await.unwrap();
1134
1135 let vars = handle.credential_env_vars(&config);
1136 assert_eq!(vars.len(), 1);
1137 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1138 assert!(vars[0].1.contains("/openai"));
1139
1140 handle.shutdown();
1141 }
1142
1143 #[test]
1144 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1145 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1149 let handle = ProxyHandle {
1150 port: 12345,
1151 token: Zeroizing::new("test_token".to_string()),
1152 audit_log: audit::new_audit_log(),
1153 shutdown_tx,
1154 loaded_routes: ["openai".to_string()].into_iter().collect(),
1155 no_proxy_hosts: Vec::new(),
1156 intercept_ca_path: None,
1157 };
1158 let config = ProxyConfig {
1159 routes: vec![crate::config::RouteConfig {
1160 prefix: "openai".to_string(),
1161 upstream: "https://api.openai.com".to_string(),
1162 credential_key: Some("openai_api_key".to_string()),
1163 inject_mode: crate::config::InjectMode::Header,
1164 inject_header: "Authorization".to_string(),
1165 credential_format: "Bearer {}".to_string(),
1166 path_pattern: None,
1167 path_replacement: None,
1168 query_param_name: None,
1169 proxy: None,
1170 env_var: None, endpoint_rules: vec![],
1172 tls_ca: None,
1173 tls_client_cert: None,
1174 tls_client_key: None,
1175 oauth2: None,
1176 }],
1177 ..Default::default()
1178 };
1179
1180 let vars = handle.credential_env_vars(&config);
1181 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1185 assert!(
1186 api_key_var.is_some(),
1187 "Should derive env var name from credential_key.to_uppercase()"
1188 );
1189
1190 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1191 assert_eq!(val, "test_token");
1192 }
1193
1194 #[test]
1195 fn test_proxy_credential_env_vars_with_explicit_env_var() {
1196 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1204 let handle = ProxyHandle {
1205 port: 12345,
1206 token: Zeroizing::new("test_token".to_string()),
1207 audit_log: audit::new_audit_log(),
1208 shutdown_tx,
1209 loaded_routes: ["openai".to_string()].into_iter().collect(),
1210 no_proxy_hosts: Vec::new(),
1211 intercept_ca_path: None,
1212 };
1213 let config = ProxyConfig {
1214 routes: vec![crate::config::RouteConfig {
1215 prefix: "openai".to_string(),
1216 upstream: "https://api.openai.com".to_string(),
1217 credential_key: Some("op://Development/OpenAI/credential".to_string()),
1218 inject_mode: crate::config::InjectMode::Header,
1219 inject_header: "Authorization".to_string(),
1220 credential_format: "Bearer {}".to_string(),
1221 path_pattern: None,
1222 path_replacement: None,
1223 query_param_name: None,
1224 proxy: None,
1225 env_var: Some("OPENAI_API_KEY".to_string()),
1226 endpoint_rules: vec![],
1227 tls_ca: None,
1228 tls_client_cert: None,
1229 tls_client_key: None,
1230 oauth2: None,
1231 }],
1232 ..Default::default()
1233 };
1234
1235 let vars = handle.credential_env_vars(&config);
1236 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1239 assert!(
1240 api_key_var.is_some(),
1241 "Should use explicit env_var name, not derive from credential_key"
1242 );
1243
1244 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1246 assert_eq!(val, "test_token");
1247
1248 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1250 assert!(
1251 bad_var.is_none(),
1252 "Should not generate env var from op:// URI uppercase"
1253 );
1254 }
1255
1256 #[test]
1257 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1258 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1263 let handle = ProxyHandle {
1264 port: 12345,
1265 token: Zeroizing::new("test_token".to_string()),
1266 audit_log: audit::new_audit_log(),
1267 shutdown_tx,
1268 loaded_routes: ["openai".to_string()].into_iter().collect(),
1270 no_proxy_hosts: Vec::new(),
1271 intercept_ca_path: None,
1272 };
1273 let config = ProxyConfig {
1274 routes: vec![
1275 crate::config::RouteConfig {
1276 prefix: "openai".to_string(),
1277 upstream: "https://api.openai.com".to_string(),
1278 credential_key: Some("openai_api_key".to_string()),
1279 inject_mode: crate::config::InjectMode::Header,
1280 inject_header: "Authorization".to_string(),
1281 credential_format: "Bearer {}".to_string(),
1282 path_pattern: None,
1283 path_replacement: None,
1284 query_param_name: None,
1285 proxy: None,
1286 env_var: None,
1287 endpoint_rules: vec![],
1288 tls_ca: None,
1289 tls_client_cert: None,
1290 tls_client_key: None,
1291 oauth2: None,
1292 },
1293 crate::config::RouteConfig {
1294 prefix: "github".to_string(),
1295 upstream: "https://api.github.com".to_string(),
1296 credential_key: Some("env://GITHUB_TOKEN".to_string()),
1297 inject_mode: crate::config::InjectMode::Header,
1298 inject_header: "Authorization".to_string(),
1299 credential_format: "token {}".to_string(),
1300 path_pattern: None,
1301 path_replacement: None,
1302 query_param_name: None,
1303 proxy: None,
1304 env_var: Some("GITHUB_TOKEN".to_string()),
1305 endpoint_rules: vec![],
1306 tls_ca: None,
1307 tls_client_cert: None,
1308 tls_client_key: None,
1309 oauth2: None,
1310 },
1311 ],
1312 ..Default::default()
1313 };
1314
1315 let vars = handle.credential_env_vars(&config);
1316
1317 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1319 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1320 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1321 assert!(openai_key.is_some(), "loaded route should have API key");
1322
1323 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1326 assert!(
1327 github_base.is_some(),
1328 "declared route should still have BASE_URL"
1329 );
1330 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1331 assert!(
1332 github_token.is_none(),
1333 "unloaded route must not inject phantom GITHUB_TOKEN"
1334 );
1335 }
1336
1337 #[test]
1338 fn test_proxy_credential_env_vars_strips_slashes() {
1339 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1344 let handle = ProxyHandle {
1345 port: 58406,
1346 token: Zeroizing::new("test_token".to_string()),
1347 audit_log: audit::new_audit_log(),
1348 shutdown_tx,
1349 loaded_routes: std::collections::HashSet::new(),
1350 no_proxy_hosts: Vec::new(),
1351 intercept_ca_path: None,
1352 };
1353
1354 let config = ProxyConfig {
1356 routes: vec![crate::config::RouteConfig {
1357 prefix: "/anthropic".to_string(),
1358 upstream: "https://api.anthropic.com".to_string(),
1359 credential_key: None,
1360 inject_mode: crate::config::InjectMode::Header,
1361 inject_header: "Authorization".to_string(),
1362 credential_format: "Bearer {}".to_string(),
1363 path_pattern: None,
1364 path_replacement: None,
1365 query_param_name: None,
1366 proxy: None,
1367 env_var: None,
1368 endpoint_rules: vec![],
1369 tls_ca: None,
1370 tls_client_cert: None,
1371 tls_client_key: None,
1372 oauth2: None,
1373 }],
1374 ..Default::default()
1375 };
1376
1377 let vars = handle.credential_env_vars(&config);
1378 assert_eq!(vars.len(), 1);
1379 assert_eq!(
1380 vars[0].0, "ANTHROPIC_BASE_URL",
1381 "env var name must not have leading slash"
1382 );
1383 assert_eq!(
1384 vars[0].1, "http://127.0.0.1:58406/anthropic",
1385 "URL must not have double slash"
1386 );
1387
1388 let config = ProxyConfig {
1390 routes: vec![crate::config::RouteConfig {
1391 prefix: "openai/".to_string(),
1392 upstream: "https://api.openai.com".to_string(),
1393 credential_key: None,
1394 inject_mode: crate::config::InjectMode::Header,
1395 inject_header: "Authorization".to_string(),
1396 credential_format: "Bearer {}".to_string(),
1397 path_pattern: None,
1398 path_replacement: None,
1399 query_param_name: None,
1400 proxy: None,
1401 env_var: None,
1402 endpoint_rules: vec![],
1403 tls_ca: None,
1404 tls_client_cert: None,
1405 tls_client_key: None,
1406 oauth2: None,
1407 }],
1408 ..Default::default()
1409 };
1410
1411 let vars = handle.credential_env_vars(&config);
1412 assert_eq!(
1413 vars[0].0, "OPENAI_BASE_URL",
1414 "env var name must not have trailing slash"
1415 );
1416 assert_eq!(
1417 vars[0].1, "http://127.0.0.1:58406/openai",
1418 "URL must not have trailing slash in path"
1419 );
1420 }
1421
1422 #[test]
1423 fn test_anthropic_credential_phantom_token_regression() {
1424 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1432 let handle_no_env_var = ProxyHandle {
1433 port: 12345,
1434 token: Zeroizing::new("phantom".to_string()),
1435 audit_log: audit::new_audit_log(),
1436 shutdown_tx: shutdown_tx.clone(),
1437 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1438 no_proxy_hosts: Vec::new(),
1439 intercept_ca_path: None,
1440 };
1441 let config_no_env_var = ProxyConfig {
1442 routes: vec![crate::config::RouteConfig {
1443 prefix: "anthropic".to_string(),
1444 upstream: "https://api.anthropic.com".to_string(),
1445 credential_key: None,
1446 inject_mode: crate::config::InjectMode::Header,
1447 inject_header: "x-api-key".to_string(),
1448 credential_format: "{}".to_string(),
1449 path_pattern: None,
1450 path_replacement: None,
1451 query_param_name: None,
1452 proxy: None,
1453 env_var: None,
1454 endpoint_rules: vec![],
1455 tls_ca: None,
1456 tls_client_cert: None,
1457 tls_client_key: None,
1458 oauth2: None,
1459 }],
1460 ..Default::default()
1461 };
1462 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1463 assert!(
1464 vars_no_env_var.iter().all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1465 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1466 );
1467
1468 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1471 let handle_fixed = ProxyHandle {
1472 port: 12345,
1473 token: Zeroizing::new("phantom".to_string()),
1474 audit_log: audit::new_audit_log(),
1475 shutdown_tx: shutdown_tx2,
1476 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1477 no_proxy_hosts: Vec::new(),
1478 intercept_ca_path: None,
1479 };
1480 let config_fixed = ProxyConfig {
1481 routes: vec![crate::config::RouteConfig {
1482 prefix: "anthropic".to_string(),
1483 upstream: "https://api.anthropic.com".to_string(),
1484 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1485 inject_mode: crate::config::InjectMode::Header,
1486 inject_header: "x-api-key".to_string(),
1487 credential_format: "{}".to_string(),
1488 path_pattern: None,
1489 path_replacement: None,
1490 query_param_name: None,
1491 proxy: None,
1492 env_var: Some("ANTHROPIC_API_KEY".to_string()),
1493 endpoint_rules: vec![],
1494 tls_ca: None,
1495 tls_client_cert: None,
1496 tls_client_key: None,
1497 oauth2: None,
1498 }],
1499 ..Default::default()
1500 };
1501 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1502 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1503 assert!(
1504 api_key_var.is_some(),
1505 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1506 );
1507 assert_eq!(api_key_var.unwrap().1, "phantom");
1508 }
1509
1510 #[test]
1511 fn test_no_proxy_excludes_credential_upstreams() {
1512 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1513 let handle = ProxyHandle {
1514 port: 12345,
1515 token: Zeroizing::new("test_token".to_string()),
1516 audit_log: audit::new_audit_log(),
1517 shutdown_tx,
1518 loaded_routes: std::collections::HashSet::new(),
1519 no_proxy_hosts: vec![
1520 "nats.internal:4222".to_string(),
1521 "opencode.internal:4096".to_string(),
1522 ],
1523 intercept_ca_path: None,
1524 };
1525
1526 let vars = handle.env_vars();
1527 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1528 assert!(
1529 no_proxy.1.contains("nats.internal"),
1530 "non-credential host should be in NO_PROXY"
1531 );
1532 assert!(
1533 no_proxy.1.contains("opencode.internal"),
1534 "non-credential host should be in NO_PROXY"
1535 );
1536 assert!(
1537 no_proxy.1.contains("localhost"),
1538 "localhost should always be in NO_PROXY"
1539 );
1540 }
1541
1542 #[test]
1543 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1544 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1545 let handle = ProxyHandle {
1546 port: 12345,
1547 token: Zeroizing::new("test_token".to_string()),
1548 audit_log: audit::new_audit_log(),
1549 shutdown_tx,
1550 loaded_routes: std::collections::HashSet::new(),
1551 no_proxy_hosts: Vec::new(),
1552 intercept_ca_path: None,
1553 };
1554
1555 let vars = handle.env_vars();
1556 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1557 assert_eq!(
1558 no_proxy.1, "localhost,127.0.0.1",
1559 "NO_PROXY should only contain loopback when no bypass hosts"
1560 );
1561 }
1562
1563 #[tokio::test]
1564 async fn test_no_proxy_empty_without_direct_connect_ports() {
1565 let config = ProxyConfig {
1569 allowed_hosts: vec!["github.com".to_string()],
1570 ..Default::default()
1571 };
1572 let handle = start(config).await.unwrap();
1573
1574 let vars = handle.env_vars();
1575 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1576 assert_eq!(
1577 no_proxy.1, "localhost,127.0.0.1",
1578 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1579 );
1580
1581 handle.shutdown();
1582 }
1583
1584 #[cfg(not(target_os = "macos"))]
1585 #[tokio::test]
1586 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1587 let config = ProxyConfig {
1591 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1592 direct_connect_ports: vec![443],
1593 ..Default::default()
1594 };
1595 let handle = start(config).await.unwrap();
1596
1597 let vars = handle.env_vars();
1598 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1599 assert!(
1600 no_proxy.1.contains("github.com"),
1601 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1602 );
1603 assert!(
1604 !no_proxy.1.contains("server.internal"),
1605 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1606 );
1607
1608 handle.shutdown();
1609 }
1610}