Skip to main content

netget/server/socks5/
mod.rs

1//! SOCKS5 proxy server implementation with LLM control
2//!
3//! This module implements a SOCKS5 proxy server with:
4//! - SOCKS5 protocol handshake and authentication
5//! - LLM-controlled connection decisions (allow/deny)
6//! - Pattern-based filtering for selective LLM involvement
7//! - Optional MITM mode for traffic inspection
8//! - Support for IPv4, IPv6, and domain name targets
9
10pub mod actions;
11pub mod filter;
12
13use crate::server::connection::ConnectionId;
14use anyhow::{bail, Context, Result};
15use filter::{FilterMode, Socks5FilterConfig};
16use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
17use std::sync::Arc;
18use tokio::io::{AsyncReadExt, AsyncWriteExt};
19use tokio::net::TcpStream;
20use tokio::sync::mpsc;
21use tracing::{error, info, warn};
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::actions::protocol_trait::ActionResult;
25use crate::llm::ollama_client::OllamaClient;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::Socks5Protocol;
29use crate::state::app_state::AppState;
30use crate::state::ServerId;
31use actions::{SOCKS5_AUTH_REQUEST_EVENT, SOCKS5_CONNECT_REQUEST_EVENT};
32
33/// SOCKS5 protocol constants
34const SOCKS5_VERSION: u8 = 0x05;
35const AUTH_METHOD_NO_AUTH: u8 = 0x00;
36const AUTH_METHOD_USERNAME_PASSWORD: u8 = 0x02;
37const AUTH_METHOD_NO_ACCEPTABLE: u8 = 0xFF;
38
39const CMD_CONNECT: u8 = 0x01;
40const ATYP_IPV4: u8 = 0x01;
41const ATYP_DOMAIN: u8 = 0x03;
42const ATYP_IPV6: u8 = 0x04;
43
44const REPLY_SUCCESS: u8 = 0x00;
45const REPLY_GENERAL_FAILURE: u8 = 0x01;
46const REPLY_CONNECTION_NOT_ALLOWED: u8 = 0x02;
47const REPLY_NETWORK_UNREACHABLE: u8 = 0x03;
48const REPLY_HOST_UNREACHABLE: u8 = 0x04;
49const REPLY_CONNECTION_REFUSED: u8 = 0x05;
50
51const _REPLY_COMMAND_NOT_SUPPORTED: u8 = 0x07;
52const _REPLY_ADDRESS_TYPE_NOT_SUPPORTED: u8 = 0x08;
53
54/// How long a peer has to complete each read of the SOCKS5 handshake.
55///
56/// Every handshake read is a `read_exact`, and until this existed none of them had a deadline:
57/// a peer that connected and sent one byte held a connection task, a `ServerInstance` entry and
58/// a socket for as long as the process lived. No authentication happens before any of it, so
59/// the cost of holding one was a single `connect()`.
60const HANDSHAKE_TIMEOUT_SECS: u64 = 30;
61
62/// How long an established tunnel may carry nothing **in either direction** before it is closed.
63///
64/// `HANDSHAKE_TIMEOUT_SECS` ends at the CONNECT reply; after it the relay used to have no bound
65/// at all, so a tunnel whose client and target had both gone quiet — typically because one end
66/// vanished without a FIN — held two sockets and a slot for as long as the process lived.
67///
68/// **Silence is measured on both directions combined, not per direction.** A download is a
69/// tunnel where only the target speaks and a slow upload one where only the client does; either
70/// is a live tunnel, so a byte read from *either* side resets the clock. Only a tunnel that has
71/// moved nothing at all, both ways, is idle.
72///
73/// One hour, because a proxy carries whatever TCP its clients bring and some of that is
74/// long-idle by design: IMAP IDLE is re-issued every 29 minutes (RFC 2177), an SSH session with no
75/// `ServerAliveInterval` can sit silent indefinitely, and RFC 5382 REQ-5 asks NATs to keep an idle
76/// TCP mapping for over two hours. An hour clears the application keepalives in common use and
77/// the 300-second window a `manual` rule gives a human, and a tunnel silent both ways for that
78/// long has almost certainly lost an end. Declared as the `idle_timeout_secs` startup parameter —
79/// raise it for SSH without keepalives, lower it for a proxy exposed to strangers.
80///
81/// In MITM mode every chunk is answered by the model before it is forwarded, and that answer —
82/// a model round-trip, or a `manual` rule parked for a human — holds the tunnel busy, so the
83/// clock never runs against a chunk that is being decided.
84pub const RELAY_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3600);
85
86/// A borrowed stream that records every byte it reads on a shared
87/// [`ConnectionActivity`](crate::server::accept_bounded::ConnectionActivity), so one idle clock can
88/// cover both directions of a `copy_bidirectional` relay. Writes pass straight through.
89struct TouchOnRead<'a> {
90    inner: &'a mut TcpStream,
91    activity: Arc<crate::server::accept_bounded::ConnectionActivity>,
92    bytes_read: u64,
93}
94
95impl<'a> TouchOnRead<'a> {
96    fn new(
97        inner: &'a mut TcpStream,
98        activity: Arc<crate::server::accept_bounded::ConnectionActivity>,
99    ) -> Self {
100        Self {
101            inner,
102            activity,
103            bytes_read: 0,
104        }
105    }
106}
107
108impl tokio::io::AsyncRead for TouchOnRead<'_> {
109    fn poll_read(
110        self: std::pin::Pin<&mut Self>,
111        cx: &mut std::task::Context<'_>,
112        buf: &mut tokio::io::ReadBuf<'_>,
113    ) -> std::task::Poll<std::io::Result<()>> {
114        let this = self.get_mut();
115        let before = buf.filled().len();
116        let poll = std::pin::Pin::new(&mut *this.inner).poll_read(cx, buf);
117        if let std::task::Poll::Ready(Ok(())) = &poll {
118            let n = buf.filled().len() - before;
119            if n > 0 {
120                this.bytes_read += n as u64;
121                this.activity.touch();
122            }
123        }
124        poll
125    }
126}
127
128impl tokio::io::AsyncWrite for TouchOnRead<'_> {
129    fn poll_write(
130        self: std::pin::Pin<&mut Self>,
131        cx: &mut std::task::Context<'_>,
132        buf: &[u8],
133    ) -> std::task::Poll<std::io::Result<usize>> {
134        std::pin::Pin::new(&mut *self.get_mut().inner).poll_write(cx, buf)
135    }
136
137    fn poll_flush(
138        self: std::pin::Pin<&mut Self>,
139        cx: &mut std::task::Context<'_>,
140    ) -> std::task::Poll<std::io::Result<()>> {
141        std::pin::Pin::new(&mut *self.get_mut().inner).poll_flush(cx)
142    }
143
144    fn poll_shutdown(
145        self: std::pin::Pin<&mut Self>,
146        cx: &mut std::task::Context<'_>,
147    ) -> std::task::Poll<std::io::Result<()>> {
148        std::pin::Pin::new(&mut *self.get_mut().inner).poll_shutdown(cx)
149    }
150}
151
152/// Concurrent client connections this proxy admits before it starts refusing.
153///
154/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. [`HANDSHAKE_TIMEOUT_SECS`]
155/// bounds how long one peer can hold a slot saying nothing; only a cap bounds how many of
156/// them there can be at once, and no authentication happens before either bound applies.
157///
158/// A SOCKS5 connection is worth more than most in this tree, which argues for a cap rather
159/// than against the number: an established one holds **two** sockets — the client's and the
160/// one this proxy opened to the target on its behalf — plus the relay buffers between them, so
161/// an uncapped accept loop lets a stranger spend this process's descriptors at two apiece and
162/// spend the *target's* at one. A proxy is also the one server here whose legitimate
163/// connection count tracks a browser's parallelism rather than a single application's, so the
164/// house default is the right side of generous: 256 concurrent tunnels is far above a
165/// workstation's working set and far below where descriptor exhaustion starts.
166const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
167
168/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes: `05 FF`, the
169/// method-selection message carrying `NO ACCEPTABLE METHODS` (RFC 1928 §3).
170///
171/// **This is the one message RFC 1928 lets a server send without having read anything, and
172/// that is why it is the one used.** The method-selection reply is two self-contained bytes —
173/// a version and a chosen method — and echoes nothing from the greeting, so unlike `ipp`'s
174/// `server-error-busy` or a MongoDB reply it cannot be mis-matched against a request the peer
175/// never sent. §3 then requires the client to close the connection on `X'FF'`, so the refusal
176/// is complete rather than advisory, and it is what `curl`, `ssh -o ProxyCommand` and
177/// tokio-socks all already handle.
178///
179/// The alternative inside the protocol is worse, and it is worth naming so nobody reaches for
180/// it later: `X'01' general SOCKS server failure` is a **request-phase** reply (§6), and a
181/// client still in method negotiation reads those same first two bytes as
182/// `VER=5, METHOD=0x01` — GSSAPI — and starts a GSSAPI exchange with a server that has
183/// already gone. That is precisely the mis-parse this codebase refuses to ship.
184///
185/// The honest cost of `X'FF'` is that RFC 1928 has no "busy" code at all, so the client is
186/// told *that* it was refused and not *why*: an operator reading only the client's log sees
187/// "no acceptable authentication method" and might go looking at auth configuration. The
188/// server-side log line carries the real reason
189/// (`decision=fail_closed_connection_cap`, with the limit), and a clean refusal the client
190/// acts on immediately beats a silent close it has to infer.
191const CONNECTION_CAP_REFUSAL: &[u8] = &[SOCKS5_VERSION, AUTH_METHOD_NO_ACCEPTABLE];
192
193/// Render relayed bytes for the LLM: printable payloads as text, everything else
194/// as hex. Returns the string and the encoding label to put on the event so the
195/// model knows which form it is looking at (and which to echo back).
196fn encode_relay_data(data: &[u8]) -> (String, &'static str) {
197    if data
198        .iter()
199        .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
200    {
201        (String::from_utf8_lossy(data).to_string(), "utf8")
202    } else {
203        (hex::encode(data), "hex")
204    }
205}
206
207/// Map an outbound connection failure onto the SOCKS5 reply code the client
208/// expects (RFC 1928 section 6).
209fn socks5_reply_for_connect_error(err: &anyhow::Error) -> u8 {
210    for cause in err.chain() {
211        if let Some(io_err) = cause.downcast_ref::<std::io::Error>() {
212            return match io_err.kind() {
213                std::io::ErrorKind::ConnectionRefused => REPLY_CONNECTION_REFUSED,
214                std::io::ErrorKind::TimedOut => REPLY_HOST_UNREACHABLE,
215                std::io::ErrorKind::NetworkUnreachable => REPLY_NETWORK_UNREACHABLE,
216                std::io::ErrorKind::HostUnreachable => REPLY_HOST_UNREACHABLE,
217                _ => REPLY_GENERAL_FAILURE,
218            };
219        }
220    }
221    REPLY_GENERAL_FAILURE
222}
223
224/// Target address for SOCKS5 connection
225#[derive(Debug, Clone)]
226pub enum TargetAddr {
227    Ipv4(Ipv4Addr, u16),
228    Ipv6(Ipv6Addr, u16),
229    Domain(String, u16),
230}
231
232impl std::fmt::Display for TargetAddr {
233    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        match self {
235            TargetAddr::Ipv4(ip, port) => write!(f, "{}:{}", ip, port),
236            TargetAddr::Ipv6(ip, port) => write!(f, "[{}]:{}", ip, port),
237            TargetAddr::Domain(domain, port) => write!(f, "{}:{}", domain, port),
238        }
239    }
240}
241
242/// SOCKS5 proxy server that forwards connections via LLM decisions
243pub struct Socks5Server;
244
245impl Socks5Server {
246    /// Spawn SOCKS5 proxy server with integrated LLM actions
247    pub async fn spawn_with_llm_actions(
248        listen_addr: SocketAddr,
249        llm_client: OllamaClient,
250        app_state: Arc<AppState>,
251        status_tx: mpsc::UnboundedSender<String>,
252        server_id: ServerId,
253        startup_params: Option<crate::protocol::StartupParams>,
254    ) -> Result<SocketAddr> {
255        Log::new(Some(&status_tx)).info(format!("SOCKS5 starting on {}", listen_addr));
256
257        // Get or initialize SOCKS5 filter configuration
258        let mut config = app_state
259            .get_socks5_filter_config(server_id)
260            .await
261            .unwrap_or_else(|| {
262                info!("No SOCKS5 filter config found, using defaults");
263                Socks5FilterConfig::default()
264            });
265
266        let mut relay_idle = RELAY_IDLE_TIMEOUT;
267
268        // Apply startup parameters if provided
269        if let Some(ref params) = startup_params {
270            Log::new(Some(&status_tx)).info("Applying SOCKS5 startup parameters");
271
272            // Parse auth methods
273            if let Some(methods) = params.get_optional_array("auth_methods")? {
274                config.auth_methods.clear();
275                for method in methods {
276                    if let Some(method_str) = method.as_str() {
277                        match method_str {
278                            "none" => config.auth_methods.push(AUTH_METHOD_NO_AUTH),
279                            "username_password" => {
280                                config.auth_methods.push(AUTH_METHOD_USERNAME_PASSWORD)
281                            }
282                            _ => warn!("Unknown auth method: {}", method_str),
283                        }
284                    }
285                }
286                Log::new(Some(&status_tx)).info(format!("Auth methods: {:?}", config.auth_methods));
287            }
288
289            // Parse default action
290            if let Some(action_str) = params.get_optional_string("default_action")? {
291                config.default_action = action_str;
292                Log::new(Some(&status_tx))
293                    .info(format!("Default action: {}", config.default_action));
294            }
295
296            // Parse filter configuration
297            if let Some(filter) = params.get_optional_object("filter")? {
298                if let Some(patterns) = filter
299                    .get("target_host_patterns")
300                    .and_then(|v| v.as_array())
301                {
302                    config.target_host_patterns = patterns
303                        .iter()
304                        .filter_map(|v| v.as_str())
305                        .map(|s| s.to_string())
306                        .collect();
307                }
308                if let Some(ranges) = filter.get("target_port_ranges").and_then(|v| v.as_array()) {
309                    config.target_port_ranges = ranges
310                        .iter()
311                        .filter_map(|v| v.as_array())
312                        .filter_map(|arr| {
313                            if arr.len() == 2 {
314                                let start = arr[0].as_u64()? as u16;
315                                let end = arr[1].as_u64()? as u16;
316                                Some((start, end))
317                            } else {
318                                None
319                            }
320                        })
321                        .collect();
322                }
323            }
324
325            // Parse filter mode
326            if let Some(mode_str) = params.get_optional_string("filter_mode")? {
327                config.filter_mode = match mode_str.as_str() {
328                    "allow_all" => FilterMode::AllowAll,
329                    "deny_all" => FilterMode::DenyAll,
330                    "ask_llm" => FilterMode::AskLlm,
331                    "selective" => FilterMode::Selective,
332                    _ => {
333                        warn!("Unknown filter mode: {}, using default", mode_str);
334                        config.filter_mode
335                    }
336                };
337                Log::new(Some(&status_tx)).info(format!("Filter mode: {:?}", config.filter_mode));
338            }
339
340            // Parse the relay idle bound (argued beside RELAY_IDLE_TIMEOUT).
341            if let Some(secs) = params.get_optional_u64("idle_timeout_secs")? {
342                relay_idle = std::time::Duration::from_secs(secs);
343            }
344
345            // Parse MITM mode
346            if let Some(mitm) = params.get_optional_bool("mitm_by_default")? {
347                config.mitm_by_default = mitm;
348                Log::new(Some(&status_tx))
349                    .info(format!("MITM by default: {}", config.mitm_by_default));
350            }
351        }
352
353        // Store config in app state
354        app_state
355            .set_socks5_filter_config(server_id, config.clone())
356            .await;
357
358        let listener =
359            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
360        let local_addr = listener.local_addr()?;
361        Log::new(Some(&status_tx)).info(format!("SOCKS5 proxy ready on {}", local_addr));
362
363        let protocol = Arc::new(Socks5Protocol::new());
364
365        let task_registrar = app_state.clone();
366        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
367        let accept_handle = tokio::spawn(async move {
368            loop {
369                match crate::server::accept_bounded::accept_bounded(
370                    &listener,
371                    &limiter,
372                    CONNECTION_CAP_REFUSAL,
373                    "SOCKS5",
374                    Some(&status_tx),
375                )
376                .await
377                {
378                    Ok((stream, remote_addr, permit)) => {
379                        let connection_id =
380                            ConnectionId::new(app_state.get_next_unified_id().await);
381                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
382                        let llm_clone = llm_client.clone();
383                        let state_clone = app_state.clone();
384                        let status_clone = status_tx.clone();
385                        let protocol_clone = protocol.clone();
386                        let config_clone = config.clone();
387
388                        // Tracked, not detached: stop_server must abort this task too.
389                        let task_owner = app_state.clone();
390                        task_owner
391                            .spawn_server_task(server_id, async move {
392                                // Released when this task ends, and this task is the whole of
393                                // the connection: the relay between the client and the target
394                                // runs inline in `handle_connection`, so there is no second
395                                // task to outlive this one and `MAX_CONNECTIONS` caps live
396                                // tunnels rather than accepts.
397                                let _permit = permit;
398                                Log::new(Some(&status_clone)).info(format!(
399                                    "SOCKS5 connection {} from {}",
400                                    connection_id, remote_addr
401                                ));
402
403                                if let Err(e) = Self::handle_connection(
404                                    stream,
405                                    connection_id,
406                                    remote_addr,
407                                    local_addr_conn,
408                                    llm_clone,
409                                    state_clone.clone(),
410                                    status_clone.clone(),
411                                    protocol_clone,
412                                    server_id,
413                                    config_clone,
414                                    relay_idle,
415                                )
416                                .await
417                                {
418                                    Log::new(Some(&status_clone)).error(format!(
419                                        "SOCKS5 connection {} error: {}",
420                                        connection_id, e
421                                    ));
422                                }
423
424                                // Connection closed - mark as closed
425                                state_clone
426                                    .close_connection_on_server(server_id, connection_id)
427                                    .await;
428                                let _ = status_clone.send("__UPDATE_UI__".to_string());
429                            })
430                            .await;
431                    }
432                    Err(e) => {
433                        error!("Failed to accept SOCKS5 connection: {}", e);
434                        break;
435                    }
436                }
437            }
438        });
439
440        task_registrar
441            .register_server_task(server_id, accept_handle)
442            .await;
443
444        Ok(local_addr)
445    }
446
447    /// Handle individual SOCKS5 connection
448    async fn handle_connection(
449        mut client_stream: TcpStream,
450        connection_id: ConnectionId,
451        remote_addr: SocketAddr,
452        local_addr: SocketAddr,
453        llm_client: OllamaClient,
454        app_state: Arc<AppState>,
455        status_tx: mpsc::UnboundedSender<String>,
456        protocol: Arc<Socks5Protocol>,
457        server_id: ServerId,
458        config: Socks5FilterConfig,
459        relay_idle: std::time::Duration,
460    ) -> Result<()> {
461        // Add connection to ServerInstance
462        use crate::state::server::{
463            ConnectionState as ServerConnectionState, ConnectionStatus, ProtocolConnectionInfo,
464        };
465        let now = crate::utils::clock::Instant::now();
466        let conn_state = ServerConnectionState {
467            id: connection_id,
468            remote_addr,
469            local_addr,
470            bytes_sent: 0,
471            bytes_received: 0,
472            packets_sent: 0,
473            packets_received: 0,
474            last_activity: now,
475            status: ConnectionStatus::Active,
476            status_changed_at: now,
477            protocol_info: ProtocolConnectionInfo::empty(),
478        };
479        app_state
480            .add_connection_to_server(server_id, conn_state)
481            .await;
482        let _ = status_tx.send("__UPDATE_UI__".to_string());
483
484        // Phase 1: Handshake - negotiate auth method
485        Log::new(Some(&status_tx)).debug(format!("SOCKS5 {} phase 1: handshake", connection_id));
486
487        let selected_method = tokio::time::timeout(
488            std::time::Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
489            Self::negotiate_auth(&mut client_stream, &config, connection_id, &status_tx),
490        )
491        .await
492        .map_err(|_| anyhow::anyhow!("timed out waiting for the SOCKS5 greeting"))??;
493
494        Log::new(Some(&status_tx)).debug(format!(
495            "SOCKS5 {} selected auth method: 0x{:02x}",
496            connection_id, selected_method
497        ));
498
499        // Phase 2: Authentication (if required)
500        let username = if selected_method == AUTH_METHOD_USERNAME_PASSWORD {
501            Log::new(Some(&status_tx))
502                .debug(format!("SOCKS5 {} phase 2: authentication", connection_id));
503
504            let auth_result = Self::authenticate_username_password(
505                &mut client_stream,
506                connection_id,
507                &llm_client,
508                &app_state,
509                &status_tx,
510                &protocol,
511                server_id,
512            )
513            .await?;
514
515            Some(auth_result)
516        } else {
517            None
518        };
519
520        // Phase 3: Process CONNECT request
521        Log::new(Some(&status_tx))
522            .debug(format!("SOCKS5 {} phase 3: CONNECT request", connection_id));
523
524        let target_addr = tokio::time::timeout(
525            std::time::Duration::from_secs(HANDSHAKE_TIMEOUT_SECS),
526            Self::parse_connect_request(&mut client_stream, connection_id, &status_tx),
527        )
528        .await
529        .map_err(|_| anyhow::anyhow!("timed out waiting for the SOCKS5 CONNECT request"))??;
530
531        Log::new(Some(&status_tx)).info(format!(
532            "SOCKS5 {} CONNECT to {}",
533            connection_id, target_addr
534        ));
535
536        // Update connection with target address
537        app_state
538            .update_socks5_target(
539                server_id,
540                connection_id,
541                Some(target_addr.to_string()),
542                username.clone(),
543            )
544            .await;
545
546        // Check if target matches filter
547        let matches_filter = Self::check_filter_match(&target_addr, &config);
548
549        Log::new(Some(&status_tx)).debug(format!(
550            "SOCKS5 {} filter match: {}",
551            connection_id, matches_filter
552        ));
553
554        // Decide whether to ask LLM or use default action
555        let (should_allow, mitm_enabled) = match (&config.filter_mode, matches_filter) {
556            (FilterMode::AllowAll, _) => (true, config.mitm_by_default),
557            (FilterMode::DenyAll, _) => (false, false),
558            (FilterMode::Selective, true) | (FilterMode::AskLlm, _) => {
559                // Ask LLM. A failure here (model unreachable, invalid actions,
560                // retries exhausted) must still produce a SOCKS5 reply: returning
561                // Err dropped the connection without one, so the client saw an
562                // unexplained EOF instead of a refusal. Fail closed.
563                match Self::ask_llm_for_decision(
564                    &target_addr,
565                    username.as_deref(),
566                    connection_id,
567                    &llm_client,
568                    &app_state,
569                    &status_tx,
570                    &protocol,
571                    server_id,
572                )
573                .await
574                {
575                    Ok(decision) => decision,
576                    Err(e) => {
577                        // `decision=` tags follow src/server/radius/: the wire carries only
578                        // "not allowed" (REPLY_CONNECTION_NOT_ALLOWED is the single refusal
579                        // code SOCKS5 has), so the log is the only place a backend outage can
580                        // be told apart from the model deliberately refusing.
581                        Log::new(Some(&status_tx)).warn(format!(
582                            "SOCKS5 {} decision=fail_closed_llm_error target={} - denying: {}",
583                            connection_id, target_addr, e
584                        ));
585                        (false, false)
586                    }
587                }
588            }
589            (FilterMode::Selective, false) => {
590                // No filter match, use default action
591                let allow = config.default_action == "allow";
592                (allow, allow && config.mitm_by_default)
593            }
594        };
595
596        if !should_allow {
597            Log::new(Some(&status_tx)).warn(format!(
598                "SOCKS5 {} decision=deny target={} connection denied",
599                connection_id, target_addr
600            ));
601
602            // Send SOCKS5 reply: connection not allowed
603            Self::send_connect_reply(
604                &mut client_stream,
605                REPLY_CONNECTION_NOT_ALLOWED,
606                &target_addr,
607            )
608            .await?;
609            return Ok(());
610        }
611
612        // Connect to target.
613        //
614        // On failure the client must be told with a SOCKS5 reply; returning Err
615        // here used to drop the connection without any reply at all, so clients
616        // (curl included) sat waiting for the CONNECT response until they timed
617        // out instead of reporting the real error.
618        let mut target_stream =
619            match Self::connect_to_target(&target_addr, connection_id, &status_tx).await {
620                Ok(stream) => stream,
621                Err(e) => {
622                    let reply = socks5_reply_for_connect_error(&e);
623                    Log::new(Some(&status_tx)).warn(format!(
624                        "SOCKS5 {} connect to {} failed: {} (reply 0x{:02x})",
625                        connection_id, target_addr, e, reply
626                    ));
627                    Self::send_connect_reply(&mut client_stream, reply, &target_addr).await?;
628                    return Ok(());
629                }
630            };
631
632        Log::new(Some(&status_tx)).info(format!(
633            "SOCKS5 {} connected to {}",
634            connection_id, target_addr
635        ));
636
637        // Send SOCKS5 reply: success
638        Self::send_connect_reply(&mut client_stream, REPLY_SUCCESS, &target_addr).await?;
639
640        // Phase 4: Relay data bidirectionally
641        Log::new(Some(&status_tx)).debug(format!(
642            "SOCKS5 {} phase 4: relay (MITM: {})",
643            connection_id, mitm_enabled
644        ));
645
646        if mitm_enabled {
647            // MITM mode: inspect and modify data
648            Self::relay_with_mitm(
649                client_stream,
650                target_stream,
651                connection_id,
652                &target_addr,
653                username.as_deref(),
654                &llm_client,
655                &app_state,
656                &status_tx,
657                &protocol,
658                server_id,
659                relay_idle,
660            )
661            .await?;
662        } else {
663            // Passthrough mode: direct relay, raced against one idle clock that both directions
664            // touch. See RELAY_IDLE_TIMEOUT.
665            let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
666            let mut client = TouchOnRead::new(&mut client_stream, Arc::clone(&activity));
667            let mut target = TouchOnRead::new(&mut target_stream, Arc::clone(&activity));
668            let outcome = tokio::select! {
669                result = tokio::io::copy_bidirectional(&mut client, &mut target) => Some(result),
670                _ = crate::server::accept_bounded::watch_idle(Arc::clone(&activity), relay_idle) => None,
671            };
672            // Counted by the wrappers, so the figures are right on the idle path too, where
673            // `copy_bidirectional` never returns its own.
674            let (client_to_target_bytes, target_to_client_bytes) =
675                (client.bytes_read, target.bytes_read);
676            match outcome {
677                Some(Ok(_)) => {
678                    Log::new(Some(&status_tx)).info(format!(
679                        "SOCKS5 {} relay complete: {}↑ {}↓",
680                        connection_id, client_to_target_bytes, target_to_client_bytes
681                    ));
682                }
683                Some(Err(e)) => {
684                    Log::new(Some(&status_tx))
685                        .warn(format!("SOCKS5 {} relay error: {}", connection_id, e));
686                }
687                None => {
688                    Log::new(Some(&status_tx)).info(format!(
689                        "SOCKS5 {} tunnel to {} moved nothing either way for {}s; closing \
690                         decision=idle_timeout ({}↑ {}↓)",
691                        connection_id,
692                        target_addr,
693                        relay_idle.as_secs(),
694                        client_to_target_bytes,
695                        target_to_client_bytes
696                    ));
697                }
698            }
699            // Without this the rail's ↓/↑ counters sit at zero for the whole life of a
700            // connection that may have relayed gigabytes.
701            app_state
702                .update_connection_stats(
703                    server_id,
704                    connection_id,
705                    Some(client_to_target_bytes),
706                    Some(target_to_client_bytes),
707                    None,
708                    None,
709                )
710                .await;
711        }
712
713        Ok(())
714    }
715
716    /// Relay with MITM inspection - asks LLM for each data chunk
717    async fn relay_with_mitm(
718        mut client_stream: TcpStream,
719        mut target_stream: TcpStream,
720        connection_id: ConnectionId,
721        target_addr: &TargetAddr,
722        username: Option<&str>,
723        llm_client: &OllamaClient,
724        app_state: &Arc<AppState>,
725        status_tx: &mpsc::UnboundedSender<String>,
726        protocol: &Arc<Socks5Protocol>,
727        server_id: ServerId,
728        relay_idle: std::time::Duration,
729    ) -> Result<()> {
730        use actions::{SOCKS5_DATA_FROM_TARGET_EVENT, SOCKS5_DATA_TO_TARGET_EVENT};
731
732        Log::new(Some(status_tx)).info(format!("SOCKS5 {} MITM relay active", connection_id));
733
734        let mut client_buf = vec![0u8; 8192];
735        let mut target_buf = vec![0u8; 8192];
736        let mut client_to_target_total = 0u64;
737        let mut target_to_client_total = 0u64;
738
739        // One clock for both directions (see RELAY_IDLE_TIMEOUT). Each chunk holds it busy while
740        // the model decides it, so a decision parked for a human is never mistaken for silence.
741        let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
742
743        loop {
744            tokio::select! {
745                _ = crate::server::accept_bounded::watch_idle(Arc::clone(&activity), relay_idle) => {
746                    Log::new(Some(status_tx)).info(format!(
747                        "SOCKS5 {} MITM tunnel to {} moved nothing either way for {}s; closing \
748                         decision=idle_timeout",
749                        connection_id,
750                        target_addr,
751                        relay_idle.as_secs()
752                    ));
753                    break;
754                }
755
756                // Read from client (data going to target)
757                result = client_stream.read(&mut client_buf) => {
758                    match result {
759                        Ok(0) => {
760                            Log::new(Some(status_tx)).debug(format!("SOCKS5 {} client closed", connection_id));
761                            break;
762                        }
763                        Ok(n) => {
764                            let _busy = activity.busy();
765                            let data = &client_buf[..n];
766                            Log::new(Some(status_tx)).trace(format!("SOCKS5 {} client→target {} bytes: {:?}", connection_id, n, data));
767
768                            // Ask LLM what to do with this data. Binary payloads
769                            // are hex-encoded rather than run through
770                            // from_utf8_lossy, which replaced every non-UTF-8 byte
771                            // with U+FFFD and made the payload unrecoverable.
772                            let (data_str, encoding) = encode_relay_data(data);
773                            let event = Event::new(&SOCKS5_DATA_TO_TARGET_EVENT, serde_json::json!({
774                                "data": data_str,
775                                "encoding": encoding,
776                                "target": target_addr.to_string(),
777                                "username": username,
778                            }));
779
780                            let execution_result = call_llm(
781                                llm_client,
782                                app_state,
783                                server_id,
784                                Some(connection_id),
785                                &event,
786                                protocol.as_ref(),
787                            ).await?;
788
789                            // Process LLM actions
790                            let mut should_close = false;
791                            let mut data_to_send: Option<Vec<u8>> = Some(data.to_vec());
792
793                            for result in &execution_result.protocol_results {
794                                match result {
795                                    ActionResult::NoAction => {
796                                        // Forward as-is (already set)
797                                    }
798                                    ActionResult::Output(modified_data) => {
799                                        // Use modified data
800                                        data_to_send = Some(modified_data.clone());
801                                        Log::new(Some(status_tx)).debug(format!("SOCKS5 {} data modified: {} → {} bytes",
802                                                                       connection_id, n, modified_data.len()));
803                                    }
804                                    ActionResult::CloseConnection => {
805                                        should_close = true;
806                                        data_to_send = None;
807                                        Log::new(Some(status_tx)).warn(format!("SOCKS5 {} LLM close request", connection_id));
808                                    }
809                                    _ => {}
810                                }
811                            }
812
813                            if should_close {
814                                break;
815                            }
816
817                            // Send data to target
818                            if let Some(data) = data_to_send {
819                                target_stream.write_all(&data).await?;
820                                target_stream.flush().await?;
821                                client_to_target_total += data.len() as u64;
822                            }
823                        }
824                        Err(e) => {
825                            Log::new(Some(status_tx)).error(format!("SOCKS5 {} client read error: {}", connection_id, e));
826                            break;
827                        }
828                    }
829                }
830
831                // Read from target (data going to client)
832                result = target_stream.read(&mut target_buf) => {
833                    match result {
834                        Ok(0) => {
835                            Log::new(Some(status_tx)).debug(format!("SOCKS5 {} target closed", connection_id));
836                            break;
837                        }
838                        Ok(n) => {
839                            let _busy = activity.busy();
840                            let data = &target_buf[..n];
841                            Log::new(Some(status_tx)).trace(format!("SOCKS5 {} target→client {} bytes: {:?}", connection_id, n, data));
842
843                            // Ask LLM what to do with this data (see note above
844                            // on hex encoding for binary payloads).
845                            let (data_str, encoding) = encode_relay_data(data);
846                            let event = Event::new(&SOCKS5_DATA_FROM_TARGET_EVENT, serde_json::json!({
847                                "data": data_str,
848                                "encoding": encoding,
849                                "target": target_addr.to_string(),
850                                "username": username,
851                            }));
852
853                            let execution_result = call_llm(
854                                llm_client,
855                                app_state,
856                                server_id,
857                                Some(connection_id),
858                                &event,
859                                protocol.as_ref(),
860                            ).await?;
861
862                            // Process LLM actions
863                            let mut should_close = false;
864                            let mut data_to_send: Option<Vec<u8>> = Some(data.to_vec());
865
866                            for result in &execution_result.protocol_results {
867                                match result {
868                                    ActionResult::NoAction => {
869                                        // Forward as-is (already set)
870                                    }
871                                    ActionResult::Output(modified_data) => {
872                                        // Use modified data
873                                        data_to_send = Some(modified_data.clone());
874                                        Log::new(Some(status_tx)).debug(format!("SOCKS5 {} data modified: {} → {} bytes",
875                                                                       connection_id, n, modified_data.len()));
876                                    }
877                                    ActionResult::CloseConnection => {
878                                        should_close = true;
879                                        data_to_send = None;
880                                        Log::new(Some(status_tx)).warn(format!("SOCKS5 {} LLM close request", connection_id));
881                                    }
882                                    _ => {}
883                                }
884                            }
885
886                            if should_close {
887                                break;
888                            }
889
890                            // Send data to client
891                            if let Some(data) = data_to_send {
892                                client_stream.write_all(&data).await?;
893                                client_stream.flush().await?;
894                                target_to_client_total += data.len() as u64;
895                            }
896                        }
897                        Err(e) => {
898                            Log::new(Some(status_tx)).error(format!("SOCKS5 {} target read error: {}", connection_id, e));
899                            break;
900                        }
901                    }
902                }
903            }
904        }
905
906        Log::new(Some(status_tx)).info(format!(
907            "SOCKS5 {} MITM relay complete: {}↑ {}↓",
908            connection_id, client_to_target_total, target_to_client_total
909        ));
910
911        Ok(())
912    }
913
914    /// Negotiate authentication method with client
915    async fn negotiate_auth(
916        stream: &mut TcpStream,
917        config: &Socks5FilterConfig,
918        connection_id: ConnectionId,
919        status_tx: &mpsc::UnboundedSender<String>,
920    ) -> Result<u8> {
921        // Read handshake: [VER, NMETHODS, METHODS...]
922        let mut buf = [0u8; 2];
923        stream.read_exact(&mut buf).await?;
924
925        let version = buf[0];
926        let nmethods = buf[1];
927
928        Log::new(Some(status_tx)).trace(format!(
929            "SOCKS5 {} handshake: version={}, nmethods={}",
930            connection_id, version, nmethods
931        ));
932
933        if version != SOCKS5_VERSION {
934            bail!("Unsupported SOCKS version: {}", version);
935        }
936
937        if nmethods == 0 {
938            bail!("No authentication methods provided");
939        }
940
941        // Read methods
942        let mut methods = vec![0u8; nmethods as usize];
943        stream.read_exact(&mut methods).await?;
944
945        Log::new(Some(status_tx)).trace(format!(
946            "SOCKS5 {} client methods: {:?}",
947            connection_id, methods
948        ));
949
950        // Select method based on config
951        let selected_method = config
952            .auth_methods
953            .iter()
954            .find(|&&method| methods.contains(&method))
955            .copied()
956            .unwrap_or(AUTH_METHOD_NO_ACCEPTABLE);
957
958        // Send method selection: [VER, METHOD]
959        let response = [SOCKS5_VERSION, selected_method];
960        stream.write_all(&response).await?;
961        stream.flush().await?;
962
963        if selected_method == AUTH_METHOD_NO_ACCEPTABLE {
964            bail!("No acceptable authentication methods");
965        }
966
967        Ok(selected_method)
968    }
969
970    /// Authenticate using username/password
971    async fn authenticate_username_password(
972        stream: &mut TcpStream,
973        connection_id: ConnectionId,
974        llm_client: &OllamaClient,
975        app_state: &Arc<AppState>,
976        status_tx: &mpsc::UnboundedSender<String>,
977        protocol: &Arc<Socks5Protocol>,
978        server_id: ServerId,
979    ) -> Result<String> {
980        // Read auth request: [VER(1), ULEN, UNAME, PLEN, PASSWD]
981        let mut buf = [0u8; 1];
982        stream.read_exact(&mut buf).await?;
983
984        let auth_version = buf[0];
985        if auth_version != 0x01 {
986            bail!(
987                "Unsupported username/password auth version: {}",
988                auth_version
989            );
990        }
991
992        // Read username
993        stream.read_exact(&mut buf).await?;
994        let ulen = buf[0] as usize;
995        let mut username_bytes = vec![0u8; ulen];
996        stream.read_exact(&mut username_bytes).await?;
997        let username = String::from_utf8_lossy(&username_bytes).to_string();
998
999        // Read password
1000        stream.read_exact(&mut buf).await?;
1001        let plen = buf[0] as usize;
1002        let mut password_bytes = vec![0u8; plen];
1003        stream.read_exact(&mut password_bytes).await?;
1004        let password = String::from_utf8_lossy(&password_bytes).to_string();
1005
1006        Log::new(Some(status_tx)).debug(format!(
1007            "SOCKS5 {} auth request: username={}",
1008            connection_id, username
1009        ));
1010
1011        // Ask LLM to validate credentials
1012        let event = Event::new(
1013            &SOCKS5_AUTH_REQUEST_EVENT,
1014            serde_json::json!({
1015                "username": username,
1016                "password": password,
1017            }),
1018        );
1019
1020        // A failed LLM call must still produce an auth response; returning Err
1021        // left the client waiting on a reply that never came. Fail closed.
1022        let execution_result = match call_llm(
1023            llm_client,
1024            app_state,
1025            server_id,
1026            Some(connection_id),
1027            &event,
1028            protocol.as_ref(),
1029        )
1030        .await
1031        {
1032            Ok(result) => result,
1033            Err(e) => {
1034                Log::new(Some(status_tx)).warn(format!(
1035                    "SOCKS5 {} decision=fail_closed_llm_error user={} auth rejected: {}",
1036                    connection_id, username, e
1037                ));
1038                let _ = stream.write_all(&[0x01, 0x01]).await;
1039                let _ = stream.flush().await;
1040                bail!(
1041                    "Authentication decision failed for user {}: {}",
1042                    username,
1043                    e
1044                );
1045            }
1046        };
1047
1048        // Same reasoning as the CONNECT decision: match on the action the model
1049        // emitted rather than on ActionResult::NoAction, which several unrelated
1050        // actions also return.
1051        let allowed_action = execution_result
1052            .raw_actions
1053            .iter()
1054            .any(|action| action.get("type").and_then(|v| v.as_str()) == Some("allow_socks5_auth"));
1055        let denied_action = execution_result
1056            .raw_actions
1057            .iter()
1058            .any(|action| action.get("type").and_then(|v| v.as_str()) == Some("deny_socks5_auth"));
1059        let auth_allowed = allowed_action && !denied_action;
1060
1061        // Send auth response: [VER(1), STATUS]
1062        let status = if auth_allowed { 0x00 } else { 0x01 };
1063        let response = [0x01, status];
1064        stream.write_all(&response).await?;
1065        stream.flush().await?;
1066
1067        if !auth_allowed {
1068            let decision = if denied_action {
1069                "model_reject"
1070            } else {
1071                "model_silent"
1072            };
1073            Log::new(Some(status_tx)).warn(format!(
1074                "SOCKS5 {} decision={} user={} auth rejected",
1075                connection_id, decision, username
1076            ));
1077            bail!("Authentication failed for user: {}", username);
1078        }
1079
1080        Log::new(Some(status_tx)).info(format!(
1081            "SOCKS5 {} authenticated as {}",
1082            connection_id, username
1083        ));
1084
1085        Ok(username)
1086    }
1087
1088    /// Parse CONNECT request from client
1089    async fn parse_connect_request(
1090        stream: &mut TcpStream,
1091        connection_id: ConnectionId,
1092        status_tx: &mpsc::UnboundedSender<String>,
1093    ) -> Result<TargetAddr> {
1094        // Read request: [VER, CMD, RSV(0), ATYP, DST.ADDR, DST.PORT]
1095        let mut buf = [0u8; 4];
1096        stream.read_exact(&mut buf).await?;
1097
1098        let version = buf[0];
1099        let cmd = buf[1];
1100        let _rsv = buf[2];
1101        let atyp = buf[3];
1102
1103        Log::new(Some(status_tx)).trace(format!(
1104            "SOCKS5 {} request: version={}, cmd=0x{:02x}, atyp=0x{:02x}",
1105            connection_id, version, cmd, atyp
1106        ));
1107
1108        if version != SOCKS5_VERSION {
1109            bail!("Unsupported SOCKS version: {}", version);
1110        }
1111
1112        if cmd != CMD_CONNECT {
1113            bail!(
1114                "Unsupported command: 0x{:02x} (only CONNECT supported)",
1115                cmd
1116            );
1117        }
1118
1119        // Parse destination address
1120        let target_addr = match atyp {
1121            ATYP_IPV4 => {
1122                let mut addr = [0u8; 4];
1123                stream.read_exact(&mut addr).await?;
1124                let ip = Ipv4Addr::from(addr);
1125                let mut port_buf = [0u8; 2];
1126                stream.read_exact(&mut port_buf).await?;
1127                let port = u16::from_be_bytes(port_buf);
1128                TargetAddr::Ipv4(ip, port)
1129            }
1130            ATYP_DOMAIN => {
1131                let mut len_buf = [0u8; 1];
1132                stream.read_exact(&mut len_buf).await?;
1133                let len = len_buf[0] as usize;
1134                let mut domain_bytes = vec![0u8; len];
1135                stream.read_exact(&mut domain_bytes).await?;
1136                let domain = String::from_utf8_lossy(&domain_bytes).to_string();
1137                let mut port_buf = [0u8; 2];
1138                stream.read_exact(&mut port_buf).await?;
1139                let port = u16::from_be_bytes(port_buf);
1140                TargetAddr::Domain(domain, port)
1141            }
1142            ATYP_IPV6 => {
1143                let mut addr = [0u8; 16];
1144                stream.read_exact(&mut addr).await?;
1145                let ip = Ipv6Addr::from(addr);
1146                let mut port_buf = [0u8; 2];
1147                stream.read_exact(&mut port_buf).await?;
1148                let port = u16::from_be_bytes(port_buf);
1149                TargetAddr::Ipv6(ip, port)
1150            }
1151            _ => bail!("Unsupported address type: 0x{:02x}", atyp),
1152        };
1153
1154        Ok(target_addr)
1155    }
1156
1157    /// Send CONNECT reply to client
1158    async fn send_connect_reply(
1159        stream: &mut TcpStream,
1160        reply_code: u8,
1161        target_addr: &TargetAddr,
1162    ) -> Result<()> {
1163        // Build reply: [VER, REP, RSV(0), ATYP, BND.ADDR, BND.PORT]
1164        let mut response = vec![SOCKS5_VERSION, reply_code, 0x00];
1165
1166        // Add bound address (use target address for simplicity)
1167        match target_addr {
1168            TargetAddr::Ipv4(ip, port) => {
1169                response.push(ATYP_IPV4);
1170                response.extend_from_slice(&ip.octets());
1171                response.extend_from_slice(&port.to_be_bytes());
1172            }
1173            TargetAddr::Ipv6(ip, port) => {
1174                response.push(ATYP_IPV6);
1175                response.extend_from_slice(&ip.octets());
1176                response.extend_from_slice(&port.to_be_bytes());
1177            }
1178            TargetAddr::Domain(domain, port) => {
1179                response.push(ATYP_DOMAIN);
1180                response.push(domain.len() as u8);
1181                response.extend_from_slice(domain.as_bytes());
1182                response.extend_from_slice(&port.to_be_bytes());
1183            }
1184        }
1185
1186        stream.write_all(&response).await?;
1187        stream.flush().await?;
1188
1189        Ok(())
1190    }
1191
1192    /// Connect to target address
1193    async fn connect_to_target(
1194        target_addr: &TargetAddr,
1195        connection_id: ConnectionId,
1196        status_tx: &mpsc::UnboundedSender<String>,
1197    ) -> Result<TcpStream> {
1198        let target_str = target_addr.to_string();
1199
1200        Log::new(Some(status_tx)).debug(format!(
1201            "SOCKS5 {} connecting to {}",
1202            connection_id, target_str
1203        ));
1204
1205        let stream = TcpStream::connect(&target_str)
1206            .await
1207            .context(format!("Failed to connect to {}", target_str))?;
1208
1209        Ok(stream)
1210    }
1211
1212    /// Check if target matches filter patterns
1213    fn check_filter_match(target_addr: &TargetAddr, config: &Socks5FilterConfig) -> bool {
1214        let target_host = match target_addr {
1215            TargetAddr::Ipv4(ip, _) => ip.to_string(),
1216            TargetAddr::Ipv6(ip, _) => ip.to_string(),
1217            TargetAddr::Domain(domain, _) => domain.clone(),
1218        };
1219
1220        let target_port = match target_addr {
1221            TargetAddr::Ipv4(_, port) => *port,
1222            TargetAddr::Ipv6(_, port) => *port,
1223            TargetAddr::Domain(_, port) => *port,
1224        };
1225
1226        // Check host patterns
1227        let host_matches = if config.target_host_patterns.is_empty() {
1228            true
1229        } else {
1230            config.target_host_patterns.iter().any(|pattern| {
1231                if let Ok(re) = regex::Regex::new(pattern) {
1232                    re.is_match(&target_host)
1233                } else {
1234                    false
1235                }
1236            })
1237        };
1238
1239        // Check port ranges
1240        let port_matches = if config.target_port_ranges.is_empty() {
1241            true
1242        } else {
1243            config
1244                .target_port_ranges
1245                .iter()
1246                .any(|(start, end)| target_port >= *start && target_port <= *end)
1247        };
1248
1249        host_matches && port_matches
1250    }
1251
1252    /// Ask LLM whether to allow connection (returns: allowed, mitm_enabled)
1253    async fn ask_llm_for_decision(
1254        target_addr: &TargetAddr,
1255        username: Option<&str>,
1256        connection_id: ConnectionId,
1257        llm_client: &OllamaClient,
1258        app_state: &Arc<AppState>,
1259        status_tx: &mpsc::UnboundedSender<String>,
1260        protocol: &Arc<Socks5Protocol>,
1261        server_id: ServerId,
1262    ) -> Result<(bool, bool)> {
1263        Log::new(Some(status_tx))
1264            .debug(format!("SOCKS5 {} asking LLM for decision", connection_id));
1265
1266        let event = Event::new(
1267            &SOCKS5_CONNECT_REQUEST_EVENT,
1268            serde_json::json!({
1269                "target": target_addr.to_string(),
1270                "username": username,
1271            }),
1272        );
1273
1274        let execution_result = call_llm(
1275            llm_client,
1276            app_state,
1277            server_id,
1278            Some(connection_id),
1279            &event,
1280            protocol.as_ref(),
1281        )
1282        .await?;
1283
1284        // Decide from the action the model actually emitted, not from the shape
1285        // of the result. Several unrelated SOCKS5 actions also map to
1286        // ActionResult::NoAction (forward_socks5_data, allow_socks5_auth), so
1287        // "any NoAction" would have allowed a connection off the back of an
1288        // action that says nothing about this decision. An explicit deny, or no
1289        // decision at all, keeps the connection closed.
1290        let allow_action = execution_result.raw_actions.iter().find(|action| {
1291            action.get("type").and_then(|v| v.as_str()) == Some("allow_socks5_connect")
1292        });
1293        let denied = execution_result.raw_actions.iter().any(|action| {
1294            action.get("type").and_then(|v| v.as_str()) == Some("deny_socks5_connect")
1295        });
1296
1297        let allowed = allow_action.is_some() && !denied;
1298
1299        // Extract MITM flag from the allow action
1300        let mitm_enabled = allowed
1301            && allow_action
1302                .and_then(|action| action.get("mitm"))
1303                .and_then(|v| v.as_bool())
1304                .unwrap_or(false);
1305
1306        // Distinguish the three outcomes the wire cannot: the model refused, the model said
1307        // nothing usable, or the model allowed it. Only the first two look identical to the
1308        // peer (REPLY_CONNECTION_NOT_ALLOWED either way).
1309        let decision = if denied {
1310            "model_reject"
1311        } else if allow_action.is_some() {
1312            "model_allow"
1313        } else {
1314            "model_silent"
1315        };
1316        Log::new(Some(status_tx)).info(format!(
1317            "SOCKS5 {} decision={} target={} allowed={} mitm={}",
1318            connection_id, decision, target_addr, allowed, mitm_enabled
1319        ));
1320
1321        Ok((allowed, mitm_enabled))
1322    }
1323}