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    ephemeral_ports: EphemeralPorts,
140}
141
142impl InboundRelay {
143    pub(crate) fn new(
144        reply_tx: mpsc::Sender<Vec<u8>>,
145        gateway_mac: [u8; 6],
146        gateway_ip: Ipv4Addr,
147        guest_ip: Ipv4Addr,
148    ) -> Self {
149        Self {
150            udp_flows: HashMap::new(),
151            reply_tx,
152            gateway_mac,
153            gateway_ip,
154            guest_ip,
155            ephemeral_ports: EphemeralPorts::new(),
156        }
157    }
158
159    // -----------------------------------------------------------------------
160    // Frame matching — called on every outbound guest frame
161    // -----------------------------------------------------------------------
162
163    /// Attempts to match an outbound guest frame as a reply to an inbound
164    /// connection. Returns `true` if the frame was consumed.
165    ///
166    /// Fast-path: `EphemeralPorts::in_range(dst_port)` rejects 99%+ of
167    /// outbound frames before any `HashMap` lookup.
168    pub(crate) fn try_handle_reply(&mut self, frame: &[u8], _guest_mac: [u8; 6]) -> bool {
169        if frame.len() < ETH_HEADER_LEN + 20 {
170            return false;
171        }
172
173        let ip_start = ETH_HEADER_LEN;
174        let protocol = frame[ip_start + 9];
175
176        let ihl = ((frame[ip_start] & 0x0F) as usize) * 4;
177        let l4_start = ip_start + ihl;
178
179        match protocol {
180            6 => false, // TCP is handled by TcpBridge, not the inbound relay
181            17 => self.try_handle_udp_reply(frame, ip_start, l4_start),
182            _ => false,
183        }
184    }
185
186    /// Checks if a UDP frame is a reply to an inbound flow.
187    fn try_handle_udp_reply(&mut self, frame: &[u8], ip_start: usize, udp_start: usize) -> bool {
188        if frame.len() < udp_start + 8 {
189            return false;
190        }
191
192        let dst_port = u16::from_be_bytes([frame[udp_start + 2], frame[udp_start + 3]]);
193        if !EphemeralPorts::in_range(dst_port) {
194            return false;
195        }
196
197        let src_ip = Ipv4Addr::new(
198            frame[ip_start + 12],
199            frame[ip_start + 13],
200            frame[ip_start + 14],
201            frame[ip_start + 15],
202        );
203        let dst_ip = Ipv4Addr::new(
204            frame[ip_start + 16],
205            frame[ip_start + 17],
206            frame[ip_start + 18],
207            frame[ip_start + 19],
208        );
209        let src_port = u16::from_be_bytes([frame[udp_start], frame[udp_start + 1]]);
210
211        let key = (dst_ip, dst_port, src_ip, src_port);
212
213        if let Some(flow) = self.udp_flows.get_mut(&key) {
214            let udp_len = u16::from_be_bytes([frame[udp_start + 4], frame[udp_start + 5]]) as usize;
215            if udp_len >= 8 && udp_start + udp_len <= frame.len() {
216                let payload = frame[udp_start + 8..udp_start + udp_len].to_vec();
217                flow.last_active = Instant::now();
218                let _ = flow.client_tx.try_send(payload);
219            }
220            return true;
221        }
222
223        false
224    }
225
226    // -----------------------------------------------------------------------
227    // UDP: inject datagram to guest
228    // -----------------------------------------------------------------------
229
230    /// Injects a UDP datagram to the guest and sets up a flow for replies.
231    pub(crate) fn inject_udp(
232        &mut self,
233        container_port: u16,
234        data: &[u8],
235        client_tx: mpsc::Sender<Vec<u8>>,
236        guest_mac: [u8; 6],
237    ) {
238        let ephemeral_port = self.ephemeral_ports.allocate();
239        let key = (
240            self.gateway_ip,
241            ephemeral_port,
242            self.guest_ip,
243            container_port,
244        );
245
246        self.udp_flows.insert(
247            key,
248            InboundUdpFlow {
249                client_tx,
250                last_active: Instant::now(),
251            },
252        );
253
254        let frame = build_udp_ip_ethernet(
255            self.gateway_ip,
256            self.guest_ip,
257            ephemeral_port,
258            container_port,
259            data,
260            self.gateway_mac,
261            guest_mac,
262        );
263
264        let _ = self.reply_tx.try_send(frame);
265
266        tracing::debug!(
267            "Inbound UDP: injected {} bytes  gw:{} → guest:{}",
268            data.len(),
269            ephemeral_port,
270            container_port,
271        );
272    }
273
274    // -----------------------------------------------------------------------
275    // Maintenance
276    // -----------------------------------------------------------------------
277
278    /// Removes expired UDP flows.
279    pub(crate) fn cleanup(&mut self) {
280        let now = Instant::now();
281        self.udp_flows
282            .retain(|_, flow| now.duration_since(flow.last_active).as_secs() < 60);
283    }
284}
285
286// ---------------------------------------------------------------------------
287// InboundListenerManager
288// ---------------------------------------------------------------------------
289
290/// Protocol for port forwarding rules.
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
292pub enum InboundProtocol {
293    Tcp,
294    Udp,
295}
296
297/// Manages host-side listeners that accept incoming connections / datagrams
298/// and send `InboundCommand` messages to the datapath.
299pub struct InboundListenerManager {
300    cmd_tx: mpsc::Sender<InboundCommand>,
301    listeners: HashMap<(Ipv4Addr, u16, InboundProtocol), (JoinHandle<()>, CancellationToken)>,
302}
303
304impl InboundListenerManager {
305    /// Creates a new listener manager.
306    #[must_use]
307    pub fn new(cmd_tx: mpsc::Sender<InboundCommand>) -> Self {
308        Self {
309            cmd_tx,
310            listeners: HashMap::new(),
311        }
312    }
313
314    /// Adds a forwarding rule and spawns a listener task.
315    ///
316    /// # Errors
317    ///
318    /// Returns an error if the listener cannot bind.
319    pub async fn add_rule(
320        &mut self,
321        host_ip: Ipv4Addr,
322        host_port: u16,
323        container_port: u16,
324        protocol: InboundProtocol,
325    ) -> std::io::Result<()> {
326        let key = (host_ip, host_port, protocol);
327        if self.listeners.contains_key(&key) {
328            return Ok(()); // Already listening.
329        }
330
331        let cancel = CancellationToken::new();
332        let cmd_tx = self.cmd_tx.clone();
333
334        let handle = match protocol {
335            InboundProtocol::Tcp => {
336                let listener =
337                    TcpListener::bind(SocketAddr::V4(SocketAddrV4::new(host_ip, host_port)))
338                        .await?;
339                tracing::info!(
340                    "Inbound listener: TCP {}:{} → container :{}",
341                    host_ip,
342                    host_port,
343                    container_port,
344                );
345                let cancel_clone = cancel.clone();
346                tokio::spawn(async move {
347                    tcp_listener_task(listener, container_port, cmd_tx, cancel_clone).await;
348                })
349            }
350            InboundProtocol::Udp => {
351                let socket =
352                    UdpSocket::bind(SocketAddr::V4(SocketAddrV4::new(host_ip, host_port))).await?;
353                tracing::info!(
354                    "Inbound listener: UDP {}:{} → container :{}",
355                    host_ip,
356                    host_port,
357                    container_port,
358                );
359                let cancel_clone = cancel.clone();
360                tokio::spawn(async move {
361                    udp_listener_task(socket, container_port, cmd_tx, cancel_clone).await;
362                })
363            }
364        };
365
366        self.listeners.insert(key, (handle, cancel));
367        Ok(())
368    }
369
370    /// Removes a forwarding rule and stops its listener.
371    pub fn remove_rule(&mut self, host_ip: Ipv4Addr, host_port: u16, protocol: InboundProtocol) {
372        let key = (host_ip, host_port, protocol);
373        if let Some((handle, cancel)) = self.listeners.remove(&key) {
374            cancel.cancel();
375            handle.abort();
376            tracing::debug!(
377                "Inbound listener removed: {:?} {}:{}",
378                protocol,
379                host_ip,
380                host_port
381            );
382        }
383    }
384
385    /// Stops all listeners.
386    pub fn stop_all(&mut self) {
387        for ((ip, port, proto), (handle, cancel)) in self.listeners.drain() {
388            cancel.cancel();
389            handle.abort();
390            tracing::debug!("Inbound listener stopped: {:?} {}:{}", proto, ip, port);
391        }
392    }
393}
394
395// ---------------------------------------------------------------------------
396// Listener tasks
397// ---------------------------------------------------------------------------
398
399/// TCP listener task: accepts connections and sends `InboundCommand::TcpAccepted`.
400async fn tcp_listener_task(
401    listener: TcpListener,
402    container_port: u16,
403    cmd_tx: mpsc::Sender<InboundCommand>,
404    cancel: CancellationToken,
405) {
406    let host_port = listener.local_addr().map_or(0, |a| a.port());
407    loop {
408        tokio::select! {
409            biased;
410            () = cancel.cancelled() => break,
411            result = listener.accept() => {
412                match result {
413                    Ok((stream, peer)) => {
414                        tracing::debug!(
415                            "Inbound TCP accept: {} → host:{} → container:{}",
416                            peer, host_port, container_port,
417                        );
418                        // Raise send/recv buffers so the TCP window can grow to
419                        // localhost BDP. Failures here are non-fatal — the OS
420                        // default still works, just throttles throughput.
421                        let sock = SockRef::from(&stream);
422                        if let Err(e) = sock.set_recv_buffer_size(INBOUND_TCP_BUF_SIZE) {
423                            tracing::warn!("Failed to set SO_RCVBUF on inbound stream: {e}");
424                        }
425                        if let Err(e) = sock.set_send_buffer_size(INBOUND_TCP_BUF_SIZE) {
426                            tracing::warn!("Failed to set SO_SNDBUF on inbound stream: {e}");
427                        }
428                        let cmd = InboundCommand::TcpAccepted {
429                            host_port,
430                            container_port,
431                            stream,
432                        };
433                        if cmd_tx.send(cmd).await.is_err() {
434                            break;
435                        }
436                    }
437                    Err(e) => {
438                        tracing::warn!("Inbound TCP accept error on :{}: {}", host_port, e);
439                    }
440                }
441            }
442        }
443    }
444}
445
446/// UDP listener task: receives datagrams and sends `InboundCommand::UdpReceived`.
447async fn udp_listener_task(
448    socket: UdpSocket,
449    container_port: u16,
450    cmd_tx: mpsc::Sender<InboundCommand>,
451    cancel: CancellationToken,
452) {
453    let host_port = socket.local_addr().map_or(0, |a| a.port());
454    let socket = Arc::new(socket);
455    let mut reply_flows: HashMap<SocketAddr, mpsc::Sender<Vec<u8>>> = HashMap::new();
456    let mut buf = vec![0u8; 65535];
457
458    loop {
459        tokio::select! {
460            biased;
461            () = cancel.cancelled() => break,
462            result = socket.recv_from(&mut buf) => {
463                match result {
464                    Ok((n, client_addr)) => {
465                        let reply_tx = if let Some(tx) = reply_flows.get(&client_addr) {
466                            if tx.is_closed() {
467                                reply_flows.remove(&client_addr);
468                                create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
469                            } else {
470                                tx.clone()
471                            }
472                        } else {
473                            create_udp_reply_flow(client_addr, &socket, &cancel, &mut reply_flows)
474                        };
475
476                        let cmd = InboundCommand::UdpReceived {
477                            host_port,
478                            container_port,
479                            data: buf[..n].to_vec(),
480                            reply_tx,
481                            client_addr,
482                        };
483                        if cmd_tx.send(cmd).await.is_err() {
484                            break;
485                        }
486                    }
487                    Err(e) => {
488                        tracing::warn!("Inbound UDP recv error on :{}: {}", host_port, e);
489                    }
490                }
491            }
492        }
493    }
494}
495
496fn create_udp_reply_flow(
497    client_addr: SocketAddr,
498    socket: &Arc<UdpSocket>,
499    cancel: &CancellationToken,
500    reply_flows: &mut HashMap<SocketAddr, mpsc::Sender<Vec<u8>>>,
501) -> mpsc::Sender<Vec<u8>> {
502    let (reply_tx, mut reply_rx) = mpsc::channel::<Vec<u8>>(16);
503    let reply_sock = Arc::clone(socket);
504    let flow_cancel = cancel.clone();
505    tokio::spawn(async move {
506        loop {
507            tokio::select! {
508                biased;
509                () = flow_cancel.cancelled() => break,
510                maybe_data = reply_rx.recv() => {
511                    let Some(data) = maybe_data else {
512                        break;
513                    };
514                    let _ = reply_sock.send_to(&data, client_addr).await;
515                }
516            }
517        }
518    });
519    reply_flows.insert(client_addr, reply_tx.clone());
520    reply_tx
521}
522
523// ---------------------------------------------------------------------------
524// Tests
525// ---------------------------------------------------------------------------
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    const GW_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 1);
532    const GUEST_IP: Ipv4Addr = Ipv4Addr::new(192, 168, 64, 2);
533    const GW_MAC: [u8; 6] = [0x02, 0xAB, 0xCD, 0x00, 0x00, 0x01];
534    const GUEST_MAC: [u8; 6] = [0x02, 0x00, 0x00, 0x00, 0x00, 0x99];
535
536    #[test]
537    fn ephemeral_ports_allocation() {
538        let mut ep = EphemeralPorts::new();
539        assert_eq!(ep.allocate(), 61000);
540        assert_eq!(ep.allocate(), 61001);
541    }
542
543    #[test]
544    fn ephemeral_ports_wrap_around() {
545        let mut ep = EphemeralPorts::new();
546        ep.next = EPHEMERAL_END;
547        assert_eq!(ep.allocate(), EPHEMERAL_END);
548        assert_eq!(ep.allocate(), EPHEMERAL_START);
549    }
550
551    #[test]
552    fn ephemeral_ports_in_range() {
553        assert!(EphemeralPorts::in_range(61000));
554        assert!(EphemeralPorts::in_range(65535));
555        assert!(EphemeralPorts::in_range(63000));
556        assert!(!EphemeralPorts::in_range(60999));
557        assert!(!EphemeralPorts::in_range(32768));
558        assert!(!EphemeralPorts::in_range(80));
559    }
560
561    #[test]
562    fn inbound_relay_rejects_non_ephemeral() {
563        let (tx, _rx) = mpsc::channel(16);
564        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP);
565
566        // Build a minimal TCP frame with dst_port=80 (not in ephemeral range).
567        let mut frame = vec![0u8; ETH_HEADER_LEN + 40];
568        frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes());
569        let ip = &mut frame[ETH_HEADER_LEN..];
570        ip[0] = 0x45;
571        ip[9] = 6; // TCP
572        ip[12..16].copy_from_slice(&GUEST_IP.octets());
573        ip[16..20].copy_from_slice(&GW_IP.octets());
574        // TCP header: src_port=8080, dst_port=80
575        let tcp = &mut frame[ETH_HEADER_LEN + 20..];
576        tcp[0..2].copy_from_slice(&8080u16.to_be_bytes());
577        tcp[2..4].copy_from_slice(&80u16.to_be_bytes());
578        tcp[12] = 0x50; // data offset = 5
579
580        assert!(!relay.try_handle_reply(&frame, GUEST_MAC));
581    }
582
583    #[tokio::test]
584    async fn inject_udp_sends_frame_and_tracks_flow() {
585        let (tx, mut rx) = mpsc::channel(16);
586        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP);
587
588        let (client_tx, _client_rx) = mpsc::channel(16);
589        relay.inject_udp(53, b"dns query", client_tx, GUEST_MAC);
590
591        // Flow should be tracked.
592        let key = (GW_IP, EPHEMERAL_START, GUEST_IP, 53);
593        assert!(relay.udp_flows.contains_key(&key));
594
595        // A UDP frame should have been sent.
596        let frame = rx.recv().await.expect("should receive UDP frame");
597        assert!(frame.len() >= ETH_HEADER_LEN + 28, "UDP frame too short");
598
599        // Verify IP protocol = UDP (17).
600        assert_eq!(frame[ETH_HEADER_LEN + 9], 17);
601
602        // Verify ports.
603        let udp_start = ETH_HEADER_LEN + 20;
604        let src_port = u16::from_be_bytes([frame[udp_start], frame[udp_start + 1]]);
605        let dst_port = u16::from_be_bytes([frame[udp_start + 2], frame[udp_start + 3]]);
606        assert_eq!(src_port, EPHEMERAL_START);
607        assert_eq!(dst_port, 53);
608    }
609
610    #[test]
611    fn cleanup_removes_expired_udp_flows() {
612        let (tx, _rx) = mpsc::channel(16);
613        let mut relay = InboundRelay::new(tx, GW_MAC, GW_IP, GUEST_IP);
614
615        let (client_tx, _client_rx) = mpsc::channel(16);
616        let key = (GW_IP, 61000, GUEST_IP, 53);
617        relay.udp_flows.insert(
618            key,
619            InboundUdpFlow {
620                client_tx,
621                last_active: Instant::now()
622                    .checked_sub(std::time::Duration::from_secs(120))
623                    .unwrap(),
624            },
625        );
626        assert_eq!(relay.udp_flows.len(), 1);
627
628        relay.cleanup();
629        assert_eq!(
630            relay.udp_flows.len(),
631            0,
632            "expired UDP flow should be removed"
633        );
634    }
635
636    #[tokio::test]
637    async fn listener_manager_add_and_remove_rule() {
638        let (cmd_tx, mut cmd_rx) = mpsc::channel(16);
639        let mut manager = InboundListenerManager::new(cmd_tx);
640
641        // Add a TCP rule on an ephemeral port.
642        manager
643            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
644            .await
645            .expect("should bind to port 0 (OS-assigned)");
646
647        // Remove it.
648        manager.remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp);
649
650        // The cmd_rx channel should still be valid (no panic).
651        assert!(cmd_rx.try_recv().is_err(), "no commands expected yet");
652    }
653
654    #[tokio::test]
655    async fn listener_manager_stop_all() {
656        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
657        let mut manager = InboundListenerManager::new(cmd_tx);
658
659        manager
660            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
661            .await
662            .unwrap();
663        manager
664            .add_rule(Ipv4Addr::LOCALHOST, 0, 53, InboundProtocol::Udp)
665            .await
666            .unwrap();
667
668        manager.stop_all();
669        // After stop_all, the internal map should be empty. Since we can't
670        // inspect it directly, adding the same rule again should succeed (no
671        // duplicate key).
672        manager
673            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
674            .await
675            .unwrap();
676    }
677
678    #[tokio::test]
679    async fn same_port_different_ip_coexist() {
680        let (cmd_tx, _cmd_rx) = mpsc::channel(16);
681        let mut manager = InboundListenerManager::new(cmd_tx);
682
683        // Bind the same container port on two different host IPs (port 0 = OS-assigned).
684        manager
685            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
686            .await
687            .unwrap();
688        manager
689            .add_rule(Ipv4Addr::UNSPECIFIED, 0, 80, InboundProtocol::Tcp)
690            .await
691            .unwrap();
692
693        // Remove only the localhost rule; re-adding it should succeed (not a dup).
694        manager.remove_rule(Ipv4Addr::LOCALHOST, 0, InboundProtocol::Tcp);
695        manager
696            .add_rule(Ipv4Addr::LOCALHOST, 0, 80, InboundProtocol::Tcp)
697            .await
698            .unwrap();
699    }
700
701    #[test]
702    fn invalid_host_ip_is_rejected() {
703        // Verify that HostIp parsing used by runtime rejects non-IPv4 strings.
704        // The runtime calls `host_ip_str.parse::<Ipv4Addr>()` and skips on Err.
705        assert!(
706            "::1".parse::<Ipv4Addr>().is_err(),
707            "IPv6 should fail Ipv4Addr parse"
708        );
709        assert!("not-an-ip".parse::<Ipv4Addr>().is_err());
710        assert!("".parse::<Ipv4Addr>().is_err());
711        // Valid cases the runtime accepts:
712        assert!("127.0.0.1".parse::<Ipv4Addr>().is_ok());
713        assert!("0.0.0.0".parse::<Ipv4Addr>().is_ok());
714    }
715}