Skip to main content

smolvm_network/
tcp_relay.rs

1//! TCP relay support for the virtio-net backend.
2//!
3//! Context
4//! =======
5//!
6//! In the Phase 1 virtio-net design, guest TCP does not flow directly from the
7//! guest to the outside network through the host kernel. Instead, the host-side
8//! smoltcp runtime terminates the guest-visible TCP connection in userspace and
9//! relays payloads to a normal host `TcpStream`.
10//!
11//! Conceptually:
12//!
13//! ```text
14//! guest app
15//!   -> guest kernel TCP
16//!   -> Ethernet frame
17//!   -> smoltcp TCP socket (inside smolvm)
18//!   -> channel
19//!   -> host TcpStream
20//!   -> remote server
21//! ```
22//!
23//! That means:
24//! - the host runtime can observe every guest TCP byte stream on this NIC
25//! - smoltcp owns the guest-facing TCP state machine
26//! - the relay thread owns the host-facing TCP socket
27//! - channels bridge payloads between them
28
29use crate::egress::EgressPolicy;
30use crate::queues::WakePipe;
31use crate::virtio_net_log;
32use smoltcp::iface::{Interface, SocketHandle, SocketSet};
33use smoltcp::socket::tcp;
34use smoltcp::wire::IpListenEndpoint;
35use std::collections::{HashMap, HashSet};
36use std::io::{self, Read, Write};
37use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr, TcpStream};
38use std::sync::atomic::{AtomicU8, Ordering};
39use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
40use std::sync::Arc;
41use std::thread;
42use std::time::Duration;
43
44const TCP_RX_BUFFER_BYTES: usize = 64 * 1024;
45const TCP_TX_BUFFER_BYTES: usize = 64 * 1024;
46const MAX_CONNECTIONS: usize = 256;
47const CHANNEL_CAPACITY: usize = 32;
48const RELAY_BUFFER_BYTES: usize = 16 * 1024;
49const CLOSE_RETRY_LIMIT: u16 = 64;
50const PROXY_IDLE_SLEEP: Duration = Duration::from_millis(10);
51const PUBLISHED_PORT_START: u16 = 49_152;
52const PUBLISHED_PORT_END: u16 = 65_535;
53
54/// Track all active guest TCP connections bridged through host sockets.
55///
56/// One entry corresponds to one `(guest source, destination)` tuple. The table
57/// lives in the smoltcp poll thread and owns all guest-facing socket handles.
58pub struct TcpRelayTable {
59    connections: HashMap<SocketHandle, TrackedConnection>,
60    connection_keys: HashSet<(SocketAddr, SocketAddr)>,
61    used_published_ports: HashSet<u16>,
62    next_published_port: u16,
63    max_connections: usize,
64    /// Outbound allow-list applied before opening a host connection for a
65    /// guest-initiated flow. Inbound published-port connections bypass it.
66    egress: EgressPolicy,
67    /// The guest-visible gateway addresses (IPv4/IPv6/link-local). A guest flow
68    /// destined to one of these is dialing "the host" via its default gateway,
69    /// so the host-side relay connects to loopback instead of the gateway's own
70    /// (non-routable) userspace address. See `host_connect_addr`.
71    gateway_ips: Vec<IpAddr>,
72}
73
74/// Newly established guest connection ready for a host relay thread.
75///
76/// The poll loop emits these once the guest-side smoltcp socket reaches
77/// `Established`. At that point we can safely create the host-side relay
78/// thread and give it channel endpoints for payload exchange.
79pub struct NewTcpConnection {
80    /// Destination originally requested by the guest.
81    pub destination: SocketAddr,
82    /// How the host-side relay should be started.
83    pub relay_target: RelayTarget,
84    /// Guest-to-host payloads read from the smoltcp socket.
85    pub from_smoltcp: Receiver<Vec<u8>>,
86    /// Host-to-guest payloads written back into the smoltcp socket.
87    pub to_smoltcp: SyncSender<Vec<u8>>,
88    /// Shared relay exit state.
89    pub exit_state: RelayExitState,
90}
91
92#[derive(Debug)]
93struct TrackedConnection {
94    // `source` and `destination` identify the guest-side flow.
95    source: SocketAddr,
96    destination: SocketAddr,
97    // guest -> host relay payloads
98    to_proxy: SyncSender<Vec<u8>>,
99    // host -> guest relay payloads
100    from_proxy: Receiver<Vec<u8>>,
101    // endpoints are held here until the guest-side handshake completes
102    pending_proxy_endpoints: Option<PendingProxyEndpoints>,
103    // once true, a dedicated host relay thread exists
104    relay_spawned: bool,
105    // partial guest->host payload already consumed from smoltcp but not yet
106    // accepted by the relay thread channel
107    buffered_guest_data: Option<Vec<u8>>,
108    // partial host->guest payload not yet fully accepted by smoltcp
109    buffered_proxy_data: Option<(Vec<u8>, usize)>,
110    // bounded retry count for closing with unsent buffered data
111    close_attempts: u16,
112    // set once we've sent FIN to the guest after the host half-closed, so the
113    // HalfClosed handling closes the guest send-half exactly once
114    guest_send_closed: bool,
115    // relay thread termination mode observed by the poll loop
116    exit_state: RelayExitState,
117    // reserved local source port for published inbound connections
118    reserved_published_port: Option<u16>,
119}
120
121#[derive(Debug)]
122struct PendingProxyEndpoints {
123    from_smoltcp: Receiver<Vec<u8>>,
124    to_smoltcp: SyncSender<Vec<u8>>,
125    relay_target: RelayTarget,
126}
127
128/// How a host-side TCP relay should obtain its remote socket.
129#[derive(Debug)]
130pub enum RelayTarget {
131    /// Open a new outbound host `TcpStream` to the destination.
132    Connect(SocketAddr),
133    /// Use an already-accepted host `TcpStream` from a published port listener.
134    Attached(TcpStream),
135}
136
137/// Host relay termination state shared between the poll loop and the relay thread.
138///
139/// The relay thread cannot mutate smoltcp sockets directly because those sockets
140/// are owned by the poll loop thread. Instead it reports how it finished, and
141/// the poll loop interprets that into guest-side socket actions:
142/// - `Graceful`   -> close guest socket cleanly
143/// - `HalfClosed` -> host closed its send half; send FIN to the guest but keep
144///   the guest socket open so guest->host output still drains
145/// - `Abort`      -> abort/reset guest socket
146#[derive(Clone, Debug)]
147pub struct RelayExitState {
148    inner: Arc<AtomicU8>,
149}
150
151/// How a host TCP relay thread terminated.
152#[derive(Clone, Copy, Debug, PartialEq, Eq)]
153#[repr(u8)]
154pub enum RelayExitMode {
155    /// Relay thread is still running.
156    Running = 0,
157    /// Remote side closed normally; send FIN toward the guest.
158    Graceful = 1,
159    /// Remote connect or I/O failed; abort the guest TCP socket.
160    Abort = 2,
161    /// Host closed its send half (host-read EOF) but the guest may still be
162    /// sending. Mirror the FIN toward the guest and keep pumping guest->host
163    /// until the guest closes too. Without this a hijacked-attach response is
164    /// dropped: the docker CLI half-closes (`shutdown(SHUT_WR)`) after
165    /// `101 UPGRADED` when it has no stdin, while the daemon is still streaming.
166    HalfClosed = 3,
167}
168
169impl RelayExitState {
170    fn new() -> Self {
171        Self {
172            inner: Arc::new(AtomicU8::new(RelayExitMode::Running as u8)),
173        }
174    }
175
176    fn load(&self) -> RelayExitMode {
177        match self.inner.load(Ordering::Relaxed) {
178            1 => RelayExitMode::Graceful,
179            2 => RelayExitMode::Abort,
180            3 => RelayExitMode::HalfClosed,
181            _ => RelayExitMode::Running,
182        }
183    }
184
185    fn store(&self, mode: RelayExitMode) {
186        self.inner.store(mode as u8, Ordering::Relaxed);
187    }
188}
189
190impl TcpRelayTable {
191    /// Create a new relay table.
192    pub fn new(
193        max_connections: Option<usize>,
194        egress: EgressPolicy,
195        gateway_ips: Vec<IpAddr>,
196    ) -> Self {
197        Self {
198            connections: HashMap::new(),
199            connection_keys: HashSet::new(),
200            used_published_ports: HashSet::new(),
201            next_published_port: PUBLISHED_PORT_START,
202            max_connections: max_connections.unwrap_or(MAX_CONNECTIONS),
203            egress,
204            gateway_ips,
205        }
206    }
207
208    /// The host-side address the relay should dial for a guest flow.
209    ///
210    /// The guest's default gateway IP is its stand-in for "the host" (the slirp /
211    /// `host.docker.internal` convention): a guest reaches host-local services by
212    /// connecting to its gateway. But that gateway IP is THIS userspace stack's
213    /// own address, not a routable host interface — a literal relay to it
214    /// blackholes (the guest completes the smoltcp handshake, then never receives
215    /// reply bytes). Map a gateway-IP destination to loopback so it actually
216    /// reaches the host; every other destination is dialed as-is. Egress already
217    /// gated the ORIGINAL destination in `create_tcp_socket`, so under the
218    /// multi-tenant `Strict` floor the gateway/CGNAT range is refused before it
219    /// reaches here — this redirect only applies where reaching the host is the
220    /// intended, local-default behavior.
221    fn host_connect_addr(&self, destination: SocketAddr) -> SocketAddr {
222        if self.gateway_ips.contains(&destination.ip()) {
223            let loopback = if destination.is_ipv4() {
224                IpAddr::V4(Ipv4Addr::LOCALHOST)
225            } else {
226                IpAddr::V6(Ipv6Addr::LOCALHOST)
227            };
228            return SocketAddr::new(loopback, destination.port());
229        }
230        destination
231    }
232
233    /// Whether a relay socket already exists for the same guest source and destination.
234    pub fn has_socket_for(&self, source: &SocketAddr, destination: &SocketAddr) -> bool {
235        self.connection_keys.contains(&(*source, *destination))
236    }
237
238    /// Create a smoltcp TCP socket for a guest SYN.
239    ///
240    /// Why this happens before full ingress processing:
241    /// - when the first guest SYN arrives, smoltcp needs a matching socket to
242    ///   receive it
243    /// - the poll loop therefore pre-creates a listening socket keyed to the
244    ///   destination the guest is trying to reach
245    /// - only after the guest-facing connection reaches `Established` do we
246    ///   spawn the host relay thread
247    ///
248    /// Data path after creation:
249    ///
250    /// ```text
251    /// smoltcp socket --to_proxy channel--> host relay thread
252    /// host relay thread --from_proxy channel--> smoltcp socket
253    /// ```
254    pub fn create_tcp_socket(
255        &mut self,
256        source: SocketAddr,
257        destination: SocketAddr,
258        sockets: &mut SocketSet<'_>,
259    ) -> bool {
260        if self.connections.len() >= self.max_connections {
261            tracing::warn!("dropping TCP connection because the relay table is full");
262            return false;
263        }
264
265        // Egress policy: drop the guest SYN before any socket is created when the
266        // destination isn't allowed, so the guest just sees the connection fail.
267        // Inbound published-port flows take a separate path and are unaffected.
268        if !self.egress.allows(destination.ip()) {
269            tracing::debug!(
270                %destination,
271                "virtio-net: blocking outbound connection by egress policy"
272            );
273            return false;
274        }
275
276        let rx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUFFER_BYTES]);
277        let tx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUFFER_BYTES]);
278        let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
279
280        let listen_endpoint = IpListenEndpoint {
281            addr: Some(destination.ip().into()),
282            port: destination.port(),
283        };
284        if socket.listen(listen_endpoint).is_err() {
285            return false;
286        }
287
288        let handle = sockets.add(socket);
289
290        let (to_proxy_tx, to_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
291        let (from_proxy_tx, from_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
292        let exit_state = RelayExitState::new();
293
294        self.connection_keys.insert((source, destination));
295        self.connections.insert(
296            handle,
297            TrackedConnection {
298                source,
299                destination,
300                to_proxy: to_proxy_tx,
301                from_proxy: from_proxy_rx,
302                pending_proxy_endpoints: Some(PendingProxyEndpoints {
303                    from_smoltcp: to_proxy_rx,
304                    to_smoltcp: from_proxy_tx,
305                    relay_target: RelayTarget::Connect(self.host_connect_addr(destination)),
306                }),
307                relay_spawned: false,
308                buffered_guest_data: None,
309                buffered_proxy_data: None,
310                close_attempts: 0,
311                guest_send_closed: false,
312                exit_state,
313                reserved_published_port: None,
314            },
315        );
316
317        true
318    }
319
320    /// Create a guest-facing TCP connection for a published host socket.
321    ///
322    /// This is the host->guest mirror of `create_tcp_socket`:
323    ///
324    /// ```text
325    /// host client connects to published port
326    ///   -> host listener accepts TcpStream
327    ///   -> poll loop creates smoltcp TCP socket from gateway_ip:ephemeral
328    ///      to guest_ip:guest_port
329    ///   -> guest kernel sees a normal inbound TCP connection on guest_port
330    /// ```
331    ///
332    /// The guest-visible source address is the gateway IP, not the original
333    /// host peer address. That keeps the first version simple and matches the
334    /// fact that this runtime is acting as a userspace gateway/proxy.
335    pub fn create_published_socket(
336        &mut self,
337        interface: &mut Interface,
338        gateway_ip: Ipv4Addr,
339        destination: SocketAddr,
340        host_stream: TcpStream,
341        sockets: &mut SocketSet<'_>,
342    ) -> bool {
343        if self.connections.len() >= self.max_connections {
344            tracing::warn!("dropping published TCP connection because the relay table is full");
345            return false;
346        }
347
348        let Some(local_port) = self.allocate_published_port() else {
349            tracing::warn!(
350                "dropping published TCP connection because no gateway source port is available"
351            );
352            return false;
353        };
354
355        // Inbound published connections always target the guest's IPv4 on the
356        // internal link (the host listener family is independent of this).
357        let std::net::IpAddr::V4(destination_ip) = destination.ip() else {
358            self.used_published_ports.remove(&local_port);
359            return false;
360        };
361
362        let rx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUFFER_BYTES]);
363        let tx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUFFER_BYTES]);
364        let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
365        let local_endpoint = IpListenEndpoint {
366            addr: Some(gateway_ip.into()),
367            port: local_port,
368        };
369        if socket
370            .connect(
371                interface.context(),
372                (destination_ip, destination.port()),
373                local_endpoint,
374            )
375            .is_err()
376        {
377            self.used_published_ports.remove(&local_port);
378            return false;
379        }
380
381        let handle = sockets.add(socket);
382        let source = SocketAddr::new(std::net::IpAddr::V4(gateway_ip), local_port);
383
384        let (to_proxy_tx, to_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
385        let (from_proxy_tx, from_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
386        let exit_state = RelayExitState::new();
387
388        self.connection_keys.insert((source, destination));
389        self.connections.insert(
390            handle,
391            TrackedConnection {
392                source,
393                destination,
394                to_proxy: to_proxy_tx,
395                from_proxy: from_proxy_rx,
396                pending_proxy_endpoints: Some(PendingProxyEndpoints {
397                    from_smoltcp: to_proxy_rx,
398                    to_smoltcp: from_proxy_tx,
399                    relay_target: RelayTarget::Attached(host_stream),
400                }),
401                relay_spawned: false,
402                buffered_guest_data: None,
403                buffered_proxy_data: None,
404                close_attempts: 0,
405                guest_send_closed: false,
406                exit_state,
407                reserved_published_port: Some(local_port),
408            },
409        );
410
411        true
412    }
413
414    /// Relay TCP payloads between smoltcp sockets and host relay threads.
415    ///
416    /// This runs in the poll thread. It is responsible for:
417    /// - draining bytes received from the guest-facing smoltcp socket and
418    ///   pushing them toward the host relay thread
419    /// - draining bytes received from the host relay thread and writing them
420    ///   back into the smoltcp socket
421    /// - interpreting relay exit state into guest-side `close()` or `abort()`
422    pub fn relay_data(&mut self, sockets: &mut SocketSet<'_>) {
423        let mut read_buffer = [0u8; RELAY_BUFFER_BYTES];
424
425        for (&handle, connection) in &mut self.connections {
426            if !connection.relay_spawned {
427                continue;
428            }
429
430            let socket = sockets.get_mut::<tcp::Socket>(handle);
431
432            match connection.exit_state.load() {
433                RelayExitMode::Abort => {
434                    socket.abort();
435                    continue;
436                }
437                RelayExitMode::Graceful => {
438                    flush_proxy_data(socket, connection);
439                    if connection.buffered_proxy_data.is_none() {
440                        socket.close();
441                    } else {
442                        connection.close_attempts += 1;
443                        if connection.close_attempts >= CLOSE_RETRY_LIMIT {
444                            socket.abort();
445                        }
446                    }
447                    continue;
448                }
449                RelayExitMode::HalfClosed => {
450                    // Host closed its send half: flush any remaining host->guest
451                    // bytes, then send FIN to the guest exactly once. Crucially
452                    // we do NOT `continue` — fall through to the guest->host
453                    // drain below so the guest's in-flight response still
454                    // reaches the host. The thread flips to Graceful once the
455                    // guest closes too, and the connection is torn down then.
456                    flush_proxy_data(socket, connection);
457                    if connection.buffered_proxy_data.is_none() && !connection.guest_send_closed {
458                        socket.close();
459                        connection.guest_send_closed = true;
460                    }
461                }
462                RelayExitMode::Running => {}
463            }
464
465            flush_guest_data(connection);
466            while connection.buffered_guest_data.is_none() && socket.can_recv() {
467                match socket.recv_slice(&mut read_buffer) {
468                    Ok(bytes_read) if bytes_read > 0 => {
469                        let payload = read_buffer[..bytes_read].to_vec();
470                        if !send_guest_payload(connection, payload) {
471                            break;
472                        }
473                    }
474                    _ => break,
475                }
476            }
477
478            flush_proxy_data(socket, connection);
479        }
480    }
481
482    /// Collect connections that reached ESTABLISHED and need a host relay thread.
483    ///
484    /// The separation between `create_tcp_socket` and this method is important:
485    /// the guest TCP handshake is accepted first on the smoltcp side, and only
486    /// once that succeeds do we commit to opening the host-side `TcpStream`.
487    pub fn take_new_connections(&mut self, sockets: &mut SocketSet<'_>) -> Vec<NewTcpConnection> {
488        let mut new_connections = Vec::new();
489
490        for (&handle, connection) in &mut self.connections {
491            if connection.relay_spawned {
492                continue;
493            }
494
495            let socket = sockets.get::<tcp::Socket>(handle);
496            if socket.state() == tcp::State::Established {
497                connection.relay_spawned = true;
498
499                if let Some(endpoints) = connection.pending_proxy_endpoints.take() {
500                    new_connections.push(NewTcpConnection {
501                        destination: connection.destination,
502                        relay_target: endpoints.relay_target,
503                        from_smoltcp: endpoints.from_smoltcp,
504                        to_smoltcp: endpoints.to_smoltcp,
505                        exit_state: connection.exit_state.clone(),
506                    });
507                }
508            }
509        }
510
511        new_connections
512    }
513
514    /// Remove closed sockets and drop their relay endpoints.
515    ///
516    /// This is the final ownership cleanup step for a guest TCP flow.
517    pub fn cleanup_closed(&mut self, sockets: &mut SocketSet<'_>) {
518        let keys = &mut self.connection_keys;
519        let published_ports = &mut self.used_published_ports;
520        self.connections.retain(|&handle, connection| {
521            let socket = sockets.get::<tcp::Socket>(handle);
522            if socket.state() == tcp::State::Closed {
523                keys.remove(&(connection.source, connection.destination));
524                if let Some(port) = connection.reserved_published_port {
525                    published_ports.remove(&port);
526                }
527                sockets.remove(handle);
528                false
529            } else {
530                true
531            }
532        });
533    }
534
535    fn allocate_published_port(&mut self) -> Option<u16> {
536        let start = self.next_published_port;
537
538        loop {
539            let candidate = self.next_published_port;
540            self.next_published_port = if candidate == PUBLISHED_PORT_END {
541                PUBLISHED_PORT_START
542            } else {
543                candidate + 1
544            };
545
546            if self.used_published_ports.insert(candidate) {
547                return Some(candidate);
548            }
549
550            if self.next_published_port == start {
551                return None;
552            }
553        }
554    }
555}
556
557/// Spawn one host TCP relay thread for an established guest connection.
558///
559/// Thread responsibilities:
560/// - connect a host `TcpStream` to the guest-requested destination
561/// - copy bytes guest->host from `from_smoltcp`
562/// - copy bytes host->guest into `to_smoltcp`
563/// - wake the poll loop when host->guest data arrives or guest->host backpressure eases
564/// - report termination mode through `exit_state`
565pub fn spawn_tcp_relay(
566    destination: SocketAddr,
567    relay_target: RelayTarget,
568    from_smoltcp: Receiver<Vec<u8>>,
569    to_smoltcp: SyncSender<Vec<u8>>,
570    relay_wake: Arc<WakePipe>,
571    exit_state: RelayExitState,
572) {
573    let thread_name = format!("smolvm-tcp-{}", destination.port());
574    virtio_net_log!(
575        "virtio-net: spawning host TCP relay thread destination={} thread={}",
576        destination,
577        thread_name
578    );
579    let _ = thread::Builder::new().name(thread_name).spawn(move || {
580        run_tcp_relay(
581            destination,
582            relay_target,
583            from_smoltcp,
584            to_smoltcp,
585            relay_wake,
586            exit_state,
587        )
588    });
589}
590
591fn run_tcp_relay(
592    destination: SocketAddr,
593    relay_target: RelayTarget,
594    from_smoltcp: Receiver<Vec<u8>>,
595    to_smoltcp: SyncSender<Vec<u8>>,
596    relay_wake: Arc<WakePipe>,
597    exit_state: RelayExitState,
598) {
599    // The relay thread is intentionally isolated from smoltcp internals. Its
600    // contract is just channels in, channels out, and an exit code back.
601    virtio_net_log!(
602        "virtio-net: host TCP relay thread started destination={}",
603        destination
604    );
605    match tcp_relay_loop(
606        destination,
607        relay_target,
608        from_smoltcp,
609        to_smoltcp,
610        relay_wake,
611        &exit_state,
612    ) {
613        Ok(mode) => {
614            virtio_net_log!(
615                "virtio-net: host TCP relay thread exited destination={} mode={:?}",
616                destination,
617                mode
618            );
619            exit_state.store(mode)
620        }
621        Err(err) => {
622            virtio_net_log!(
623                "virtio-net: host TCP relay failed destination={} error={}",
624                destination,
625                err
626            );
627            exit_state.store(RelayExitMode::Abort);
628        }
629    }
630}
631
632fn tcp_relay_loop(
633    destination: SocketAddr,
634    relay_target: RelayTarget,
635    from_smoltcp: Receiver<Vec<u8>>,
636    to_smoltcp: SyncSender<Vec<u8>>,
637    relay_wake: Arc<WakePipe>,
638    exit_state: &RelayExitState,
639) -> io::Result<RelayExitMode> {
640    // Host-side flow:
641    //
642    // 1. Connect a normal host TcpStream to the destination.
643    // 2. Non-blockingly drain guest payloads from the channel into the socket.
644    // 3. Non-blockingly read remote payloads from the socket into the channel.
645    // 4. If neither side made progress, sleep briefly to avoid a hot spin loop.
646    let mut stream = match relay_target {
647        RelayTarget::Connect(destination) => {
648            virtio_net_log!(
649                "virtio-net: connecting host TCP relay socket destination={}",
650                destination
651            );
652            let stream = TcpStream::connect(destination)?;
653            virtio_net_log!(
654                "virtio-net: host TCP relay socket connected destination={}",
655                destination
656            );
657            stream
658        }
659        RelayTarget::Attached(stream) => {
660            virtio_net_log!(
661                "virtio-net: using accepted host TCP socket for published port guest_destination={} peer_addr={:?} local_addr={:?}",
662                destination,
663                stream.peer_addr().ok(),
664                stream.local_addr().ok()
665            );
666            stream
667        }
668    };
669    stream.set_nonblocking(true)?;
670
671    let mut guest_write_closed = false;
672    let mut guest_channel_closed = false;
673    let mut host_read_closed = false;
674    let mut pending_guest_data: Option<(Vec<u8>, usize)> = None;
675    let mut read_buffer = [0u8; RELAY_BUFFER_BYTES];
676
677    loop {
678        let mut did_work = false;
679
680        if pending_guest_data.is_none() && !guest_channel_closed {
681            match from_smoltcp.try_recv() {
682                Ok(payload) => {
683                    pending_guest_data = Some((payload, 0));
684                    // Consuming from the bounded guest->host channel may free
685                    // capacity for a payload buffered in the smoltcp poll
686                    // thread. Wake it so backpressure clears promptly even for
687                    // one-way guest->host streams.
688                    relay_wake.wake();
689                    did_work = true;
690                }
691                Err(TryRecvError::Empty) => {}
692                Err(TryRecvError::Disconnected) => {
693                    guest_channel_closed = true;
694                }
695            }
696        }
697
698        if let Some((payload, offset)) = &mut pending_guest_data {
699            while *offset < payload.len() {
700                match stream.write(&payload[*offset..]) {
701                    Ok(0) => {
702                        return Err(io::Error::new(
703                            io::ErrorKind::WriteZero,
704                            "host TCP relay wrote zero bytes",
705                        ));
706                    }
707                    Ok(bytes_written) => {
708                        *offset += bytes_written;
709                        did_work = true;
710                    }
711                    Err(err) if err.kind() == io::ErrorKind::WouldBlock => break,
712                    Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
713                    Err(err) => return Err(err),
714                }
715            }
716
717            if *offset >= payload.len() {
718                pending_guest_data = None;
719            }
720        }
721
722        if guest_channel_closed && pending_guest_data.is_none() && !guest_write_closed {
723            // The guest side closed its write half. Mirror that toward the
724            // remote peer only after all buffered guest bytes were written.
725            let _ = stream.shutdown(Shutdown::Write);
726            guest_write_closed = true;
727        }
728
729        // Both directions are done — the host stopped sending (host_read_closed)
730        // and the guest stopped sending and was fully flushed. Finish with a
731        // clean close; the poll loop tears the guest socket down.
732        if host_read_closed && guest_channel_closed && pending_guest_data.is_none() {
733            return Ok(RelayExitMode::Graceful);
734        }
735
736        // host -> guest, only while the host's send half is still open. On host
737        // read-EOF we do NOT tear the relay down: signal HalfClosed so the poll
738        // loop mirrors the FIN to the guest, then keep draining guest->host. A
739        // hijacked docker attach half-closes here (`shutdown(SHUT_WR)` with no
740        // stdin) while the daemon is still streaming its response back.
741        if !host_read_closed {
742            match stream.read(&mut read_buffer) {
743                Ok(0) => {
744                    host_read_closed = true;
745                    exit_state.store(RelayExitMode::HalfClosed);
746                    relay_wake.wake();
747                    did_work = true;
748                }
749                Ok(bytes_read) => {
750                    if to_smoltcp.send(read_buffer[..bytes_read].to_vec()).is_err() {
751                        return Ok(RelayExitMode::Graceful);
752                    }
753                    relay_wake.wake();
754                    did_work = true;
755                }
756                Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
757                Err(err) => return Err(err),
758            }
759        }
760
761        if !did_work {
762            thread::sleep(PROXY_IDLE_SLEEP);
763        }
764    }
765}
766
767fn flush_guest_data(connection: &mut TrackedConnection) {
768    let Some(payload) = connection.buffered_guest_data.take() else {
769        return;
770    };
771    send_guest_payload(connection, payload);
772}
773
774fn send_guest_payload(connection: &mut TrackedConnection, payload: Vec<u8>) -> bool {
775    match connection.to_proxy.try_send(payload) {
776        Ok(()) => true,
777        Err(TrySendError::Full(payload)) => {
778            connection.buffered_guest_data = Some(payload);
779            false
780        }
781        Err(TrySendError::Disconnected(_)) => false,
782    }
783}
784
785fn flush_proxy_data(socket: &mut tcp::Socket<'_>, connection: &mut TrackedConnection) {
786    // smoltcp send windows may accept only part of an inbound host payload.
787    // `buffered_proxy_data` remembers the unwritten remainder so the next poll
788    // iteration can continue where it left off instead of dropping bytes.
789    if let Some((data, offset)) = &mut connection.buffered_proxy_data {
790        if socket.can_send() {
791            match socket.send_slice(&data[*offset..]) {
792                Ok(written) => {
793                    *offset += written;
794                    if *offset >= data.len() {
795                        connection.buffered_proxy_data = None;
796                    }
797                }
798                Err(_) => return,
799            }
800        } else {
801            return;
802        }
803    }
804
805    while connection.buffered_proxy_data.is_none() {
806        match connection.from_proxy.try_recv() {
807            Ok(payload) => {
808                if socket.can_send() {
809                    match socket.send_slice(&payload) {
810                        Ok(written) if written < payload.len() => {
811                            connection.buffered_proxy_data = Some((payload, written));
812                        }
813                        Err(_) => {
814                            connection.buffered_proxy_data = Some((payload, 0));
815                        }
816                        _ => {}
817                    }
818                } else {
819                    connection.buffered_proxy_data = Some((payload, 0));
820                }
821            }
822            Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
823        }
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830
831    fn test_connection(to_proxy: SyncSender<Vec<u8>>) -> TrackedConnection {
832        let (_from_proxy_tx, from_proxy) = mpsc::sync_channel(CHANNEL_CAPACITY);
833        TrackedConnection {
834            source: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 12_345),
835            destination: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 80),
836            to_proxy,
837            from_proxy,
838            pending_proxy_endpoints: None,
839            relay_spawned: true,
840            buffered_guest_data: None,
841            buffered_proxy_data: None,
842            close_attempts: 0,
843            guest_send_closed: false,
844            exit_state: RelayExitState::new(),
845            reserved_published_port: None,
846        }
847    }
848
849    #[test]
850    fn guest_payload_is_buffered_when_relay_channel_is_full() {
851        let (to_proxy, from_smoltcp) = mpsc::sync_channel(1);
852        to_proxy.send(vec![1]).unwrap();
853        let mut connection = test_connection(to_proxy);
854
855        assert!(!send_guest_payload(&mut connection, vec![2]));
856        assert_eq!(connection.buffered_guest_data.as_deref(), Some(&[2][..]));
857
858        assert_eq!(from_smoltcp.recv().unwrap(), vec![1]);
859        flush_guest_data(&mut connection);
860
861        assert!(connection.buffered_guest_data.is_none());
862        assert_eq!(from_smoltcp.recv().unwrap(), vec![2]);
863    }
864
865    #[test]
866    fn gateway_ip_destination_dials_the_host_over_loopback() {
867        let gw4: IpAddr = "100.96.0.1".parse().unwrap();
868        let gw6: IpAddr = "fd00::1".parse().unwrap();
869        let table = TcpRelayTable::new(None, EgressPolicy::unrestricted(), vec![gw4, gw6]);
870
871        // A guest reaching "the host" via its gateway IP must dial loopback, not
872        // the gateway's own (non-routable) userspace address — the bug that made
873        // the handshake succeed but blackholed the reply. Port is preserved.
874        assert_eq!(
875            table.host_connect_addr(SocketAddr::new(gw4, 19997)),
876            SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 19997),
877        );
878        assert_eq!(
879            table.host_connect_addr(SocketAddr::new(gw6, 6379)),
880            SocketAddr::new(Ipv6Addr::LOCALHOST.into(), 6379),
881        );
882
883        // A non-gateway destination (external host / the host's LAN IP) is dialed
884        // unchanged.
885        let lan: IpAddr = "192.168.1.5".parse().unwrap();
886        assert_eq!(
887            table.host_connect_addr(SocketAddr::new(lan, 3306)),
888            SocketAddr::new(lan, 3306),
889        );
890    }
891}