1pub mod actions;
10pub mod cert_cache;
11pub mod filter;
12pub mod tls_mitm;
13
14use crate::server::connection::ConnectionId;
15use anyhow::{Context, Result};
16use cert_cache::CertificateCache;
17use filter::{
18 CertificateMode, FullRequestInfo, HttpsConnectionAction, HttpsConnectionInfo,
19 ProxyFilterConfig, RequestAction,
20};
21use std::collections::HashMap;
22use std::net::SocketAddr;
23use std::sync::Arc;
24use tokio::sync::mpsc;
25use tracing::{debug, error, info, trace, warn};
26
27use crate::llm::action_helper::call_llm;
28use crate::llm::actions::protocol_trait::{ActionResult, Server};
29use crate::llm::ollama_client::OllamaClient;
30use crate::logging::emit::Log;
31use crate::protocol::Event;
32use crate::server::ProxyProtocol;
33use crate::state::app_state::AppState;
34use crate::state::ServerId;
35use actions::{PROXY_HTTPS_CONNECT_EVENT, PROXY_HTTP_REQUEST_EVENT};
36
37use rcgen::{Certificate, CertificateParams, KeyPair};
38use regex::Regex;
39use serde_json::json;
40
41const MAX_UPSTREAM_RESPONSE: usize = 8 * 1024 * 1024;
43
44pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
46
47const REQUEST_READ_TIMEOUT_SECS: u64 = 30;
49
50const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
61
62const CONNECTION_CAP_BODY: &str = "Too many connections, try again later.\n";
64
65static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
74 format!(
75 "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 30\r\nContent-Type: \
76 text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
77 CONNECTION_CAP_BODY.len(),
78 CONNECTION_CAP_BODY
79 )
80 .into_bytes()
81});
82
83pub(crate) fn connect_authority(host: &str, port: u16) -> String {
86 if host.contains(':') {
87 format!("[{}]:{}", host, port)
88 } else {
89 format!("{}:{}", host, port)
90 }
91}
92
93async fn read_request_head(stream: &mut tokio::net::TcpStream) -> Result<Vec<u8>> {
99 use tokio::io::AsyncReadExt;
100
101 let mut buf = Vec::with_capacity(8192);
102 let mut chunk = [0u8; 8192];
103 loop {
104 let n = stream.read(&mut chunk).await?;
105 if n == 0 {
106 return Ok(buf);
108 }
109 buf.extend_from_slice(&chunk[..n]);
110
111 if buf.windows(4).any(|w| w == b"\r\n\r\n") {
112 return Ok(buf);
113 }
114 if buf.len() >= MAX_REQUEST_BYTES {
115 anyhow::bail!("request head exceeded {MAX_REQUEST_BYTES} bytes without terminating");
116 }
117 }
118}
119
120async fn tunnel_bidirectional(
131 client_stream: &mut tokio::net::TcpStream,
132 dest_stream: &mut tokio::net::TcpStream,
133) -> (u64, u64) {
134 match tokio::io::copy_bidirectional(client_stream, dest_stream).await {
135 Ok((up, down)) => (up, down),
136 Err(e) => {
137 debug!("Proxy tunnel ended with an I/O error: {}", e);
139 (0, 0)
140 }
141 }
142}
143
144pub struct ProxyServer;
146
147impl ProxyServer {
148 pub async fn spawn_with_llm_actions(
150 listen_addr: SocketAddr,
151 llm_client: OllamaClient,
152 app_state: Arc<AppState>,
153 status_tx: mpsc::UnboundedSender<String>,
154 server_id: ServerId,
155 startup_params: Option<crate::protocol::StartupParams>,
156 ) -> Result<SocketAddr> {
157 Log::new(Some(&status_tx)).info(format!(
158 "Proxy server (action-based) starting on {}",
159 listen_addr
160 ));
161
162 let mut config = app_state
164 .get_proxy_filter_config(server_id)
165 .await
166 .unwrap_or_else(|| {
167 info!("No proxy filter config found, using defaults (MITM with cert generation)");
168 ProxyFilterConfig::default()
169 });
170
171 if let Some(ref params) = startup_params {
173 Log::new(Some(&status_tx)).info("Applying proxy startup parameters");
174
175 if let Some(cert_mode_str) = params.get_optional_string("certificate_mode")? {
177 config.certificate_mode = match cert_mode_str.as_str() {
178 "generate" => CertificateMode::Generate,
179 "none" => CertificateMode::None,
180 "load_from_file" => {
181 let cert_path = params
182 .get_optional_string("cert_path")?
183 .context("Missing cert_path for load_from_file mode")?;
184 let key_path = params
185 .get_optional_string("key_path")?
186 .context("Missing key_path for load_from_file mode")?;
187 CertificateMode::LoadFromFile {
188 cert_path: cert_path.into(),
189 key_path: key_path.into(),
190 }
191 }
192 _ => {
193 warn!("Invalid certificate_mode: {}, using default", cert_mode_str);
194 config.certificate_mode
195 }
196 };
197 Log::new(Some(&status_tx))
198 .info(format!("Certificate mode: {:?}", config.certificate_mode));
199 }
200
201 if let Some(mode_str) = params.get_optional_string("request_filter_mode")? {
203 if let Ok(mode) = serde_json::from_value(json!(mode_str)) {
204 Log::new(Some(&status_tx)).info(format!("Request filter mode: {mode:?}"));
205 config.request_filter_mode = mode;
206 }
207 }
208
209 if let Some(mode_str) = params.get_optional_string("response_filter_mode")? {
210 if let Ok(mode) = serde_json::from_value(json!(mode_str)) {
211 Log::new(Some(&status_tx)).info(format!("Response filter mode: {mode:?}"));
212 config.response_filter_mode = mode;
213 }
214 }
215
216 if let Some(mode_str) = params.get_optional_string("https_connection_filter_mode")? {
217 if let Ok(mode) = serde_json::from_value(json!(mode_str)) {
218 Log::new(Some(&status_tx))
219 .info(format!("HTTPS connection filter mode: {:?}", mode));
220 config.https_connection_filter_mode = mode;
221 }
222 }
223 }
224
225 let cert_cache: Option<Arc<CertificateCache>> = match &config.certificate_mode {
227 CertificateMode::Generate => {
228 Log::new(Some(&status_tx)).info("Generating self-signed CA certificate for MITM");
229 let (ca_cert, ca_key, ca_params) = Self::generate_ca_certificate()?;
230 Some(Arc::new(CertificateCache::new(ca_cert, ca_key, ca_params)))
231 }
232 CertificateMode::LoadFromFile {
233 cert_path,
234 key_path,
235 } => {
236 return Err(anyhow::anyhow!(
248 "certificate_mode 'load_from_file' is not implemented (cert_path={:?}, \
249 key_path={:?}). Use certificate_mode 'generate' and distribute the \
250 generated CA via the ca_export_path startup parameter, or 'none' for \
251 pass-through mode.",
252 cert_path,
253 key_path
254 ));
255 }
256 CertificateMode::None => {
257 Log::new(Some(&status_tx))
258 .info("Proxy running in pass-through mode (no MITM, origin certificates)");
259 None
260 }
261 };
262
263 if let (Some(cache), Some(params)) = (cert_cache.as_ref(), startup_params.as_ref()) {
266 if let Some(path) = params.get_optional_string("ca_export_path")? {
267 write_ca_export(std::path::Path::new(&path), &cache.ca_cert_pem())
268 .with_context(|| format!("Failed to write CA certificate to {}", path))?;
269 Log::new(Some(&status_tx)).info(format!(
270 "MITM CA certificate written to {} - clients must trust this file \
271 for interception to work",
272 path
273 ));
274 }
275 }
276
277 app_state
279 .set_proxy_filter_config(server_id, config.clone())
280 .await;
281
282 let protocol = Arc::new(ProxyProtocol::new());
283
284 let listener = tokio::net::TcpListener::bind(listen_addr)
286 .await
287 .context("Failed to bind proxy listener")?;
288
289 let actual_addr = listener
290 .local_addr()
291 .context("Failed to get local address")?;
292
293 Log::new(Some(&status_tx)).info(format!("Proxy server listening on {}", actual_addr));
294
295 if cert_cache.is_some() {
296 Log::new(Some(&status_tx))
297 .info("MITM mode enabled - full HTTPS decryption and inspection");
298 } else {
299 Log::new(Some(&status_tx)).info("Pass-through mode - HTTPS allow/block only");
300 }
301
302 if let Some(ref cache) = cert_cache {
310 let cache_weak = Arc::downgrade(cache);
311 let status_tx_clone = status_tx.clone();
312 let task_owner = app_state.clone();
314 task_owner
315 .spawn_server_task(server_id, async move {
316 let mut interval =
317 tokio::time::interval(tokio::time::Duration::from_secs(3600)); loop {
319 interval.tick().await;
320 let Some(cache_clone) = cache_weak.upgrade() else {
321 debug!("Proxy server stopped, ending certificate cache cleanup task");
322 break;
323 };
324 debug!("Running periodic certificate cache cleanup");
325 cache_clone.cleanup_expired().await;
326 let stats = cache_clone.get_stats().await;
327 Log::new(Some(&status_tx_clone)).debug(format!(
329 "Certificate cache stats: {} total, {} valid, {} expired",
330 stats.total_certificates,
331 stats.valid_certificates,
332 stats.expired_certificates
333 ));
334 }
335 })
336 .await;
337 Log::new(Some(&status_tx)).info("Certificate cache cleanup task started (hourly)");
338 }
339
340 let task_registrar = app_state.clone();
342 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
343 let accept_handle = tokio::spawn(async move {
344 Log::new(Some(&status_tx)).debug("Proxy accept loop started");
345 loop {
346 match crate::server::accept_bounded::accept_bounded(
347 &listener,
348 &limiter,
349 &CONNECTION_CAP_REFUSAL,
350 "Proxy",
351 Some(&status_tx),
352 )
353 .await
354 {
355 Ok((stream, peer_addr, permit)) => {
356 let connection_id =
357 ConnectionId::new(app_state.get_next_unified_id().await);
358 Log::new(Some(&status_tx)).info(format!(
359 "Proxy connection {} from {}",
360 connection_id, peer_addr
361 ));
362
363 use crate::state::server::{
365 ConnectionState as ServerConnectionState, ConnectionStatus,
366 ProtocolConnectionInfo,
367 };
368 let now = crate::utils::clock::Instant::now();
369 let conn_state = ServerConnectionState {
370 id: connection_id,
371 remote_addr: peer_addr,
372 local_addr: actual_addr,
373 bytes_sent: 0,
374 bytes_received: 0,
375 packets_sent: 0,
376 packets_received: 0,
377 last_activity: now,
378 status: ConnectionStatus::Active,
379 status_changed_at: now,
380 protocol_info: ProtocolConnectionInfo::empty(),
381 };
382 app_state
383 .add_connection_to_server(server_id, conn_state)
384 .await;
385 let _ = status_tx.send("__UPDATE_UI__".to_string());
386
387 let llm_clone = llm_client.clone();
388 let app_clone = app_state.clone();
389 let status_clone = status_tx.clone();
390 let protocol_clone = protocol.clone();
391 let config_clone = config.clone();
392 let cert_cache_clone = cert_cache.clone();
393
394 let task_owner = app_state.clone();
397 task_owner
398 .spawn_server_task(server_id, async move {
399 let _permit = permit;
404 if let Err(e) = Self::handle_proxy_connection(
405 stream,
406 peer_addr,
407 connection_id,
408 server_id,
409 cert_cache_clone,
410 config_clone,
411 llm_clone,
412 app_clone.clone(),
413 status_clone.clone(),
414 protocol_clone,
415 )
416 .await
417 {
418 Log::new(Some(&status_clone)).error(format!(
419 "Proxy connection {} error: {}",
420 connection_id, e
421 ));
422 }
423
424 app_clone
426 .close_connection_on_server(server_id, connection_id)
427 .await;
428 Log::new(Some(&status_clone))
429 .info(format!("Proxy connection {} closed", connection_id));
430 let _ = status_clone.send("__UPDATE_UI__".to_string());
431 })
432 .await;
433 }
434 Err(e) => {
435 Log::new(Some(&status_tx))
436 .error(format!("Failed to accept proxy connection: {}", e));
437 break;
438 }
439 }
440 }
441 });
442
443 task_registrar
445 .register_server_task(server_id, accept_handle)
446 .await;
447
448 Ok(actual_addr)
449 }
450
451 async fn handle_proxy_connection(
453 mut stream: tokio::net::TcpStream,
454 peer_addr: SocketAddr,
455 connection_id: ConnectionId,
456 server_id: ServerId,
457 cert_cache: Option<Arc<CertificateCache>>,
458 config: ProxyFilterConfig,
459 llm_client: OllamaClient,
460 app_state: Arc<AppState>,
461 status_tx: mpsc::UnboundedSender<String>,
462 protocol: Arc<ProxyProtocol>,
463 ) -> Result<()> {
464 use tokio::io::AsyncWriteExt;
465
466 Log::new(Some(&status_tx)).info(format!(
467 "Proxy: handling connection {} from {}",
468 connection_id, peer_addr
469 ));
470
471 let buffer = match tokio::time::timeout(
480 std::time::Duration::from_secs(REQUEST_READ_TIMEOUT_SECS),
481 read_request_head(&mut stream),
482 )
483 .await
484 {
485 Ok(Ok(b)) => b,
486 Ok(Err(e)) => return Err(e).context("Failed to read initial request"),
487 Err(_) => {
488 debug!(
489 "Proxy connection {} timed out waiting for a request head",
490 connection_id
491 );
492 let _ = stream
493 .write_all(b"HTTP/1.1 408 Request Timeout\r\nContent-Length: 0\r\n\r\n")
494 .await;
495 return Ok(());
496 }
497 };
498 let n = buffer.len();
499
500 Log::new(Some(&status_tx)).debug(format!(
501 "Proxy connection {} received {} bytes",
502 connection_id, n
503 ));
504
505 if n == 0 {
506 debug!("Client closed connection before sending data");
507 return Ok(()); }
509
510 let request_data = &buffer[..n];
511 let request_str = String::from_utf8_lossy(request_data);
512
513 debug!(
514 "Proxy {} received request:\n{}",
515 connection_id,
516 if request_str.len() > 200 {
517 format!(
518 "{}... ({} bytes total)",
519 truncate_str(&request_str, 200),
520 request_str.len()
521 )
522 } else {
523 request_str.to_string()
524 }
525 );
526 Log::new(Some(&status_tx)).debug(format!("Proxy {} parsing request", connection_id));
527
528 let first_line = request_str.lines().next().context("Empty request")?;
530
531 Log::new(Some(&status_tx)).debug(format!("Request line: {}", first_line));
532
533 let parts: Vec<&str> = first_line.split_whitespace().collect();
534 if parts.len() < 3 {
535 error!("Invalid HTTP request line: {}", first_line);
536 return Err(anyhow::anyhow!("Invalid HTTP request line"));
537 }
538
539 let method = parts[0];
540 let uri = parts[1];
541
542 Log::new(Some(&status_tx)).debug(format!("Parsed: method={}, uri={}", method, uri));
543
544 if method == "CONNECT" {
546 return Self::handle_https_connect(
548 stream,
549 uri,
550 peer_addr,
551 connection_id,
552 server_id,
553 cert_cache,
554 config,
555 llm_client,
556 app_state,
557 status_tx,
558 protocol,
559 )
560 .await;
561 } else {
562 return Self::handle_http_request(
564 stream,
565 request_data,
566 method,
567 uri,
568 peer_addr,
569 connection_id,
570 server_id,
571 config,
572 llm_client,
573 app_state,
574 status_tx,
575 protocol,
576 )
577 .await;
578 }
579 }
580
581 async fn handle_https_connect(
583 mut client_stream: tokio::net::TcpStream,
584 uri: &str,
585 peer_addr: SocketAddr,
586 connection_id: ConnectionId,
587 server_id: ServerId,
588 cert_cache: Option<Arc<CertificateCache>>,
589 config: ProxyFilterConfig,
590 llm_client: OllamaClient,
591 app_state: Arc<AppState>,
592 status_tx: mpsc::UnboundedSender<String>,
593 protocol: Arc<ProxyProtocol>,
594 ) -> Result<()> {
595 use tokio::io::AsyncWriteExt;
596
597 let start_time = crate::utils::clock::Instant::now();
598
599 let (dest_host, port_str) = uri
606 .rsplit_once(':')
607 .ok_or_else(|| anyhow::anyhow!("Invalid CONNECT uri (no port): {}", uri))?;
608 let dest_host = dest_host
609 .strip_prefix('[')
610 .and_then(|h| h.strip_suffix(']'))
611 .unwrap_or(dest_host);
612 if dest_host.is_empty() {
613 return Err(anyhow::anyhow!("Invalid CONNECT uri (empty host): {}", uri));
614 }
615 let dest_port: u16 = port_str
616 .parse()
617 .context("Invalid port in CONNECT request")?;
618
619 trace!(
621 "HTTPS CONNECT from {} to {}:{}",
622 peer_addr,
623 dest_host,
624 dest_port
625 );
626 trace!(" SNI: {} (from CONNECT)", dest_host);
627 trace!(
628 " Certificate mode: {:?}",
629 if cert_cache.is_some() {
630 "MITM"
631 } else {
632 "Pass-through"
633 }
634 );
635 Log::new(Some(&status_tx)).trace(format!(
636 "HTTPS CONNECT {} -> {}:{} ({})",
637 peer_addr,
638 dest_host,
639 dest_port,
640 if cert_cache.is_some() {
641 "MITM"
642 } else {
643 "pass-through"
644 }
645 ));
646
647 if let Some(cache) = cert_cache {
648 info!(
650 "MITM mode: will decrypt and inspect HTTPS traffic for {}:{}",
651 dest_host, dest_port
652 );
653
654 return tls_mitm::perform_mitm(
656 client_stream,
657 dest_host,
658 dest_port,
659 peer_addr,
660 connection_id,
661 server_id,
662 cache,
663 config,
664 llm_client,
665 app_state,
666 status_tx,
667 protocol,
668 )
669 .await;
670 }
671
672 let client_addr_str = peer_addr.to_string();
675 if config.should_intercept_https_connection(
676 dest_host,
677 dest_port,
678 Some(dest_host), &client_addr_str,
680 ) {
681 let conn_info = HttpsConnectionInfo {
683 destination_host: dest_host.to_string(),
684 destination_port: dest_port,
685 sni: Some(dest_host.to_string()),
686 client_addr: client_addr_str,
687 };
688
689 info!(
690 "Consulting LLM about HTTPS connection to {}:{}",
691 dest_host, dest_port
692 );
693
694 let action = Self::consult_llm_https_connection(
696 &conn_info,
697 server_id,
698 &llm_client,
699 &app_state,
700 &protocol,
701 &status_tx,
702 )
703 .await
704 .unwrap_or_else(|e| {
705 Log::new(Some(&status_tx)).warn(format!(
708 "Proxy blocking CONNECT {}:{}: {} - LLM consultation failed",
709 dest_host, dest_port, e
710 ));
711 HttpsConnectionAction::Block {
712 reason: Some(
713 "netget proxy: no filtering decision could be obtained".to_string(),
714 ),
715 }
716 });
717
718 match action {
719 HttpsConnectionAction::Allow => {
720 Log::new(Some(&status_tx))
721 .info(format!("Allowed HTTPS to {}:{}", dest_host, dest_port));
722
723 let dest_addr = connect_authority(dest_host, dest_port);
725 let mut dest_stream = tokio::net::TcpStream::connect(&dest_addr)
726 .await
727 .context("Failed to connect to destination")?;
728
729 client_stream
731 .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
732 .await?;
733
734 let (up_bytes, down_bytes) =
736 tunnel_bidirectional(&mut client_stream, &mut dest_stream).await;
737 let total_bytes = up_bytes + down_bytes;
738 app_state
741 .update_connection_stats(
742 server_id,
743 connection_id,
744 Some(up_bytes),
745 Some(down_bytes),
746 None,
747 None,
748 )
749 .await;
750
751 let duration = start_time.elapsed();
752
753 Log::new(Some(&status_tx)).debug(format!(
755 "[ACCESS] {} CONNECT {}:{} -> TUNNEL {} bytes ({} up, {} down) in {:?}",
756 peer_addr,
757 dest_host,
758 dest_port,
759 total_bytes,
760 up_bytes,
761 down_bytes,
762 duration
763 ));
764
765 trace!("HTTPS tunnel closed: {} bytes transferred", total_bytes);
766
767 Ok(())
768 }
769 HttpsConnectionAction::Block { reason } => {
770 let duration = start_time.elapsed();
771 let reason_str = reason.clone().unwrap_or_default();
772
773 Log::new(Some(&status_tx)).debug(format!(
775 "[ACCESS] {} CONNECT {}:{} -> 403 {} in {:?}",
776 peer_addr,
777 dest_host,
778 dest_port,
779 reason_str.len(),
780 duration
781 ));
782
783 let response = format!(
785 "HTTP/1.1 403 Forbidden\r\n\
786 Content-Type: text/plain\r\n\
787 Content-Length: {}\r\n\
788 \r\n\
789 {}",
790 reason_str.len(),
791 reason_str
792 );
793 client_stream.write_all(response.as_bytes()).await?;
794
795 Ok(())
796 }
797 }
798 } else {
799 trace!(
801 "Pass-through HTTPS connection to {}:{} (no LLM consultation)",
802 dest_host,
803 dest_port
804 );
805
806 let dest_addr = connect_authority(dest_host, dest_port);
808 let mut dest_stream = tokio::net::TcpStream::connect(&dest_addr)
809 .await
810 .context("Failed to connect to destination")?;
811
812 client_stream
814 .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
815 .await?;
816
817 let (up_bytes, down_bytes) =
819 tunnel_bidirectional(&mut client_stream, &mut dest_stream).await;
820 let total_bytes = up_bytes + down_bytes;
821 app_state
822 .update_connection_stats(
823 server_id,
824 connection_id,
825 Some(up_bytes),
826 Some(down_bytes),
827 None,
828 None,
829 )
830 .await;
831
832 let duration = start_time.elapsed();
833
834 Log::new(Some(&status_tx)).debug(format!(
836 "[ACCESS] {} CONNECT {}:{} -> TUNNEL {} bytes ({} up, {} down) in {:?}",
837 peer_addr, dest_host, dest_port, total_bytes, up_bytes, down_bytes, duration
838 ));
839
840 Ok(())
841 }
842 }
843
844 async fn handle_http_request(
846 mut client_stream: tokio::net::TcpStream,
847 request_data: &[u8],
848 method: &str,
849 uri: &str,
850 peer_addr: SocketAddr,
851 _connection_id: ConnectionId,
852 server_id: ServerId,
853 config: ProxyFilterConfig,
854 llm_client: OllamaClient,
855 app_state: Arc<AppState>,
856 status_tx: mpsc::UnboundedSender<String>,
857 protocol: Arc<ProxyProtocol>,
858 ) -> Result<()> {
859 use tokio::io::AsyncWriteExt;
860
861 let start_time = crate::utils::clock::Instant::now();
862
863 let request_str = String::from_utf8_lossy(request_data);
865 let mut headers = HashMap::new();
866 let mut body_start = 0;
867
868 for (i, line) in request_str.lines().enumerate() {
870 if i == 0 {
871 continue; }
873 if line.is_empty() {
874 body_start =
876 request_str[..request_str.find("\r\n\r\n").unwrap_or(request_str.len())].len()
877 + 4;
878 break;
879 }
880 if let Some(colon_pos) = line.find(':') {
881 let name = line[..colon_pos].trim().to_string();
882 let value = line[colon_pos + 1..].trim().to_string();
883 headers.insert(name, value);
884 }
885 }
886
887 let body = if body_start < request_data.len() {
888 &request_data[body_start..]
889 } else {
890 &[]
891 };
892
893 let host = headers
895 .get("Host")
896 .map(|s| s.as_str())
897 .or_else(|| {
898 if uri.starts_with("http://") {
900 uri.strip_prefix("http://")
901 .and_then(|s| s.split('/').next())
902 } else {
903 None
904 }
905 })
906 .unwrap_or("unknown");
907
908 let path = if uri.starts_with("http://") {
909 uri.find('/').map(|pos| &uri[pos..]).unwrap_or("/")
911 } else {
912 uri
913 };
914
915 trace!("Proxy HTTP request: {} {} from {}", method, uri, peer_addr);
917 trace!(" Headers: {:#?}", headers);
918 if !body.is_empty() {
919 if let Ok(body_str) = std::str::from_utf8(body) {
920 trace!(" Body: {}", body_str);
921 } else {
922 trace!(" Body: {} bytes (binary)", body.len());
923 }
924 }
925 Log::new(Some(&status_tx)).trace(format!(
926 "Proxy request: {} {} from {} ({} bytes body)",
927 method,
928 uri,
929 peer_addr,
930 body.len()
931 ));
932
933 if config.should_intercept_request(host, path, method, &headers, body) {
935 Log::new(Some(&status_tx)).debug("Request matched filters, consulting LLM");
936
937 let request_info = FullRequestInfo {
939 method: method.to_string(),
940 url: uri.to_string(),
941 path: path.to_string(),
942 host: host.to_string(),
943 headers: headers.clone(),
944 body: body.to_vec(),
945 client_addr: peer_addr.to_string(),
946 };
947
948 let action = Self::consult_llm_http_request(
950 &request_info,
951 server_id,
952 &llm_client,
953 &app_state,
954 &protocol,
955 &status_tx,
956 )
957 .await
958 .unwrap_or_else(|e| {
959 let overloaded = crate::llm::is_overload_error(&e);
969 let status = if overloaded { 503 } else { 502 };
970 Log::new(Some(&status_tx)).warn(format!(
972 "Proxy blocking {} {} with {} (overload={}): {}",
973 request_info.method, request_info.url, status, overloaded, e
974 ));
975 RequestAction::Block {
976 status,
977 body: "netget proxy: no filtering decision could be obtained, request refused"
978 .to_string(),
979 }
980 });
981
982 match action {
983 RequestAction::Pass => {
984 info!("LLM passed request through");
985 Self::forward_http_request(
987 client_stream,
988 request_data,
989 host,
990 method,
991 uri,
992 peer_addr,
993 start_time,
994 status_tx,
995 )
996 .await
997 }
998 RequestAction::Block { status, body } => {
999 let duration = start_time.elapsed();
1000 let body_len = body.len();
1001
1002 Log::new(Some(&status_tx)).debug(format!(
1004 "[ACCESS] {} {} {} -> {} {} bytes in {:?}",
1005 peer_addr, method, uri, status, body_len, duration
1006 ));
1007
1008 trace!(
1010 "Blocking response: status={}, body_len={}",
1011 status,
1012 body_len
1013 );
1014 trace!(" Response body: {}", body);
1015
1016 let response = format!(
1017 "HTTP/1.1 {} Blocked\r\n\
1018 Content-Type: text/plain\r\n\
1019 Content-Length: {}\r\n\
1020 \r\n\
1021 {}",
1022 status, body_len, body
1023 );
1024 client_stream.write_all(response.as_bytes()).await?;
1025 Ok(())
1026 }
1027 ref modify_action @ RequestAction::Modify { .. } => {
1028 Log::new(Some(&status_tx)).debug("LLM requested modifications, applying");
1029
1030 let modified = Self::apply_request_modifications(request_data, modify_action);
1035 let modified_request = match modified {
1036 Ok(modified) => modified,
1037 Err(e) => {
1038 Log::new(Some(&status_tx)).warn(format!(
1039 "Proxy answering {} {} with 400: {:#}",
1040 method, uri, e
1041 ));
1042 client_stream
1043 .write_all(&modification_refused_response())
1044 .await?;
1045 return Ok(());
1046 }
1047 };
1048
1049 Self::forward_http_request(
1051 client_stream,
1052 &modified_request,
1053 host,
1054 method,
1055 uri,
1056 peer_addr,
1057 start_time,
1058 status_tx,
1059 )
1060 .await
1061 }
1062 }
1063 } else {
1064 info!("Request doesn't match filters, passing through");
1066 Self::forward_http_request(
1067 client_stream,
1068 request_data,
1069 host,
1070 method,
1071 uri,
1072 peer_addr,
1073 start_time,
1074 status_tx,
1075 )
1076 .await
1077 }
1078 }
1079
1080 async fn forward_http_request(
1082 mut client_stream: tokio::net::TcpStream,
1083 request_data: &[u8],
1084 host: &str,
1085 method: &str,
1086 uri: &str,
1087 peer_addr: SocketAddr,
1088 start_time: crate::utils::clock::Instant,
1089 status_tx: mpsc::UnboundedSender<String>,
1090 ) -> Result<()> {
1091 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1092
1093 let (dest_host, dest_port) = if let Some(colon_pos) = host.find(':') {
1095 (
1096 &host[..colon_pos],
1097 host[colon_pos + 1..].parse().unwrap_or(80),
1098 )
1099 } else {
1100 (host, 80)
1101 };
1102
1103 info!("Forwarding to {}:{}", dest_host, dest_port);
1104
1105 let forwarded = Self::to_origin_form(request_data);
1114
1115 let dest_addr = format!("{}:{}", dest_host, dest_port);
1117 let mut dest_stream = tokio::net::TcpStream::connect(&dest_addr)
1118 .await
1119 .context(format!("Failed to connect to {}", dest_addr))?;
1120
1121 dest_stream.write_all(&forwarded).await?;
1123 trace!("Sent {} bytes to upstream {}", forwarded.len(), dest_addr);
1124
1125 let mut response_buffer = Vec::new();
1130 let mut temp_buffer = [0u8; 8192];
1131 let mut content_length: Option<usize> = None;
1132 let mut headers_complete = false;
1133 let mut headers_end = 0;
1134
1135 loop {
1136 match tokio::time::timeout(
1137 tokio::time::Duration::from_secs(5),
1138 dest_stream.read(&mut temp_buffer),
1139 )
1140 .await
1141 {
1142 Ok(Ok(0)) => break, Ok(Ok(n)) => {
1144 response_buffer.extend_from_slice(&temp_buffer[..n]);
1145
1146 if response_buffer.len() > MAX_UPSTREAM_RESPONSE {
1147 Log::new(Some(&status_tx)).warn(format!(
1148 "Proxy upstream {} sent more than {} bytes; truncating the response",
1149 dest_addr, MAX_UPSTREAM_RESPONSE
1150 ));
1151 response_buffer.truncate(MAX_UPSTREAM_RESPONSE);
1152 break;
1153 }
1154
1155 if !headers_complete && response_buffer.len() > 4 {
1157 let response_str = String::from_utf8_lossy(&response_buffer);
1158 if let Some(header_end) = response_str.find("\r\n\r\n") {
1159 headers_complete = true;
1160 headers_end = header_end + 4;
1161
1162 for line in response_str[..header_end].lines() {
1164 if line.to_lowercase().starts_with("content-length:") {
1165 if let Some(len_str) = line.split(':').nth(1) {
1166 content_length = len_str.trim().parse().ok();
1167 }
1168 }
1169 }
1170 }
1171 }
1172
1173 if headers_complete {
1175 if let Some(len) = content_length {
1176 if response_buffer.len() >= headers_end + len {
1177 break; }
1179 } else {
1180 tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
1182 break;
1183 }
1184 }
1185 }
1186 Ok(Err(e)) => {
1187 error!("Error reading from destination: {}", e);
1188 break;
1189 }
1190 Err(_) => {
1191 debug!("Timeout reading from destination, proceeding with what we have");
1192 break;
1193 }
1194 }
1195 }
1196
1197 let status =
1199 if let Some(first_line) = String::from_utf8_lossy(&response_buffer).lines().next() {
1200 first_line
1201 .split_whitespace()
1202 .nth(1)
1203 .and_then(|s| s.parse().ok())
1204 .unwrap_or(0)
1205 } else {
1206 0
1207 };
1208
1209 let duration = start_time.elapsed();
1210
1211 Log::new(Some(&status_tx)).debug(format!(
1213 "[ACCESS] {} {} {} -> {} {} bytes in {:?}",
1214 peer_addr,
1215 method,
1216 uri,
1217 status,
1218 response_buffer.len(),
1219 duration
1220 ));
1221
1222 if response_buffer.len() > 0 {
1224 let response_str = String::from_utf8_lossy(&response_buffer);
1225 let lines: Vec<&str> = response_str.lines().collect();
1226 if !lines.is_empty() {
1227 trace!("Response status line: {}", lines[0]);
1228 trace!("Response headers:");
1229 for line in &lines[1..] {
1230 if line.is_empty() {
1231 break;
1232 }
1233 trace!(" {}", line);
1234 }
1235 }
1236 }
1237
1238 client_stream.write_all(&response_buffer).await?;
1240 trace!("Forwarded {} bytes to client", response_buffer.len());
1241
1242 Ok(())
1243 }
1244
1245 async fn consult_llm_http_request(
1247 request_info: &FullRequestInfo,
1248 server_id: ServerId,
1249 llm_client: &OllamaClient,
1250 app_state: &Arc<AppState>,
1251 protocol: &Arc<ProxyProtocol>,
1252 status_tx: &mpsc::UnboundedSender<String>,
1253 ) -> Result<RequestAction> {
1254 Log::new(Some(&status_tx)).debug("Consulting LLM about HTTP request...");
1255
1256 let event = Event::new(
1258 &PROXY_HTTP_REQUEST_EVENT,
1259 json!({
1260 "method": request_info.method,
1261 "url": request_info.url,
1262 "host": request_info.host,
1263 "path": request_info.path,
1264 }),
1265 );
1266
1267 let execution_result = call_llm(
1268 llm_client,
1269 app_state,
1270 server_id,
1271 None, &event,
1273 protocol.as_ref() as &dyn Server,
1274 )
1275 .await
1276 .context("LLM request failed")?;
1277
1278 for result in execution_result.protocol_results {
1280 if let ActionResult::Output(bytes) = result {
1281 let action: RequestAction = serde_json::from_slice(&bytes)
1283 .context("Failed to deserialize RequestAction")?;
1284 return Ok(action);
1285 }
1286 }
1287
1288 Log::new(Some(&status_tx)).warn(format!(
1294 "Proxy blocking {} {}: handler produced no filtering decision (decision=no_decision)",
1295 request_info.method, request_info.url
1296 ));
1297 Ok(RequestAction::Block {
1298 status: 502,
1299 body: "netget proxy: the filtering handler returned no decision for this request, \
1300 so it was refused"
1301 .to_string(),
1302 })
1303 }
1304
1305 async fn consult_llm_https_connection(
1307 conn_info: &HttpsConnectionInfo,
1308 server_id: ServerId,
1309 llm_client: &OllamaClient,
1310 app_state: &Arc<AppState>,
1311 protocol: &Arc<ProxyProtocol>,
1312 status_tx: &mpsc::UnboundedSender<String>,
1313 ) -> Result<HttpsConnectionAction> {
1314 Log::new(Some(&status_tx)).debug("Consulting LLM about HTTPS connection...");
1315
1316 let event = Event::new(
1318 &PROXY_HTTPS_CONNECT_EVENT,
1319 json!({
1320 "destination_host": conn_info.destination_host,
1321 "destination_port": conn_info.destination_port,
1322 "sni": conn_info.sni.as_ref().unwrap_or(&String::new()),
1323 }),
1324 );
1325
1326 let execution_result = call_llm(
1327 llm_client,
1328 app_state,
1329 server_id,
1330 None, &event,
1332 protocol.as_ref() as &dyn Server,
1333 )
1334 .await
1335 .context("LLM request failed")?;
1336
1337 for result in execution_result.protocol_results {
1339 if let ActionResult::Output(bytes) = result {
1340 let action: HttpsConnectionAction = serde_json::from_slice(&bytes)
1342 .context("Failed to deserialize HttpsConnectionAction")?;
1343 return Ok(action);
1344 }
1345 }
1346
1347 Log::new(Some(&status_tx)).warn(format!(
1351 "Proxy blocking CONNECT {}:{}: handler produced no filtering decision \
1352 (decision=no_decision)",
1353 conn_info.destination_host, conn_info.destination_port
1354 ));
1355 Ok(HttpsConnectionAction::Block {
1356 reason: Some(
1357 "netget proxy: the filtering handler returned no decision for this connection, \
1358 so it was refused"
1359 .to_string(),
1360 ),
1361 })
1362 }
1363
1364 fn to_origin_form(request_data: &[u8]) -> Vec<u8> {
1370 let sep = b"\r\n\r\n";
1372 let headers_end = request_data
1373 .windows(sep.len())
1374 .position(|w| w == sep)
1375 .map(|p| p + sep.len())
1376 .unwrap_or(request_data.len());
1377
1378 let (head, body) = request_data.split_at(headers_end);
1379 let Ok(head_str) = std::str::from_utf8(head) else {
1380 return request_data.to_vec(); };
1382
1383 let mut out = String::with_capacity(head_str.len());
1384 for (i, line) in head_str.split("\r\n").enumerate() {
1385 if i == 0 {
1386 let parts: Vec<&str> = line.split_whitespace().collect();
1387 if parts.len() >= 3 {
1388 let target = parts[1];
1389 let origin_form = strip_absolute_form(target);
1390 out.push_str(&format!("{} {} {}", parts[0], origin_form, parts[2]));
1391 } else {
1392 out.push_str(line);
1393 }
1394 } else if line
1395 .split_once(':')
1396 .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case("proxy-connection"))
1397 {
1398 continue;
1400 } else {
1401 out.push_str(line);
1402 }
1403 out.push_str("\r\n");
1404 }
1405
1406 if out.ends_with("\r\n\r\n\r\n") {
1410 out.truncate(out.len() - 2);
1411 }
1412
1413 let mut result = out.into_bytes();
1414 result.extend_from_slice(body);
1415 result
1416 }
1417
1418 pub fn apply_request_modifications(
1429 request_data: &[u8],
1430 modifications: &RequestAction,
1431 ) -> Result<Vec<u8>> {
1432 if let RequestAction::Modify {
1433 headers,
1434 remove_headers,
1435 new_path,
1436 query_params,
1437 new_body,
1438 body_replacements,
1439 } = modifications
1440 {
1441 let separator = b"\r\n\r\n";
1443 let separator_pos = request_data
1444 .windows(separator.len())
1445 .position(|window| window == separator);
1446
1447 if separator_pos.is_none() {
1448 return Ok(request_data.to_vec());
1449 }
1450
1451 let headers_end = separator_pos.unwrap();
1452 let body_start = headers_end + 4; let headers_bytes = &request_data[..headers_end];
1456 let headers_str = String::from_utf8_lossy(headers_bytes);
1457 let header_lines: Vec<&str> = headers_str.lines().collect();
1458
1459 if header_lines.is_empty() {
1460 return Ok(request_data.to_vec());
1461 }
1462
1463 let mut request_line = header_lines[0].to_string();
1465 let original_parts: Vec<&str> = header_lines[0].split_whitespace().collect();
1466 if (new_path.is_some() || query_params.is_some()) && original_parts.len() >= 3 {
1467 let method = original_parts[0];
1468 let version = original_parts[2];
1469 let target_ok = new_path
1473 .as_deref()
1474 .map(|p| filter::check_request_target_part("new_path", p).is_ok())
1475 .unwrap_or(true)
1476 && query_params
1477 .as_ref()
1478 .map(|q| filter::check_query_params(q).is_ok())
1479 .unwrap_or(true);
1480 if target_ok {
1481 let target = new_path.as_deref().unwrap_or(original_parts[1]);
1482 let target = apply_query_params(target, query_params.as_ref());
1483 request_line = format!("{} {} {}", method, target, version);
1484 } else {
1485 warn!("Request target modification contains whitespace or a control character; keeping the original request line");
1486 }
1487 }
1488
1489 let transfer_codings: Vec<String> = header_lines[1..]
1494 .iter()
1495 .filter_map(|line| {
1496 let (name, value) = line.split_once(':')?;
1497 name.trim()
1498 .eq_ignore_ascii_case("transfer-encoding")
1499 .then(|| value.trim().to_ascii_lowercase())
1500 })
1501 .collect();
1502 let chunked = !transfer_codings.is_empty();
1503 if chunked {
1504 let codings: Vec<&str> = transfer_codings
1505 .iter()
1506 .flat_map(|v| v.split(','))
1507 .map(str::trim)
1508 .filter(|c| !c.is_empty())
1509 .collect();
1510 anyhow::ensure!(
1511 codings == ["chunked"],
1512 "refusing to modify a request with Transfer-Encoding {:?}: only plain \
1513 chunked framing can be rebuilt",
1514 transfer_codings.join(", ")
1515 );
1516 }
1517
1518 let mut headers_map: HashMap<String, (String, String)> = HashMap::new();
1526 for line in &header_lines[1..] {
1527 if let Some(colon_pos) = line.find(':') {
1528 let name = line[..colon_pos].trim().to_string();
1529 let value = line[colon_pos + 1..].trim().to_string();
1530 headers_map.insert(name.to_lowercase(), (name, value));
1531 }
1532 }
1533
1534 if let Some(remove) = remove_headers {
1536 for header_name in remove {
1537 headers_map.remove(&header_name.to_lowercase());
1538 }
1539 }
1540
1541 if let Some(add_headers) = headers {
1545 for (name, value) in add_headers {
1546 if let Err(e) = filter::check_header_name(name)
1547 .and_then(|_| filter::check_header_value(name, value))
1548 {
1549 warn!("Dropping request header modification: {}", e);
1550 continue;
1551 }
1552 let key = name.to_lowercase();
1553 if filter::FRAMING_HEADERS.contains(&key.as_str()) {
1554 warn!("Dropping request header {name:?}: the proxy frames the body itself");
1555 continue;
1556 }
1557 headers_map.insert(key, (name.clone(), value.clone()));
1558 }
1559 }
1560
1561 let raw_body = if body_start < request_data.len() {
1565 &request_data[body_start..]
1566 } else {
1567 &[]
1568 };
1569 let decoded_body;
1570 let original_body: &[u8] = if chunked {
1571 decoded_body = decode_chunked_body(raw_body).context(
1572 "refusing to modify a chunked request whose body is malformed or incomplete",
1573 )?;
1574 &decoded_body
1575 } else {
1576 raw_body
1577 };
1578
1579 let mut body = String::from_utf8_lossy(original_body).to_string();
1580
1581 if let Some(new_body_text) = new_body {
1583 body = new_body_text.clone();
1584 }
1585
1586 if let Some(replacements) = body_replacements {
1587 for replacement in replacements {
1588 match Regex::new(&replacement.pattern) {
1589 Ok(re) => {
1590 body = re
1591 .replace_all(&body, replacement.replacement.as_str())
1592 .to_string();
1593 }
1594 Err(e) => warn!(
1595 "Invalid body_replacements pattern {:?}: {} (skipped)",
1596 replacement.pattern, e
1597 ),
1598 }
1599 }
1600 }
1601
1602 if chunked || !body.is_empty() || new_body.is_some() || body_replacements.is_some() {
1607 headers_map.remove("transfer-encoding");
1608 headers_map.insert(
1609 "content-length".to_string(),
1610 ("Content-Length".to_string(), body.len().to_string()),
1611 );
1612 }
1613
1614 let mut result = Vec::new();
1616 result.extend_from_slice(request_line.as_bytes());
1617 result.extend_from_slice(b"\r\n");
1618
1619 for (_key, (name, value)) in headers_map {
1620 result.extend_from_slice(name.as_bytes());
1621 result.extend_from_slice(b": ");
1622 result.extend_from_slice(value.as_bytes());
1623 result.extend_from_slice(b"\r\n");
1624 }
1625
1626 result.extend_from_slice(b"\r\n");
1627 if !body.is_empty() {
1628 result.extend_from_slice(body.as_bytes());
1629 }
1630
1631 Ok(result)
1632 } else {
1633 Ok(request_data.to_vec())
1634 }
1635 }
1636
1637 fn generate_ca_certificate() -> Result<(Certificate, KeyPair, CertificateParams)> {
1643 let mut params = CertificateParams::default();
1644 params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
1645 params
1646 .distinguished_name
1647 .push(rcgen::DnType::CommonName, CA_COMMON_NAME);
1648
1649 let key_pair = KeyPair::generate()?;
1650 let cert = params.self_signed(&key_pair)?;
1651
1652 Ok((cert, key_pair, params))
1653 }
1654}
1655
1656pub(crate) fn strip_absolute_form(target: &str) -> &str {
1660 let rest = match target.split_once("://") {
1661 Some((scheme, rest)) if !scheme.is_empty() && !scheme.contains('/') => rest,
1662 _ => return target,
1663 };
1664 match rest.find('/') {
1665 Some(pos) => &rest[pos..],
1666 None => "/", }
1668}
1669
1670pub(crate) fn modification_refused_response() -> Vec<u8> {
1674 let body = "Request framing could not be rebuilt by the proxy\n";
1675 format!(
1676 "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1677 body.len(),
1678 body
1679 )
1680 .into_bytes()
1681}
1682
1683pub(crate) fn decode_chunked_body(data: &[u8]) -> Result<Vec<u8>> {
1692 fn crlf_line(data: &[u8], pos: usize) -> Result<(&[u8], usize)> {
1694 let rest = &data[pos..];
1695 let end = rest
1696 .windows(2)
1697 .position(|w| w == b"\r\n")
1698 .ok_or_else(|| anyhow::anyhow!("chunked body ends without a CRLF at byte {pos}"))?;
1699 let line = &rest[..end];
1700 anyhow::ensure!(
1701 !line.contains(&b'\r') && !line.contains(&b'\n'),
1702 "bare CR or LF in chunked framing at byte {pos}"
1703 );
1704 Ok((line, pos + end + 2))
1705 }
1706
1707 let mut payload = Vec::new();
1708 let mut pos = 0;
1709 loop {
1710 let (size_line, next) = crlf_line(data, pos)?;
1711 let size_field = match size_line.iter().position(|&b| b == b';') {
1712 Some(i) => {
1714 let mut field = &size_line[..i];
1715 while let Some((&last, rest)) = field.split_last() {
1716 if last != b' ' && last != b'\t' {
1717 break;
1718 }
1719 field = rest;
1720 }
1721 field
1722 }
1723 None => size_line,
1724 };
1725 anyhow::ensure!(
1726 !size_field.is_empty()
1727 && size_field.len() <= 16
1728 && size_field.iter().all(u8::is_ascii_hexdigit),
1729 "invalid chunk size line {:?} at byte {pos}",
1730 String::from_utf8_lossy(size_line)
1731 );
1732 let size = u64::from_str_radix(std::str::from_utf8(size_field)?, 16)?;
1733 pos = next;
1734 if size == 0 {
1735 break;
1736 }
1737 let end = usize::try_from(size)
1738 .ok()
1739 .and_then(|size| pos.checked_add(size))
1740 .filter(|&end| end <= data.len())
1741 .ok_or_else(|| anyhow::anyhow!("chunked body ends inside a {size}-byte chunk"))?;
1742 payload.extend_from_slice(&data[pos..end]);
1743 anyhow::ensure!(
1744 data[end..].starts_with(b"\r\n"),
1745 "chunk data at byte {end} is not followed by CRLF"
1746 );
1747 pos = end + 2;
1748 }
1749
1750 loop {
1752 let (trailer, next) = crlf_line(data, pos)?;
1753 pos = next;
1754 if trailer.is_empty() {
1755 break;
1756 }
1757 }
1758 anyhow::ensure!(
1759 pos == data.len(),
1760 "{} bytes follow the end of the chunked body",
1761 data.len() - pos
1762 );
1763 Ok(payload)
1764}
1765
1766pub const CA_COMMON_NAME: &str = "NetGet MITM Proxy CA";
1769
1770const CA_EXPORT_MAX_BYTES: u64 = 64 * 1024;
1772
1773fn is_earlier_ca_export(text: &str) -> bool {
1776 use base64::Engine as _;
1777 const BEGIN: &str = "-----BEGIN CERTIFICATE-----";
1778 const END: &str = "-----END CERTIFICATE-----";
1779 let Some(body) = text
1780 .trim()
1781 .strip_prefix(BEGIN)
1782 .and_then(|rest| rest.strip_suffix(END))
1783 else {
1784 return false;
1785 };
1786 if body.contains("-----") {
1787 return false;
1788 }
1789 let b64: String = body.chars().filter(|c| !c.is_ascii_whitespace()).collect();
1790 base64::engine::general_purpose::STANDARD
1791 .decode(b64)
1792 .is_ok_and(|der| {
1793 der.windows(CA_COMMON_NAME.len())
1794 .any(|w| w == CA_COMMON_NAME.as_bytes())
1795 })
1796}
1797
1798pub fn write_ca_export(path: &std::path::Path, pem: &str) -> Result<()> {
1814 use std::io::{ErrorKind, Read, Seek, Write};
1815
1816 let refuse_symlink = || {
1817 anyhow::anyhow!(
1818 "{} is a symlink; ca_export_path must be a regular file path",
1819 path.display()
1820 )
1821 };
1822
1823 #[cfg(not(unix))]
1824 if std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink()) {
1825 return Err(refuse_symlink());
1826 }
1827
1828 let mut create = std::fs::OpenOptions::new();
1829 create.write(true).create_new(true);
1830 #[cfg(unix)]
1831 {
1832 use std::os::unix::fs::OpenOptionsExt;
1833 create.custom_flags(libc::O_NOFOLLOW);
1834 }
1835 match create.open(path) {
1836 Ok(mut file) => {
1837 file.write_all(pem.as_bytes())?;
1838 return Ok(());
1839 }
1840 Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
1841 Err(e) => {
1842 return Err(anyhow::Error::new(e).context(format!("cannot create {}", path.display())))
1843 }
1844 }
1845
1846 let mut open = std::fs::OpenOptions::new();
1850 open.read(true).write(true);
1851 #[cfg(unix)]
1852 {
1853 use std::os::unix::fs::OpenOptionsExt;
1854 open.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
1855 }
1856 let mut file = match open.open(path) {
1857 Ok(file) => file,
1858 #[cfg(unix)]
1859 Err(e) if e.raw_os_error() == Some(libc::ELOOP) => return Err(refuse_symlink()),
1860 Err(e) => {
1861 return Err(anyhow::Error::new(e).context(format!("cannot open {}", path.display())))
1862 }
1863 };
1864 anyhow::ensure!(
1865 file.metadata()?.is_file(),
1866 "{} exists and is not a regular file",
1867 path.display()
1868 );
1869 let mut existing = Vec::new();
1870 Read::take(&mut file, CA_EXPORT_MAX_BYTES + 1).read_to_end(&mut existing)?;
1871 let earlier_export = existing.len() as u64 <= CA_EXPORT_MAX_BYTES
1872 && std::str::from_utf8(&existing).is_ok_and(is_earlier_ca_export);
1873 anyhow::ensure!(
1874 earlier_export,
1875 "{} already exists and is not an earlier NetGet CA export; refusing to overwrite it \
1876 (choose a new path, or remove the file yourself)",
1877 path.display()
1878 );
1879 file.set_len(0)?;
1880 file.rewind()?;
1881 file.write_all(pem.as_bytes())?;
1882 Ok(())
1883}
1884
1885pub(crate) fn apply_query_params(
1889 target: &str,
1890 query_params: Option<&HashMap<String, String>>,
1891) -> String {
1892 let Some(params) = query_params.filter(|p| !p.is_empty()) else {
1893 return target.to_string();
1894 };
1895
1896 let (path, existing_query) = match target.split_once('?') {
1897 Some((p, q)) => (p, Some(q)),
1898 None => (target, None),
1899 };
1900
1901 let mut pairs: Vec<(String, String)> = Vec::new();
1902 if let Some(query) = existing_query {
1903 for pair in query.split('&').filter(|p| !p.is_empty()) {
1904 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
1905 pairs.push((k.to_string(), v.to_string()));
1906 }
1907 }
1908
1909 for (name, value) in params {
1910 if let Some(existing) = pairs.iter_mut().find(|(k, _)| k == name) {
1911 existing.1 = value.clone();
1912 } else {
1913 pairs.push((name.clone(), value.clone()));
1914 }
1915 }
1916
1917 let query = pairs
1918 .into_iter()
1919 .map(|(k, v)| if v.is_empty() { k } else { format!("{k}={v}") })
1920 .collect::<Vec<_>>()
1921 .join("&");
1922
1923 if query.is_empty() {
1924 path.to_string()
1925 } else {
1926 format!("{path}?{query}")
1927 }
1928}
1929
1930pub(crate) fn truncate_str(s: &str, max_bytes: usize) -> &str {
1935 if s.len() <= max_bytes {
1936 return s;
1937 }
1938 let mut end = max_bytes;
1939 while end > 0 && !s.is_char_boundary(end) {
1940 end -= 1;
1941 }
1942 &s[..end]
1943}