Skip to main content

netget/server/proxy/
mod.rs

1//! HTTP/HTTPS Proxy server implementation using MITM with LLM control
2//!
3//! This module implements a sophisticated proxy server with:
4//! - Full MITM capabilities with certificate generation/loading
5//! - Pass-through mode for HTTPS (no decryption, allow/block only)
6//! - LLM-controlled filtering and modification of requests/responses
7//! - Regex-based filtering for selective interception
8
9pub 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
41/// Largest upstream response `forward_http_request` will buffer before truncating.
42const MAX_UPSTREAM_RESPONSE: usize = 8 * 1024 * 1024;
43
44/// Largest client request head the proxy will read before giving up on it.
45pub const MAX_REQUEST_BYTES: usize = 64 * 1024;
46
47/// How long a client has to send its request line and headers after connecting.
48const REQUEST_READ_TIMEOUT_SECS: u64 = 30;
49
50/// Concurrent client connections this proxy admits before it starts refusing.
51///
52/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. A proxy connection is the most
53/// expensive kind in this tree: a `CONNECT` tunnel holds a *second* socket to the upstream for
54/// as long as the client keeps it, so an uncapped accept loop exhausts descriptors at twice
55/// the rate, and with MITM enabled each one also drives a TLS handshake and a certificate
56/// mint. That argues for a number, not for a smaller one — a browser opens six connections per
57/// origin and a handful of origins is already dozens, so anything tight would break the
58/// ordinary case. 256 is generous for a human's browser and far below what socket exhaustion
59/// needs.
60const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
61
62/// The body of [`CONNECTION_CAP_REFUSAL`].
63const CONNECTION_CAP_BODY: &str = "Too many connections, try again later.\n";
64
65/// What a client over [`MAX_CONNECTIONS`] is told before the socket closes.
66///
67/// A proxy speaks HTTP to its client whatever the client is asking for: a plain request gets
68/// an HTTP response, and a `CONNECT` gets an HTTP status line before any tunnel exists
69/// (RFC 9110 §9.3.6), so one 503 is correct for both and neither is mis-parsed. 503 +
70/// `Retry-After` is also what this server already sends when its own backend is saturated,
71/// which keeps "the proxy is full" and "the proxy is overloaded" in the same vocabulary for a
72/// client deciding whether to retry.
73static 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
83/// Re-assemble `host` and `port` into something `TcpStream::connect` accepts, re-bracketing an
84/// IPv6 literal that the CONNECT parser stripped.
85pub(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
93/// Read an HTTP request head (through the blank line) plus whatever body bytes arrived with
94/// it, bounded by `MAX_REQUEST_BYTES`.
95///
96/// Returns as soon as `\r\n\r\n` is seen; a peer that stops mid-head simply stops the future,
97/// which the caller wraps in a timeout.
98async 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            // EOF: hand back whatever we have and let the caller decide.
107            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
120/// Relay a CONNECT tunnel until both directions are finished, and report the byte counts.
121///
122/// This is `copy_bidirectional`, not two `tokio::io::copy` futures under `join!`, and the
123/// difference is a connection leak rather than a style point. `tokio::io::copy` returns on EOF
124/// without shutting down the write half it was feeding, so when the client half-closed its
125/// side the destination never saw a FIN. A keep-alive upstream then held the socket open
126/// indefinitely, the other `copy` never completed, `join!` never returned, and the connection
127/// task -- plus its dashboard entry -- lived until the operating system gave up.
128/// `copy_bidirectional` shuts down the opposite write half on EOF, which is what RFC 9110
129/// tunnelling requires.
130async 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            // A reset mid-tunnel is ordinary; the byte counts are lost with it.
138            debug!("Proxy tunnel ended with an I/O error: {}", e);
139            (0, 0)
140        }
141    }
142}
143
144/// HTTP/HTTPS Proxy server that intercepts and forwards requests via LLM
145pub struct ProxyServer;
146
147impl ProxyServer {
148    /// Spawn HTTP Proxy server with integrated LLM actions
149    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        // Get or initialize proxy filter configuration
163        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        // Apply startup parameters if provided
172        if let Some(ref params) = startup_params {
173            Log::new(Some(&status_tx)).info("Applying proxy startup parameters");
174
175            // Parse certificate_mode
176            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            // Parse filter modes
202            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        // Generate or load certificate based on configuration
226        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                // Loading an operator-supplied CA is deliberately refused rather
237                // than approximated. The previous implementation read the key,
238                // ignored the certificate file entirely, and minted a *different*
239                // self-signed CA from the operator's private key. Clients that
240                // trusted the real CA rejected the result, so interception
241                // silently failed while appearing configured -- and the operator's
242                // CA key had been used to sign a certificate they never asked for.
243                //
244                // Using the real certificate requires reading its subject, which
245                // needs rcgen's "x509-parser" feature (not currently enabled in
246                // Cargo.toml).
247                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        // Export the CA certificate if the operator asked for it. Only the public
264        // certificate is written; the private key never leaves memory.
265        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        // Save the config back to state
278        app_state
279            .set_proxy_filter_config(server_id, config.clone())
280            .await;
281
282        let protocol = Arc::new(ProxyProtocol::new());
283
284        // Start TCP listener for proxy connections
285        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        // Spawn cache cleanup task if MITM mode is enabled.
303        //
304        // Only ONE task handle can be registered per server, and that slot belongs
305        // to the accept loop (registering a second one silently drops the first,
306        // leaving the port held after stop_server). So this task holds a *weak*
307        // reference and exits on its own once the accept loop is aborted and the
308        // last strong reference to the cache goes away.
309        if let Some(ref cache) = cert_cache {
310            let cache_weak = Arc::downgrade(cache);
311            let status_tx_clone = status_tx.clone();
312            // Tracked, not detached: stop_server must abort this task too.
313            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)); // 1 hour
318                    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                        // Hourly cache stats are a summary: file-only DEBUG.
328                        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        // Spawn proxy handler task
341        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                        // Add connection to ServerInstance
364                        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                        // Handle each proxy connection in a separate task
395                        // Tracked, not detached: stop_server must abort this task too.
396                        let task_owner = app_state.clone();
397                        task_owner
398                            .spawn_server_task(server_id, async move {
399                                // Released when this task ends. A CONNECT tunnel is driven to
400                                // completion *inside* `handle_proxy_connection` rather than in
401                                // a task of its own, so the slot covers the upstream socket
402                                // too — which is the resource the cap is really about here.
403                                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                                // Mark connection as closed
425                                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        // Register the accept loop so stop_server can abort it and release the port.
444        task_registrar
445            .register_server_task(server_id, accept_handle)
446            .await;
447
448        Ok(actual_addr)
449    }
450
451    /// Handle a single proxy connection
452    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        // Read the initial HTTP request, bounded in both size and time.
472        //
473        // A single `read()` with no deadline was two problems at once. A peer that connected
474        // and then said nothing parked this task forever -- unauthenticated, so any number of
475        // them -- and a request head split across TCP segments (any client that writes the
476        // request line and the headers separately, and any head over the MTU) was parsed from
477        // whatever the first segment happened to contain. Read until the head is complete,
478        // stop at `MAX_REQUEST_BYTES`, and give up after `REQUEST_READ_TIMEOUT_SECS`.
479        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(()); // Client closed connection
508        }
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        // Parse the request line
529        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        // Check if this is an HTTPS CONNECT request
545        if method == "CONNECT" {
546            // HTTPS tunneling request
547            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            // Regular HTTP request
563            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    /// Handle HTTPS CONNECT request (tunneling)
582    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        // Parse host:port from CONNECT uri.
600        //
601        // Split on the *last* colon and strip the brackets an IPv6 literal is required to
602        // carry (RFC 9110 authority-form, RFC 3986 section 3.2.2). Splitting on every colon
603        // and demanding exactly two pieces rejected `[::1]:443` outright, so no client could
604        // CONNECT to an IPv6 destination through this proxy.
605        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: Log full connection details (metadata only, no content in pass-through)
620        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            // MITM mode - full decryption and inspection
649            info!(
650                "MITM mode: will decrypt and inspect HTTPS traffic for {}:{}",
651                dest_host, dest_port
652            );
653
654            // Call MITM implementation
655            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        // Pass-through mode or fallback - no decryption
673        // Note: SNI could be extracted from TLS handshake, but we use dest_host for now
674        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), // SNI - could be extracted from TLS handshake
679            &client_addr_str,
680        ) {
681            // Consult LLM about whether to allow this HTTPS connection
682            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            // Consult LLM
695            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                // Blocking on failure was already right here; the HTTP path above is the one
706                // that fell open. Non-fatal (the client gets a 403), so WARN not ERROR.
707                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                    // Establish connection to destination
724                    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                    // Send 200 Connection Established to client
730                    client_stream
731                        .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
732                        .await?;
733
734                    // Bidirectional copy between client and destination
735                    let (up_bytes, down_bytes) =
736                        tunnel_bidirectional(&mut client_stream, &mut dest_stream).await;
737                    let total_bytes = up_bytes + down_bytes;
738                    // Without this the rail's ↓/↑ counters stay at zero for a tunnel that may
739                    // have carried gigabytes.
740                    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                    // DEBUG: Access log (pass-through - no HTTP status)
754                    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                    // DEBUG: Access log
774                    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                    // Send 403 Forbidden to client
784                    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            // Filter mode is "none" or doesn't match - pass through without LLM
800            trace!(
801                "Pass-through HTTPS connection to {}:{} (no LLM consultation)",
802                dest_host,
803                dest_port
804            );
805
806            // Establish connection to destination
807            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            // Send 200 Connection Established to client
813            client_stream
814                .write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
815                .await?;
816
817            // Bidirectional copy
818            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            // DEBUG: Access log
835            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    /// Handle regular HTTP request (no TLS)
845    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        // Parse HTTP request
864        let request_str = String::from_utf8_lossy(request_data);
865        let mut headers = HashMap::new();
866        let mut body_start = 0;
867
868        // Parse headers
869        for (i, line) in request_str.lines().enumerate() {
870            if i == 0 {
871                continue; // Skip request line
872            }
873            if line.is_empty() {
874                // End of headers
875                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        // Extract host from headers or URI
894        let host = headers
895            .get("Host")
896            .map(|s| s.as_str())
897            .or_else(|| {
898                // Try to extract from absolute URI
899                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            // Absolute URI - extract path
910            uri.find('/').map(|pos| &uri[pos..]).unwrap_or("/")
911        } else {
912            uri
913        };
914
915        // TRACE: Log full request details
916        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        // Check if we should intercept this request
934        if config.should_intercept_request(host, path, method, &headers, body) {
935            Log::new(Some(&status_tx)).debug("Request matched filters, consulting LLM");
936
937            // Build request info for LLM
938            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            // Consult LLM
949            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                // Fail closed. This used to default to `Pass`, which forwarded the request to
960                // its destination unfiltered - so a proxy whose entire purpose is to let the
961                // model decide what may leave the network became an open relay for exactly as
962                // long as the backend was down, and the access log recorded each request as
963                // having been passed on purpose. The HTTPS half of this same handler has
964                // always defaulted to Block; only this one fell open.
965                //
966                // 502 is the proxy's own "I could not complete this on your behalf"; 503 when
967                // the backend is merely saturated, which a client retries.
968                let overloaded = crate::llm::is_overload_error(&e);
969                let status = if overloaded { 503 } else { 502 };
970                // Non-fatal: the client gets a 502/503, so WARN not ERROR.
971                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                    // Forward request to destination
986                    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                    // DEBUG: Access log
1003                    Log::new(Some(&status_tx)).debug(format!(
1004                        "[ACCESS] {} {} {} -> {} {} bytes in {:?}",
1005                        peer_addr, method, uri, status, body_len, duration
1006                    ));
1007
1008                    // TRACE: Full response details
1009                    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                    // Apply modifications. A request the proxy cannot rebuild (a malformed or
1031                    // incomplete chunked body, an undecodable transfer coding) is refused:
1032                    // forwarding it unmodified would ignore the model's decision, and its
1033                    // framing is exactly what the proxy could not read.
1034                    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                    // Forward modified request
1050                    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            // Pass through without LLM consultation
1065            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    /// Forward HTTP request to destination and return response to client
1081    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        // Parse host:port
1094        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        // Rewrite the request-target from proxy absolute-form to origin-form.
1106        //
1107        // A client talking to a proxy sends "GET http://host/path HTTP/1.1"
1108        // (RFC 9112 3.2.2 absolute-form). Origin servers expect origin-form
1109        // ("GET /path HTTP/1.1") -- RFC 9112 3.2.1 requires a proxy to convert
1110        // before forwarding. Forwarding verbatim made every plain-HTTP request
1111        // through this proxy fail: python's http.server answers 404 because it
1112        // treats the whole absolute URI as a filename.
1113        let forwarded = Self::to_origin_form(request_data);
1114
1115        // Connect to destination
1116        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        // Send request to destination
1122        dest_stream.write_all(&forwarded).await?;
1123        trace!("Sent {} bytes to upstream {}", forwarded.len(), dest_addr);
1124
1125        // Read response from destination, bounded. The whole response is buffered here before
1126        // any of it reaches the client, so without a cap a destination -- which the *peer*
1127        // chose, not the operator -- decides how much memory this process allocates. On
1128        // loopback that is gigabytes inside the 5s read window.
1129        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, // EOF
1143                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                    // Parse Content-Length if we haven't yet
1156                    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                            // Extract Content-Length
1163                            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                    // Check if we have full response
1174                    if headers_complete {
1175                        if let Some(len) = content_length {
1176                            if response_buffer.len() >= headers_end + len {
1177                                break; // Have complete response
1178                            }
1179                        } else {
1180                            // No Content-Length, wait a bit more
1181                            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        // Parse response status for access log
1198        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        // DEBUG: Access log
1212        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        // TRACE: Full response details
1223        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        // Send response back to client
1239        client_stream.write_all(&response_buffer).await?;
1240        trace!("Forwarded {} bytes to client", response_buffer.len());
1241
1242        Ok(())
1243    }
1244
1245    /// Consult LLM about an HTTP request
1246    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        // Create HTTP request event
1257        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, // TODO: Add connection_id for proxy requests
1272            &event,
1273            protocol.as_ref() as &dyn Server,
1274        )
1275        .await
1276        .context("LLM request failed")?;
1277
1278        // Extract request action from protocol results
1279        for result in execution_result.protocol_results {
1280            if let ActionResult::Output(bytes) = result {
1281                // Deserialize the RequestAction
1282                let action: RequestAction = serde_json::from_slice(&bytes)
1283                    .context("Failed to deserialize RequestAction")?;
1284                return Ok(action);
1285            }
1286        }
1287
1288        // Fail closed, for the same reason the `Err` branch at the call site does: a request
1289        // this proxy forwards is a request the model was asked to rule on and did not. The
1290        // caller cannot tell the two apart from the action alone, so the distinction is in
1291        // the body and the log - `no_decision` here, backend failure there - exactly so a
1292        // silent model and a dead backend are never diagnosed as each other.
1293        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    /// Consult LLM about an HTTPS connection (pass-through mode)
1306    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        // Create HTTPS CONNECT event
1317        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, // TODO: Add connection_id for proxy responses
1331            &event,
1332            protocol.as_ref() as &dyn Server,
1333        )
1334        .await
1335        .context("LLM request failed")?;
1336
1337        // Extract HTTPS connection action from protocol results
1338        for result in execution_result.protocol_results {
1339            if let ActionResult::Output(bytes) = result {
1340                // Deserialize the HttpsConnectionAction
1341                let action: HttpsConnectionAction = serde_json::from_slice(&bytes)
1342                    .context("Failed to deserialize HttpsConnectionAction")?;
1343                return Ok(action);
1344            }
1345        }
1346
1347        // Fail closed. An `Allow` here opens a bidirectional tunnel to an arbitrary host on
1348        // the strength of a decision nobody made, and the access log then records it as a
1349        // deliberate pass. See the sibling comment in `consult_llm_http_request`.
1350        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    /// Convert a proxy-style request (absolute-form request-target, plus
1365    /// proxy-only hop-by-hop headers) into what an origin server expects.
1366    ///
1367    /// Only the request line and the `Proxy-Connection` header are touched; the
1368    /// rest of the message, including the body, is passed through byte-for-byte.
1369    fn to_origin_form(request_data: &[u8]) -> Vec<u8> {
1370        // Split headers from body without decoding the body.
1371        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(); // Not text: leave untouched
1381        };
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                // Hop-by-hop header that must not be forwarded upstream.
1399                continue;
1400            } else {
1401                out.push_str(line);
1402            }
1403            out.push_str("\r\n");
1404        }
1405
1406        // The split above turns the trailing "\r\n\r\n" into two empty
1407        // segments, so the reassembled head already ends with a blank line.
1408        // Drop the one extra CRLF that the final empty segment added.
1409        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    /// Apply a model's `handle_request_modify` decision to a raw HTTP/1.1 request.
1419    ///
1420    /// Anything other than `RequestAction::Modify` returns the request unchanged. A
1421    /// modified request is framed by the proxy: whenever the rebuild writes its own
1422    /// `Content-Length` it removes `Transfer-Encoding`, so the upstream never receives both
1423    /// (the CL.TE request-smuggling desync). A `Transfer-Encoding: chunked` body is decoded
1424    /// to its payload before `new_body` / `body_replacements` apply and is always forwarded
1425    /// with a `Content-Length`. A chunked body that is malformed or incomplete, or a
1426    /// transfer coding other than plain `chunked`, is an `Err`: the caller refuses the
1427    /// request rather than forwarding framing the proxy could not read.
1428    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            // Find the \r\n\r\n separator between headers and body
1442            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; // After \r\n\r\n
1453
1454            // Extract headers section as string
1455            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            // Rebuild the request line, applying new_path and query_params.
1464            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                // The executor refused these already; a target that still carries
1470                // whitespace or a control character keeps the original line rather than
1471                // becoming a second request.
1472                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            // How the client framed the body. Read from every header line rather than from
1490            // the map below, which keeps one value per name. Only plain `chunked` is
1491            // decodable; any other coding (`gzip, chunked`, a doubled `chunked`, an empty
1492            // value) is refused, because dropping the header would misdescribe the bytes.
1493            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            // Build headers map.
1519            //
1520            // HTTP field names are case-insensitive (RFC 9110 §5.1) and hyper/reqwest put them
1521            // on the wire lowercased, while a model writes `User-Agent` / `Content-Type`. The
1522            // map is keyed on the lowercased name, with the original spelling kept only for
1523            // output, so `remove_headers: ["User-Agent"]` removes `user-agent` and
1524            // `headers: {"Host": ...}` replaces the existing `host` rather than adding a second.
1525            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            // Remove headers
1535            if let Some(remove) = remove_headers {
1536                for header_name in remove {
1537                    headers_map.remove(&header_name.to_lowercase());
1538                }
1539            }
1540
1541            // Add/modify headers. The executor refused names that are not tokens, values
1542            // with CR/LF and the framing headers; dropping any that still arrive is what
1543            // keeps one header from becoming a second request.
1544            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            // Get body as bytes, then convert to string for modification. A chunked body is
1562            // decoded to its payload first, so the modifications see the real content and the
1563            // Content-Length written below counts it.
1564            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            // Apply body modifications
1582            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            // The proxy frames the body it writes: a Content-Length counting `body`, and no
1603            // Transfer-Encoding beside it. A body that was chunked, non-empty, or explicitly
1604            // modified (possibly to empty) gets one; an untouched empty body keeps the head
1605            // as it was.
1606            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            // Reconstruct request with proper \r\n line endings
1615            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    /// Generate a self-signed CA certificate for MITM proxy.
1638    ///
1639    /// A fresh key is generated per server start; there is no fixed key and
1640    /// nothing is persisted. The params are returned alongside so that leaf
1641    /// certificates can be issued under this CA's real distinguished name.
1642    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
1656/// Reduce an absolute-form request-target ("http://host/path?q") to origin-form
1657/// ("/path?q"). Targets that are already origin-form, or the asterisk-form "*",
1658/// are returned unchanged.
1659pub(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 => "/", // "http://host" with no path
1667    }
1668}
1669
1670/// The answer to a request whose modification could not be applied
1671/// (`ProxyServer::apply_request_modifications` returned `Err`). The reason goes to the log;
1672/// the peer gets only this fixed text.
1673pub(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
1683/// Decode an HTTP/1.1 `chunked` message body (RFC 9112 §7.1) into its payload.
1684///
1685/// `data` must be the whole chunked body: every chunk, the terminating zero-size chunk, any
1686/// trailer fields and the final CRLF, with nothing after it. Chunk extensions and trailer
1687/// fields are discarded. Everything else is an error — a size that is not hex, a chunk
1688/// shorter than its declared size, a missing or bare CR/LF, a body that ends early, bytes
1689/// after the final CRLF — because a proxy that guesses at framing disagrees with the
1690/// upstream about where the request ends.
1691pub(crate) fn decode_chunked_body(data: &[u8]) -> Result<Vec<u8>> {
1692    /// The line starting at `pos` (without its CRLF) and the offset just past the CRLF.
1693    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            // BWS is allowed only before a chunk extension.
1713            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    // Trailer section: field lines up to the empty line that ends the body.
1751    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
1766/// The common name of the CA NetGet generates. A file at `ca_export_path` whose single
1767/// certificate carries it is an earlier NetGet export.
1768pub const CA_COMMON_NAME: &str = "NetGet MITM Proxy CA";
1769
1770/// Largest file inspected as a possible earlier export. One certificate is a few KiB.
1771const CA_EXPORT_MAX_BYTES: u64 = 64 * 1024;
1772
1773/// Whether `text` is exactly one PEM certificate (nothing but whitespace around it) whose DER
1774/// contains [`CA_COMMON_NAME`].
1775fn 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
1798/// Write the CA certificate to `ca_export_path` without destroying anything else.
1799///
1800/// `ca_export_path` is a startup parameter, so the model can set it through `open_server`,
1801/// and a wrong path must cost nothing:
1802///
1803/// - A path that does not exist is created with `create_new`, so a file that appears there
1804///   in the meantime is never truncated.
1805/// - A symlink is refused, never followed: on Unix every open carries `O_NOFOLLOW` (and
1806///   `create_new` does not follow one either); elsewhere `symlink_metadata` refuses it first.
1807/// - An existing file is replaced only when it is an earlier NetGet export: a regular file
1808///   holding exactly one PEM certificate, and nothing else, whose subject is
1809///   [`CA_COMMON_NAME`] — what a restart finds, since the CA is regenerated per start. The
1810///   check and the rewrite go through the same handle, so there is no window between them.
1811///   Any other file — a PEM certificate or CA bundle included — is refused by name and left
1812///   untouched.
1813pub 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    // The path exists. Open it for reading and writing without truncating: `O_NOFOLLOW`
1847    // fails with ELOOP on a symlink, and `O_NONBLOCK` keeps a FIFO from stalling the open
1848    // (a regular file ignores it).
1849    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
1885/// Merge `query_params` into a request target, replacing same-named parameters
1886/// and appending the rest. Returns the target unchanged when there is nothing to
1887/// merge.
1888pub(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
1930/// Truncate a string to at most `max_bytes`, never splitting a UTF-8 character.
1931///
1932/// `&s[..n]` panics when `n` lands inside a multi-byte character, which any
1933/// client can arrange by sending a request whose 200th byte is mid-character.
1934pub(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}