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