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/// Manages host-side listeners that accept incoming connections / datagrams
308/// and send `InboundCommand` messages to the datapath.
309pub struct InboundListenerManager {
310    cmd_tx: mpsc::Sender<InboundCommand>,
311    listeners: HashMap<(Ipv4Addr, u16, InboundProtocol), (JoinHandle<()>, CancellationToken)>,
312}
313
314impl InboundListenerManager {
315    /// Creates a new listener manager.
316    #[must_use]
317    pub fn new(cmd_tx: mpsc::Sender<InboundCommand>) -> Self {
318        Self {
319            cmd_tx,
320            listeners: HashMap::new(),
321        }
322    }
323
324    /// Adds a forwarding rule and spawns a listener task.
325    ///
326    /// # Errors
327    ///
328    /// Returns an error if the listener cannot bind.
329    pub async fn add_rule(
330        &mut self,
331        host_ip: Ipv4Addr,
332        host_port: u16,
333        container_port: u16,
334        protocol: InboundProtocol,
335    ) -> std::io::Result<()> {
336        let key = (host_ip, host_port, protocol);
337        if self.listeners.contains_key(&key) {
338            return Ok(()); // Already listening.
339        }
340
341        let cancel = CancellationToken::new();
342        let cmd_tx = self.cmd_tx.clone();
343
344        let handle = match protocol {
345            InboundProtocol::Tcp => {
346                let listener =
347                    TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(host_ip, host_port)))
348                        .await?;
349                tracing::info!(
350                    "Inbound listener: TCP {}:{} → container :{}",
351                    host_ip,
352                    host_port,
353                    container_port,
354                );
355                let cancel_clone = cancel.clone();
356                tokio::spawn(async move {
357                    tcp_listener_task(listener, container_port, cmd_tx, cancel_clone).await;
358                })
359            }
360            InboundProtocol::Udp => {
361                let socket =
362                    UdpSocket::bind(SocketAddr::V4(SocketAddrV4::new(host_ip, host_port))).await?;
363                tracing::info!(
364                    "Inbound listener: UDP {}:{} → container :{}",
365                    host_ip,
366                    host_port,
367                    container_port,
368                );
369                let cancel_clone = cancel.clone();
370                tokio::spawn(async move {
371                    udp_listener_task(socket, container_port, cmd_tx, cancel_clone).await;
372                })
373            }
374        };
375
376        self.listeners.insert(key, (handle, cancel));
377        Ok(())
378    }
379
380    /// Removes a forwarding rule and stops its listener.
381    pub fn remove_rule(&mut self, host_ip: Ipv4Addr, host_port: u16, protocol: InboundProtocol) {
382        let key = (host_ip, host_port, protocol);
383        if let Some((handle, cancel)) = self.listeners.remove(&key) {
384            cancel.cancel();
385            handle.abort();
386            tracing::debug!(
387                "Inbound listener removed: {:?} {}:{}",
388                protocol,
389                host_ip,
390                host_port
391            );
392        }
393    }
394
395    /// Stops all listeners.
396    pub fn stop_all(&mut self) {
397        for ((ip, port, proto), (handle, cancel)) in self.listeners.drain() {
398            cancel.cancel();
399            handle.abort();
400            tracing::debug!("Inbound listener stopped: {:?} {}:{}", proto, ip, port);
401        }
402    }
403}
404
405// ---------------------------------------------------------------------------
406// Listener tasks
407// ---------------------------------------------------------------------------
408
409/// TCP listener task: accepts connections and sends `InboundCommand::TcpAccepted`.
410async fn tcp_listener_task(
411    listener: TcpListener,
412    container_port: u16,
413    cmd_tx: mpsc::Sender<InboundCommand>,
414    cancel: CancellationToken,
415) {
416    let host_port = listener.local_addr().map_or(0, |a| a.port());
417    loop {
418        tokio::select! {
419            biased;
420            () = cancel.cancelled() => break,
421            result = listener.accept() => {
422                match result {
423                    Ok((stream, peer)) => {
424                        tracing::debug!(
425                            "Inbound TCP accept: {} → host:{} → container:{}",
426                            peer, host_port, container_port,
427                        );
428                        // Raise send/recv buffers so the TCP window can grow to
429                        // localhost BDP. Failures here are non-fatal — the OS
430                        // default still works, just throttles throughput.
431                        let sock = SockRef::from(&stream);
432                        if let Err(e) = sock.set_recv_buffer_size(INBOUND_TCP_BUF_SIZE) {
433                            tracing::warn!("Failed to set SO_RCVBUF on inbound stream: {e}");
434                        }
435                        if let Err(e) = sock.set_send_buffer_size(INBOUND_TCP_BUF_SIZE) {
436                            tracing::warn!("Failed to set SO_SNDBUF on inbound stream: {e}");
437                        }
438                        let cmd = InboundCommand::TcpAccepted {
439                            host_port,
440                            container_port,
441                            stream,
442                        };
443                        if cmd_tx.send(cmd).await.is_err() {
444                            break;
445                        }
446                    }
447                    Err(e) => {
448                        tracing::warn!("Inbound TCP accept error on :{}: {}", host_port, e);
449                    }
450                }
451            }
452        }
453    }
454}
455
456/// UDP listener task: receives datagrams and sends `InboundCommand::UdpReceived`.
457async fn udp_listener_task(
458    socket: UdpSocket,
459    container_port: u16,
460    cmd_tx: mpsc::Sender<InboundCommand>,
461    cancel: CancellationToken,
462) {
463    let host_port = socket.local_addr().map_or(0, |a| a.port());
464    let socket = Arc::new(socket);
465    let mut reply_flows: HashMap<SocketAddr, mpsc::Sender<Vec<u8>>> = HashMap::new();
466    let mut buf = vec![0u8; 65535];
467
468    loop {
469        tokio::select! {
470            biased;
471            () = cancel.cancelled() => break,
472            result = socket.recv_from(&mut buf) => {
473                match result {
474                    Ok((n, client_addr)) => {
475                        let reply_tx = if let Some(tx) = reply_flows.get(&client_addr) {
476                            if tx.is_closed() {
477                                reply_flows.remove(&client_addr);
478                                create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
479                            } else {
480                                tx.clone()
481                            }
482                        } else {
483                            create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
484                        };
485
486                        let cmd = InboundCommand::UdpReceived {
487                            host_port,
488                            container_port,
489                            data: buf[..n].to_vec(),
490                            reply_tx,
491                            client_addr,
492                        };
493                        if cmd_tx.send(cmd).await.is_err() {
494                            break;
495                        }
496                    }
497                    Err(e) => {
498                        tracing::warn!("Inbound UDP recv error on :{}: {}", host_port, e);
499                    }
500                }
501            }
502        }
503    }
504}
505
506fn create_udp_reply_flow(
507    client_addr: SocketAddr,
508    socket: &Arc<UdpSocket>,
509    cancel: &CancellationToken,
510    reply_flows: &mut HashMap<SocketAddr, mpsc::Sender<Vec<u8>>>,
511) -> mpsc::Sender<Vec<u8>> {
512    let (reply_tx, mut reply_rx) = mpsc::channel::<Vec<u8>>(16);
513    let reply_sock = Arc::clone(socket);
514    let flow_cancel = cancel.clone();
515    tokio::spawn(async move {
516        loop {
517            tokio::select! {
518                biased;
519                () = flow_cancel.cancelled() => break,
520                maybe_data = reply_rx.recv() => {
521                    let Some(data) = maybe_data else {
522                        break;
523                    };
524                    let _ = reply_sock.send_to(&data, client_addr).await;
525                }
526            }
527        }
528    });
529    reply_flows.insert(client_addr, reply_tx.clone());
530    reply_tx
531}
532
533// ---------------------------------------------------------------------------
534// Tests
535// ---------------------------------------------------------------------------
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    const GW_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
542    const GUEST_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 2);
543    const GW_MAC: [u8; 6] = [0x02, 0xAB, 0xCD, 0x00, 0x00, 0x01];
544    const GUEST_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x99];
545
546    #[test]
547    fn ephemeral_ports_allocation() {
548        let mut ep = EphemeralPorts::new();
549        assert_eq!(ep.allocate(), 61000);
550        assert_eq!(ep.allocate(), 61001);
551    }
552
553    #[test]
554    fn ephemeral_ports_wrap_around() {
555        let mut ep = EphemeralPorts::new();
556        ep.next = EPHEMERAL_END;
557        assert_eq!(ep.allocate(), EPHEMERAL_END);
558        assert_eq!(ep.allocate(), EPHEMERAL_START);
559    }
560
561    #[test]
562    fn ephemeral_ports_in_range() {
563        assert!(EphemeralPorts::in_range(61000));
564        assert!(EphemeralPorts::in_range(65535));
565        assert!(EphemeralPorts::in_range(63000));
566        assert!(!EphemeralPorts::in_range(60999));
567        assert!(!EphemeralPorts::in_range(32768));
568        assert!(!EphemeralPorts::in_range(80));
569    }
570
571    #[test]
572    fn inbound_relay_rejects_non_ephemeral() {
573        let (tx, _rx) = mpsc::channel(16);
574        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
575
576        // Build a minimal TCP frame with dst_port=80 (not in ephemeral range).
577        let mut frame = vec![0u8; ETH_HEADER_LEN + 40];
578        frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes());
579        let ip = &mut frame[ETH_HEADER_LEN..];
580        ip[0] = 0x45;
581        ip[9] = 6; // TCP
582        ip[12..16].copy_from_slice(&GUEST_IP.octets());
583        ip[16..20].copy_from_slice(&GW_IP.octets());
584        // TCP header: src_port=8080, dst_port=80
585        let tcp = &mut frame[ETH_HEADER_LEN + 20..];
586        tcp[0..2].copy_from_slice(&8080u16.to_be_bytes());
587        tcp[2..4].copy_from_slice(&80u16.to_be_bytes());
588        tcp[12] = 0x50; // data offset = 5
589
590        assert!(!relay.try_handle_reply(&frame, GUEST_MAC));
591    }
592
593    #[tokio::test]
594    async fn inject_udp_sends_frame_and_tracks_flow() {
595        let (tx, mut rx) = mpsc::channel(16);
596        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
597
598        let (client_tx, _client_rx) = mpsc::channel(16);
599        relay.inject_udp(53, b"dns query", client_tx, GUEST_MAC);
600
601        // Flow should be tracked.
602        let key = (GW_IP, EPHEMERAL_START, GUEST_IP, 53);
603        assert!(relay.udp_flows.contains_key(&key));
604
605        // A UDP frame should have been sent.
606        let frame = rx.recv().await.expect("should receive UDP frame");
607        assert!(frame.len() >= ETH_HEADER_LEN + 28, "UDP frame too short");
608
609        // Verify IP protocol = UDP (17).
610        assert_eq!(frame[ETH_HEADER_LEN + 9], 17);
611
612        // Verify ports.
613        let udp_start = ETH_HEADER_LEN + 20;
614        let src_port = u16::from_be_bytes([frame[udp_start], frame[udp_start + 1]]);
615        let dst_port = u16::from_be_bytes([frame[udp_start + 2], frame[udp_start + 3]]);
616        assert_eq!(src_port, EPHEMERAL_START);
617        assert_eq!(dst_port, 53);
618    }
619
620    #[test]
621    fn cleanup_removes_expired_udp_flows() {
622        let (tx, _rx) = mpsc::channel(16);
623        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP, 1500);
624
625        let (client_tx, _client_rx) = mpsc::channel(16);
626        let key = (GW_IP, 61000, GUEST_IP, 53);
627        relay.udp_flows.insert(
628            key,
629            InboundUdpFlow {
630                client_tx,
631                last_active: Instant::now()
632                    .checked_sub(std::time::Duration::from_secs(120))
633                    .unwrap(),
634            },
635        );
636        assert_eq!(relay.udp_flows.len(), 1);
637
638        relay.cleanup();
639        assert_eq!(
640            relay.udp_flows.len(),
641            0,
642            "expired UDP flow should be removed"
643        );
644    }
645
646    #[tokio::test]
647    async fn listener_manager_add_and_remove_rule() {
648        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
649        let mut manager = InboundListenerManager::new(cmd_tx);
650
651        // Add a TCP rule on an ephemeral port.
652        manager
653            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
654            .await
655            .expect("should bind to port 0 (OS-assigned)");
656
657        // Remove it.
658        manager.remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp);
659
660        // The cmd_rx channel should still be valid (no panic).
661        assert!(cmd_rx.try_recv().is_err(), "no commands expected yet");
662    }
663
664    #[tokio::test]
665    async fn listener_manager_stop_all() {
666        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
667        let mut manager = InboundListenerManager::new(cmd_tx);
668
669        manager
670            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
671            .await
672            .unwrap();
673        manager
674            .add_rule(Ipv4Addr::LOCALHOST, 0, 53, InboundProtocol::Udp)
675            .await
676            .unwrap();
677
678        manager.stop_all();
679        // After stop_all, the internal map should be empty. Since we can't
680        // inspect it directly, adding the same rule again should succeed (no
681        // duplicate key).
682        manager
683            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
684            .await
685            .unwrap();
686    }
687
688    #[tokio::test]
689    async fn same_port_different_ip_coexist() {
690        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
691        let mut manager = InboundListenerManager::new(cmd_tx);
692
693        // Bind the same container port on two different host IPs (port 0 = OS-assigned).
694        manager
695            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
696            .await
697            .unwrap();
698        manager
699            .add_rule(Ipv4Addr::UNSPECIFIED, 0, 80, InboundProtocol::Tcp)
700            .await
701            .unwrap();
702
703        // Remove only the localhost rule; re-adding it should succeed (not a dup).
704        manager.remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp);
705        manager
706            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
707            .await
708            .unwrap();
709    }
710
711    #[test]
712    fn invalid_host_ip_is_rejected() {
713        // Verify that HostIp parsing used by runtime rejects non-IPv4 strings.
714        // The runtime calls `host_ip_str.parse::<Ipv4Addr>()` and skips on Err.
715        assert!(
716            "::1".parse::<Ipv4Addr>().is_err(),
717            "IPv6 should fail Ipv4Addr parse"
718        );
719        assert!("not-an-ip".parse::<Ipv4Addr>().is_err());
720        assert!("".parse::<Ipv4Addr>().is_err());
721        // Valid cases the runtime accepts:
722        assert!("127.0.0.1".parse::<Ipv4Addr>().is_ok());
723        assert!("0.0.0.0".parse::<Ipv4Addr>().is_ok());
724    }
725}