Skip to main content

arcbox_proxy/
inbound_relay.rs

1//! Inbound port forwarding via L2 frame injection.
2//!
3//! Instead of using utun + kernel routing, we inject crafted L2 Ethernet frames
4//! directly into the guest FD (socketpair) so that host-side TCP/UDP listeners
5//! can reach services inside the guest VM.
6//!
7//! # Architecture
8//!
9//! ```text
10//! External client (host:8080)
11//!     │
12//!     ▼
13//! InboundListenerManager (TcpListener / UdpSocket per rule)
14//!     │ accept / recv
15//!     ▼
16//! InboundCommand channel  ──►  NetworkDatapath select! arm
17//!     │
18//!     ▼
19//! InboundRelay
20//!     └─ UDP: inject datagram → guest reply → forward to client
21//!     │
22//!     ▼
23//! reply_tx ──► datapath ──► guest_fd (socketpair) ──► Guest VM
24//! ```
25
26use std::collections::HashMap;
27use std::net::{Ipv4Addr, SocketAddr, SocketAddrV4};
28use std::time::Instant;
29
30use std::sync::Arc;
31
32use socket2::SockRef;
33use tokio::net::{TcpListener, UdpSocket};
34use tokio::sync::mpsc;
35use tokio::task::JoinHandle;
36use tokio_util::sync::CancellationToken;
37
38use arcbox_packet::ethernet::{ETH_HEADER_LEN, build_udp_ip_ethernet};
39
40/// Socket buffer size applied to accepted inbound TCP streams.
41///
42/// The OS default on macOS is ~128 KiB which forces TCP to shrink the window
43/// under high-throughput bulk transfers (e.g. iperf3). Raising to 4 MiB lets
44/// the window grow to match the BDP of localhost / high-speed paths.
45///
46/// Requires `kern.ipc.maxsockbuf` to allow at least this value (default 8 MiB
47/// on macOS; confirm with `sysctl kern.ipc.maxsockbuf`). setsockopt silently
48/// clamps to the maxsockbuf ceiling, so oversizing is harmless.
49const INBOUND_TCP_BUF_SIZE: usize = 4 * 1024 * 1024;
50
51// ---------------------------------------------------------------------------
52// Ephemeral port allocator
53// ---------------------------------------------------------------------------
54
55/// Start of the inbound ephemeral port range (guest kernel uses 32768-60999).
56const EPHEMERAL_START: u16 = 61000;
57/// End of the inbound ephemeral port range (inclusive).
58const EPHEMERAL_END: u16 = 65535;
59
60/// Wrapping ephemeral port allocator for inbound connections.
61pub(crate) struct EphemeralPorts {
62    next: u16,
63}
64
65impl EphemeralPorts {
66    pub(crate) fn new() -> Self {
67        Self {
68            next: EPHEMERAL_START,
69        }
70    }
71
72    /// Allocates the next ephemeral port, wrapping at the end of the range.
73    pub(crate) fn allocate(&mut self) -> u16 {
74        let port = self.next;
75        self.next = if self.next == EPHEMERAL_END {
76            EPHEMERAL_START
77        } else {
78            self.next + 1
79        };
80        port
81    }
82
83    /// Returns whether `port` falls within the inbound ephemeral range.
84    #[inline]
85    pub(crate) fn in_range(port: u16) -> bool {
86        (EPHEMERAL_START..=EPHEMERAL_END).contains(&port)
87    }
88}
89
90// ---------------------------------------------------------------------------
91// Inbound command (sent from listener tasks to the datapath)
92// ---------------------------------------------------------------------------
93
94/// Command sent from `InboundListenerManager` listener tasks to the datapath.
95pub enum InboundCommand {
96    /// A new TCP connection was accepted on a host listener.
97    TcpAccepted {
98        host_port: u16,
99        container_port: u16,
100        stream: tokio::net::TcpStream,
101    },
102    /// A UDP datagram was received on a host listener.
103    UdpReceived {
104        host_port: u16,
105        container_port: u16,
106        data: Vec<u8>,
107        /// Channel to send reply datagrams back to the host-side client.
108        reply_tx: mpsc::Sender<Vec<u8>>,
109        client_addr: SocketAddr,
110    },
111}
112
113// ---------------------------------------------------------------------------
114// UDP flow state
115// ---------------------------------------------------------------------------
116
117/// Per-flow inbound UDP state.
118struct InboundUdpFlow {
119    /// Channel to send reply datagrams back to the host-side client.
120    client_tx: mpsc::Sender<Vec<u8>>,
121    /// Last time traffic was seen on this flow.
122    last_active: Instant,
123}
124
125// ---------------------------------------------------------------------------
126// InboundRelay
127// ---------------------------------------------------------------------------
128
129/// Handles inbound (host → guest) connections by injecting L2 Ethernet frames
130/// directly into the guest FD through the `reply_tx` channel.
131pub(crate) struct InboundRelay {
132    /// Active UDP flows keyed by (gateway_ip, ephemeral_port, guest_ip, container_port).
133    udp_flows: HashMap<(Ipv4Addr, u16, Ipv4Addr, u16), InboundUdpFlow>,
134    /// Channel to inject L2 frames towards the guest.
135    reply_tx: mpsc::Sender<Vec<u8>>,
136    gateway_mac: [u8; 6],
137    gateway_ip: Ipv4Addr,
138    guest_ip: Ipv4Addr,
139    /// Guest link MTU; injected datagrams above it are IPv4-fragmented.
140    mtu: usize,
141    ephemeral_ports: EphemeralPorts,
142}
143
144impl InboundRelay {
145    pub(crate) fn new(
146        reply_tx: mpsc::Sender<Vec<u8>>,
147        gateway_mac: [u8; 6],
148        gateway_ip: Ipv4Addr,
149        guest_ip: Ipv4Addr,
150        mtu: usize,
151    ) -> Self {
152        Self {
153            udp_flows: HashMap::new(),
154            reply_tx,
155            gateway_mac,
156            gateway_ip,
157            guest_ip,
158            mtu,
159            ephemeral_ports: EphemeralPorts::new(),
160        }
161    }
162
163    // -----------------------------------------------------------------------
164    // Frame matching — called on every outbound guest frame
165    // -----------------------------------------------------------------------
166
167    /// Attempts to match an outbound guest frame as a reply to an inbound
168    /// connection. Returns `true` if the frame was consumed.
169    ///
170    /// Fast-path: `EphemeralPorts::in_range(dst_port)` rejects 99%+ of
171    /// outbound frames before any `HashMap` lookup.
172    pub(crate) fn try_handle_reply(&mut self, frame: &[u8], _guest_mac: [u8; 6]) -> bool {
173        if frame.len() < ETH_HEADER_LEN + 20 {
174            return false;
175        }
176
177        let ip_start = ETH_HEADER_LEN;
178        let protocol = frame[ip_start + 9];
179
180        let ihl = ((frame[ip_start] & 0x0F) as usize) * 4;
181        let l4_start = ip_start + ihl;
182
183        match protocol {
184            6 => false, // TCP is handled by TcpBridge, not the inbound relay
185            17 => self.try_handle_udp_reply(frame, ip_start, l4_start),
186            _ => false,
187        }
188    }
189
190    /// Checks if a UDP frame is a reply to an inbound flow.
191    fn try_handle_udp_reply(&mut self, frame: &[u8], ip_start: usize, udp_start: usize) -> bool {
192        if frame.len() < udp_start + 8 {
193            return false;
194        }
195
196        let dst_port = u16::from_be_bytes([frame[udp_start + 2], frame[udp_start + 3]]);
197        if !EphemeralPorts::in_range(dst_port) {
198            return false;
199        }
200
201        let src_ip = Ipv4Addr::new(
202            frame[ip_start + 12],
203            frame[ip_start + 13],
204            frame[ip_start + 14],
205            frame[ip_start + 15],
206        );
207        let dst_ip = Ipv4Addr::new(
208            frame[ip_start + 16],
209            frame[ip_start + 17],
210            frame[ip_start + 18],
211            frame[ip_start + 19],
212        );
213        let src_port = u16::from_be_bytes([frame[udp_start], frame[udp_start + 1]]);
214
215        let key = (dst_ip, dst_port, src_ip, src_port);
216
217        if let Some(flow) = self.udp_flows.get_mut(&key) {
218            let udp_len = u16::from_be_bytes([frame[udp_start + 4], frame[udp_start + 5]]) as usize;
219            if udp_len >= 8 && udp_start + udp_len <= frame.len() {
220                let payload = frame[udp_start + 8..udp_start + udp_len].to_vec();
221                flow.last_active = Instant::now();
222                let _ = flow.client_tx.try_send(payload);
223            }
224            return true;
225        }
226
227        false
228    }
229
230    // -----------------------------------------------------------------------
231    // UDP: inject datagram to guest
232    // -----------------------------------------------------------------------
233
234    /// Injects a UDP datagram to the guest and sets up a flow for replies.
235    pub(crate) fn inject_udp(
236        &mut self,
237        container_port: u16,
238        data: &[u8],
239        client_tx: mpsc::Sender<Vec<u8>>,
240        guest_mac: [u8; 6],
241    ) {
242        let ephemeral_port = self.ephemeral_ports.allocate();
243        let key = (
244            self.gateway_ip,
245            ephemeral_port,
246            self.guest_ip,
247            container_port,
248        );
249
250        self.udp_flows.insert(
251            key,
252            InboundUdpFlow {
253                client_tx,
254                last_active: Instant::now(),
255            },
256        );
257
258        let frames = build_udp_ip_ethernet(
259            self.gateway_ip,
260            self.guest_ip,
261            ephemeral_port,
262            container_port,
263            data,
264            self.gateway_mac,
265            guest_mac,
266            self.mtu,
267        );
268
269        for frame in frames {
270            if self.reply_tx.try_send(frame).is_err() {
271                // Dropping a fragment kills the whole datagram; stop early.
272                break;
273            }
274        }
275
276        tracing::debug!(
277            "Inbound UDP: injected {} bytes  gw:{} → guest:{}",
278            data.len(),
279            ephemeral_port,
280            container_port,
281        );
282    }
283
284    // -----------------------------------------------------------------------
285    // Maintenance
286    // -----------------------------------------------------------------------
287
288    /// Removes expired UDP flows.
289    pub(crate) fn cleanup(&mut self) {
290        let now = Instant::now();
291        self.udp_flows
292            .retain(|_, flow| now.duration_since(flow.last_active).as_secs() < 60);
293    }
294}
295
296// ---------------------------------------------------------------------------
297// InboundListenerManager
298// ---------------------------------------------------------------------------
299
300/// Protocol for port forwarding rules.
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
302pub enum InboundProtocol {
303    Tcp,
304    Udp,
305}
306
307/// Identifies a rule by the host address and the port the caller *asked* for.
308///
309/// Deliberately the requested port, not the bound one, so `remove_rule` can
310/// undo an `add_rule` with the same arguments the caller passed in.
311type ListenerKey = (Ipv4Addr, u16, InboundProtocol);
312
313/// A live listener: its task, its cancellation token, and the port it actually
314/// bound — which differs from the key's port only when the caller passed 0.
315type ListenerEntry = (JoinHandle<()>, CancellationToken, u16);
316
317/// Manages host-side listeners that accept incoming connections / datagrams
318/// and send `InboundCommand` messages to the datapath.
319pub struct InboundListenerManager {
320    cmd_tx: mpsc::Sender<InboundCommand>,
321    listeners: HashMap<ListenerKey, ListenerEntry>,
322}
323
324impl InboundListenerManager {
325    /// Creates a new listener manager.
326    #[must_use]
327    pub fn new(cmd_tx: mpsc::Sender<InboundCommand>) -> Self {
328        Self {
329            cmd_tx,
330            listeners: HashMap::new(),
331        }
332    }
333
334    /// Adds a forwarding rule and spawns a listener task.
335    ///
336    /// Returns the port actually bound. That equals `host_port` unless the
337    /// caller passed 0 to let the OS choose, in which case it is the only way
338    /// to learn where the listener ended up — binding 0 and then probing for
339    /// the port separately would race anything else on the machine.
340    ///
341    /// # Errors
342    ///
343    /// Returns an error if the listener cannot bind.
344    pub async fn add_rule(
345        &mut self,
346        host_ip: Ipv4Addr,
347        host_port: u16,
348        container_port: u16,
349        protocol: InboundProtocol,
350    ) -> std::io::Result<u16> {
351        let key = (host_ip, host_port, protocol);
352        if self.listeners.contains_key(&key) {
353            return Err(std::io::Error::new(
354                std::io::ErrorKind::AddrInUse,
355                format!("inbound listener already exists on {host_ip}:{host_port}"),
356            ));
357        }
358
359        let cancel = CancellationToken::new();
360        let cmd_tx = self.cmd_tx.clone();
361
362        let (handle, bound_port) = match protocol {
363            InboundProtocol::Tcp => {
364                let listener =
365                    TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(host_ip, host_port)))
366                        .await?;
367                let bound = listener.local_addr()?.port();
368                tracing::info!(
369                    "Inbound listener: TCP {}:{} → container :{}",
370                    host_ip,
371                    bound,
372                    container_port,
373                );
374                let cancel_clone = cancel.clone();
375                let handle = tokio::spawn(async move {
376                    tcp_listener_task(listener, container_port, cmd_tx, cancel_clone).await;
377                });
378                (handle, bound)
379            }
380            InboundProtocol::Udp => {
381                let socket =
382                    UdpSocket::bind(SocketAddr::V4(SocketAddrV4::new(host_ip, host_port))).await?;
383                let bound = socket.local_addr()?.port();
384                tracing::info!(
385                    "Inbound listener: UDP {}:{} → container :{}",
386                    host_ip,
387                    bound,
388                    container_port,
389                );
390                let cancel_clone = cancel.clone();
391                let handle = tokio::spawn(async move {
392                    udp_listener_task(socket, container_port, cmd_tx, cancel_clone).await;
393                });
394                (handle, bound)
395            }
396        };
397
398        self.listeners.insert(key, (handle, cancel, bound_port));
399        Ok(bound_port)
400    }
401
402    /// Removes a forwarding rule and waits until its listener has dropped the
403    /// bound socket.
404    pub async fn remove_rule(
405        &mut self,
406        host_ip: Ipv4Addr,
407        host_port: u16,
408        protocol: InboundProtocol,
409    ) {
410        let key = (host_ip, host_port, protocol);
411        if let Some((handle, cancel, bound)) = self.listeners.remove(&key) {
412            cancel.cancel();
413            handle.abort();
414            let _ = handle.await;
415            tracing::debug!(
416                "Inbound listener removed: {:?} {}:{}",
417                protocol,
418                host_ip,
419                bound
420            );
421        }
422    }
423
424    /// Stops all listeners.
425    pub async fn stop_all(&mut self) {
426        let keys: Vec<_> = self.listeners.keys().copied().collect();
427        for (ip, port, protocol) in keys {
428            self.remove_rule(ip, port, protocol).await;
429        }
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Listener tasks
435// ---------------------------------------------------------------------------
436
437/// TCP listener task: accepts connections and sends `InboundCommand::TcpAccepted`.
438async fn tcp_listener_task(
439    listener: TcpListener,
440    container_port: u16,
441    cmd_tx: mpsc::Sender<InboundCommand>,
442    cancel: CancellationToken,
443) {
444    let host_port = listener.local_addr().map_or(0, |a| a.port());
445    loop {
446        tokio::select! {
447            biased;
448            () = cancel.cancelled() => break,
449            result = listener.accept() => {
450                match result {
451                    Ok((stream, peer)) => {
452                        tracing::debug!(
453                            "Inbound TCP accept: {} → host:{} → container:{}",
454                            peer, host_port, container_port,
455                        );
456                        // Raise send/recv buffers so the TCP window can grow to
457                        // localhost BDP. Failures here are non-fatal — the OS
458                        // default still works, just throttles throughput.
459                        let sock = SockRef::from(&stream);
460                        if let Err(e) = sock.set_recv_buffer_size(INBOUND_TCP_BUF_SIZE) {
461                            tracing::warn!("Failed to set SO_RCVBUF on inbound stream: {e}");
462                        }
463                        if let Err(e) = sock.set_send_buffer_size(INBOUND_TCP_BUF_SIZE) {
464                            tracing::warn!("Failed to set SO_SNDBUF on inbound stream: {e}");
465                        }
466                        let cmd = InboundCommand::TcpAccepted {
467                            host_port,
468                            container_port,
469                            stream,
470                        };
471                        if cmd_tx.send(cmd).await.is_err() {
472                            break;
473                        }
474                    }
475                    Err(e) => {
476                        tracing::warn!("Inbound TCP accept error on :{}: {}", host_port, e);
477                    }
478                }
479            }
480        }
481    }
482}
483
484/// UDP listener task: receives datagrams and sends `InboundCommand::UdpReceived`.
485async fn udp_listener_task(
486    socket: UdpSocket,
487    container_port: u16,
488    cmd_tx: mpsc::Sender<InboundCommand>,
489    cancel: CancellationToken,
490) {
491    let host_port = socket.local_addr().map_or(0, |a| a.port());
492    let socket = Arc::new(socket);
493    let mut reply_flows: HashMap<SocketAddr, mpsc::Sender<Vec<u8>>> = HashMap::new();
494    let mut buf = vec![0u8; 65535];
495
496    loop {
497        tokio::select! {
498            biased;
499            () = cancel.cancelled() => break,
500            result = socket.recv_from(&mut buf) => {
501                match result {
502                    Ok((n, client_addr)) => {
503                        let reply_tx = if let Some(tx) = reply_flows.get(&client_addr) {
504                            if tx.is_closed() {
505                                reply_flows.remove(&client_addr);
506                                create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
507                            } else {
508                                tx.clone()
509                            }
510                        } else {
511                            create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
512                        };
513
514                        let cmd = InboundCommand::UdpReceived {
515                            host_port,
516                            container_port,
517                            data: buf[..n].to_vec(),
518                            reply_tx,
519                            client_addr,
520                        };
521                        if cmd_tx.send(cmd).await.is_err() {
522                            break;
523                        }
524                    }
525                    Err(e) => {
526                        tracing::warn!("Inbound UDP recv error on :{}: {}", host_port, e);
527                    }
528                }
529            }
530        }
531    }
532}
533
534fn create_udp_reply_flow(
535    client_addr: SocketAddr,
536    socket: &Arc<UdpSocket>,
537    cancel: &CancellationToken,
538    reply_flows: &mut HashMap<SocketAddr, mpsc::Sender<Vec<u8>>>,
539) -> mpsc::Sender<Vec<u8>> {
540    let (reply_tx, mut reply_rx) = mpsc::channel::<Vec<u8>>(16);
541    let reply_sock = Arc::clone(socket);
542    let flow_cancel = cancel.clone();
543    tokio::spawn(async move {
544        loop {
545            tokio::select! {
546                biased;
547                () = flow_cancel.cancelled() => break,
548                maybe_data = reply_rx.recv() => {
549                    let Some(data) = maybe_data else {
550                        break;
551                    };
552                    let _ = reply_sock.send_to(&data, client_addr).await;
553                }
554            }
555        }
556    });
557    reply_flows.insert(client_addr, reply_tx.clone());
558    reply_tx
559}
560
561// ---------------------------------------------------------------------------
562// Tests
563// ---------------------------------------------------------------------------
564
565#[cfg(test)]
566mod tests {
567    use std::time::Duration;
568
569    use super::*;
570
571    const GW_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
572    const GUEST_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 2);
573    const GW_MAC: [u8; 6] = [0x02, 0xAB, 0xCD, 0x00, 0x00, 0x01];
574    const GUEST_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x99];
575
576    #[test]
577    fn ephemeral_ports_allocation() {
578        let mut ep = EphemeralPorts::new();
579        assert_eq!(ep.allocate(), 61000);
580        assert_eq!(ep.allocate(), 61001);
581    }
582
583    #[test]
584    fn ephemeral_ports_wrap_around() {
585        let mut ep = EphemeralPorts::new();
586        ep.next = EPHEMERAL_END;
587        assert_eq!(ep.allocate(), EPHEMERAL_END);
588        assert_eq!(ep.allocate(), EPHEMERAL_START);
589    }
590
591    #[test]
592    fn ephemeral_ports_in_range() {
593        assert!(EphemeralPorts::in_range(61000));
594        assert!(EphemeralPorts::in_range(65535));
595        assert!(EphemeralPorts::in_range(63000));
596        assert!(!EphemeralPorts::in_range(60999));
597        assert!(!EphemeralPorts::in_range(32768));
598        assert!(!EphemeralPorts::in_range(80));
599    }
600
601    #[test]
602    fn inbound_relay_rejects_non_ephemeral() {
603        let (tx, _rx) = mpsc::channel(16);
604        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
605
606        // Build a minimal TCP frame with dst_port=80 (not in ephemeral range).
607        let mut frame = vec![0u8; ETH_HEADER_LEN + 40];
608        frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes());
609        let ip = &mut frame[ETH_HEADER_LEN..];
610        ip[0] = 0x45;
611        ip[9] = 6; // TCP
612        ip[12..16].copy_from_slice(&GUEST_IP.octets());
613        ip[16..20].copy_from_slice(&GW_IP.octets());
614        // TCP header: src_port=8080, dst_port=80
615        let tcp = &mut frame[ETH_HEADER_LEN + 20..];
616        tcp[0..2].copy_from_slice(&8080u16.to_be_bytes());
617        tcp[2..4].copy_from_slice(&80u16.to_be_bytes());
618        tcp[12] = 0x50; // data offset = 5
619
620        assert!(!relay.try_handle_reply(&frame, GUEST_MAC));
621    }
622
623    #[tokio::test]
624    async fn inject_udp_sends_frame_and_tracks_flow() {
625        let (tx, mut rx) = mpsc::channel(16);
626        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
627
628        let (client_tx, _client_rx) = mpsc::channel(16);
629        relay.inject_udp(53, b"dns query", client_tx, GUEST_MAC);
630
631        // Flow should be tracked.
632        let key = (GW_IP, EPHEMERAL_START, GUEST_IP, 53);
633        assert!(relay.udp_flows.contains_key(&key));
634
635        // A UDP frame should have been sent.
636        let frame = rx.recv().await.expect("should receive UDP frame");
637        assert!(frame.len() >= ETH_HEADER_LEN + 28, "UDP frame too short");
638
639        // Verify IP protocol = UDP (17).
640        assert_eq!(frame[ETH_HEADER_LEN + 9], 17);
641
642        // Verify ports.
643        let udp_start = ETH_HEADER_LEN + 20;
644        let src_port = u16::from_be_bytes([frame[udp_start], frame[udp_start + 1]]);
645        let dst_port = u16::from_be_bytes([frame[udp_start + 2], frame[udp_start + 3]]);
646        assert_eq!(src_port, EPHEMERAL_START);
647        assert_eq!(dst_port, 53);
648    }
649
650    #[test]
651    fn cleanup_removes_expired_udp_flows() {
652        let (tx, _rx) = mpsc::channel(16);
653        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
654
655        let (client_tx, _client_rx) = mpsc::channel(16);
656        let key = (GW_IP, 61000, GUEST_IP, 53);
657        relay.udp_flows.insert(
658            key,
659            InboundUdpFlow {
660                client_tx,
661                last_active: Instant::now()
662                    .checked_sub(std::time::Duration::from_secs(120))
663                    .unwrap(),
664            },
665        );
666        assert_eq!(relay.udp_flows.len(), 1);
667
668        relay.cleanup();
669        assert_eq!(
670            relay.udp_flows.len(),
671            0,
672            "expired UDP flow should be removed"
673        );
674    }
675
676    #[tokio::test]
677    async fn listener_manager_add_and_remove_rule() {
678        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
679        let mut manager = InboundListenerManager::new(cmd_tx);
680
681        // Add a TCP rule on an ephemeral port.
682        manager
683            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
684            .await
685            .expect("should bind to port 0 (OS-assigned)");
686
687        // Remove it.
688        manager
689            .remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp)
690            .await;
691
692        // The cmd_rx channel should still be valid (no panic).
693        assert!(cmd_rx.try_recv().is_err(), "no commands expected yet");
694    }
695
696    /// A real host connection to a registered rule produces `TcpAccepted`
697    /// carrying the container port the rule was created with.
698    ///
699    /// `listener_manager_add_and_remove_rule` only exercises the manager's
700    /// bookkeeping — it never connects, so nothing proved the listener task
701    /// actually accepts and reports. The relay's job ends here: SYN
702    /// generation toward the guest belongs to `splicetcp`'s active open.
703    ///
704    /// Binds port 0 and uses the port `add_rule` reports. Probing for a free
705    /// port and then binding it would race anything else on the machine into
706    /// the gap.
707    #[tokio::test]
708    async fn host_connection_produces_a_tcp_accepted_command() {
709        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
710        let mut manager = InboundListenerManager::new(cmd_tx);
711
712        let host_port = manager
713            .add_rule(Ipv4Addr::LOCALHOST, 0, 8080, InboundProtocol::Tcp)
714            .await
715            .expect("rule should bind an OS-assigned port");
716        assert_ne!(host_port, 0, "add_rule must report the port it bound");
717
718        let _client = tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, host_port))
719            .await
720            .expect("host should be able to connect to a registered rule");
721
722        let cmd = tokio::time::timeout(Duration::from_secs(5), cmd_rx.recv())
723            .await
724            .expect("a TcpAccepted command should arrive within 5s")
725            .expect("command channel stayed open");
726
727        match cmd {
728            InboundCommand::TcpAccepted {
729                host_port: got_host,
730                container_port,
731                ..
732            } => {
733                assert_eq!(got_host, host_port, "command reports the wrong host port");
734                assert_eq!(
735                    container_port, 8080,
736                    "command must carry the container port the rule was created with"
737                );
738            }
739            // Named rather than `{:?}`-formatted: deriving Debug on the
740            // command type (it carries a TcpStream) to serve a panic message
741            // would widen production surface for a test's benefit.
742            InboundCommand::UdpReceived { .. } => {
743                panic!("a TCP rule produced UdpReceived instead of TcpAccepted")
744            }
745        }
746    }
747
748    /// Removing a rule closes its listener, so a later connect is refused
749    /// rather than hanging or silently succeeding against a stale listener.
750    #[tokio::test]
751    async fn removing_a_rule_closes_the_listener() {
752        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
753        let mut manager = InboundListenerManager::new(cmd_tx);
754
755        let host_port = manager
756            .add_rule(Ipv4Addr::LOCALHOST, 0, 8080, InboundProtocol::Tcp)
757            .await
758            .expect("rule should bind an OS-assigned port");
759        tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, host_port))
760            .await
761            .expect("connect should succeed while the rule exists");
762
763        // Keyed by the port that was *requested*, hence 0 rather than the
764        // bound port — see the `listeners` field comment.
765        manager
766            .remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp)
767            .await;
768
769        // `remove_rule` waits for the listener task to drop its socket.
770        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
771        loop {
772            match tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, host_port)).await {
773                Err(_) => break,
774                Ok(_) if tokio::time::Instant::now() >= deadline => {
775                    panic!("port {host_port} still accepts connections after remove_rule")
776                }
777                Ok(_) => tokio::time::sleep(Duration::from_millis(50)).await,
778            }
779        }
780    }
781
782    #[tokio::test]
783    async fn listener_manager_stop_all() {
784        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
785        let mut manager = InboundListenerManager::new(cmd_tx);
786
787        manager
788            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
789            .await
790            .unwrap();
791        manager
792            .add_rule(Ipv4Addr::LOCALHOST, 0, 53, InboundProtocol::Udp)
793            .await
794            .unwrap();
795
796        manager.stop_all().await;
797        // After stop_all, the internal map should be empty. Since we can't
798        // inspect it directly, adding the same rule again should succeed (no
799        // duplicate key).
800        manager
801            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
802            .await
803            .unwrap();
804    }
805
806    #[tokio::test]
807    async fn listener_manager_rejects_duplicate_host_endpoint() {
808        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
809        let mut manager = InboundListenerManager::new(cmd_tx);
810        manager
811            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
812            .await
813            .unwrap();
814
815        let error = manager
816            .add_rule(Ipv4Addr::LOCALHOST, 0, 81, InboundProtocol::Tcp)
817            .await
818            .expect_err("the existing listener must not be reused for another destination");
819
820        assert_eq!(error.kind(), std::io::ErrorKind::AddrInUse);
821        assert_eq!(manager.listeners.len(), 1);
822    }
823
824    #[tokio::test]
825    async fn listener_remove_waits_until_socket_is_reusable() {
826        let reservation = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
827        let port = reservation.local_addr().unwrap().port();
828        drop(reservation);
829
830        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
831        let mut manager = InboundListenerManager::new(cmd_tx);
832        manager
833            .add_rule(Ipv4Addr::LOCALHOST, port, 80, InboundProtocol::Tcp)
834            .await
835            .unwrap();
836        manager
837            .remove_rule(Ipv4Addr::LOCALHOST, port, InboundProtocol::Tcp)
838            .await;
839
840        std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port))
841            .expect("remove_rule must release the socket before returning");
842    }
843
844    #[tokio::test]
845    async fn same_port_different_ip_coexist() {
846        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
847        let mut manager = InboundListenerManager::new(cmd_tx);
848
849        // Bind the same container port on two different host IPs (port 0 = OS-assigned).
850        manager
851            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
852            .await
853            .unwrap();
854        manager
855            .add_rule(Ipv4Addr::UNSPECIFIED, 0, 80, InboundProtocol::Tcp)
856            .await
857            .unwrap();
858
859        // Remove only the localhost rule; re-adding it should succeed (not a dup).
860        manager
861            .remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp)
862            .await;
863        manager
864            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
865            .await
866            .unwrap();
867    }
868
869    #[test]
870    fn invalid_host_ip_is_rejected() {
871        // Verify that HostIp parsing used by runtime rejects non-IPv4 strings.
872        // The runtime calls `host_ip_str.parse::<Ipv4Addr>()` and skips on Err.
873        assert!(
874            "::1".parse::<Ipv4Addr>().is_err(),
875            "IPv6 should fail Ipv4Addr parse"
876        );
877        assert!("not-an-ip".parse::<Ipv4Addr>().is_err());
878        assert!("".parse::<Ipv4Addr>().is_err());
879        // Valid cases the runtime accepts:
880        assert!("127.0.0.1".parse::<Ipv4Addr>().is_ok());
881        assert!("0.0.0.0".parse::<Ipv4Addr>().is_ok());
882    }
883}