Skip to main content

microsandbox_network/
conn.rs

1//! Connection tracker: manages smoltcp TCP sockets for the poll loop.
2//!
3//! Creates sockets on SYN detection, tracks connection lifecycle, relays data
4//! between smoltcp sockets and proxy task channels, and cleans up closed
5//! connections.
6
7use std::collections::{HashMap, HashSet};
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU8, Ordering};
11
12use bytes::Bytes;
13use smoltcp::iface::{SocketHandle, SocketSet};
14use smoltcp::socket::tcp;
15use smoltcp::wire::IpListenEndpoint;
16use tokio::sync::mpsc;
17
18//--------------------------------------------------------------------------------------------------
19// Constants
20//--------------------------------------------------------------------------------------------------
21
22/// TCP socket receive buffer size (64 KiB).
23const TCP_RX_BUF_SIZE: usize = 65536;
24
25/// TCP socket transmit buffer size (64 KiB).
26const TCP_TX_BUF_SIZE: usize = 65536;
27
28/// Default max concurrent connections.
29const DEFAULT_MAX_CONNECTIONS: usize = 256;
30
31/// Capacity of the mpsc channels between the poll loop and proxy tasks.
32const CHANNEL_CAPACITY: usize = 32;
33
34/// Buffer size for reading from smoltcp sockets.
35const RELAY_BUF_SIZE: usize = 16384;
36
37//--------------------------------------------------------------------------------------------------
38// Types
39//--------------------------------------------------------------------------------------------------
40
41/// Terminal connection status reported by an outbound proxy task.
42#[repr(u8)]
43#[derive(Clone, Copy, Debug, Eq, PartialEq)]
44pub enum ProxyConnectStatus {
45    /// No final proxy connection status has been reported yet.
46    Pending = 0,
47    /// The proxy connected to the upstream.
48    Connected = 1,
49    /// The proxy denied the connection before dialing upstream.
50    PolicyDenied = 2,
51    /// The proxy attempted to dial upstream and the connect failed.
52    UpstreamConnectFailed = 3,
53}
54
55/// Shared status for an outbound proxy task.
56///
57/// The smoltcp poll loop reads this when the proxy task exits to decide
58/// whether the guest should see a clean close or a TCP reset.
59pub struct ProxyConnectState {
60    status: AtomicU8,
61}
62
63/// Tracks TCP connections between guest and proxy tasks.
64///
65/// Each guest TCP connection maps to a smoltcp socket and a pair of channels
66/// connecting it to a tokio proxy task. The tracker handles:
67///
68/// - **Socket creation** — on SYN detection, before smoltcp processes the frame.
69/// - **Data relay** — shuttles bytes between smoltcp sockets and channels.
70/// - **Lifecycle detection** — identifies newly-established connections for
71///   proxy spawning.
72/// - **Cleanup** — removes closed sockets from the socket set.
73pub struct ConnectionTracker {
74    /// Active connections keyed by smoltcp socket handle.
75    connections: HashMap<SocketHandle, Connection>,
76    /// Secondary index for O(1) duplicate-SYN detection by (src, dst) 4-tuple.
77    connection_keys: HashSet<(SocketAddr, SocketAddr)>,
78    /// Max concurrent connections (from NetworkConfig).
79    max_connections: usize,
80}
81
82/// Maximum number of poll iterations to attempt flushing remaining data
83/// after the proxy task has exited before force-aborting the socket.
84const DEFERRED_CLOSE_LIMIT: u16 = 64;
85
86/// Internal state for a single tracked TCP connection.
87struct Connection {
88    /// Guest source address (from the guest's SYN).
89    src: SocketAddr,
90    /// Original destination (from the guest's SYN).
91    dst: SocketAddr,
92    /// Sends data from smoltcp socket to proxy task (guest → server).
93    ///
94    /// Set to `None` once the guest half-closes (FIN) and all its data has
95    /// been relayed: dropping the sender makes the proxy task's
96    /// `from_smoltcp.recv()` return `None`, propagating the half-close
97    /// upstream while the server → guest direction stays open.
98    to_proxy: Option<mpsc::Sender<Bytes>>,
99    /// Receives data from proxy task to write to smoltcp socket (server → guest).
100    from_proxy: mpsc::Receiver<Bytes>,
101    /// Proxy-side channel ends, held until the connection is ESTABLISHED.
102    /// Taken by [`ConnectionTracker::take_new_connections()`].
103    proxy_channels: Option<ProxyChannels>,
104    /// Whether a proxy task has been spawned for this connection.
105    proxy_spawned: bool,
106    /// Status reported by the proxy task before it exits.
107    proxy_connect: Arc<ProxyConnectState>,
108    /// Partial data from proxy that couldn't be fully written to smoltcp socket.
109    write_buf: Option<(Bytes, usize)>,
110    /// Data read from smoltcp socket that couldn't be sent to proxy (channel full).
111    /// Must be sent before reading more from the socket to preserve stream order.
112    read_buf: Option<Bytes>,
113    /// Counter for deferred close attempts (prevents stalling forever).
114    close_attempts: u16,
115}
116
117/// Proxy-side channel ends, created at socket creation time and taken when
118/// the connection becomes ESTABLISHED.
119struct ProxyChannels {
120    /// Receive data from smoltcp socket (guest → proxy task).
121    from_smoltcp: mpsc::Receiver<Bytes>,
122    /// Send data to smoltcp socket (proxy task → guest).
123    to_smoltcp: mpsc::Sender<Bytes>,
124}
125
126/// Information for spawning a proxy task for a newly established connection.
127///
128/// Returned by [`ConnectionTracker::take_new_connections()`]. The poll loop
129/// passes this to the proxy task spawner.
130pub struct NewConnection {
131    /// Original destination the guest was connecting to.
132    pub dst: SocketAddr,
133    /// Receive data from smoltcp socket (guest → proxy task).
134    pub from_smoltcp: mpsc::Receiver<Bytes>,
135    /// Send data to smoltcp socket (proxy task → guest).
136    pub to_smoltcp: mpsc::Sender<Bytes>,
137    /// Status the proxy task updates before it exits.
138    pub proxy_connect: Arc<ProxyConnectState>,
139}
140
141//--------------------------------------------------------------------------------------------------
142// Methods
143//--------------------------------------------------------------------------------------------------
144
145impl ProxyConnectStatus {
146    fn as_u8(self) -> u8 {
147        self as u8
148    }
149
150    fn from_u8(value: u8) -> Self {
151        match value {
152            value if value == Self::Connected as u8 => Self::Connected,
153            value if value == Self::PolicyDenied as u8 => Self::PolicyDenied,
154            value if value == Self::UpstreamConnectFailed as u8 => Self::UpstreamConnectFailed,
155            _ => Self::Pending,
156        }
157    }
158}
159
160impl ProxyConnectState {
161    /// Create a new pending proxy connection status.
162    pub fn new() -> Self {
163        Self {
164            status: AtomicU8::new(ProxyConnectStatus::Pending.as_u8()),
165        }
166    }
167
168    /// Mark the proxy as successfully connected to upstream.
169    pub fn mark_connected(&self) {
170        self.store(ProxyConnectStatus::Connected);
171    }
172
173    /// Mark the proxy as denied by egress policy before dialing upstream.
174    pub fn mark_policy_denied(&self) {
175        self.store(ProxyConnectStatus::PolicyDenied);
176    }
177
178    /// Mark the proxy as failed while dialing upstream.
179    pub fn mark_upstream_connect_failed(&self) {
180        self.store(ProxyConnectStatus::UpstreamConnectFailed);
181    }
182
183    /// Load the latest proxy connection status.
184    pub fn status(&self) -> ProxyConnectStatus {
185        ProxyConnectStatus::from_u8(self.status.load(Ordering::Acquire))
186    }
187
188    fn store(&self, status: ProxyConnectStatus) {
189        self.status.store(status.as_u8(), Ordering::Release);
190    }
191}
192
193impl Default for ProxyConnectState {
194    fn default() -> Self {
195        Self::new()
196    }
197}
198
199impl ConnectionTracker {
200    /// Create a new tracker with the given connection limit.
201    pub fn new(max_connections: Option<usize>) -> Self {
202        Self {
203            connections: HashMap::new(),
204            connection_keys: HashSet::new(),
205            max_connections: max_connections.unwrap_or(DEFAULT_MAX_CONNECTIONS),
206        }
207    }
208
209    /// Returns `true` if a tracked socket already exists for this exact
210    /// connection (same source AND destination). O(1) via HashSet lookup.
211    pub fn has_socket_for(&self, src: &SocketAddr, dst: &SocketAddr) -> bool {
212        self.connection_keys.contains(&(*src, *dst))
213    }
214
215    /// Create a smoltcp TCP socket for an incoming SYN and register it.
216    ///
217    /// The socket is put into LISTEN state on the destination IP + port so
218    /// smoltcp will complete the three-way handshake when it processes the
219    /// SYN frame. Binding to the specific destination IP (not just port)
220    /// prevents socket dispatch ambiguity when multiple connections target
221    /// different IPs on the same port.
222    ///
223    /// Returns `false` if at `max_connections` limit.
224    pub fn create_tcp_socket(
225        &mut self,
226        src: SocketAddr,
227        dst: SocketAddr,
228        sockets: &mut SocketSet<'_>,
229    ) -> bool {
230        if self.connections.len() >= self.max_connections {
231            return false;
232        }
233
234        // Create smoltcp TCP socket with buffers.
235        let rx_buf = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUF_SIZE]);
236        let tx_buf = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUF_SIZE]);
237        let mut socket = tcp::Socket::new(rx_buf, tx_buf);
238
239        // Listen on the specific destination IP + port. With any_ip mode,
240        // binding to the IP ensures the correct socket accepts each SYN
241        // when multiple connections target the same port on different IPs.
242        let listen_endpoint = IpListenEndpoint {
243            addr: Some(dst.ip().into()),
244            port: dst.port(),
245        };
246        if socket.listen(listen_endpoint).is_err() {
247            return false;
248        }
249
250        let handle = sockets.add(socket);
251
252        // Create channel pairs for proxy task communication.
253        //
254        // smoltcp → proxy (guest sends data, proxy relays to server):
255        let (to_proxy_tx, to_proxy_rx) = mpsc::channel(CHANNEL_CAPACITY);
256        // proxy → smoltcp (server sends data, proxy relays to guest):
257        let (from_proxy_tx, from_proxy_rx) = mpsc::channel(CHANNEL_CAPACITY);
258
259        self.connection_keys.insert((src, dst));
260        self.connections.insert(
261            handle,
262            Connection {
263                src,
264                dst,
265                to_proxy: Some(to_proxy_tx),
266                from_proxy: from_proxy_rx,
267                proxy_channels: Some(ProxyChannels {
268                    from_smoltcp: to_proxy_rx,
269                    to_smoltcp: from_proxy_tx,
270                }),
271                proxy_spawned: false,
272                proxy_connect: Arc::new(ProxyConnectState::new()),
273                write_buf: None,
274                read_buf: None,
275                close_attempts: 0,
276            },
277        );
278
279        true
280    }
281
282    /// Relay data between smoltcp sockets and proxy task channels.
283    ///
284    /// For each connection with a spawned proxy:
285    /// - Reads data from the smoltcp socket and sends it to the proxy channel.
286    /// - Receives data from the proxy channel and writes it to the smoltcp socket.
287    pub fn relay_data(&mut self, sockets: &mut SocketSet<'_>) {
288        let mut relay_buf = [0u8; RELAY_BUF_SIZE];
289
290        for (&handle, conn) in &mut self.connections {
291            if !conn.proxy_spawned {
292                continue;
293            }
294
295            let socket = sockets.get_mut::<tcp::Socket>(handle);
296
297            // Already torn down (e.g. abort fired on a previous pass).
298            // Leave it for `cleanup_closed` to evict.
299            if matches!(socket.state(), tcp::State::Closed) {
300                continue;
301            }
302
303            // Detect proxy task exit: when the proxy drops its channel
304            // ends, close the smoltcp socket so the guest gets a FIN.
305            //
306            // If the proxy attempted and failed to reach upstream,
307            // an RST via `abort()` is instead sent so happy-eyeballs
308            // clients fall back to another family instead of committing
309            // to this half-open connection.
310            let proxy_exited = match &conn.to_proxy {
311                Some(to_proxy) => to_proxy.is_closed(),
312                // The guest already half-closed (sender dropped below), so
313                // proxy exit is detected on the other channel instead: the
314                // proxy drops its `to_smoltcp` sender when it returns.
315                None => conn.from_proxy.is_closed(),
316            };
317            if proxy_exited {
318                if matches!(
319                    conn.proxy_connect.status(),
320                    ProxyConnectStatus::UpstreamConnectFailed
321                ) {
322                    tracing::debug!(
323                        src = %conn.src,
324                        dst = %conn.dst,
325                        "upstream connect failed; aborting smoltcp socket (RST to guest)"
326                    );
327                    socket.abort();
328                    continue;
329                }
330                write_proxy_data(socket, conn);
331                if conn.write_buf.is_none() {
332                    socket.close();
333                } else {
334                    // Abort if we've been trying to flush for too long
335                    // (guest stopped reading, socket send buffer full).
336                    conn.close_attempts += 1;
337                    if conn.close_attempts >= DEFERRED_CLOSE_LIMIT {
338                        socket.abort();
339                    }
340                }
341                continue;
342            }
343
344            // smoltcp → proxy: flush read_buf first, then read from socket.
345            if let Some(to_proxy) = &conn.to_proxy {
346                if let Some(pending) = conn.read_buf.take()
347                    && let Err(e) = to_proxy.try_send(pending)
348                {
349                    conn.read_buf = Some(e.into_inner());
350                }
351
352                if conn.read_buf.is_none() {
353                    while socket.can_recv() {
354                        match socket.recv_slice(&mut relay_buf) {
355                            Ok(n) if n > 0 => {
356                                let data = Bytes::copy_from_slice(&relay_buf[..n]);
357                                if let Err(e) = to_proxy.try_send(data) {
358                                    conn.read_buf = Some(e.into_inner());
359                                    break;
360                                }
361                            }
362                            _ => break,
363                        }
364                    }
365                }
366
367                // Guest half-close: the guest sent a FIN (CLOSE_WAIT) and
368                // everything it sent has been relayed. Drop the sender so
369                // the proxy task sees EOF and can shut down the guest →
370                // server direction upstream. The server → guest direction
371                // stays open; the socket is closed once the proxy task
372                // exits (see `proxy_exited` above).
373                if matches!(socket.state(), tcp::State::CloseWait)
374                    && conn.read_buf.is_none()
375                    && !socket.can_recv()
376                {
377                    conn.to_proxy = None;
378                }
379            }
380
381            // proxy → smoltcp: write pending data, then drain channel.
382            write_proxy_data(socket, conn);
383        }
384    }
385
386    /// Collect newly-established connections that need proxy tasks.
387    ///
388    /// Returns a list of [`NewConnection`] structs containing the channel ends
389    /// for the proxy task. The poll loop is responsible for spawning the task.
390    pub fn take_new_connections(&mut self, sockets: &mut SocketSet<'_>) -> Vec<NewConnection> {
391        let mut new = Vec::new();
392
393        for (&handle, conn) in &mut self.connections {
394            if conn.proxy_spawned {
395                continue;
396            }
397
398            let socket = sockets.get::<tcp::Socket>(handle);
399            if matches!(
400                socket.state(),
401                tcp::State::Established | tcp::State::CloseWait
402            ) {
403                conn.proxy_spawned = true;
404
405                if let Some(channels) = conn.proxy_channels.take() {
406                    new.push(NewConnection {
407                        dst: conn.dst,
408                        from_smoltcp: channels.from_smoltcp,
409                        to_smoltcp: channels.to_smoltcp,
410                        proxy_connect: conn.proxy_connect.clone(),
411                    });
412                }
413            }
414        }
415
416        new
417    }
418
419    /// Remove closed connections and their sockets.
420    ///
421    /// Only removes sockets in the `Closed` state. Sockets in `TimeWait`
422    /// are left for smoltcp to handle naturally (2*MSL timer), preventing
423    /// delayed duplicate segments from being accepted by a reused port.
424    pub fn cleanup_closed(&mut self, sockets: &mut SocketSet<'_>) {
425        let keys = &mut self.connection_keys;
426        self.connections.retain(|&handle, conn| {
427            let socket = sockets.get::<tcp::Socket>(handle);
428            if matches!(socket.state(), tcp::State::Closed) {
429                keys.remove(&(conn.src, conn.dst));
430                sockets.remove(handle);
431                false
432            } else {
433                true
434            }
435        });
436    }
437}
438
439//--------------------------------------------------------------------------------------------------
440// Functions
441//--------------------------------------------------------------------------------------------------
442
443/// Try to write proxy data to the smoltcp socket.
444fn write_proxy_data(socket: &mut tcp::Socket<'_>, conn: &mut Connection) {
445    // First, try to finish writing any pending partial data.
446    if let Some((data, offset)) = &mut conn.write_buf {
447        if socket.can_send() {
448            match socket.send_slice(&data[*offset..]) {
449                Ok(written) => {
450                    *offset += written;
451                    if *offset >= data.len() {
452                        conn.write_buf = None;
453                    }
454                }
455                Err(_) => return,
456            }
457        } else {
458            return;
459        }
460    }
461
462    // Then drain the channel.
463    while conn.write_buf.is_none() {
464        match conn.from_proxy.try_recv() {
465            Ok(data) => {
466                if socket.can_send() {
467                    match socket.send_slice(&data) {
468                        Ok(written) if written < data.len() => {
469                            conn.write_buf = Some((data, written));
470                        }
471                        Err(_) => {
472                            conn.write_buf = Some((data, 0));
473                        }
474                        _ => {}
475                    }
476                } else {
477                    conn.write_buf = Some((data, 0));
478                }
479            }
480            Err(_) => break,
481        }
482    }
483}