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 let ca_result = if let Some(ref preloaded) = config.preloaded_ca {
492 EphemeralCa::from_existing(&preloaded.key_der, &preloaded.cert_pem)
493 } else {
494 let validity = config
495 .ca_validity
496 .unwrap_or(crate::tls_intercept::ca::CA_VALIDITY_DEFAULT);
497 EphemeralCa::generate_with_cn("nono-session-ca", validity)
498 };
499 match ca_result.and_then(|ca| {
500 let ca = Arc::new(ca);
501 let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
502 let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
503 dir,
504 filename: "intercept-ca.pem",
505 parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
506 ephemeral_ca_pem: ca.cert_pem(),
507 })?;
508 Ok((cache, path))
509 }) {
510 Ok((cache, path)) => {
511 info!(
512 "TLS interception active for {} route(s); trust bundle at {}",
513 intercept_route_count,
514 path.display()
515 );
516 (Some(cache), Some(path))
517 }
518 Err(e) => {
519 warn!(
520 "TLS interception setup failed for {} route(s): {}. \
521 Continuing with interception disabled; reverse-proxy routes remain available.",
522 intercept_route_count, e
523 );
524 (None, None)
525 }
526 }
527 }
528 (Some(_), false) => {
529 debug!(
530 "TLS interception requested but no configured route requires L7 visibility; \
531 skipping CA generation"
532 );
533 (None, None)
534 }
535 (None, _) => (None, None),
536 };
537
538 let state = Arc::new(ProxyState {
539 filter,
540 session_token: session_token.clone(),
541 route_store,
542 credential_store,
543 config,
544 tls_connector,
545 active_connections: AtomicUsize::new(0),
546 audit_log: Arc::clone(&audit_log),
547 bypass_matcher,
548 cert_cache,
549 });
550
551 tokio::spawn(accept_loop(listener, state, shutdown_rx));
555
556 Ok(ProxyHandle {
557 port,
558 token: session_token,
559 audit_log,
560 shutdown_tx,
561 loaded_routes,
562 no_proxy_hosts,
563 intercept_ca_path,
564 })
565}
566
567async fn accept_loop(
569 listener: TcpListener,
570 state: Arc<ProxyState>,
571 mut shutdown_rx: watch::Receiver<bool>,
572) {
573 loop {
574 tokio::select! {
575 result = listener.accept() => {
576 match result {
577 Ok((stream, addr)) => {
578 let max = state.config.max_connections;
580 if max > 0 {
581 let current = state.active_connections.load(Ordering::Relaxed);
582 if current >= max {
583 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
584 drop(stream);
586 continue;
587 }
588 }
589 state.active_connections.fetch_add(1, Ordering::Relaxed);
590
591 debug!("Accepted connection from {}", addr);
592 let state = Arc::clone(&state);
593 tokio::spawn(async move {
594 if let Err(e) = handle_connection(stream, &state).await {
595 debug!("Connection handler error: {}", e);
596 }
597 state.active_connections.fetch_sub(1, Ordering::Relaxed);
598 });
599 }
600 Err(e) => {
601 warn!("Accept error: {}", e);
602 }
603 }
604 }
605 _ = shutdown_rx.changed() => {
606 if *shutdown_rx.borrow() {
607 info!("Proxy server shutting down");
608 return;
609 }
610 }
611 }
612 }
613}
614
615async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
621 let mut buf_reader = BufReader::new(&mut stream);
625 let mut first_line = String::new();
626 buf_reader.read_line(&mut first_line).await?;
627
628 if first_line.is_empty() {
629 return Ok(()); }
631
632 let mut header_bytes = Vec::new();
634 loop {
635 let mut line = String::new();
636 let n = buf_reader.read_line(&mut line).await?;
637 if n == 0 || line.trim().is_empty() {
638 break;
639 }
640 header_bytes.extend_from_slice(line.as_bytes());
641 if header_bytes.len() > MAX_HEADER_SIZE {
642 drop(buf_reader);
643 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
644 stream.write_all(response.as_bytes()).await?;
645 return Ok(());
646 }
647 }
648
649 let buffered = buf_reader.buffer().to_vec();
654 drop(buf_reader);
655
656 let first_line = first_line.trim_end();
657
658 if first_line.starts_with("CONNECT ") {
660 if !state.route_store.is_empty()
676 && let Some(authority) = first_line.split_whitespace().nth(1)
677 {
678 let host_port = if authority.starts_with('[') {
681 if authority.contains("]:") {
682 authority.to_lowercase()
683 } else {
684 format!("{}:443", authority.to_lowercase())
685 }
686 } else if authority.contains(':') {
687 authority.to_lowercase()
688 } else {
689 format!("{}:443", authority.to_lowercase())
690 };
691
692 if state.route_store.is_route_upstream(&host_port) {
693 let route_id = state
694 .route_store
695 .lookup_by_upstream(&host_port)
696 .map(|(prefix, _)| prefix);
697 let (host, port) = host_port
698 .rsplit_once(':')
699 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
700 .unwrap_or_else(|| (host_port.clone(), 443));
701
702 let intercept_eligible = state.route_store.has_intercept_route(&host_port);
703
704 match (intercept_eligible, state.cert_cache.as_ref()) {
705 (true, Some(cache)) => {
707 if let Err(e) =
712 token::validate_proxy_auth(&header_bytes, &state.session_token)
713 {
714 debug!(
715 "tls_intercept: rejecting CONNECT to {}:{} — {}",
716 host, port, e
717 );
718 audit::log_denied(
719 Some(&state.audit_log),
720 audit::ProxyMode::ConnectIntercept,
721 &audit::EventContext {
722 route_id,
723 auth_mechanism: Some(
724 nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
725 ),
726 auth_outcome: Some(
727 nono::undo::NetworkAuditAuthOutcome::Failed,
728 ),
729 denial_category: Some(
730 nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
731 ),
732 ..audit::EventContext::default()
733 },
734 &host,
735 port,
736 "proxy auth missing or invalid",
737 );
738 let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
739 stream.write_all(response.as_bytes()).await?;
740 return Ok(());
741 }
742
743 let ctx = tls_intercept::InterceptCtx {
744 route_id,
745 host: &host,
746 port,
747 route_store: &state.route_store,
748 credential_store: &state.credential_store,
749 session_token: &state.session_token,
750 cert_cache: Arc::clone(cache),
751 tls_connector: &state.tls_connector,
752 filter: &state.filter,
753 audit_log: Some(&state.audit_log),
754 };
755 return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
756 }
757 _ => {
761 debug!(
762 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
763 authority
764 );
765 audit::log_denied(
766 Some(&state.audit_log),
767 audit::ProxyMode::Connect,
768 &audit::EventContext {
769 route_id,
770 denial_category: Some(
771 nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
772 ),
773 ..audit::EventContext::default()
774 },
775 &host,
776 port,
777 "route upstream: CONNECT bypasses L7 filtering",
778 );
779 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
780 stream.write_all(response.as_bytes()).await?;
781 return Ok(());
782 }
783 }
784 }
785 }
786
787 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
789 if state.bypass_matcher.is_empty() {
790 Some(ext_config)
791 } else {
792 let host = first_line
794 .split_whitespace()
795 .nth(1)
796 .and_then(|authority| {
797 authority
798 .rsplit_once(':')
799 .map(|(h, _)| h)
800 .or(Some(authority))
801 })
802 .unwrap_or("");
803 if state.bypass_matcher.matches(host) {
804 debug!("Bypassing external proxy for {}", host);
805 None
806 } else {
807 Some(ext_config)
808 }
809 }
810 } else {
811 None
812 };
813
814 if let Some(ext_config) = use_external {
815 external::handle_external_proxy(
816 first_line,
817 &mut stream,
818 &header_bytes,
819 &state.filter,
820 &state.session_token,
821 ext_config,
822 Some(&state.audit_log),
823 )
824 .await
825 } else if state.config.external_proxy.is_some() {
826 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
831 connect::handle_connect(
832 first_line,
833 &mut stream,
834 &state.filter,
835 &state.session_token,
836 &header_bytes,
837 Some(&state.audit_log),
838 )
839 .await
840 } else {
841 connect::handle_connect(
842 first_line,
843 &mut stream,
844 &state.filter,
845 &state.session_token,
846 &header_bytes,
847 Some(&state.audit_log),
848 )
849 .await
850 }
851 } else if !state.route_store.is_empty() {
852 let ctx = reverse::ReverseProxyCtx {
854 route_store: &state.route_store,
855 credential_store: &state.credential_store,
856 session_token: &state.session_token,
857 filter: &state.filter,
858 tls_connector: &state.tls_connector,
859 audit_log: Some(&state.audit_log),
860 };
861 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
862 } else {
863 let response = "HTTP/1.1 400 Bad Request\r\n\r\n";
865 stream.write_all(response.as_bytes()).await?;
866 Ok(())
867 }
868}
869
870#[cfg(test)]
871#[allow(clippy::unwrap_used)]
872mod tests {
873 use super::*;
874
875 #[tokio::test]
876 async fn test_proxy_starts_and_binds() {
877 let config = ProxyConfig::default();
878 let handle = start(config).await.unwrap();
879
880 assert!(handle.port > 0);
882 assert_eq!(handle.token.len(), 64);
884
885 handle.shutdown();
887 }
888
889 #[tokio::test]
897 async fn test_intercept_lifecycle_end_to_end() {
898 let dir = tempfile::tempdir().unwrap();
899 let ca_path_clone;
900
901 {
902 let config = ProxyConfig {
903 routes: vec![crate::config::RouteConfig {
904 prefix: "openai".to_string(),
905 upstream: "https://api.openai.com".to_string(),
906 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
907 inject_mode: Default::default(),
908 inject_header: "Authorization".to_string(),
909 credential_format: Some("Bearer {}".to_string()),
910 path_pattern: None,
911 path_replacement: None,
912 query_param_name: None,
913 proxy: None,
914 env_var: None,
915 endpoint_rules: vec![],
916 tls_ca: None,
917 tls_client_cert: None,
918 tls_client_key: None,
919 oauth2: None,
920 }],
921 intercept_ca_dir: Some(dir.path().to_path_buf()),
922 ..Default::default()
923 };
924 let handle = start(config).await.unwrap();
925 assert!(
926 handle.intercept_ca_path().is_some(),
927 "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
928 );
929 ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
930 assert!(
931 ca_path_clone.exists(),
932 "bundle file should have been written"
933 );
934
935 let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
936 assert!(
937 contents.contains("BEGIN CERTIFICATE"),
938 "bundle should contain at least one PEM block"
939 );
940
941 let vars = handle.env_vars();
943 let ssl = vars
944 .iter()
945 .find(|(k, _)| k == "SSL_CERT_FILE")
946 .expect("SSL_CERT_FILE should be set when intercept active");
947 assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
948 assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
949 assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
950 assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
951
952 handle.shutdown();
953 }
954 assert!(
956 !ca_path_clone.exists(),
957 "bundle should be removed when ProxyHandle drops"
958 );
959 }
960
961 #[tokio::test]
964 async fn test_intercept_skipped_for_purely_declarative_routes() {
965 let dir = tempfile::tempdir().unwrap();
966 let config = ProxyConfig {
967 routes: vec![crate::config::RouteConfig {
968 prefix: "alias".to_string(),
969 upstream: "https://aliased.example.com".to_string(),
970 credential_key: None,
971 inject_mode: Default::default(),
972 inject_header: "Authorization".to_string(),
973 credential_format: Some("Bearer {}".to_string()),
974 path_pattern: None,
975 path_replacement: None,
976 query_param_name: None,
977 proxy: None,
978 env_var: None,
979 endpoint_rules: vec![],
980 tls_ca: None,
981 tls_client_cert: None,
982 tls_client_key: None,
983 oauth2: None,
984 }],
985 intercept_ca_dir: Some(dir.path().to_path_buf()),
986 ..Default::default()
987 };
988 let handle = start(config).await.unwrap();
989 assert!(
990 handle.intercept_ca_path().is_none(),
991 "no L7-bearing route → no CA should be generated"
992 );
993 let vars = handle.env_vars();
994 assert!(
995 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
996 "trust env vars must not be set when intercept inactive"
997 );
998 handle.shutdown();
999 }
1000
1001 #[tokio::test]
1006 async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
1007 let missing_dir = tempfile::tempdir()
1008 .unwrap()
1009 .path()
1010 .join("missing")
1011 .join("intercept");
1012 let config = ProxyConfig {
1013 routes: vec![crate::config::RouteConfig {
1014 prefix: "openai".to_string(),
1015 upstream: "https://api.openai.com".to_string(),
1016 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1017 inject_mode: Default::default(),
1018 inject_header: "Authorization".to_string(),
1019 credential_format: Some("Bearer {}".to_string()),
1020 path_pattern: None,
1021 path_replacement: None,
1022 query_param_name: None,
1023 proxy: None,
1024 env_var: None,
1025 endpoint_rules: vec![],
1026 tls_ca: None,
1027 tls_client_cert: None,
1028 tls_client_key: None,
1029 oauth2: None,
1030 }],
1031 intercept_ca_dir: Some(missing_dir),
1032 ..Default::default()
1033 };
1034 let handle = start(config.clone()).await.unwrap();
1035 assert!(
1036 handle.intercept_ca_path().is_none(),
1037 "intercept setup failure should disable interception instead of aborting startup"
1038 );
1039 let vars = handle.env_vars();
1040 assert!(
1041 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1042 "trust env vars must not be set when interception setup fails"
1043 );
1044 let route_vars = handle.credential_env_vars(&config);
1045 assert!(
1046 route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1047 "reverse-proxy route env vars should still be emitted"
1048 );
1049 handle.shutdown();
1050 }
1051
1052 #[tokio::test]
1055 async fn test_route_diagnostics_summarises_each_route() {
1056 let dir = tempfile::tempdir().unwrap();
1057 let config = ProxyConfig {
1058 routes: vec![
1059 crate::config::RouteConfig {
1060 prefix: "openai".to_string(),
1061 upstream: "https://api.openai.com".to_string(),
1062 credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1063 inject_mode: Default::default(),
1064 inject_header: "Authorization".to_string(),
1065 credential_format: Some("Bearer {}".to_string()),
1066 path_pattern: None,
1067 path_replacement: None,
1068 query_param_name: None,
1069 proxy: None,
1070 env_var: None,
1071 endpoint_rules: vec![],
1072 tls_ca: None,
1073 tls_client_cert: None,
1074 tls_client_key: None,
1075 oauth2: None,
1076 },
1077 crate::config::RouteConfig {
1078 prefix: "alias".to_string(),
1079 upstream: "https://aliased.example.com".to_string(),
1080 credential_key: None,
1081 inject_mode: Default::default(),
1082 inject_header: "Authorization".to_string(),
1083 credential_format: Some("Bearer {}".to_string()),
1084 path_pattern: None,
1085 path_replacement: None,
1086 query_param_name: None,
1087 proxy: None,
1088 env_var: None,
1089 endpoint_rules: vec![],
1090 tls_ca: None,
1091 tls_client_cert: None,
1092 tls_client_key: None,
1093 oauth2: None,
1094 },
1095 ],
1096 intercept_ca_dir: Some(dir.path().to_path_buf()),
1097 ..Default::default()
1098 };
1099 let handle = start(config.clone()).await.unwrap();
1100 let rows = handle.route_diagnostics(&config);
1101 assert_eq!(rows.len(), 2);
1102
1103 let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1104 assert!(openai.1.contains("api.openai.com"));
1105 assert!(openai.1.contains("intercept: on"));
1106 assert!(
1107 openai.1.contains("✗") || openai.1.contains("not found"),
1108 "missing credential should show ✗, got: {}",
1109 openai.1
1110 );
1111
1112 let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1113 assert!(alias.1.contains("creds: none"));
1114 assert!(alias.1.contains("intercept: off"));
1115
1116 handle.shutdown();
1117 }
1118
1119 #[tokio::test]
1120 async fn test_proxy_env_vars() {
1121 let config = ProxyConfig::default();
1122 let handle = start(config).await.unwrap();
1123
1124 let vars = handle.env_vars();
1125 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1126 assert!(http_proxy.is_some());
1127 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1128
1129 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1130 assert!(token_var.is_some());
1131 assert_eq!(token_var.unwrap().1.len(), 64);
1132
1133 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1134 assert!(
1135 node_proxy_flag.is_some(),
1136 "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1137 );
1138 assert_eq!(
1139 node_proxy_flag.unwrap().1,
1140 "1",
1141 "NODE_USE_ENV_PROXY must be '1'"
1142 );
1143
1144 handle.shutdown();
1145 }
1146
1147 #[tokio::test]
1148 async fn test_proxy_credential_env_vars() {
1149 let config = ProxyConfig {
1150 routes: vec![crate::config::RouteConfig {
1151 prefix: "openai".to_string(),
1152 upstream: "https://api.openai.com".to_string(),
1153 credential_key: None,
1154 inject_mode: crate::config::InjectMode::Header,
1155 inject_header: "Authorization".to_string(),
1156 credential_format: Some("Bearer {}".to_string()),
1157 path_pattern: None,
1158 path_replacement: None,
1159 query_param_name: None,
1160 proxy: None,
1161 env_var: None,
1162 endpoint_rules: vec![],
1163 tls_ca: None,
1164 tls_client_cert: None,
1165 tls_client_key: None,
1166 oauth2: None,
1167 }],
1168 ..Default::default()
1169 };
1170 let handle = start(config.clone()).await.unwrap();
1171
1172 let vars = handle.credential_env_vars(&config);
1173 assert_eq!(vars.len(), 1);
1174 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1175 assert!(vars[0].1.contains("/openai"));
1176
1177 handle.shutdown();
1178 }
1179
1180 #[test]
1181 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1182 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1186 let handle = ProxyHandle {
1187 port: 12345,
1188 token: Zeroizing::new("test_token".to_string()),
1189 audit_log: audit::new_audit_log(),
1190 shutdown_tx,
1191 loaded_routes: ["openai".to_string()].into_iter().collect(),
1192 no_proxy_hosts: Vec::new(),
1193 intercept_ca_path: None,
1194 };
1195 let config = ProxyConfig {
1196 routes: vec![crate::config::RouteConfig {
1197 prefix: "openai".to_string(),
1198 upstream: "https://api.openai.com".to_string(),
1199 credential_key: Some("openai_api_key".to_string()),
1200 inject_mode: crate::config::InjectMode::Header,
1201 inject_header: "Authorization".to_string(),
1202 credential_format: Some("Bearer {}".to_string()),
1203 path_pattern: None,
1204 path_replacement: None,
1205 query_param_name: None,
1206 proxy: None,
1207 env_var: None, endpoint_rules: vec![],
1209 tls_ca: None,
1210 tls_client_cert: None,
1211 tls_client_key: None,
1212 oauth2: None,
1213 }],
1214 ..Default::default()
1215 };
1216
1217 let vars = handle.credential_env_vars(&config);
1218 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1222 assert!(
1223 api_key_var.is_some(),
1224 "Should derive env var name from credential_key.to_uppercase()"
1225 );
1226
1227 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1228 assert_eq!(val, "test_token");
1229 }
1230
1231 #[test]
1232 fn test_proxy_credential_env_vars_with_explicit_env_var() {
1233 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1241 let handle = ProxyHandle {
1242 port: 12345,
1243 token: Zeroizing::new("test_token".to_string()),
1244 audit_log: audit::new_audit_log(),
1245 shutdown_tx,
1246 loaded_routes: ["openai".to_string()].into_iter().collect(),
1247 no_proxy_hosts: Vec::new(),
1248 intercept_ca_path: None,
1249 };
1250 let config = ProxyConfig {
1251 routes: vec![crate::config::RouteConfig {
1252 prefix: "openai".to_string(),
1253 upstream: "https://api.openai.com".to_string(),
1254 credential_key: Some("op://Development/OpenAI/credential".to_string()),
1255 inject_mode: crate::config::InjectMode::Header,
1256 inject_header: "Authorization".to_string(),
1257 credential_format: Some("Bearer {}".to_string()),
1258 path_pattern: None,
1259 path_replacement: None,
1260 query_param_name: None,
1261 proxy: None,
1262 env_var: Some("OPENAI_API_KEY".to_string()),
1263 endpoint_rules: vec![],
1264 tls_ca: None,
1265 tls_client_cert: None,
1266 tls_client_key: None,
1267 oauth2: None,
1268 }],
1269 ..Default::default()
1270 };
1271
1272 let vars = handle.credential_env_vars(&config);
1273 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1276 assert!(
1277 api_key_var.is_some(),
1278 "Should use explicit env_var name, not derive from credential_key"
1279 );
1280
1281 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1283 assert_eq!(val, "test_token");
1284
1285 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1287 assert!(
1288 bad_var.is_none(),
1289 "Should not generate env var from op:// URI uppercase"
1290 );
1291 }
1292
1293 #[test]
1294 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1295 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1300 let handle = ProxyHandle {
1301 port: 12345,
1302 token: Zeroizing::new("test_token".to_string()),
1303 audit_log: audit::new_audit_log(),
1304 shutdown_tx,
1305 loaded_routes: ["openai".to_string()].into_iter().collect(),
1307 no_proxy_hosts: Vec::new(),
1308 intercept_ca_path: None,
1309 };
1310 let config = ProxyConfig {
1311 routes: vec![
1312 crate::config::RouteConfig {
1313 prefix: "openai".to_string(),
1314 upstream: "https://api.openai.com".to_string(),
1315 credential_key: Some("openai_api_key".to_string()),
1316 inject_mode: crate::config::InjectMode::Header,
1317 inject_header: "Authorization".to_string(),
1318 credential_format: Some("Bearer {}".to_string()),
1319 path_pattern: None,
1320 path_replacement: None,
1321 query_param_name: None,
1322 proxy: None,
1323 env_var: None,
1324 endpoint_rules: vec![],
1325 tls_ca: None,
1326 tls_client_cert: None,
1327 tls_client_key: None,
1328 oauth2: None,
1329 },
1330 crate::config::RouteConfig {
1331 prefix: "github".to_string(),
1332 upstream: "https://api.github.com".to_string(),
1333 credential_key: Some("env://GITHUB_TOKEN".to_string()),
1334 inject_mode: crate::config::InjectMode::Header,
1335 inject_header: "Authorization".to_string(),
1336 credential_format: Some("token {}".to_string()),
1337 path_pattern: None,
1338 path_replacement: None,
1339 query_param_name: None,
1340 proxy: None,
1341 env_var: Some("GITHUB_TOKEN".to_string()),
1342 endpoint_rules: vec![],
1343 tls_ca: None,
1344 tls_client_cert: None,
1345 tls_client_key: None,
1346 oauth2: None,
1347 },
1348 ],
1349 ..Default::default()
1350 };
1351
1352 let vars = handle.credential_env_vars(&config);
1353
1354 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1356 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1357 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1358 assert!(openai_key.is_some(), "loaded route should have API key");
1359
1360 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1363 assert!(
1364 github_base.is_some(),
1365 "declared route should still have BASE_URL"
1366 );
1367 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1368 assert!(
1369 github_token.is_none(),
1370 "unloaded route must not inject phantom GITHUB_TOKEN"
1371 );
1372 }
1373
1374 #[test]
1375 fn test_proxy_credential_env_vars_strips_slashes() {
1376 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1381 let handle = ProxyHandle {
1382 port: 58406,
1383 token: Zeroizing::new("test_token".to_string()),
1384 audit_log: audit::new_audit_log(),
1385 shutdown_tx,
1386 loaded_routes: std::collections::HashSet::new(),
1387 no_proxy_hosts: Vec::new(),
1388 intercept_ca_path: None,
1389 };
1390
1391 let config = ProxyConfig {
1393 routes: vec![crate::config::RouteConfig {
1394 prefix: "/anthropic".to_string(),
1395 upstream: "https://api.anthropic.com".to_string(),
1396 credential_key: None,
1397 inject_mode: crate::config::InjectMode::Header,
1398 inject_header: "Authorization".to_string(),
1399 credential_format: Some("Bearer {}".to_string()),
1400 path_pattern: None,
1401 path_replacement: None,
1402 query_param_name: None,
1403 proxy: None,
1404 env_var: None,
1405 endpoint_rules: vec![],
1406 tls_ca: None,
1407 tls_client_cert: None,
1408 tls_client_key: None,
1409 oauth2: None,
1410 }],
1411 ..Default::default()
1412 };
1413
1414 let vars = handle.credential_env_vars(&config);
1415 assert_eq!(vars.len(), 1);
1416 assert_eq!(
1417 vars[0].0, "ANTHROPIC_BASE_URL",
1418 "env var name must not have leading slash"
1419 );
1420 assert_eq!(
1421 vars[0].1, "http://127.0.0.1:58406/anthropic",
1422 "URL must not have double slash"
1423 );
1424
1425 let config = ProxyConfig {
1427 routes: vec![crate::config::RouteConfig {
1428 prefix: "openai/".to_string(),
1429 upstream: "https://api.openai.com".to_string(),
1430 credential_key: None,
1431 inject_mode: crate::config::InjectMode::Header,
1432 inject_header: "Authorization".to_string(),
1433 credential_format: Some("Bearer {}".to_string()),
1434 path_pattern: None,
1435 path_replacement: None,
1436 query_param_name: None,
1437 proxy: None,
1438 env_var: None,
1439 endpoint_rules: vec![],
1440 tls_ca: None,
1441 tls_client_cert: None,
1442 tls_client_key: None,
1443 oauth2: None,
1444 }],
1445 ..Default::default()
1446 };
1447
1448 let vars = handle.credential_env_vars(&config);
1449 assert_eq!(
1450 vars[0].0, "OPENAI_BASE_URL",
1451 "env var name must not have trailing slash"
1452 );
1453 assert_eq!(
1454 vars[0].1, "http://127.0.0.1:58406/openai",
1455 "URL must not have trailing slash in path"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_anthropic_credential_phantom_token_regression() {
1461 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1469 let handle_no_env_var = ProxyHandle {
1470 port: 12345,
1471 token: Zeroizing::new("phantom".to_string()),
1472 audit_log: audit::new_audit_log(),
1473 shutdown_tx: shutdown_tx.clone(),
1474 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1475 no_proxy_hosts: Vec::new(),
1476 intercept_ca_path: None,
1477 };
1478 let config_no_env_var = ProxyConfig {
1479 routes: vec![crate::config::RouteConfig {
1480 prefix: "anthropic".to_string(),
1481 upstream: "https://api.anthropic.com".to_string(),
1482 credential_key: None,
1483 inject_mode: crate::config::InjectMode::Header,
1484 inject_header: "x-api-key".to_string(),
1485 credential_format: Some("{}".to_string()),
1486 path_pattern: None,
1487 path_replacement: None,
1488 query_param_name: None,
1489 proxy: None,
1490 env_var: None,
1491 endpoint_rules: vec![],
1492 tls_ca: None,
1493 tls_client_cert: None,
1494 tls_client_key: None,
1495 oauth2: None,
1496 }],
1497 ..Default::default()
1498 };
1499 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1500 assert!(
1501 vars_no_env_var
1502 .iter()
1503 .all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1504 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1505 );
1506
1507 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1510 let handle_fixed = ProxyHandle {
1511 port: 12345,
1512 token: Zeroizing::new("phantom".to_string()),
1513 audit_log: audit::new_audit_log(),
1514 shutdown_tx: shutdown_tx2,
1515 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1516 no_proxy_hosts: Vec::new(),
1517 intercept_ca_path: None,
1518 };
1519 let config_fixed = ProxyConfig {
1520 routes: vec![crate::config::RouteConfig {
1521 prefix: "anthropic".to_string(),
1522 upstream: "https://api.anthropic.com".to_string(),
1523 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1524 inject_mode: crate::config::InjectMode::Header,
1525 inject_header: "x-api-key".to_string(),
1526 credential_format: Some("{}".to_string()),
1527 path_pattern: None,
1528 path_replacement: None,
1529 query_param_name: None,
1530 proxy: None,
1531 env_var: Some("ANTHROPIC_API_KEY".to_string()),
1532 endpoint_rules: vec![],
1533 tls_ca: None,
1534 tls_client_cert: None,
1535 tls_client_key: None,
1536 oauth2: None,
1537 }],
1538 ..Default::default()
1539 };
1540 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1541 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1542 assert!(
1543 api_key_var.is_some(),
1544 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1545 );
1546 assert_eq!(api_key_var.unwrap().1, "phantom");
1547 }
1548
1549 #[test]
1550 fn test_no_proxy_excludes_credential_upstreams() {
1551 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1552 let handle = ProxyHandle {
1553 port: 12345,
1554 token: Zeroizing::new("test_token".to_string()),
1555 audit_log: audit::new_audit_log(),
1556 shutdown_tx,
1557 loaded_routes: std::collections::HashSet::new(),
1558 no_proxy_hosts: vec![
1559 "nats.internal:4222".to_string(),
1560 "opencode.internal:4096".to_string(),
1561 ],
1562 intercept_ca_path: None,
1563 };
1564
1565 let vars = handle.env_vars();
1566 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1567 assert!(
1568 no_proxy.1.contains("nats.internal"),
1569 "non-credential host should be in NO_PROXY"
1570 );
1571 assert!(
1572 no_proxy.1.contains("opencode.internal"),
1573 "non-credential host should be in NO_PROXY"
1574 );
1575 assert!(
1576 no_proxy.1.contains("localhost"),
1577 "localhost should always be in NO_PROXY"
1578 );
1579 }
1580
1581 #[test]
1582 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1583 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1584 let handle = ProxyHandle {
1585 port: 12345,
1586 token: Zeroizing::new("test_token".to_string()),
1587 audit_log: audit::new_audit_log(),
1588 shutdown_tx,
1589 loaded_routes: std::collections::HashSet::new(),
1590 no_proxy_hosts: Vec::new(),
1591 intercept_ca_path: None,
1592 };
1593
1594 let vars = handle.env_vars();
1595 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1596 assert_eq!(
1597 no_proxy.1, "localhost,127.0.0.1",
1598 "NO_PROXY should only contain loopback when no bypass hosts"
1599 );
1600 }
1601
1602 #[tokio::test]
1603 async fn test_no_proxy_empty_without_direct_connect_ports() {
1604 let config = ProxyConfig {
1608 allowed_hosts: vec!["github.com".to_string()],
1609 ..Default::default()
1610 };
1611 let handle = start(config).await.unwrap();
1612
1613 let vars = handle.env_vars();
1614 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1615 assert_eq!(
1616 no_proxy.1, "localhost,127.0.0.1",
1617 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1618 );
1619
1620 handle.shutdown();
1621 }
1622
1623 #[cfg(not(target_os = "macos"))]
1624 #[tokio::test]
1625 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1626 let config = ProxyConfig {
1630 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1631 direct_connect_ports: vec![443],
1632 ..Default::default()
1633 };
1634 let handle = start(config).await.unwrap();
1635
1636 let vars = handle.env_vars();
1637 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1638 assert!(
1639 no_proxy.1.contains("github.com"),
1640 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1641 );
1642 assert!(
1643 !no_proxy.1.contains("server.internal"),
1644 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1645 );
1646
1647 handle.shutdown();
1648 }
1649}