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::Arc;
24use std::sync::atomic::{AtomicUsize, Ordering};
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 native = rustls_native_certs::load_native_certs();
372 if !native.errors.is_empty() {
373 debug!(
374 "failed to load {} native cert(s); continuing with webpki roots + any that succeeded",
375 native.errors.len()
376 );
377 }
378 let native_count = native.certs.len();
379 for cert in native.certs {
380 if let Err(e) = root_store.add(cert) {
381 debug!("skipping unparseable native cert: {e}");
382 }
383 }
384 if native_count > 0 {
385 debug!("added {native_count} native system CA(s) to upstream trust store");
386 }
387 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
388 rustls::crypto::ring::default_provider(),
389 ))
390 .with_safe_default_protocol_versions()
391 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
392 .with_root_certificates(root_store)
393 .with_no_client_auth();
394 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
395
396 let credential_store = if config.routes.is_empty() {
398 CredentialStore::empty()
399 } else {
400 CredentialStore::load(&config.routes, &tls_connector)?
401 };
402 let loaded_routes = credential_store.loaded_prefixes();
403
404 let filter = if config.allowed_hosts.is_empty() {
406 ProxyFilter::allow_all()
407 } else {
408 ProxyFilter::new(&config.allowed_hosts)
409 };
410
411 let bypass_matcher = config
413 .external_proxy
414 .as_ref()
415 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
416 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
417
418 let (shutdown_tx, shutdown_rx) = watch::channel(false);
420 let audit_log = audit::new_audit_log();
421
422 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
434 Vec::new()
435 } else {
436 let route_hosts = route_store.route_upstream_hosts();
437 config
438 .allowed_hosts
439 .iter()
440 .filter(|host| {
441 let normalised = {
442 let h = host.to_lowercase();
443 if h.starts_with('[') {
444 if h.contains("]:") {
446 h
447 } else {
448 format!("{}:443", h)
449 }
450 } else if h.contains(':') {
451 h
452 } else {
453 format!("{}:443", h)
454 }
455 };
456 if route_hosts.contains(&normalised) {
457 return false;
458 }
459 let port = normalised
462 .rsplit_once(':')
463 .and_then(|(_, p)| p.parse::<u16>().ok())
464 .unwrap_or(443);
465 config.direct_connect_ports.contains(&port)
466 })
467 .cloned()
468 .collect()
469 };
470
471 if !no_proxy_hosts.is_empty() {
472 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
473 }
474
475 let any_intercept_route = route_store
481 .route_upstream_hosts()
482 .iter()
483 .any(|hp| route_store.has_intercept_route(hp));
484 let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
485 (Some(dir), true) => {
486 let intercept_route_count = route_store
487 .route_upstream_hosts()
488 .iter()
489 .filter(|hp| route_store.has_intercept_route(hp))
490 .count();
491 match EphemeralCa::generate().and_then(|ca| {
492 let ca = Arc::new(ca);
493 let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
494 let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
495 dir,
496 filename: "intercept-ca.pem",
497 parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
498 ephemeral_ca_pem: ca.cert_pem(),
499 })?;
500 Ok((cache, path))
501 }) {
502 Ok((cache, path)) => {
503 info!(
504 "TLS interception active for {} route(s); trust bundle at {}",
505 intercept_route_count,
506 path.display()
507 );
508 (Some(cache), Some(path))
509 }
510 Err(e) => {
511 warn!(
512 "TLS interception setup failed for {} route(s): {}. \
513 Continuing with interception disabled; reverse-proxy routes remain available.",
514 intercept_route_count, e
515 );
516 (None, None)
517 }
518 }
519 }
520 (Some(_), false) => {
521 debug!(
522 "TLS interception requested but no configured route requires L7 visibility; \
523 skipping CA generation"
524 );
525 (None, None)
526 }
527 (None, _) => (None, None),
528 };
529
530 let state = Arc::new(ProxyState {
531 filter,
532 session_token: session_token.clone(),
533 route_store,
534 credential_store,
535 config,
536 tls_connector,
537 active_connections: AtomicUsize::new(0),
538 audit_log: Arc::clone(&audit_log),
539 bypass_matcher,
540 cert_cache,
541 });
542
543 tokio::spawn(accept_loop(listener, state, shutdown_rx));
547
548 Ok(ProxyHandle {
549 port,
550 token: session_token,
551 audit_log,
552 shutdown_tx,
553 loaded_routes,
554 no_proxy_hosts,
555 intercept_ca_path,
556 })
557}
558
559async fn accept_loop(
561 listener: TcpListener,
562 state: Arc<ProxyState>,
563 mut shutdown_rx: watch::Receiver<bool>,
564) {
565 loop {
566 tokio::select! {
567 result = listener.accept() => {
568 match result {
569 Ok((stream, addr)) => {
570 let max = state.config.max_connections;
572 if max > 0 {
573 let current = state.active_connections.load(Ordering::Relaxed);
574 if current >= max {
575 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
576 drop(stream);
578 continue;
579 }
580 }
581 state.active_connections.fetch_add(1, Ordering::Relaxed);
582
583 debug!("Accepted connection from {}", addr);
584 let state = Arc::clone(&state);
585 tokio::spawn(async move {
586 if let Err(e) = handle_connection(stream, &state).await {
587 debug!("Connection handler error: {}", e);
588 }
589 state.active_connections.fetch_sub(1, Ordering::Relaxed);
590 });
591 }
592 Err(e) => {
593 warn!("Accept error: {}", e);
594 }
595 }
596 }
597 _ = shutdown_rx.changed() => {
598 if *shutdown_rx.borrow() {
599 info!("Proxy server shutting down");
600 return;
601 }
602 }
603 }
604 }
605}
606
607async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
613 let mut buf_reader = BufReader::new(&mut stream);
617 let mut first_line = String::new();
618 buf_reader.read_line(&mut first_line).await?;
619
620 if first_line.is_empty() {
621 return Ok(()); }
623
624 let mut header_bytes = Vec::new();
626 loop {
627 let mut line = String::new();
628 let n = buf_reader.read_line(&mut line).await?;
629 if n == 0 || line.trim().is_empty() {
630 break;
631 }
632 header_bytes.extend_from_slice(line.as_bytes());
633 if header_bytes.len() > MAX_HEADER_SIZE {
634 drop(buf_reader);
635 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
636 stream.write_all(response.as_bytes()).await?;
637 return Ok(());
638 }
639 }
640
641 let buffered = buf_reader.buffer().to_vec();
646 drop(buf_reader);
647
648 let first_line = first_line.trim_end();
649
650 if first_line.starts_with("CONNECT ") {
652 if !state.route_store.is_empty()
668 && let Some(authority) = first_line.split_whitespace().nth(1)
669 {
670 let host_port = if authority.starts_with('[') {
673 if authority.contains("]:") {
674 authority.to_lowercase()
675 } else {
676 format!("{}:443", authority.to_lowercase())
677 }
678 } else if authority.contains(':') {
679 authority.to_lowercase()
680 } else {
681 format!("{}:443", authority.to_lowercase())
682 };
683
684 if state.route_store.is_route_upstream(&host_port) {
685 let route_id = state
686 .route_store
687 .lookup_by_upstream(&host_port)
688 .map(|(prefix, _)| prefix);
689 let (host, port) = host_port
690 .rsplit_once(':')
691 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
692 .unwrap_or_else(|| (host_port.clone(), 443));
693
694 let intercept_eligible = state.route_store.has_intercept_route(&host_port);
695
696 match (intercept_eligible, state.cert_cache.as_ref()) {
697 (true, Some(cache)) => {
699 if let Err(e) =
704 token::validate_proxy_auth(&header_bytes, &state.session_token)
705 {
706 debug!(
707 "tls_intercept: rejecting CONNECT to {}:{} — {}",
708 host, port, e
709 );
710 audit::log_denied(
711 Some(&state.audit_log),
712 audit::ProxyMode::ConnectIntercept,
713 &audit::EventContext {
714 route_id,
715 auth_mechanism: Some(
716 nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
717 ),
718 auth_outcome: Some(
719 nono::undo::NetworkAuditAuthOutcome::Failed,
720 ),
721 denial_category: Some(
722 nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
723 ),
724 ..audit::EventContext::default()
725 },
726 &host,
727 port,
728 "proxy auth missing or invalid",
729 );
730 let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
731 stream.write_all(response.as_bytes()).await?;
732 return Ok(());
733 }
734
735 let ctx = tls_intercept::InterceptCtx {
736 route_id,
737 host: &host,
738 port,
739 route_store: &state.route_store,
740 credential_store: &state.credential_store,
741 session_token: &state.session_token,
742 cert_cache: Arc::clone(cache),
743 tls_connector: &state.tls_connector,
744 filter: &state.filter,
745 audit_log: Some(&state.audit_log),
746 };
747 return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
748 }
749 _ => {
753 debug!(
754 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
755 authority
756 );
757 audit::log_denied(
758 Some(&state.audit_log),
759 audit::ProxyMode::Connect,
760 &audit::EventContext {
761 route_id,
762 denial_category: Some(
763 nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
764 ),
765 ..audit::EventContext::default()
766 },
767 &host,
768 port,
769 "route upstream: CONNECT bypasses L7 filtering",
770 );
771 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
772 stream.write_all(response.as_bytes()).await?;
773 return Ok(());
774 }
775 }
776 }
777 }
778
779 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
781 if state.bypass_matcher.is_empty() {
782 Some(ext_config)
783 } else {
784 let host = first_line
786 .split_whitespace()
787 .nth(1)
788 .and_then(|authority| {
789 authority
790 .rsplit_once(':')
791 .map(|(h, _)| h)
792 .or(Some(authority))
793 })
794 .unwrap_or("");
795 if state.bypass_matcher.matches(host) {
796 debug!("Bypassing external proxy for {}", host);
797 None
798 } else {
799 Some(ext_config)
800 }
801 }
802 } else {
803 None
804 };
805
806 if let Some(ext_config) = use_external {
807 external::handle_external_proxy(
808 first_line,
809 &mut stream,
810 &header_bytes,
811 &state.filter,
812 &state.session_token,
813 ext_config,
814 Some(&state.audit_log),
815 )
816 .await
817 } else if state.config.external_proxy.is_some() {
818 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
823 connect::handle_connect(
824 first_line,
825 &mut stream,
826 &state.filter,
827 &state.session_token,
828 &header_bytes,
829 Some(&state.audit_log),
830 )
831 .await
832 } else {
833 connect::handle_connect(
834 first_line,
835 &mut stream,
836 &state.filter,
837 &state.session_token,
838 &header_bytes,
839 Some(&state.audit_log),
840 )
841 .await
842 }
843 } else if !state.route_store.is_empty() {
844 let ctx = reverse::ReverseProxyCtx {
846 route_store: &state.route_store,
847 credential_store: &state.credential_store,
848 session_token: &state.session_token,
849 filter: &state.filter,
850 tls_connector: &state.tls_connector,
851 audit_log: Some(&state.audit_log),
852 };
853 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
854 } else {
855 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
857 stream.write_all(response.as_bytes()).await?;
858 Ok(())
859 }
860}
861
862#[cfg(test)]
863#[allow(clippy::unwrap_used)]
864mod tests {
865 use super::*;
866
867 #[tokio::test]
868 async fn test_proxy_starts_and_binds() {
869 let config = ProxyConfig::default();
870 let handle = start(config).await.unwrap();
871
872 assert!(handle.port > 0);
874 assert_eq!(handle.token.len(), 64);
876
877 handle.shutdown();
879 }
880
881 #[tokio::test]
889 async fn test_intercept_lifecycle_end_to_end() {
890 let dir = tempfile::tempdir().unwrap();
891 let ca_path_clone;
892
893 {
894 let config = ProxyConfig {
895 routes: vec![crate::config::RouteConfig {
896 prefix: "openai".to_string(),
897 upstream: "https://api.openai.com".to_string(),
898 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
899 inject_mode: Default::default(),
900 inject_header: "Authorization".to_string(),
901 credential_format: Some("Bearer {}".to_string()),
902 path_pattern: None,
903 path_replacement: None,
904 query_param_name: None,
905 proxy: None,
906 env_var: None,
907 endpoint_rules: vec![],
908 tls_ca: None,
909 tls_client_cert: None,
910 tls_client_key: None,
911 oauth2: None,
912 }],
913 intercept_ca_dir: Some(dir.path().to_path_buf()),
914 ..Default::default()
915 };
916 let handle = start(config).await.unwrap();
917 assert!(
918 handle.intercept_ca_path().is_some(),
919 "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
920 );
921 ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
922 assert!(
923 ca_path_clone.exists(),
924 "bundle file should have been written"
925 );
926
927 let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
928 assert!(
929 contents.contains("BEGIN CERTIFICATE"),
930 "bundle should contain at least one PEM block"
931 );
932
933 let vars = handle.env_vars();
935 let ssl = vars
936 .iter()
937 .find(|(k, _)| k == "SSL_CERT_FILE")
938 .expect("SSL_CERT_FILE should be set when intercept active");
939 assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
940 assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
941 assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
942 assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
943
944 handle.shutdown();
945 }
946 assert!(
948 !ca_path_clone.exists(),
949 "bundle should be removed when ProxyHandle drops"
950 );
951 }
952
953 #[tokio::test]
956 async fn test_intercept_skipped_for_purely_declarative_routes() {
957 let dir = tempfile::tempdir().unwrap();
958 let config = ProxyConfig {
959 routes: vec![crate::config::RouteConfig {
960 prefix: "alias".to_string(),
961 upstream: "https://aliased.example.com".to_string(),
962 credential_key: None,
963 inject_mode: Default::default(),
964 inject_header: "Authorization".to_string(),
965 credential_format: Some("Bearer {}".to_string()),
966 path_pattern: None,
967 path_replacement: None,
968 query_param_name: None,
969 proxy: None,
970 env_var: None,
971 endpoint_rules: vec![],
972 tls_ca: None,
973 tls_client_cert: None,
974 tls_client_key: None,
975 oauth2: None,
976 }],
977 intercept_ca_dir: Some(dir.path().to_path_buf()),
978 ..Default::default()
979 };
980 let handle = start(config).await.unwrap();
981 assert!(
982 handle.intercept_ca_path().is_none(),
983 "no L7-bearing route → no CA should be generated"
984 );
985 let vars = handle.env_vars();
986 assert!(
987 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
988 "trust env vars must not be set when intercept inactive"
989 );
990 handle.shutdown();
991 }
992
993 #[tokio::test]
998 async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
999 let missing_dir = tempfile::tempdir()
1000 .unwrap()
1001 .path()
1002 .join("missing")
1003 .join("intercept");
1004 let config = ProxyConfig {
1005 routes: vec![crate::config::RouteConfig {
1006 prefix: "openai".to_string(),
1007 upstream: "https://api.openai.com".to_string(),
1008 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1009 inject_mode: Default::default(),
1010 inject_header: "Authorization".to_string(),
1011 credential_format: Some("Bearer {}".to_string()),
1012 path_pattern: None,
1013 path_replacement: None,
1014 query_param_name: None,
1015 proxy: None,
1016 env_var: None,
1017 endpoint_rules: vec![],
1018 tls_ca: None,
1019 tls_client_cert: None,
1020 tls_client_key: None,
1021 oauth2: None,
1022 }],
1023 intercept_ca_dir: Some(missing_dir),
1024 ..Default::default()
1025 };
1026 let handle = start(config.clone()).await.unwrap();
1027 assert!(
1028 handle.intercept_ca_path().is_none(),
1029 "intercept setup failure should disable interception instead of aborting startup"
1030 );
1031 let vars = handle.env_vars();
1032 assert!(
1033 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1034 "trust env vars must not be set when interception setup fails"
1035 );
1036 let route_vars = handle.credential_env_vars(&config);
1037 assert!(
1038 route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1039 "reverse-proxy route env vars should still be emitted"
1040 );
1041 handle.shutdown();
1042 }
1043
1044 #[tokio::test]
1047 async fn test_route_diagnostics_summarises_each_route() {
1048 let dir = tempfile::tempdir().unwrap();
1049 let config = ProxyConfig {
1050 routes: vec![
1051 crate::config::RouteConfig {
1052 prefix: "openai".to_string(),
1053 upstream: "https://api.openai.com".to_string(),
1054 credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1055 inject_mode: Default::default(),
1056 inject_header: "Authorization".to_string(),
1057 credential_format: Some("Bearer {}".to_string()),
1058 path_pattern: None,
1059 path_replacement: None,
1060 query_param_name: None,
1061 proxy: None,
1062 env_var: None,
1063 endpoint_rules: vec![],
1064 tls_ca: None,
1065 tls_client_cert: None,
1066 tls_client_key: None,
1067 oauth2: None,
1068 },
1069 crate::config::RouteConfig {
1070 prefix: "alias".to_string(),
1071 upstream: "https://aliased.example.com".to_string(),
1072 credential_key: None,
1073 inject_mode: Default::default(),
1074 inject_header: "Authorization".to_string(),
1075 credential_format: Some("Bearer {}".to_string()),
1076 path_pattern: None,
1077 path_replacement: None,
1078 query_param_name: None,
1079 proxy: None,
1080 env_var: None,
1081 endpoint_rules: vec![],
1082 tls_ca: None,
1083 tls_client_cert: None,
1084 tls_client_key: None,
1085 oauth2: None,
1086 },
1087 ],
1088 intercept_ca_dir: Some(dir.path().to_path_buf()),
1089 ..Default::default()
1090 };
1091 let handle = start(config.clone()).await.unwrap();
1092 let rows = handle.route_diagnostics(&config);
1093 assert_eq!(rows.len(), 2);
1094
1095 let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1096 assert!(openai.1.contains("api.openai.com"));
1097 assert!(openai.1.contains("intercept: on"));
1098 assert!(
1099 openai.1.contains("✗") || openai.1.contains("not found"),
1100 "missing credential should show ✗, got: {}",
1101 openai.1
1102 );
1103
1104 let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1105 assert!(alias.1.contains("creds: none"));
1106 assert!(alias.1.contains("intercept: off"));
1107
1108 handle.shutdown();
1109 }
1110
1111 #[tokio::test]
1112 async fn test_proxy_env_vars() {
1113 let config = ProxyConfig::default();
1114 let handle = start(config).await.unwrap();
1115
1116 let vars = handle.env_vars();
1117 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1118 assert!(http_proxy.is_some());
1119 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1120
1121 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1122 assert!(token_var.is_some());
1123 assert_eq!(token_var.unwrap().1.len(), 64);
1124
1125 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1126 assert!(
1127 node_proxy_flag.is_some(),
1128 "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1129 );
1130 assert_eq!(
1131 node_proxy_flag.unwrap().1,
1132 "1",
1133 "NODE_USE_ENV_PROXY must be '1'"
1134 );
1135
1136 handle.shutdown();
1137 }
1138
1139 #[tokio::test]
1140 async fn test_proxy_credential_env_vars() {
1141 let config = ProxyConfig {
1142 routes: vec![crate::config::RouteConfig {
1143 prefix: "openai".to_string(),
1144 upstream: "https://api.openai.com".to_string(),
1145 credential_key: None,
1146 inject_mode: crate::config::InjectMode::Header,
1147 inject_header: "Authorization".to_string(),
1148 credential_format: Some("Bearer {}".to_string()),
1149 path_pattern: None,
1150 path_replacement: None,
1151 query_param_name: None,
1152 proxy: None,
1153 env_var: None,
1154 endpoint_rules: vec![],
1155 tls_ca: None,
1156 tls_client_cert: None,
1157 tls_client_key: None,
1158 oauth2: None,
1159 }],
1160 ..Default::default()
1161 };
1162 let handle = start(config.clone()).await.unwrap();
1163
1164 let vars = handle.credential_env_vars(&config);
1165 assert_eq!(vars.len(), 1);
1166 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1167 assert!(vars[0].1.contains("/openai"));
1168
1169 handle.shutdown();
1170 }
1171
1172 #[test]
1173 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1174 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1178 let handle = ProxyHandle {
1179 port: 12345,
1180 token: Zeroizing::new("test_token".to_string()),
1181 audit_log: audit::new_audit_log(),
1182 shutdown_tx,
1183 loaded_routes: ["openai".to_string()].into_iter().collect(),
1184 no_proxy_hosts: Vec::new(),
1185 intercept_ca_path: None,
1186 };
1187 let config = ProxyConfig {
1188 routes: vec![crate::config::RouteConfig {
1189 prefix: "openai".to_string(),
1190 upstream: "https://api.openai.com".to_string(),
1191 credential_key: Some("openai_api_key".to_string()),
1192 inject_mode: crate::config::InjectMode::Header,
1193 inject_header: "Authorization".to_string(),
1194 credential_format: Some("Bearer {}".to_string()),
1195 path_pattern: None,
1196 path_replacement: None,
1197 query_param_name: None,
1198 proxy: None,
1199 env_var: None, endpoint_rules: vec![],
1201 tls_ca: None,
1202 tls_client_cert: None,
1203 tls_client_key: None,
1204 oauth2: None,
1205 }],
1206 ..Default::default()
1207 };
1208
1209 let vars = handle.credential_env_vars(&config);
1210 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1214 assert!(
1215 api_key_var.is_some(),
1216 "Should derive env var name from credential_key.to_uppercase()"
1217 );
1218
1219 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1220 assert_eq!(val, "test_token");
1221 }
1222
1223 #[test]
1224 fn test_proxy_credential_env_vars_with_explicit_env_var() {
1225 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1233 let handle = ProxyHandle {
1234 port: 12345,
1235 token: Zeroizing::new("test_token".to_string()),
1236 audit_log: audit::new_audit_log(),
1237 shutdown_tx,
1238 loaded_routes: ["openai".to_string()].into_iter().collect(),
1239 no_proxy_hosts: Vec::new(),
1240 intercept_ca_path: None,
1241 };
1242 let config = ProxyConfig {
1243 routes: vec![crate::config::RouteConfig {
1244 prefix: "openai".to_string(),
1245 upstream: "https://api.openai.com".to_string(),
1246 credential_key: Some("op://Development/OpenAI/credential".to_string()),
1247 inject_mode: crate::config::InjectMode::Header,
1248 inject_header: "Authorization".to_string(),
1249 credential_format: Some("Bearer {}".to_string()),
1250 path_pattern: None,
1251 path_replacement: None,
1252 query_param_name: None,
1253 proxy: None,
1254 env_var: Some("OPENAI_API_KEY".to_string()),
1255 endpoint_rules: vec![],
1256 tls_ca: None,
1257 tls_client_cert: None,
1258 tls_client_key: None,
1259 oauth2: None,
1260 }],
1261 ..Default::default()
1262 };
1263
1264 let vars = handle.credential_env_vars(&config);
1265 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1268 assert!(
1269 api_key_var.is_some(),
1270 "Should use explicit env_var name, not derive from credential_key"
1271 );
1272
1273 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1275 assert_eq!(val, "test_token");
1276
1277 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1279 assert!(
1280 bad_var.is_none(),
1281 "Should not generate env var from op:// URI uppercase"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1287 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1292 let handle = ProxyHandle {
1293 port: 12345,
1294 token: Zeroizing::new("test_token".to_string()),
1295 audit_log: audit::new_audit_log(),
1296 shutdown_tx,
1297 loaded_routes: ["openai".to_string()].into_iter().collect(),
1299 no_proxy_hosts: Vec::new(),
1300 intercept_ca_path: None,
1301 };
1302 let config = ProxyConfig {
1303 routes: vec![
1304 crate::config::RouteConfig {
1305 prefix: "openai".to_string(),
1306 upstream: "https://api.openai.com".to_string(),
1307 credential_key: Some("openai_api_key".to_string()),
1308 inject_mode: crate::config::InjectMode::Header,
1309 inject_header: "Authorization".to_string(),
1310 credential_format: Some("Bearer {}".to_string()),
1311 path_pattern: None,
1312 path_replacement: None,
1313 query_param_name: None,
1314 proxy: None,
1315 env_var: None,
1316 endpoint_rules: vec![],
1317 tls_ca: None,
1318 tls_client_cert: None,
1319 tls_client_key: None,
1320 oauth2: None,
1321 },
1322 crate::config::RouteConfig {
1323 prefix: "github".to_string(),
1324 upstream: "https://api.github.com".to_string(),
1325 credential_key: Some("env://GITHUB_TOKEN".to_string()),
1326 inject_mode: crate::config::InjectMode::Header,
1327 inject_header: "Authorization".to_string(),
1328 credential_format: Some("token {}".to_string()),
1329 path_pattern: None,
1330 path_replacement: None,
1331 query_param_name: None,
1332 proxy: None,
1333 env_var: Some("GITHUB_TOKEN".to_string()),
1334 endpoint_rules: vec![],
1335 tls_ca: None,
1336 tls_client_cert: None,
1337 tls_client_key: None,
1338 oauth2: None,
1339 },
1340 ],
1341 ..Default::default()
1342 };
1343
1344 let vars = handle.credential_env_vars(&config);
1345
1346 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1348 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1349 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1350 assert!(openai_key.is_some(), "loaded route should have API key");
1351
1352 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1355 assert!(
1356 github_base.is_some(),
1357 "declared route should still have BASE_URL"
1358 );
1359 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1360 assert!(
1361 github_token.is_none(),
1362 "unloaded route must not inject phantom GITHUB_TOKEN"
1363 );
1364 }
1365
1366 #[test]
1367 fn test_proxy_credential_env_vars_strips_slashes() {
1368 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1373 let handle = ProxyHandle {
1374 port: 58406,
1375 token: Zeroizing::new("test_token".to_string()),
1376 audit_log: audit::new_audit_log(),
1377 shutdown_tx,
1378 loaded_routes: std::collections::HashSet::new(),
1379 no_proxy_hosts: Vec::new(),
1380 intercept_ca_path: None,
1381 };
1382
1383 let config = ProxyConfig {
1385 routes: vec![crate::config::RouteConfig {
1386 prefix: "/anthropic".to_string(),
1387 upstream: "https://api.anthropic.com".to_string(),
1388 credential_key: None,
1389 inject_mode: crate::config::InjectMode::Header,
1390 inject_header: "Authorization".to_string(),
1391 credential_format: Some("Bearer {}".to_string()),
1392 path_pattern: None,
1393 path_replacement: None,
1394 query_param_name: None,
1395 proxy: None,
1396 env_var: None,
1397 endpoint_rules: vec![],
1398 tls_ca: None,
1399 tls_client_cert: None,
1400 tls_client_key: None,
1401 oauth2: None,
1402 }],
1403 ..Default::default()
1404 };
1405
1406 let vars = handle.credential_env_vars(&config);
1407 assert_eq!(vars.len(), 1);
1408 assert_eq!(
1409 vars[0].0, "ANTHROPIC_BASE_URL",
1410 "env var name must not have leading slash"
1411 );
1412 assert_eq!(
1413 vars[0].1, "http://127.0.0.1:58406/anthropic",
1414 "URL must not have double slash"
1415 );
1416
1417 let config = ProxyConfig {
1419 routes: vec![crate::config::RouteConfig {
1420 prefix: "openai/".to_string(),
1421 upstream: "https://api.openai.com".to_string(),
1422 credential_key: None,
1423 inject_mode: crate::config::InjectMode::Header,
1424 inject_header: "Authorization".to_string(),
1425 credential_format: Some("Bearer {}".to_string()),
1426 path_pattern: None,
1427 path_replacement: None,
1428 query_param_name: None,
1429 proxy: None,
1430 env_var: None,
1431 endpoint_rules: vec![],
1432 tls_ca: None,
1433 tls_client_cert: None,
1434 tls_client_key: None,
1435 oauth2: None,
1436 }],
1437 ..Default::default()
1438 };
1439
1440 let vars = handle.credential_env_vars(&config);
1441 assert_eq!(
1442 vars[0].0, "OPENAI_BASE_URL",
1443 "env var name must not have trailing slash"
1444 );
1445 assert_eq!(
1446 vars[0].1, "http://127.0.0.1:58406/openai",
1447 "URL must not have trailing slash in path"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_anthropic_credential_phantom_token_regression() {
1453 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1461 let handle_no_env_var = ProxyHandle {
1462 port: 12345,
1463 token: Zeroizing::new("phantom".to_string()),
1464 audit_log: audit::new_audit_log(),
1465 shutdown_tx: shutdown_tx.clone(),
1466 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1467 no_proxy_hosts: Vec::new(),
1468 intercept_ca_path: None,
1469 };
1470 let config_no_env_var = ProxyConfig {
1471 routes: vec![crate::config::RouteConfig {
1472 prefix: "anthropic".to_string(),
1473 upstream: "https://api.anthropic.com".to_string(),
1474 credential_key: None,
1475 inject_mode: crate::config::InjectMode::Header,
1476 inject_header: "x-api-key".to_string(),
1477 credential_format: Some("{}".to_string()),
1478 path_pattern: None,
1479 path_replacement: None,
1480 query_param_name: None,
1481 proxy: None,
1482 env_var: None,
1483 endpoint_rules: vec![],
1484 tls_ca: None,
1485 tls_client_cert: None,
1486 tls_client_key: None,
1487 oauth2: None,
1488 }],
1489 ..Default::default()
1490 };
1491 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1492 assert!(
1493 vars_no_env_var
1494 .iter()
1495 .all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1496 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1497 );
1498
1499 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1502 let handle_fixed = ProxyHandle {
1503 port: 12345,
1504 token: Zeroizing::new("phantom".to_string()),
1505 audit_log: audit::new_audit_log(),
1506 shutdown_tx: shutdown_tx2,
1507 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1508 no_proxy_hosts: Vec::new(),
1509 intercept_ca_path: None,
1510 };
1511 let config_fixed = ProxyConfig {
1512 routes: vec![crate::config::RouteConfig {
1513 prefix: "anthropic".to_string(),
1514 upstream: "https://api.anthropic.com".to_string(),
1515 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1516 inject_mode: crate::config::InjectMode::Header,
1517 inject_header: "x-api-key".to_string(),
1518 credential_format: Some("{}".to_string()),
1519 path_pattern: None,
1520 path_replacement: None,
1521 query_param_name: None,
1522 proxy: None,
1523 env_var: Some("ANTHROPIC_API_KEY".to_string()),
1524 endpoint_rules: vec![],
1525 tls_ca: None,
1526 tls_client_cert: None,
1527 tls_client_key: None,
1528 oauth2: None,
1529 }],
1530 ..Default::default()
1531 };
1532 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1533 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1534 assert!(
1535 api_key_var.is_some(),
1536 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1537 );
1538 assert_eq!(api_key_var.unwrap().1, "phantom");
1539 }
1540
1541 #[test]
1542 fn test_no_proxy_excludes_credential_upstreams() {
1543 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1544 let handle = ProxyHandle {
1545 port: 12345,
1546 token: Zeroizing::new("test_token".to_string()),
1547 audit_log: audit::new_audit_log(),
1548 shutdown_tx,
1549 loaded_routes: std::collections::HashSet::new(),
1550 no_proxy_hosts: vec![
1551 "nats.internal:4222".to_string(),
1552 "opencode.internal:4096".to_string(),
1553 ],
1554 intercept_ca_path: None,
1555 };
1556
1557 let vars = handle.env_vars();
1558 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1559 assert!(
1560 no_proxy.1.contains("nats.internal"),
1561 "non-credential host should be in NO_PROXY"
1562 );
1563 assert!(
1564 no_proxy.1.contains("opencode.internal"),
1565 "non-credential host should be in NO_PROXY"
1566 );
1567 assert!(
1568 no_proxy.1.contains("localhost"),
1569 "localhost should always be in NO_PROXY"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1575 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1576 let handle = ProxyHandle {
1577 port: 12345,
1578 token: Zeroizing::new("test_token".to_string()),
1579 audit_log: audit::new_audit_log(),
1580 shutdown_tx,
1581 loaded_routes: std::collections::HashSet::new(),
1582 no_proxy_hosts: Vec::new(),
1583 intercept_ca_path: None,
1584 };
1585
1586 let vars = handle.env_vars();
1587 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1588 assert_eq!(
1589 no_proxy.1, "localhost,127.0.0.1",
1590 "NO_PROXY should only contain loopback when no bypass hosts"
1591 );
1592 }
1593
1594 #[tokio::test]
1595 async fn test_no_proxy_empty_without_direct_connect_ports() {
1596 let config = ProxyConfig {
1600 allowed_hosts: vec!["github.com".to_string()],
1601 ..Default::default()
1602 };
1603 let handle = start(config).await.unwrap();
1604
1605 let vars = handle.env_vars();
1606 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1607 assert_eq!(
1608 no_proxy.1, "localhost,127.0.0.1",
1609 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1610 );
1611
1612 handle.shutdown();
1613 }
1614
1615 #[cfg(not(target_os = "macos"))]
1616 #[tokio::test]
1617 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1618 let config = ProxyConfig {
1622 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1623 direct_connect_ports: vec![443],
1624 ..Default::default()
1625 };
1626 let handle = start(config).await.unwrap();
1627
1628 let vars = handle.env_vars();
1629 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1630 assert!(
1631 no_proxy.1.contains("github.com"),
1632 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1633 );
1634 assert!(
1635 !no_proxy.1.contains("server.internal"),
1636 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1637 );
1638
1639 handle.shutdown();
1640 }
1641}