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    /// How many listeners are live.
433    #[must_use]
434    pub fn len(&self) -> usize {
435        self.listeners.len()
436    }
437
438    /// Whether no listener is live.
439    #[must_use]
440    pub fn is_empty(&self) -> bool {
441        self.listeners.is_empty()
442    }
443}
444
445// ---------------------------------------------------------------------------
446// Listener tasks
447// ---------------------------------------------------------------------------
448
449/// TCP listener task: accepts connections and sends `InboundCommand::TcpAccepted`.
450async fn tcp_listener_task(
451    listener: TcpListener,
452    container_port: u16,
453    cmd_tx: mpsc::Sender<InboundCommand>,
454    cancel: CancellationToken,
455) {
456    let host_port = listener.local_addr().map_or(0, |a| a.port());
457    loop {
458        tokio::select! {
459            biased;
460            () = cancel.cancelled() => break,
461            result = listener.accept() => {
462                match result {
463                    Ok((stream, peer)) => {
464                        tracing::debug!(
465                            "Inbound TCP accept: {} → host:{} → container:{}",
466                            peer, host_port, container_port,
467                        );
468                        // Raise send/recv buffers so the TCP window can grow to
469                        // localhost BDP. Failures here are non-fatal — the OS
470                        // default still works, just throttles throughput.
471                        let sock = SockRef::from(&stream);
472                        if let Err(e) = sock.set_recv_buffer_size(INBOUND_TCP_BUF_SIZE) {
473                            tracing::warn!("Failed to set SO_RCVBUF on inbound stream: {e}");
474                        }
475                        if let Err(e) = sock.set_send_buffer_size(INBOUND_TCP_BUF_SIZE) {
476                            tracing::warn!("Failed to set SO_SNDBUF on inbound stream: {e}");
477                        }
478                        let cmd = InboundCommand::TcpAccepted {
479                            host_port,
480                            container_port,
481                            stream,
482                        };
483                        if cmd_tx.send(cmd).await.is_err() {
484                            break;
485                        }
486                    }
487                    Err(e) => {
488                        tracing::warn!("Inbound TCP accept error on :{}: {}", host_port, e);
489                    }
490                }
491            }
492        }
493    }
494}
495
496/// UDP listener task: receives datagrams and sends `InboundCommand::UdpReceived`.
497async fn udp_listener_task(
498    socket: UdpSocket,
499    container_port: u16,
500    cmd_tx: mpsc::Sender<InboundCommand>,
501    cancel: CancellationToken,
502) {
503    let host_port = socket.local_addr().map_or(0, |a| a.port());
504    let socket = Arc::new(socket);
505    let mut reply_flows: HashMap<SocketAddr, mpsc::Sender<Vec<u8>>> = HashMap::new();
506    let mut buf = vec![0u8; 65535];
507
508    loop {
509        tokio::select! {
510            biased;
511            () = cancel.cancelled() => break,
512            result = socket.recv_from(&mut buf) => {
513                match result {
514                    Ok((n, client_addr)) => {
515                        let reply_tx = if let Some(tx) = reply_flows.get(&client_addr) {
516                            if tx.is_closed() {
517                                reply_flows.remove(&client_addr);
518                                create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
519                            } else {
520                                tx.clone()
521                            }
522                        } else {
523                            create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
524                        };
525
526                        let cmd = InboundCommand::UdpReceived {
527                            host_port,
528                            container_port,
529                            data: buf[..n].to_vec(),
530                            reply_tx,
531                            client_addr,
532                        };
533                        if cmd_tx.send(cmd).await.is_err() {
534                            break;
535                        }
536                    }
537                    Err(e) => {
538                        tracing::warn!("Inbound UDP recv error on :{}: {}", host_port, e);
539                    }
540                }
541            }
542        }
543    }
544}
545
546fn create_udp_reply_flow(
547    client_addr: SocketAddr,
548    socket: &Arc<UdpSocket>,
549    cancel: &CancellationToken,
550    reply_flows: &mut HashMap<SocketAddr, mpsc::Sender<Vec<u8>>>,
551) -> mpsc::Sender<Vec<u8>> {
552    let (reply_tx, mut reply_rx) = mpsc::channel::<Vec<u8>>(16);
553    let reply_sock = Arc::clone(socket);
554    let flow_cancel = cancel.clone();
555    tokio::spawn(async move {
556        loop {
557            tokio::select! {
558                biased;
559                () = flow_cancel.cancelled() => break,
560                maybe_data = reply_rx.recv() => {
561                    let Some(data) = maybe_data else {
562                        break;
563                    };
564                    let _ = reply_sock.send_to(&data, client_addr).await;
565                }
566            }
567        }
568    });
569    reply_flows.insert(client_addr, reply_tx.clone());
570    reply_tx
571}
572
573// ---------------------------------------------------------------------------
574// Tests
575// ---------------------------------------------------------------------------
576
577#[cfg(test)]
578mod tests {
579    use std::time::Duration;
580
581    use super::*;
582
583    const GW_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
584    const GUEST_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 2);
585    const GW_MAC: [u8; 6] = [0x02, 0xAB, 0xCD, 0x00, 0x00, 0x01];
586    const GUEST_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x99];
587
588    #[test]
589    fn ephemeral_ports_allocation() {
590        let mut ep = EphemeralPorts::new();
591        assert_eq!(ep.allocate(), 61000);
592        assert_eq!(ep.allocate(), 61001);
593    }
594
595    #[test]
596    fn ephemeral_ports_wrap_around() {
597        let mut ep = EphemeralPorts::new();
598        ep.next = EPHEMERAL_END;
599        assert_eq!(ep.allocate(), EPHEMERAL_END);
600        assert_eq!(ep.allocate(), EPHEMERAL_START);
601    }
602
603    #[test]
604    fn ephemeral_ports_in_range() {
605        assert!(EphemeralPorts::in_range(61000));
606        assert!(EphemeralPorts::in_range(65535));
607        assert!(EphemeralPorts::in_range(63000));
608        assert!(!EphemeralPorts::in_range(60999));
609        assert!(!EphemeralPorts::in_range(32768));
610        assert!(!EphemeralPorts::in_range(80));
611    }
612
613    #[test]
614    fn inbound_relay_rejects_non_ephemeral() {
615        let (tx, _rx) = mpsc::channel(16);
616        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
617
618        // Build a minimal TCP frame with dst_port=80 (not in ephemeral range).
619        let mut frame = vec![0u8; ETH_HEADER_LEN + 40];
620        frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes());
621        let ip = &mut frame[ETH_HEADER_LEN..];
622        ip[0] = 0x45;
623        ip[9] = 6; // TCP
624        ip[12..16].copy_from_slice(&GUEST_IP.octets());
625        ip[16..20].copy_from_slice(&GW_IP.octets());
626        // TCP header: src_port=8080, dst_port=80
627        let tcp = &mut frame[ETH_HEADER_LEN + 20..];
628        tcp[0..2].copy_from_slice(&8080u16.to_be_bytes());
629        tcp[2..4].copy_from_slice(&80u16.to_be_bytes());
630        tcp[12] = 0x50; // data offset = 5
631
632        assert!(!relay.try_handle_reply(&frame, GUEST_MAC));
633    }
634
635    #[tokio::test]
636    async fn inject_udp_sends_frame_and_tracks_flow() {
637        let (tx, mut rx) = mpsc::channel(16);
638        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
639
640        let (client_tx, _client_rx) = mpsc::channel(16);
641        relay.inject_udp(53, b"dns query", client_tx, GUEST_MAC);
642
643        // Flow should be tracked.
644        let key = (GW_IP, EPHEMERAL_START, GUEST_IP, 53);
645        assert!(relay.udp_flows.contains_key(&key));
646
647        // A UDP frame should have been sent.
648        let frame = rx.recv().await.expect("should receive UDP frame");
649        assert!(frame.len() >= ETH_HEADER_LEN + 28, "UDP frame too short");
650
651        // Verify IP protocol = UDP (17).
652        assert_eq!(frame[ETH_HEADER_LEN + 9], 17);
653
654        // Verify ports.
655        let udp_start = ETH_HEADER_LEN + 20;
656        let src_port = u16::from_be_bytes([frame[udp_start], frame[udp_start + 1]]);
657        let dst_port = u16::from_be_bytes([frame[udp_start + 2], frame[udp_start + 3]]);
658        assert_eq!(src_port, EPHEMERAL_START);
659        assert_eq!(dst_port, 53);
660    }
661
662    #[test]
663    fn cleanup_removes_expired_udp_flows() {
664        let (tx, _rx) = mpsc::channel(16);
665        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
666
667        let (client_tx, _client_rx) = mpsc::channel(16);
668        let key = (GW_IP, 61000, GUEST_IP, 53);
669        relay.udp_flows.insert(
670            key,
671            InboundUdpFlow {
672                client_tx,
673                last_active: Instant::now()
674                    .checked_sub(std::time::Duration::from_secs(120))
675                    .unwrap(),
676            },
677        );
678        assert_eq!(relay.udp_flows.len(), 1);
679
680        relay.cleanup();
681        assert_eq!(
682            relay.udp_flows.len(),
683            0,
684            "expired UDP flow should be removed"
685        );
686    }
687
688    #[tokio::test]
689    async fn listener_manager_add_and_remove_rule() {
690        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
691        let mut manager = InboundListenerManager::new(cmd_tx);
692
693        // Add a TCP rule on an ephemeral port.
694        manager
695            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
696            .await
697            .expect("should bind to port 0 (OS-assigned)");
698
699        // Remove it.
700        manager
701            .remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp)
702            .await;
703
704        // The cmd_rx channel should still be valid (no panic).
705        assert!(cmd_rx.try_recv().is_err(), "no commands expected yet");
706    }
707
708    /// A real host connection to a registered rule produces `TcpAccepted`
709    /// carrying the container port the rule was created with.
710    ///
711    /// `listener_manager_add_and_remove_rule` only exercises the manager's
712    /// bookkeeping — it never connects, so nothing proved the listener task
713    /// actually accepts and reports. The relay's job ends here: SYN
714    /// generation toward the guest belongs to `splicetcp`'s active open.
715    ///
716    /// Binds port 0 and uses the port `add_rule` reports. Probing for a free
717    /// port and then binding it would race anything else on the machine into
718    /// the gap.
719    #[tokio::test]
720    async fn host_connection_produces_a_tcp_accepted_command() {
721        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
722        let mut manager = InboundListenerManager::new(cmd_tx);
723
724        let host_port = manager
725            .add_rule(Ipv4Addr::LOCALHOST, 0, 8080, InboundProtocol::Tcp)
726            .await
727            .expect("rule should bind an OS-assigned port");
728        assert_ne!(host_port, 0, "add_rule must report the port it bound");
729
730        let _client = tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, host_port))
731            .await
732            .expect("host should be able to connect to a registered rule");
733
734        let cmd = tokio::time::timeout(Duration::from_secs(5), cmd_rx.recv())
735            .await
736            .expect("a TcpAccepted command should arrive within 5s")
737            .expect("command channel stayed open");
738
739        match cmd {
740            InboundCommand::TcpAccepted {
741                host_port: got_host,
742                container_port,
743                ..
744            } => {
745                assert_eq!(got_host, host_port, "command reports the wrong host port");
746                assert_eq!(
747                    container_port, 8080,
748                    "command must carry the container port the rule was created with"
749                );
750            }
751            // Named rather than `{:?}`-formatted: deriving Debug on the
752            // command type (it carries a TcpStream) to serve a panic message
753            // would widen production surface for a test's benefit.
754            InboundCommand::UdpReceived { .. } => {
755                panic!("a TCP rule produced UdpReceived instead of TcpAccepted")
756            }
757        }
758    }
759
760    /// Removing a rule closes its listener, so a later connect is refused
761    /// rather than hanging or silently succeeding against a stale listener.
762    #[tokio::test]
763    async fn removing_a_rule_closes_the_listener() {
764        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
765        let mut manager = InboundListenerManager::new(cmd_tx);
766
767        let host_port = manager
768            .add_rule(Ipv4Addr::LOCALHOST, 0, 8080, InboundProtocol::Tcp)
769            .await
770            .expect("rule should bind an OS-assigned port");
771        tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, host_port))
772            .await
773            .expect("connect should succeed while the rule exists");
774
775        // Keyed by the port that was *requested*, hence 0 rather than the
776        // bound port — see the `listeners` field comment.
777        manager
778            .remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp)
779            .await;
780
781        // `remove_rule` waits for the listener task to drop its socket.
782        let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
783        loop {
784            match tokio::net::TcpStream::connect((Ipv4Addr::LOCALHOST, host_port)).await {
785                Err(_) => break,
786                Ok(_) if tokio::time::Instant::now() >= deadline => {
787                    panic!("port {host_port} still accepts connections after remove_rule")
788                }
789                Ok(_) => tokio::time::sleep(Duration::from_millis(50)).await,
790            }
791        }
792    }
793
794    #[tokio::test]
795    async fn listener_manager_stop_all() {
796        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
797        let mut manager = InboundListenerManager::new(cmd_tx);
798
799        manager
800            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
801            .await
802            .unwrap();
803        manager
804            .add_rule(Ipv4Addr::LOCALHOST, 0, 53, InboundProtocol::Udp)
805            .await
806            .unwrap();
807
808        manager.stop_all().await;
809        // After stop_all, the internal map should be empty. Since we can't
810        // inspect it directly, adding the same rule again should succeed (no
811        // duplicate key).
812        manager
813            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
814            .await
815            .unwrap();
816    }
817
818    #[tokio::test]
819    async fn listener_manager_rejects_duplicate_host_endpoint() {
820        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
821        let mut manager = InboundListenerManager::new(cmd_tx);
822        manager
823            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
824            .await
825            .unwrap();
826
827        let error = manager
828            .add_rule(Ipv4Addr::LOCALHOST, 0, 81, InboundProtocol::Tcp)
829            .await
830            .expect_err("the existing listener must not be reused for another destination");
831
832        assert_eq!(error.kind(), std::io::ErrorKind::AddrInUse);
833        assert_eq!(manager.listeners.len(), 1);
834    }
835
836    #[tokio::test]
837    async fn listener_remove_waits_until_socket_is_reusable() {
838        let reservation = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
839        let port = reservation.local_addr().unwrap().port();
840        drop(reservation);
841
842        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
843        let mut manager = InboundListenerManager::new(cmd_tx);
844        manager
845            .add_rule(Ipv4Addr::LOCALHOST, port, 80, InboundProtocol::Tcp)
846            .await
847            .unwrap();
848        manager
849            .remove_rule(Ipv4Addr::LOCALHOST, port, InboundProtocol::Tcp)
850            .await;
851
852        std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, port))
853            .expect("remove_rule must release the socket before returning");
854    }
855
856    #[tokio::test]
857    async fn same_port_different_ip_coexist() {
858        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
859        let mut manager = InboundListenerManager::new(cmd_tx);
860
861        // Bind the same container port on two different host IPs (port 0 = OS-assigned).
862        manager
863            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
864            .await
865            .unwrap();
866        manager
867            .add_rule(Ipv4Addr::UNSPECIFIED, 0, 80, InboundProtocol::Tcp)
868            .await
869            .unwrap();
870
871        // Remove only the localhost rule; re-adding it should succeed (not a dup).
872        manager
873            .remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp)
874            .await;
875        manager
876            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
877            .await
878            .unwrap();
879    }
880
881    #[test]
882    fn invalid_host_ip_is_rejected() {
883        // Verify that HostIp parsing used by runtime rejects non-IPv4 strings.
884        // The runtime calls `host_ip_str.parse::<Ipv4Addr>()` and skips on Err.
885        assert!(
886            "::1".parse::<Ipv4Addr>().is_err(),
887            "IPv6 should fail Ipv4Addr parse"
888        );
889        assert!("not-an-ip".parse::<Ipv4Addr>().is_err());
890        assert!("".parse::<Ipv4Addr>().is_err());
891        // Valid cases the runtime accepts:
892        assert!("127.0.0.1".parse::<Ipv4Addr>().is_ok());
893        assert!("0.0.0.0".parse::<Ipv4Addr>().is_ok());
894    }
895}