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 url::Url;
30use zeroize::Zeroizing;
31
32const MAX_HEADER_SIZE: usize = 64 * 1024;
35
36fn parse_non_connect_target(line: &str) -> Result<(String, u16)> {
41 let mut parts = line.split_whitespace();
42 let _method = parts.next();
43 let url = parts
44 .next()
45 .ok_or_else(|| ProxyError::HttpParse(format!("malformed request line: {}", line)))?;
46 let parsed = Url::parse(url)
47 .map_err(|e| ProxyError::HttpParse(format!("invalid URL in request: {}: {}", url, e)))?;
48 let host = parsed
49 .host_str()
50 .ok_or_else(|| ProxyError::HttpParse(format!("no host in URL: {}", url)))?
51 .to_string();
52 let port = parsed.port_or_known_default().unwrap_or(80);
53 Ok((host, port))
54}
55
56#[must_use]
57fn proxy_diagnostic_code_label(code: crate::diagnostic::ProxyDiagnosticCode) -> &'static str {
58 code.as_str()
59}
60
61pub struct ProxyHandle {
66 pub port: u16,
68 pub token: Zeroizing<String>,
70 audit_log: audit::SharedAuditLog,
72 shutdown_tx: watch::Sender<bool>,
74 loaded_routes: std::collections::HashSet<String>,
78 no_proxy_hosts: Vec<String>,
81 intercept_ca_path: Option<PathBuf>,
87 diagnostics: Vec<crate::diagnostic::ProxyDiagnostic>,
89}
90
91impl ProxyHandle {
92 pub fn shutdown(&self) {
94 let _ = self.shutdown_tx.send(true);
95 }
96
97 #[must_use]
99 pub fn drain_audit_events(&self) -> Vec<nono::undo::NetworkAuditEvent> {
100 audit::drain_audit_events(&self.audit_log)
101 }
102
103 #[must_use]
114 pub fn intercept_ca_path(&self) -> Option<&std::path::Path> {
115 self.intercept_ca_path.as_deref()
116 }
117
118 #[must_use]
120 pub fn diagnostics(&self) -> &[crate::diagnostic::ProxyDiagnostic] {
121 &self.diagnostics
122 }
123
124 pub fn diagnostics_json(&self) -> crate::Result<String> {
130 serde_json::to_string(&self.diagnostics)
131 .map_err(|e| ProxyError::Config(format!("proxy diagnostics JSON error: {e}")))
132 }
133
134 #[must_use]
146 pub fn route_diagnostics(&self, config: &ProxyConfig) -> Vec<(String, String)> {
147 let mut rows = Vec::with_capacity(config.routes.len());
148 for route in &config.routes {
149 let prefix = route.prefix.trim_matches('/').to_string();
150 let cred_summary = self.credential_status_summary(&prefix, route);
151
152 let intercept_summary = if self.intercept_ca_path.is_some()
153 && (route.credential_key.is_some()
154 || route.oauth2.is_some()
155 || !route.endpoint_rules.is_empty())
156 {
157 "intercept: on"
158 } else {
159 "intercept: off"
160 };
161
162 let rules_summary = format!("endpoint_rules: {}", route.endpoint_rules.len());
163 let summary = format!(
164 "→ {} | {} | {} | {}",
165 route.upstream, cred_summary, intercept_summary, rules_summary
166 );
167 rows.push((prefix, summary));
168 }
169 rows
170 }
171
172 fn credential_status_summary(
173 &self,
174 prefix: &str,
175 route: &crate::config::RouteConfig,
176 ) -> String {
177 if let Some(diagnostic) = self
178 .diagnostics
179 .iter()
180 .find(|entry| entry.route_prefix == prefix)
181 {
182 let code = proxy_diagnostic_code_label(diagnostic.code);
183 let cred_ref = diagnostic.credential_ref.as_deref().unwrap_or("credential");
184 return format!("creds: {cred_ref} ✗ ({code})");
185 }
186
187 if let Some(ref key) = route.credential_key {
188 let resolved = self.loaded_routes.contains(prefix);
189 if resolved {
190 format!("creds: {} ✓", key)
191 } else {
192 format!("creds: {} ✗ (not found)", key)
193 }
194 } else if route.oauth2.is_some() {
195 let resolved = self.loaded_routes.contains(prefix);
196 if resolved {
197 "creds: oauth2 ✓".to_string()
198 } else {
199 "creds: oauth2 ✗ (token exchange failed)".to_string()
200 }
201 } else {
202 "creds: none".to_string()
203 }
204 }
205
206 #[must_use]
219 pub fn env_vars(&self) -> Vec<(String, String)> {
220 let proxy_url = format!("http://nono:{}@127.0.0.1:{}", &*self.token, self.port);
221
222 let mut no_proxy_parts = vec!["localhost".to_string(), "127.0.0.1".to_string()];
226 for host in &self.no_proxy_hosts {
227 let hostname = if host.contains("]:") {
230 host.rsplit_once("]:")
232 .map(|(h, _)| format!("{}]", h))
233 .unwrap_or_else(|| host.clone())
234 } else {
235 host.rsplit_once(':')
236 .and_then(|(h, p)| p.parse::<u16>().ok().map(|_| h.to_string()))
237 .unwrap_or_else(|| host.clone())
238 };
239 if !no_proxy_parts.contains(&hostname.to_string()) {
240 no_proxy_parts.push(hostname.to_string());
241 }
242 }
243 let no_proxy = no_proxy_parts.join(",");
244
245 let mut vars = vec![
246 ("HTTP_PROXY".to_string(), proxy_url.clone()),
247 ("HTTPS_PROXY".to_string(), proxy_url.clone()),
248 ("NO_PROXY".to_string(), no_proxy.clone()),
249 ("NONO_PROXY_TOKEN".to_string(), self.token.to_string()),
250 ];
251
252 vars.push(("http_proxy".to_string(), proxy_url.clone()));
254 vars.push(("https_proxy".to_string(), proxy_url));
255 vars.push(("no_proxy".to_string(), no_proxy));
256
257 vars.push(("NODE_USE_ENV_PROXY".to_string(), "1".to_string()));
264
265 if let Some(path) = self.intercept_ca_path.as_deref() {
279 let path_str = path.to_string_lossy().to_string();
280 vars.push(("SSL_CERT_FILE".to_string(), path_str.clone()));
281 vars.push(("REQUESTS_CA_BUNDLE".to_string(), path_str.clone()));
282 vars.push(("NODE_EXTRA_CA_CERTS".to_string(), path_str.clone()));
283 vars.push(("CURL_CA_BUNDLE".to_string(), path_str.clone()));
284 vars.push(("GIT_SSL_CAINFO".to_string(), path_str));
285 }
286
287 vars
288 }
289
290 #[must_use]
299 pub fn credential_env_vars(&self, config: &ProxyConfig) -> Vec<(String, String)> {
300 let mut vars = Vec::new();
301 for route in &config.routes {
302 let prefix = route.prefix.trim_matches('/');
307
308 let base_url_name = format!("{}_BASE_URL", prefix.to_uppercase());
310 let url = format!("http://127.0.0.1:{}/{}", self.port, prefix);
311 vars.push((base_url_name, url));
312
313 if !self.loaded_routes.contains(prefix) {
318 continue;
319 }
320
321 if let Some(ref env_var) = route.env_var {
325 vars.push((env_var.clone(), self.token.to_string()));
326 } else if let Some(ref cred_key) = route.credential_key {
327 if !cred_key.contains("://") {
331 let api_key_name = cred_key.to_uppercase();
332 vars.push((api_key_name, self.token.to_string()));
333 }
334 }
335 }
336 vars
337 }
338}
339
340impl Drop for ProxyHandle {
341 fn drop(&mut self) {
352 if let Some(path) = self.intercept_ca_path.take() {
353 let _ = std::fs::remove_file(&path);
354 if let Some(parent) = path.parent() {
359 let _ = std::fs::remove_dir(parent);
360 }
361 }
362 }
363}
364
365struct ProxyState {
367 filter: ProxyFilter,
368 session_token: Zeroizing<String>,
369 route_store: RouteStore,
371 credential_store: CredentialStore,
373 config: ProxyConfig,
374 tls_connector: tokio_rustls::TlsConnector,
377 active_connections: AtomicUsize,
379 audit_log: audit::SharedAuditLog,
381 bypass_matcher: external::BypassMatcher,
384 cert_cache: Option<Arc<CertCache>>,
389}
390
391pub async fn start(config: ProxyConfig) -> Result<ProxyHandle> {
399 let session_token = token::generate_session_token()?;
401
402 let bind_addr = SocketAddr::new(config.bind_addr, config.bind_port);
404 let listener = TcpListener::bind(bind_addr)
405 .await
406 .map_err(|e| ProxyError::Bind {
407 addr: bind_addr.to_string(),
408 source: e,
409 })?;
410
411 let local_addr = listener.local_addr().map_err(|e| ProxyError::Bind {
412 addr: bind_addr.to_string(),
413 source: e,
414 })?;
415 let port = local_addr.port();
416
417 info!("Proxy server listening on {}", local_addr);
418
419 let route_store = if config.routes.is_empty() {
422 RouteStore::empty()
423 } else {
424 RouteStore::load(&config.routes)?
425 };
426 let mut root_store = rustls::RootCertStore::empty();
432 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
433 let native = rustls_native_certs::load_native_certs();
434 if !native.errors.is_empty() {
435 debug!(
436 "failed to load {} native cert(s); continuing with webpki roots + any that succeeded",
437 native.errors.len()
438 );
439 }
440 let native_count = native.certs.len();
441 for cert in native.certs {
442 if let Err(e) = root_store.add(cert) {
443 debug!("skipping unparseable native cert: {e}");
444 }
445 }
446 if native_count > 0 {
447 debug!("added {native_count} native system CA(s) to upstream trust store");
448 }
449 let tls_config = rustls::ClientConfig::builder_with_provider(Arc::new(
450 rustls::crypto::ring::default_provider(),
451 ))
452 .with_safe_default_protocol_versions()
453 .map_err(|e| ProxyError::Config(format!("TLS config error: {}", e)))?
454 .with_root_certificates(root_store)
455 .with_no_client_auth();
456 let tls_connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config));
457
458 let (credential_store, proxy_diagnostics) = if config.routes.is_empty() {
460 (CredentialStore::empty(), Vec::new())
461 } else {
462 let outcome = CredentialStore::load_with_diagnostics(&config.routes, &tls_connector)?;
463 (outcome.store, outcome.diagnostics)
464 };
465 let loaded_routes = credential_store.loaded_prefixes();
466
467 let filter = if config.strict_filter {
469 ProxyFilter::new_strict(&config.allowed_hosts)
470 } else if config.allowed_hosts.is_empty() {
471 ProxyFilter::allow_all()
472 } else {
473 ProxyFilter::new(&config.allowed_hosts)
474 };
475
476 let bypass_matcher = config
478 .external_proxy
479 .as_ref()
480 .map(|ext| external::BypassMatcher::new(&ext.bypass_hosts))
481 .unwrap_or_else(|| external::BypassMatcher::new(&[]));
482
483 let (shutdown_tx, shutdown_rx) = watch::channel(false);
485 let audit_log = audit::new_audit_log();
486
487 let no_proxy_hosts: Vec<String> = if cfg!(target_os = "macos") {
499 Vec::new()
500 } else {
501 let route_hosts = route_store.route_upstream_hosts();
502 config
503 .allowed_hosts
504 .iter()
505 .filter(|host| {
506 let normalised = {
507 let h = host.to_lowercase();
508 if h.starts_with('[') {
509 if h.contains("]:") {
511 h
512 } else {
513 format!("{}:443", h)
514 }
515 } else if h.contains(':') {
516 h
517 } else {
518 format!("{}:443", h)
519 }
520 };
521 if route_hosts.contains(&normalised) {
522 return false;
523 }
524 let port = normalised
527 .rsplit_once(':')
528 .and_then(|(_, p)| p.parse::<u16>().ok())
529 .unwrap_or(443);
530 config.direct_connect_ports.contains(&port)
531 })
532 .cloned()
533 .collect()
534 };
535
536 if !no_proxy_hosts.is_empty() {
537 debug!("Smart NO_PROXY bypass hosts: {:?}", no_proxy_hosts);
538 }
539
540 let any_intercept_route = route_store
546 .route_upstream_hosts()
547 .iter()
548 .any(|hp| route_store.has_intercept_route(hp));
549 let (cert_cache, intercept_ca_path) = match (&config.intercept_ca_dir, any_intercept_route) {
550 (Some(dir), true) => {
551 let intercept_route_count = route_store
552 .route_upstream_hosts()
553 .iter()
554 .filter(|hp| route_store.has_intercept_route(hp))
555 .count();
556 let ca_result = if let Some(ref preloaded) = config.preloaded_ca {
557 EphemeralCa::from_existing(&preloaded.key_der, &preloaded.cert_pem)
558 } else {
559 let validity = config
560 .ca_validity
561 .unwrap_or(crate::tls_intercept::ca::CA_VALIDITY_DEFAULT);
562 EphemeralCa::generate_with_cn("nono-session-ca", validity)
563 };
564 match ca_result.and_then(|ca| {
565 let ca = Arc::new(ca);
566 let cache = Arc::new(CertCache::new(Arc::clone(&ca)));
567 let path = tls_intercept::write_bundle(tls_intercept::BundleInputs {
568 dir,
569 filename: "intercept-ca.pem",
570 parent_ssl_cert_file: config.intercept_parent_ca_pems.as_deref(),
571 ephemeral_ca_pem: ca.cert_pem(),
572 })?;
573 Ok((cache, path))
574 }) {
575 Ok((cache, path)) => {
576 info!(
577 "TLS interception active for {} route(s); trust bundle at {}",
578 intercept_route_count,
579 path.display()
580 );
581 (Some(cache), Some(path))
582 }
583 Err(e) => {
584 warn!(
585 "TLS interception setup failed for {} route(s): {}. \
586 Continuing with interception disabled; reverse-proxy routes remain available.",
587 intercept_route_count, e
588 );
589 (None, None)
590 }
591 }
592 }
593 (Some(_), false) => {
594 debug!(
595 "TLS interception requested but no configured route requires L7 visibility; \
596 skipping CA generation"
597 );
598 (None, None)
599 }
600 (None, _) => (None, None),
601 };
602
603 let state = Arc::new(ProxyState {
604 filter,
605 session_token: session_token.clone(),
606 route_store,
607 credential_store,
608 config,
609 tls_connector,
610 active_connections: AtomicUsize::new(0),
611 audit_log: Arc::clone(&audit_log),
612 bypass_matcher,
613 cert_cache,
614 });
615
616 tokio::spawn(accept_loop(listener, state, shutdown_rx));
620
621 Ok(ProxyHandle {
622 port,
623 token: session_token,
624 audit_log,
625 shutdown_tx,
626 loaded_routes,
627 no_proxy_hosts,
628 intercept_ca_path,
629 diagnostics: proxy_diagnostics,
630 })
631}
632
633async fn accept_loop(
635 listener: TcpListener,
636 state: Arc<ProxyState>,
637 mut shutdown_rx: watch::Receiver<bool>,
638) {
639 loop {
640 tokio::select! {
641 result = listener.accept() => {
642 match result {
643 Ok((stream, addr)) => {
644 let max = state.config.max_connections;
646 if max > 0 {
647 let current = state.active_connections.load(Ordering::Relaxed);
648 if current >= max {
649 warn!("Connection limit reached ({}/{}), rejecting {}", current, max, addr);
650 drop(stream);
652 continue;
653 }
654 }
655 state.active_connections.fetch_add(1, Ordering::Relaxed);
656
657 debug!("Accepted connection from {}", addr);
658 let state = Arc::clone(&state);
659 tokio::spawn(async move {
660 if let Err(e) = handle_connection(stream, &state).await {
661 debug!("Connection handler error: {}", e);
662 }
663 state.active_connections.fetch_sub(1, Ordering::Relaxed);
664 });
665 }
666 Err(e) => {
667 warn!("Accept error: {}", e);
668 }
669 }
670 }
671 _ = shutdown_rx.changed() => {
672 if *shutdown_rx.borrow() {
673 info!("Proxy server shutting down");
674 return;
675 }
676 }
677 }
678 }
679}
680
681fn normalize_authority(authority: &str) -> String {
685 if authority.starts_with('[') {
686 if authority.contains("]:") {
687 authority.to_lowercase()
688 } else {
689 format!("{}:443", authority.to_lowercase())
690 }
691 } else if authority.contains(':') {
692 authority.to_lowercase()
693 } else {
694 format!("{}:443", authority.to_lowercase())
695 }
696}
697
698async fn handle_connection(mut stream: tokio::net::TcpStream, state: &ProxyState) -> Result<()> {
704 let mut buf_reader = BufReader::new(&mut stream);
708 let mut first_line = String::new();
709 buf_reader.read_line(&mut first_line).await?;
710
711 if first_line.is_empty() {
712 return Ok(()); }
714
715 let mut header_bytes = Vec::new();
717 loop {
718 let mut line = String::new();
719 let n = buf_reader.read_line(&mut line).await?;
720 if n == 0 || line.trim().is_empty() {
721 break;
722 }
723 header_bytes.extend_from_slice(line.as_bytes());
724 if header_bytes.len() > MAX_HEADER_SIZE {
725 drop(buf_reader);
726 let response = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
727 stream.write_all(response.as_bytes()).await?;
728 return Ok(());
729 }
730 }
731
732 let buffered = buf_reader.buffer().to_vec();
737 drop(buf_reader);
738
739 let first_line = first_line.trim_end();
740
741 if first_line.starts_with("CONNECT ") {
743 if !state.route_store.is_empty()
759 && let Some(authority) = first_line.split_whitespace().nth(1)
760 {
761 let host_port = normalize_authority(authority);
762
763 if state.route_store.is_route_upstream(&host_port) {
764 let route_id = state
765 .route_store
766 .lookup_by_upstream(&host_port)
767 .map(|(prefix, _)| prefix);
768 let (host, port) = host_port
769 .rsplit_once(':')
770 .map(|(h, p)| (h.to_string(), p.parse::<u16>().unwrap_or(443)))
771 .unwrap_or_else(|| (host_port.clone(), 443));
772
773 let intercept_eligible = state.route_store.has_intercept_route(&host_port);
774
775 match (intercept_eligible, state.cert_cache.as_ref()) {
776 (true, Some(cache)) => {
778 let mut current_headers = header_bytes;
791 loop {
792 match token::validate_proxy_auth(¤t_headers, &state.session_token)
793 {
794 Ok(()) => break,
795 Err(e) => {
796 debug!(
797 "tls_intercept: CONNECT to {}:{} missing/invalid proxy auth — {}",
798 host, port, e
799 );
800 audit::log_denied(
801 Some(&state.audit_log),
802 audit::ProxyMode::ConnectIntercept,
803 &audit::EventContext {
804 route_id,
805 auth_mechanism: Some(
806 nono::undo::NetworkAuditAuthMechanism::ProxyAuthorization,
807 ),
808 auth_outcome: Some(
809 nono::undo::NetworkAuditAuthOutcome::Failed,
810 ),
811 denial_category: Some(
812 nono::undo::NetworkAuditDenialCategory::AuthenticationFailed,
813 ),
814 ..audit::EventContext::default()
815 },
816 &host,
817 port,
818 "proxy auth missing or invalid",
819 );
820 let response = "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"nono\"\r\nContent-Length: 0\r\n\r\n";
821 stream.write_all(response.as_bytes()).await?;
822
823 let mut buf_reader = BufReader::new(&mut stream);
826 let mut retry_line = String::new();
827 buf_reader.read_line(&mut retry_line).await?;
828 if retry_line.is_empty() {
829 return Ok(()); }
831 let mut retry_headers = Vec::new();
832 loop {
833 let mut line = String::new();
834 let n = buf_reader.read_line(&mut line).await?;
835 if n == 0 || line.trim().is_empty() {
836 break;
837 }
838 retry_headers.extend_from_slice(line.as_bytes());
839 if retry_headers.len() > MAX_HEADER_SIZE {
840 drop(buf_reader);
841 let too_large = "HTTP/1.1 431 Request Header Fields Too Large\r\n\r\n";
842 stream.write_all(too_large.as_bytes()).await?;
843 return Ok(());
844 }
845 }
846 drop(buf_reader);
847
848 let same_authority = retry_line
853 .trim_end()
854 .strip_prefix("CONNECT ")
855 .and_then(|rest| rest.split_whitespace().next())
856 .map(normalize_authority)
857 .as_deref()
858 == Some(host_port.as_str());
859 if !same_authority {
860 return Ok(());
861 }
862 current_headers = retry_headers;
863 }
864 }
865 }
866
867 let upstream_proxy =
871 if let Some(ref ext_config) = state.config.external_proxy {
872 let bypassed = !state.bypass_matcher.is_empty()
873 && state.bypass_matcher.matches(&host);
874 if bypassed {
875 debug!("tls_intercept: bypassing upstream proxy for {}", host);
876 None
877 } else if ext_config.auth.is_some() {
878 let msg = "external proxy authentication is configured \
883 but not yet implemented; remove the auth \
884 section from the external proxy config or \
885 wait for a future release";
886 audit::log_denied(
887 Some(&state.audit_log),
888 audit::ProxyMode::ConnectIntercept,
889 &audit::EventContext {
890 route_id,
891 ..audit::EventContext::default()
892 },
893 &host,
894 port,
895 msg,
896 );
897 let response =
898 "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n";
899 stream.write_all(response.as_bytes()).await?;
900 return Err(ProxyError::ExternalProxy(msg.to_string()));
901 } else {
902 Some(tls_intercept::InterceptUpstreamProxy {
903 proxy_addr: &ext_config.address,
904 proxy_auth_header: None,
905 })
906 }
907 } else {
908 None
909 };
910
911 let ctx = tls_intercept::InterceptCtx {
912 route_id,
913 host: &host,
914 port,
915 route_store: &state.route_store,
916 credential_store: &state.credential_store,
917 session_token: &state.session_token,
918 cert_cache: Arc::clone(cache),
919 tls_connector: &state.tls_connector,
920 filter: &state.filter,
921 audit_log: Some(&state.audit_log),
922 upstream_proxy,
923 };
924 return tls_intercept::handle_intercept_connect(&mut stream, ctx).await;
925 }
926 _ => {
930 debug!(
931 "Blocked CONNECT to route upstream {} — use reverse proxy path instead",
932 authority
933 );
934 audit::log_denied(
935 Some(&state.audit_log),
936 audit::ProxyMode::Connect,
937 &audit::EventContext {
938 route_id,
939 denial_category: Some(
940 nono::undo::NetworkAuditDenialCategory::ConnectBypassesL7,
941 ),
942 ..audit::EventContext::default()
943 },
944 &host,
945 port,
946 "route upstream: CONNECT bypasses L7 filtering",
947 );
948 let response = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n";
949 stream.write_all(response.as_bytes()).await?;
950 return Ok(());
951 }
952 }
953 }
954 }
955
956 let use_external = if let Some(ref ext_config) = state.config.external_proxy {
958 if state.bypass_matcher.is_empty() {
959 Some(ext_config)
960 } else {
961 let host = first_line
963 .split_whitespace()
964 .nth(1)
965 .and_then(|authority| {
966 authority
967 .rsplit_once(':')
968 .map(|(h, _)| h)
969 .or(Some(authority))
970 })
971 .unwrap_or("");
972 if state.bypass_matcher.matches(host) {
973 debug!("Bypassing external proxy for {}", host);
974 None
975 } else {
976 Some(ext_config)
977 }
978 }
979 } else {
980 None
981 };
982
983 if let Some(ext_config) = use_external {
984 external::handle_external_proxy(
985 first_line,
986 &mut stream,
987 &header_bytes,
988 &state.filter,
989 &state.session_token,
990 ext_config,
991 Some(&state.audit_log),
992 )
993 .await
994 } else if state.config.external_proxy.is_some() {
995 token::validate_proxy_auth(&header_bytes, &state.session_token)?;
1000 connect::handle_connect(
1001 first_line,
1002 &mut stream,
1003 &state.filter,
1004 &state.session_token,
1005 &header_bytes,
1006 Some(&state.audit_log),
1007 )
1008 .await
1009 } else {
1010 connect::handle_connect(
1011 first_line,
1012 &mut stream,
1013 &state.filter,
1014 &state.session_token,
1015 &header_bytes,
1016 Some(&state.audit_log),
1017 )
1018 .await
1019 }
1020 } else if !state.route_store.is_empty() {
1021 let ctx = reverse::ReverseProxyCtx {
1023 route_store: &state.route_store,
1024 credential_store: &state.credential_store,
1025 session_token: &state.session_token,
1026 filter: &state.filter,
1027 tls_connector: &state.tls_connector,
1028 audit_log: Some(&state.audit_log),
1029 };
1030 reverse::handle_reverse_proxy(first_line, &mut stream, &header_bytes, &ctx, &buffered).await
1031 } else {
1032 let (host, port) = parse_non_connect_target(first_line)?;
1034 let check = state.filter.check_host(&host, port).await?;
1035 if !check.result.is_allowed() {
1036 let reason = check.result.reason();
1037 audit::log_denied(
1038 Some(&state.audit_log),
1039 audit::ProxyMode::Connect,
1040 &audit::EventContext {
1041 denial_category: Some(nono::undo::NetworkAuditDenialCategory::HostDenied),
1042 ..audit::EventContext::default()
1043 },
1044 &host,
1045 port,
1046 &reason,
1047 );
1048 let sanitised = reason.replace(['\r', '\n'], " ");
1049 let response = format!("HTTP/1.1 403 Forbidden: {}\r\n\r\n", sanitised);
1050 stream.write_all(response.as_bytes()).await?;
1051 } else {
1052 stream
1053 .write_all(b"HTTP/1.1 502 Bad Gateway\r\n\r\n")
1054 .await?;
1055 }
1056 Ok(())
1057 }
1058}
1059
1060#[cfg(test)]
1061#[allow(clippy::unwrap_used)]
1062mod tests {
1063 use super::*;
1064
1065 #[test]
1066 fn normalize_authority_normalises_case_and_default_port() {
1067 assert_eq!(normalize_authority("API.OpenAI.com"), "api.openai.com:443");
1068 assert_eq!(
1069 normalize_authority("api.openai.com:443"),
1070 "api.openai.com:443"
1071 );
1072 assert_eq!(
1073 normalize_authority("api.openai.com:8443"),
1074 "api.openai.com:8443"
1075 );
1076 assert_eq!(normalize_authority("[::1]"), "[::1]:443");
1077 assert_eq!(normalize_authority("[::1]:8443"), "[::1]:8443");
1078 assert_eq!(
1080 normalize_authority("API.OPENAI.COM:443"),
1081 normalize_authority("api.openai.com")
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn test_proxy_starts_and_binds() {
1087 let config = ProxyConfig::default();
1088 let handle = start(config).await.unwrap();
1089
1090 assert!(handle.port > 0);
1092 assert_eq!(handle.token.len(), 64);
1094
1095 handle.shutdown();
1097 }
1098
1099 #[tokio::test]
1107 async fn test_intercept_lifecycle_end_to_end() {
1108 let dir = tempfile::tempdir().unwrap();
1109 let ca_path_clone;
1110
1111 {
1112 let config = ProxyConfig {
1113 routes: vec![crate::config::RouteConfig {
1114 prefix: "openai".to_string(),
1115 upstream: "https://api.openai.com".to_string(),
1116 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1117 inject_mode: Default::default(),
1118 inject_header: "Authorization".to_string(),
1119 credential_format: Some("Bearer {}".to_string()),
1120 path_pattern: None,
1121 path_replacement: None,
1122 query_param_name: None,
1123 proxy: None,
1124 env_var: None,
1125 endpoint_rules: vec![],
1126 tls_ca: None,
1127 tls_client_cert: None,
1128 tls_client_key: None,
1129 oauth2: None,
1130 aws_auth: None,
1131 }],
1132 intercept_ca_dir: Some(dir.path().to_path_buf()),
1133 ..Default::default()
1134 };
1135 let handle = start(config).await.unwrap();
1136 assert!(
1137 handle.intercept_ca_path().is_some(),
1138 "intercept-eligible route + intercept_ca_dir → bundle path should be Some"
1139 );
1140 ca_path_clone = handle.intercept_ca_path().unwrap().to_path_buf();
1141 assert!(
1142 ca_path_clone.exists(),
1143 "bundle file should have been written"
1144 );
1145
1146 let contents = std::fs::read_to_string(&ca_path_clone).unwrap();
1147 assert!(
1148 contents.contains("BEGIN CERTIFICATE"),
1149 "bundle should contain at least one PEM block"
1150 );
1151
1152 let vars = handle.env_vars();
1154 let ssl = vars
1155 .iter()
1156 .find(|(k, _)| k == "SSL_CERT_FILE")
1157 .expect("SSL_CERT_FILE should be set when intercept active");
1158 assert_eq!(std::path::Path::new(&ssl.1), ca_path_clone);
1159 assert!(vars.iter().any(|(k, _)| k == "REQUESTS_CA_BUNDLE"));
1160 assert!(vars.iter().any(|(k, _)| k == "NODE_EXTRA_CA_CERTS"));
1161 assert!(vars.iter().any(|(k, _)| k == "CURL_CA_BUNDLE"));
1162
1163 handle.shutdown();
1164 }
1165 assert!(
1167 !ca_path_clone.exists(),
1168 "bundle should be removed when ProxyHandle drops"
1169 );
1170 }
1171
1172 #[tokio::test]
1175 async fn test_intercept_skipped_for_purely_declarative_routes() {
1176 let dir = tempfile::tempdir().unwrap();
1177 let config = ProxyConfig {
1178 routes: vec![crate::config::RouteConfig {
1179 prefix: "alias".to_string(),
1180 upstream: "https://aliased.example.com".to_string(),
1181 credential_key: None,
1182 inject_mode: Default::default(),
1183 inject_header: "Authorization".to_string(),
1184 credential_format: Some("Bearer {}".to_string()),
1185 path_pattern: None,
1186 path_replacement: None,
1187 query_param_name: None,
1188 proxy: None,
1189 env_var: None,
1190 endpoint_rules: vec![],
1191 tls_ca: None,
1192 tls_client_cert: None,
1193 tls_client_key: None,
1194 oauth2: None,
1195 aws_auth: None,
1196 }],
1197 intercept_ca_dir: Some(dir.path().to_path_buf()),
1198 ..Default::default()
1199 };
1200 let handle = start(config).await.unwrap();
1201 assert!(
1202 handle.intercept_ca_path().is_none(),
1203 "no L7-bearing route → no CA should be generated"
1204 );
1205 let vars = handle.env_vars();
1206 assert!(
1207 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1208 "trust env vars must not be set when intercept inactive"
1209 );
1210 handle.shutdown();
1211 }
1212
1213 #[tokio::test]
1218 async fn test_intercept_setup_failure_degrades_without_aborting_proxy() {
1219 let missing_dir = tempfile::tempdir()
1220 .unwrap()
1221 .path()
1222 .join("missing")
1223 .join("intercept");
1224 let config = ProxyConfig {
1225 routes: vec![crate::config::RouteConfig {
1226 prefix: "openai".to_string(),
1227 upstream: "https://api.openai.com".to_string(),
1228 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1229 inject_mode: Default::default(),
1230 inject_header: "Authorization".to_string(),
1231 credential_format: Some("Bearer {}".to_string()),
1232 path_pattern: None,
1233 path_replacement: None,
1234 query_param_name: None,
1235 proxy: None,
1236 env_var: None,
1237 endpoint_rules: vec![],
1238 tls_ca: None,
1239 tls_client_cert: None,
1240 tls_client_key: None,
1241 oauth2: None,
1242 aws_auth: None,
1243 }],
1244 intercept_ca_dir: Some(missing_dir),
1245 ..Default::default()
1246 };
1247 let handle = start(config.clone()).await.unwrap();
1248 assert!(
1249 handle.intercept_ca_path().is_none(),
1250 "intercept setup failure should disable interception instead of aborting startup"
1251 );
1252 let vars = handle.env_vars();
1253 assert!(
1254 vars.iter().all(|(k, _)| k != "SSL_CERT_FILE"),
1255 "trust env vars must not be set when interception setup fails"
1256 );
1257 let route_vars = handle.credential_env_vars(&config);
1258 assert!(
1259 route_vars.iter().any(|(k, _)| k == "OPENAI_BASE_URL"),
1260 "reverse-proxy route env vars should still be emitted"
1261 );
1262 handle.shutdown();
1263 }
1264
1265 #[tokio::test]
1268 async fn test_route_diagnostics_summarises_each_route() {
1269 let dir = tempfile::tempdir().unwrap();
1270 let config = ProxyConfig {
1271 routes: vec![
1272 crate::config::RouteConfig {
1273 prefix: "openai".to_string(),
1274 upstream: "https://api.openai.com".to_string(),
1275 credential_key: Some("env://NONO_TEST_MISSING".to_string()),
1276 inject_mode: Default::default(),
1277 inject_header: "Authorization".to_string(),
1278 credential_format: Some("Bearer {}".to_string()),
1279 path_pattern: None,
1280 path_replacement: None,
1281 query_param_name: None,
1282 proxy: None,
1283 env_var: None,
1284 endpoint_rules: vec![],
1285 tls_ca: None,
1286 tls_client_cert: None,
1287 tls_client_key: None,
1288 oauth2: None,
1289 aws_auth: None,
1290 },
1291 crate::config::RouteConfig {
1292 prefix: "alias".to_string(),
1293 upstream: "https://aliased.example.com".to_string(),
1294 credential_key: None,
1295 inject_mode: Default::default(),
1296 inject_header: "Authorization".to_string(),
1297 credential_format: Some("Bearer {}".to_string()),
1298 path_pattern: None,
1299 path_replacement: None,
1300 query_param_name: None,
1301 proxy: None,
1302 env_var: None,
1303 endpoint_rules: vec![],
1304 tls_ca: None,
1305 tls_client_cert: None,
1306 tls_client_key: None,
1307 oauth2: None,
1308 aws_auth: None,
1309 },
1310 ],
1311 intercept_ca_dir: Some(dir.path().to_path_buf()),
1312 ..Default::default()
1313 };
1314 let handle = start(config.clone()).await.unwrap();
1315 let rows = handle.route_diagnostics(&config);
1316 assert_eq!(rows.len(), 2);
1317
1318 let openai = rows.iter().find(|(p, _)| p == "openai").unwrap();
1319 assert!(openai.1.contains("api.openai.com"));
1320 assert!(openai.1.contains("intercept: on"));
1321 assert!(
1322 openai.1.contains("✗") || openai.1.contains("credential_not_found"),
1323 "missing credential should show structured code, got: {}",
1324 openai.1
1325 );
1326
1327 let alias = rows.iter().find(|(p, _)| p == "alias").unwrap();
1328 assert!(alias.1.contains("creds: none"));
1329 assert!(alias.1.contains("intercept: off"));
1330
1331 handle.shutdown();
1332 }
1333
1334 #[tokio::test]
1335 async fn test_proxy_env_vars() {
1336 let config = ProxyConfig::default();
1337 let handle = start(config).await.unwrap();
1338
1339 let vars = handle.env_vars();
1340 let http_proxy = vars.iter().find(|(k, _)| k == "HTTP_PROXY");
1341 assert!(http_proxy.is_some());
1342 assert!(http_proxy.unwrap().1.starts_with("http://nono:"));
1343
1344 let token_var = vars.iter().find(|(k, _)| k == "NONO_PROXY_TOKEN");
1345 assert!(token_var.is_some());
1346 assert_eq!(token_var.unwrap().1.len(), 64);
1347
1348 let node_proxy_flag = vars.iter().find(|(k, _)| k == "NODE_USE_ENV_PROXY");
1349 assert!(
1350 node_proxy_flag.is_some(),
1351 "proxy env must set NODE_USE_ENV_PROXY for Node 20.6+ (undici 5.22+) built-in fetch()"
1352 );
1353 assert_eq!(
1354 node_proxy_flag.unwrap().1,
1355 "1",
1356 "NODE_USE_ENV_PROXY must be '1'"
1357 );
1358
1359 handle.shutdown();
1360 }
1361
1362 #[tokio::test]
1363 async fn test_proxy_credential_env_vars() {
1364 let config = ProxyConfig {
1365 routes: vec![crate::config::RouteConfig {
1366 prefix: "openai".to_string(),
1367 upstream: "https://api.openai.com".to_string(),
1368 credential_key: None,
1369 inject_mode: crate::config::InjectMode::Header,
1370 inject_header: "Authorization".to_string(),
1371 credential_format: Some("Bearer {}".to_string()),
1372 path_pattern: None,
1373 path_replacement: None,
1374 query_param_name: None,
1375 proxy: None,
1376 env_var: None,
1377 endpoint_rules: vec![],
1378 tls_ca: None,
1379 tls_client_cert: None,
1380 tls_client_key: None,
1381 oauth2: None,
1382 aws_auth: None,
1383 }],
1384 ..Default::default()
1385 };
1386 let handle = start(config.clone()).await.unwrap();
1387
1388 let vars = handle.credential_env_vars(&config);
1389 assert_eq!(vars.len(), 1);
1390 assert_eq!(vars[0].0, "OPENAI_BASE_URL");
1391 assert!(vars[0].1.contains("/openai"));
1392
1393 handle.shutdown();
1394 }
1395
1396 #[test]
1397 fn test_proxy_credential_env_vars_fallback_to_uppercase_key() {
1398 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1402 let handle = ProxyHandle {
1403 port: 12345,
1404 token: Zeroizing::new("test_token".to_string()),
1405 audit_log: audit::new_audit_log(),
1406 shutdown_tx,
1407 loaded_routes: ["openai".to_string()].into_iter().collect(),
1408 no_proxy_hosts: Vec::new(),
1409 intercept_ca_path: None,
1410 diagnostics: vec![],
1411 };
1412 let config = ProxyConfig {
1413 routes: vec![crate::config::RouteConfig {
1414 prefix: "openai".to_string(),
1415 upstream: "https://api.openai.com".to_string(),
1416 credential_key: Some("openai_api_key".to_string()),
1417 inject_mode: crate::config::InjectMode::Header,
1418 inject_header: "Authorization".to_string(),
1419 credential_format: Some("Bearer {}".to_string()),
1420 path_pattern: None,
1421 path_replacement: None,
1422 query_param_name: None,
1423 proxy: None,
1424 env_var: None, endpoint_rules: vec![],
1426 tls_ca: None,
1427 tls_client_cert: None,
1428 tls_client_key: None,
1429 oauth2: None,
1430 aws_auth: None,
1431 }],
1432 ..Default::default()
1433 };
1434
1435 let vars = handle.credential_env_vars(&config);
1436 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1440 assert!(
1441 api_key_var.is_some(),
1442 "Should derive env var name from credential_key.to_uppercase()"
1443 );
1444
1445 let (_, val) = api_key_var.expect("OPENAI_API_KEY should exist");
1446 assert_eq!(val, "test_token");
1447 }
1448
1449 #[test]
1450 fn test_proxy_credential_env_vars_with_explicit_env_var() {
1451 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1459 let handle = ProxyHandle {
1460 port: 12345,
1461 token: Zeroizing::new("test_token".to_string()),
1462 audit_log: audit::new_audit_log(),
1463 shutdown_tx,
1464 loaded_routes: ["openai".to_string()].into_iter().collect(),
1465 no_proxy_hosts: Vec::new(),
1466 intercept_ca_path: None,
1467 diagnostics: vec![],
1468 };
1469 let config = ProxyConfig {
1470 routes: vec![crate::config::RouteConfig {
1471 prefix: "openai".to_string(),
1472 upstream: "https://api.openai.com".to_string(),
1473 credential_key: Some("op://Development/OpenAI/credential".to_string()),
1474 inject_mode: crate::config::InjectMode::Header,
1475 inject_header: "Authorization".to_string(),
1476 credential_format: Some("Bearer {}".to_string()),
1477 path_pattern: None,
1478 path_replacement: None,
1479 query_param_name: None,
1480 proxy: None,
1481 env_var: Some("OPENAI_API_KEY".to_string()),
1482 endpoint_rules: vec![],
1483 tls_ca: None,
1484 tls_client_cert: None,
1485 tls_client_key: None,
1486 oauth2: None,
1487 aws_auth: None,
1488 }],
1489 ..Default::default()
1490 };
1491
1492 let vars = handle.credential_env_vars(&config);
1493 assert_eq!(vars.len(), 2); let api_key_var = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1496 assert!(
1497 api_key_var.is_some(),
1498 "Should use explicit env_var name, not derive from credential_key"
1499 );
1500
1501 let (_, val) = api_key_var.expect("OPENAI_API_KEY var should exist");
1503 assert_eq!(val, "test_token");
1504
1505 let bad_var = vars.iter().find(|(k, _)| k.starts_with("OP://"));
1507 assert!(
1508 bad_var.is_none(),
1509 "Should not generate env var from op:// URI uppercase"
1510 );
1511 }
1512
1513 #[test]
1514 fn test_proxy_credential_env_vars_skips_unloaded_routes() {
1515 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1520 let handle = ProxyHandle {
1521 port: 12345,
1522 token: Zeroizing::new("test_token".to_string()),
1523 audit_log: audit::new_audit_log(),
1524 shutdown_tx,
1525 loaded_routes: ["openai".to_string()].into_iter().collect(),
1527 no_proxy_hosts: Vec::new(),
1528 intercept_ca_path: None,
1529 diagnostics: vec![],
1530 };
1531 let config = ProxyConfig {
1532 routes: vec![
1533 crate::config::RouteConfig {
1534 prefix: "openai".to_string(),
1535 upstream: "https://api.openai.com".to_string(),
1536 credential_key: Some("openai_api_key".to_string()),
1537 inject_mode: crate::config::InjectMode::Header,
1538 inject_header: "Authorization".to_string(),
1539 credential_format: Some("Bearer {}".to_string()),
1540 path_pattern: None,
1541 path_replacement: None,
1542 query_param_name: None,
1543 proxy: None,
1544 env_var: None,
1545 endpoint_rules: vec![],
1546 tls_ca: None,
1547 tls_client_cert: None,
1548 tls_client_key: None,
1549 oauth2: None,
1550 aws_auth: None,
1551 },
1552 crate::config::RouteConfig {
1553 prefix: "github".to_string(),
1554 upstream: "https://api.github.com".to_string(),
1555 credential_key: Some("env://GITHUB_TOKEN".to_string()),
1556 inject_mode: crate::config::InjectMode::Header,
1557 inject_header: "Authorization".to_string(),
1558 credential_format: Some("token {}".to_string()),
1559 path_pattern: None,
1560 path_replacement: None,
1561 query_param_name: None,
1562 proxy: None,
1563 env_var: Some("GITHUB_TOKEN".to_string()),
1564 endpoint_rules: vec![],
1565 tls_ca: None,
1566 tls_client_cert: None,
1567 tls_client_key: None,
1568 oauth2: None,
1569 aws_auth: None,
1570 },
1571 ],
1572 ..Default::default()
1573 };
1574
1575 let vars = handle.credential_env_vars(&config);
1576
1577 let openai_base = vars.iter().find(|(k, _)| k == "OPENAI_BASE_URL");
1579 assert!(openai_base.is_some(), "loaded route should have BASE_URL");
1580 let openai_key = vars.iter().find(|(k, _)| k == "OPENAI_API_KEY");
1581 assert!(openai_key.is_some(), "loaded route should have API key");
1582
1583 let github_base = vars.iter().find(|(k, _)| k == "GITHUB_BASE_URL");
1586 assert!(
1587 github_base.is_some(),
1588 "declared route should still have BASE_URL"
1589 );
1590 let github_token = vars.iter().find(|(k, _)| k == "GITHUB_TOKEN");
1591 assert!(
1592 github_token.is_none(),
1593 "unloaded route must not inject phantom GITHUB_TOKEN"
1594 );
1595 }
1596
1597 #[test]
1598 fn test_proxy_credential_env_vars_strips_slashes() {
1599 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1604 let handle = ProxyHandle {
1605 port: 58406,
1606 token: Zeroizing::new("test_token".to_string()),
1607 audit_log: audit::new_audit_log(),
1608 shutdown_tx,
1609 loaded_routes: std::collections::HashSet::new(),
1610 no_proxy_hosts: Vec::new(),
1611 intercept_ca_path: None,
1612 diagnostics: vec![],
1613 };
1614
1615 let config = ProxyConfig {
1617 routes: vec![crate::config::RouteConfig {
1618 prefix: "/anthropic".to_string(),
1619 upstream: "https://api.anthropic.com".to_string(),
1620 credential_key: None,
1621 inject_mode: crate::config::InjectMode::Header,
1622 inject_header: "Authorization".to_string(),
1623 credential_format: Some("Bearer {}".to_string()),
1624 path_pattern: None,
1625 path_replacement: None,
1626 query_param_name: None,
1627 proxy: None,
1628 env_var: None,
1629 endpoint_rules: vec![],
1630 tls_ca: None,
1631 tls_client_cert: None,
1632 tls_client_key: None,
1633 oauth2: None,
1634 aws_auth: None,
1635 }],
1636 ..Default::default()
1637 };
1638
1639 let vars = handle.credential_env_vars(&config);
1640 assert_eq!(vars.len(), 1);
1641 assert_eq!(
1642 vars[0].0, "ANTHROPIC_BASE_URL",
1643 "env var name must not have leading slash"
1644 );
1645 assert_eq!(
1646 vars[0].1, "http://127.0.0.1:58406/anthropic",
1647 "URL must not have double slash"
1648 );
1649
1650 let config = ProxyConfig {
1652 routes: vec![crate::config::RouteConfig {
1653 prefix: "openai/".to_string(),
1654 upstream: "https://api.openai.com".to_string(),
1655 credential_key: None,
1656 inject_mode: crate::config::InjectMode::Header,
1657 inject_header: "Authorization".to_string(),
1658 credential_format: Some("Bearer {}".to_string()),
1659 path_pattern: None,
1660 path_replacement: None,
1661 query_param_name: None,
1662 proxy: None,
1663 env_var: None,
1664 endpoint_rules: vec![],
1665 tls_ca: None,
1666 tls_client_cert: None,
1667 tls_client_key: None,
1668 oauth2: None,
1669 aws_auth: None,
1670 }],
1671 ..Default::default()
1672 };
1673
1674 let vars = handle.credential_env_vars(&config);
1675 assert_eq!(
1676 vars[0].0, "OPENAI_BASE_URL",
1677 "env var name must not have trailing slash"
1678 );
1679 assert_eq!(
1680 vars[0].1, "http://127.0.0.1:58406/openai",
1681 "URL must not have trailing slash in path"
1682 );
1683 }
1684
1685 #[test]
1686 fn test_anthropic_credential_phantom_token_regression() {
1687 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1695 let handle_no_env_var = ProxyHandle {
1696 port: 12345,
1697 token: Zeroizing::new("phantom".to_string()),
1698 audit_log: audit::new_audit_log(),
1699 shutdown_tx: shutdown_tx.clone(),
1700 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1701 no_proxy_hosts: Vec::new(),
1702 intercept_ca_path: None,
1703 diagnostics: vec![],
1704 };
1705 let config_no_env_var = ProxyConfig {
1706 routes: vec![crate::config::RouteConfig {
1707 prefix: "anthropic".to_string(),
1708 upstream: "https://api.anthropic.com".to_string(),
1709 credential_key: None,
1710 inject_mode: crate::config::InjectMode::Header,
1711 inject_header: "x-api-key".to_string(),
1712 credential_format: Some("{}".to_string()),
1713 path_pattern: None,
1714 path_replacement: None,
1715 query_param_name: None,
1716 proxy: None,
1717 env_var: None,
1718 endpoint_rules: vec![],
1719 tls_ca: None,
1720 tls_client_cert: None,
1721 tls_client_key: None,
1722 oauth2: None,
1723 aws_auth: None,
1724 }],
1725 ..Default::default()
1726 };
1727 let vars_no_env_var = handle_no_env_var.credential_env_vars(&config_no_env_var);
1728 assert!(
1729 vars_no_env_var
1730 .iter()
1731 .all(|(k, _)| k != "ANTHROPIC_API_KEY"),
1732 "pre-fix: ANTHROPIC_API_KEY must not be set when neither env_var nor credential_key is defined (bug reproduced)"
1733 );
1734
1735 let (shutdown_tx2, _) = tokio::sync::watch::channel(false);
1738 let handle_fixed = ProxyHandle {
1739 port: 12345,
1740 token: Zeroizing::new("phantom".to_string()),
1741 audit_log: audit::new_audit_log(),
1742 shutdown_tx: shutdown_tx2,
1743 loaded_routes: ["anthropic".to_string()].into_iter().collect(),
1744 no_proxy_hosts: Vec::new(),
1745 intercept_ca_path: None,
1746 diagnostics: vec![],
1747 };
1748 let config_fixed = ProxyConfig {
1749 routes: vec![crate::config::RouteConfig {
1750 prefix: "anthropic".to_string(),
1751 upstream: "https://api.anthropic.com".to_string(),
1752 credential_key: Some("ANTHROPIC_API_KEY".to_string()),
1753 inject_mode: crate::config::InjectMode::Header,
1754 inject_header: "x-api-key".to_string(),
1755 credential_format: Some("{}".to_string()),
1756 path_pattern: None,
1757 path_replacement: None,
1758 query_param_name: None,
1759 proxy: None,
1760 env_var: Some("ANTHROPIC_API_KEY".to_string()),
1761 endpoint_rules: vec![],
1762 tls_ca: None,
1763 tls_client_cert: None,
1764 tls_client_key: None,
1765 oauth2: None,
1766 aws_auth: None,
1767 }],
1768 ..Default::default()
1769 };
1770 let vars_fixed = handle_fixed.credential_env_vars(&config_fixed);
1771 let api_key_var = vars_fixed.iter().find(|(k, _)| k == "ANTHROPIC_API_KEY");
1772 assert!(
1773 api_key_var.is_some(),
1774 "post-fix: ANTHROPIC_API_KEY must be set to the phantom token"
1775 );
1776 assert_eq!(api_key_var.unwrap().1, "phantom");
1777 }
1778
1779 #[test]
1780 fn test_no_proxy_excludes_credential_upstreams() {
1781 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1782 let handle = ProxyHandle {
1783 port: 12345,
1784 token: Zeroizing::new("test_token".to_string()),
1785 audit_log: audit::new_audit_log(),
1786 shutdown_tx,
1787 loaded_routes: std::collections::HashSet::new(),
1788 no_proxy_hosts: vec![
1789 "nats.internal:4222".to_string(),
1790 "opencode.internal:4096".to_string(),
1791 ],
1792 intercept_ca_path: None,
1793 diagnostics: vec![],
1794 };
1795
1796 let vars = handle.env_vars();
1797 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1798 assert!(
1799 no_proxy.1.contains("nats.internal"),
1800 "non-credential host should be in NO_PROXY"
1801 );
1802 assert!(
1803 no_proxy.1.contains("opencode.internal"),
1804 "non-credential host should be in NO_PROXY"
1805 );
1806 assert!(
1807 no_proxy.1.contains("localhost"),
1808 "localhost should always be in NO_PROXY"
1809 );
1810 }
1811
1812 #[test]
1813 fn test_no_proxy_empty_when_no_non_credential_hosts() {
1814 let (shutdown_tx, _) = tokio::sync::watch::channel(false);
1815 let handle = ProxyHandle {
1816 port: 12345,
1817 token: Zeroizing::new("test_token".to_string()),
1818 audit_log: audit::new_audit_log(),
1819 shutdown_tx,
1820 loaded_routes: std::collections::HashSet::new(),
1821 no_proxy_hosts: Vec::new(),
1822 intercept_ca_path: None,
1823 diagnostics: vec![],
1824 };
1825
1826 let vars = handle.env_vars();
1827 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1828 assert_eq!(
1829 no_proxy.1, "localhost,127.0.0.1",
1830 "NO_PROXY should only contain loopback when no bypass hosts"
1831 );
1832 }
1833
1834 #[tokio::test]
1835 async fn test_no_proxy_empty_without_direct_connect_ports() {
1836 let config = ProxyConfig {
1840 allowed_hosts: vec!["github.com".to_string()],
1841 ..Default::default()
1842 };
1843 let handle = start(config).await.unwrap();
1844
1845 let vars = handle.env_vars();
1846 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1847 assert_eq!(
1848 no_proxy.1, "localhost,127.0.0.1",
1849 "allowed_hosts must not appear in NO_PROXY without direct_connect_ports"
1850 );
1851
1852 handle.shutdown();
1853 }
1854
1855 #[cfg(not(target_os = "macos"))]
1856 #[tokio::test]
1857 async fn test_no_proxy_includes_hosts_with_matching_connect_port() {
1858 let config = ProxyConfig {
1862 allowed_hosts: vec!["github.com".to_string(), "server.internal:4222".to_string()],
1863 direct_connect_ports: vec![443],
1864 ..Default::default()
1865 };
1866 let handle = start(config).await.unwrap();
1867
1868 let vars = handle.env_vars();
1869 let no_proxy = vars.iter().find(|(k, _)| k == "NO_PROXY").unwrap();
1870 assert!(
1871 no_proxy.1.contains("github.com"),
1872 "host on port 443 should be in NO_PROXY when 443 is in direct_connect_ports"
1873 );
1874 assert!(
1875 !no_proxy.1.contains("server.internal"),
1876 "host on port 4222 should NOT be in NO_PROXY when only 443 is allowed"
1877 );
1878
1879 handle.shutdown();
1880 }
1881
1882 #[tokio::test]
1885 async fn test_strict_filter_with_empty_allowlist_denies_connect() {
1886 use tokio::io::AsyncReadExt;
1887 use tokio::net::TcpStream;
1888
1889 let config = ProxyConfig {
1890 strict_filter: true,
1891 allowed_hosts: Vec::new(),
1892 ..ProxyConfig::default()
1893 };
1894 let handle = start(config).await.unwrap();
1895 let addr = format!("127.0.0.1:{}", handle.port);
1896
1897 let mut stream = TcpStream::connect(&addr).await.unwrap();
1898 let request = b"CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n";
1899 tokio::io::AsyncWriteExt::write_all(&mut stream, request)
1900 .await
1901 .unwrap();
1902
1903 let mut response = Vec::new();
1904 stream.read_to_end(&mut response).await.unwrap();
1905 let response_str = String::from_utf8_lossy(&response);
1906 assert!(
1907 response_str.starts_with("HTTP/1.1 403"),
1908 "strict filter with empty allowlist must deny CONNECT, got: {}",
1909 response_str
1910 );
1911
1912 let events = handle.drain_audit_events();
1913 assert!(
1914 events
1915 .iter()
1916 .any(|e| e.decision == nono::undo::NetworkAuditDecision::Deny
1917 && e.target == "example.com"),
1918 "expected a Deny audit event for example.com, got: {:?}",
1919 events
1920 );
1921
1922 handle.shutdown();
1923 }
1924
1925 #[tokio::test]
1931 async fn reactive_proxy_auth_retry_answered_after_407() {
1932 use base64::Engine;
1933 use std::time::Duration;
1934 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1935 use tokio::net::TcpStream;
1936
1937 let dir = tempfile::tempdir().unwrap();
1938 let config = ProxyConfig {
1939 routes: vec![crate::config::RouteConfig {
1940 prefix: "openai".to_string(),
1941 upstream: "https://api.openai.com".to_string(),
1942 credential_key: Some("env://NONO_TEST_TOTALLY_MISSING".to_string()),
1943 inject_mode: Default::default(),
1944 inject_header: "Authorization".to_string(),
1945 credential_format: Some("Bearer {}".to_string()),
1946 path_pattern: None,
1947 path_replacement: None,
1948 query_param_name: None,
1949 proxy: None,
1950 env_var: None,
1951 endpoint_rules: vec![],
1952 tls_ca: None,
1953 tls_client_cert: None,
1954 tls_client_key: None,
1955 oauth2: None,
1956 aws_auth: None,
1957 }],
1958 intercept_ca_dir: Some(dir.path().to_path_buf()),
1959 ..Default::default()
1960 };
1961 let handle = start(config).await.unwrap();
1962 assert!(
1963 handle.intercept_ca_path().is_some(),
1964 "precondition: interception must be active so the 407 path is reached"
1965 );
1966 let port = handle.port;
1967 let token = handle.token.to_string();
1968
1969 let mut sock = TcpStream::connect(("127.0.0.1", port)).await.unwrap();
1970
1971 sock.write_all(b"CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\n\r\n")
1973 .await
1974 .unwrap();
1975 sock.flush().await.unwrap();
1976
1977 let mut buf = [0u8; 4096];
1978 let n = sock.read(&mut buf).await.unwrap();
1979 let response = String::from_utf8_lossy(&buf[..n]);
1980 assert!(
1981 response.starts_with("HTTP/1.1 407 "),
1982 "expected 407 challenge, got: {:?}",
1983 response
1984 );
1985
1986 let creds = base64::engine::general_purpose::STANDARD.encode(format!("nono:{}", token));
1988 let retry = format!(
1989 "CONNECT api.openai.com:443 HTTP/1.1\r\nHost: api.openai.com:443\r\nProxy-Authorization: Basic {}\r\n\r\n",
1990 creds
1991 );
1992 sock.write_all(retry.as_bytes()).await.unwrap();
1993 sock.flush().await.unwrap();
1994
1995 let mut retry_buf = [0u8; 4096];
1999 let read_result =
2000 tokio::time::timeout(Duration::from_secs(5), sock.read(&mut retry_buf)).await;
2001 match read_result {
2002 Ok(Ok(0)) => panic!(
2003 "regression: proxy closed the socket after the 407 instead of \
2004 answering the reactive retry"
2005 ),
2006 Ok(Ok(_)) => {} Ok(Err(e)) => panic!("retry read errored: {e}"),
2008 Err(_) => panic!("retry read timed out — proxy did not answer the retry"),
2009 }
2010
2011 handle.shutdown();
2012 }
2013
2014 #[test]
2015 fn test_parse_non_connect_target_default_port_80() {
2016 let (host, port) = parse_non_connect_target("GET http://google.com/ HTTP/1.1").unwrap();
2017 assert_eq!(host, "google.com");
2018 assert_eq!(port, 80);
2019 }
2020
2021 #[test]
2022 fn test_parse_non_connect_target_parses_url_with_port() {
2023 let (host, port) =
2024 parse_non_connect_target("GET http://google.com:8080/path HTTP/1.1").unwrap();
2025 assert_eq!(host, "google.com");
2026 assert_eq!(port, 8080);
2027 }
2028
2029 #[test]
2030 fn test_parse_non_connect_target_rejects_malformed_line() {
2031 let err = parse_non_connect_target("garbage").unwrap_err();
2032 assert!(err.to_string().contains("malformed request line"));
2033 }
2034
2035 #[tokio::test]
2038 async fn test_denied_non_connect_returns_403_and_audits() {
2039 use tokio::io::AsyncReadExt;
2040 use tokio::net::TcpStream;
2041
2042 let config = ProxyConfig {
2044 allowed_hosts: vec!["example.com".to_string()],
2045 ..ProxyConfig::default()
2046 };
2047 let handle = start(config).await.unwrap();
2048 let addr = format!("127.0.0.1:{}", handle.port);
2049
2050 let mut stream = TcpStream::connect(&addr).await.unwrap();
2051 let request = b"GET http://google.com/ HTTP/1.1\r\nHost: google.com\r\n\r\n";
2052 tokio::io::AsyncWriteExt::write_all(&mut stream, request)
2053 .await
2054 .unwrap();
2055
2056 let mut response = Vec::new();
2057 stream.read_to_end(&mut response).await.unwrap();
2058 let response_str = String::from_utf8_lossy(&response);
2059 assert!(
2060 response_str.starts_with("HTTP/1.1 403"),
2061 "expected 403 status, got: {}",
2062 response_str
2063 );
2064
2065 let events = handle.drain_audit_events();
2066 assert_eq!(events.len(), 1, "expected one audit event");
2067 let event = &events[0];
2068 assert_eq!(event.mode, nono::undo::NetworkAuditMode::Connect);
2069 assert_eq!(event.decision, nono::undo::NetworkAuditDecision::Deny);
2070 assert_eq!(event.target, "google.com");
2071 assert_eq!(event.port, Some(80));
2072
2073 handle.shutdown();
2074 }
2075}