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