Skip to main content

a3s_box_netproxy/
lib.rs

1#![cfg(target_os = "macos")]
2
3//! Pure-Rust userspace network proxy for libkrun on macOS.
4//!
5//! Uses a Unix datagram `socketpair()` to connect libkrun's virtio-net backend
6//! to the userspace gateway and provides the gateway services needed by the guest:
7//!
8//! - **ARP**: handled automatically by smoltcp's interface layer.
9//! - **DNS**: UDP/53 queries forwarded to the host's configured DNS servers.
10//! - **Inbound TCP port-forwarding**: `host_port → guest_ip:guest_port` pairs
11//!   parsed from the box's `port_map` config (e.g. `"8088:80"`).
12//!
13//! General outbound NAT (VM → internet) is not provided by bridge mode yet.
14
15use std::collections::VecDeque;
16use std::io::{self, Read, Write};
17use std::net::{Ipv4Addr, SocketAddrV4, TcpListener, TcpStream, UdpSocket};
18use std::os::fd::{FromRawFd, IntoRawFd, RawFd};
19use std::os::unix::net::UnixDatagram;
20use std::path::{Path, PathBuf};
21use std::sync::{
22    atomic::{AtomicBool, AtomicU64, Ordering},
23    Arc,
24};
25use std::time::Duration;
26
27use a3s_box_core::error::{BoxError, Result};
28use smoltcp::iface::{Config, Interface, SocketSet};
29use smoltcp::socket::{tcp, udp};
30use smoltcp::time::Instant;
31use smoltcp::wire::{EthernetAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address};
32
33// ── Helpers ───────────────────────────────────────────────────────────────────
34
35/// Returns the current time as a smoltcp `Instant` (microseconds since Unix epoch).
36fn smoltcp_now() -> Instant {
37    use std::time::{SystemTime, UNIX_EPOCH};
38    let us = SystemTime::now()
39        .duration_since(UNIX_EPOCH)
40        .unwrap_or_default()
41        .as_micros() as i64;
42    Instant::from_micros(us)
43}
44
45/// Convert `std::net::Ipv4Addr` to `smoltcp::wire::Ipv4Address`.
46fn to_smoltcp_ipv4(ip: Ipv4Addr) -> Ipv4Address {
47    Ipv4Address::from(ip)
48}
49
50// ── Constants ─────────────────────────────────────────────────────────────────
51
52/// MAC address we assign to the virtual gateway interface.
53const GATEWAY_MAC: EthernetAddress = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
54/// Maximum Ethernet frame size (header + MTU).
55const MAX_FRAME: usize = 1514;
56/// Ephemeral port range start for outbound TCP connections from the gateway.
57const EPHEMERAL_BASE: u16 = 49152;
58/// How often the proxy refreshes its stats file.
59const STATS_WRITE_INTERVAL: Duration = Duration::from_secs(1);
60
61#[derive(Default)]
62struct NetStats {
63    rx_bytes: AtomicU64,
64    tx_bytes: AtomicU64,
65    rx_packets: AtomicU64,
66    tx_packets: AtomicU64,
67}
68
69struct NetStatsSnapshot {
70    rx_bytes: u64,
71    tx_bytes: u64,
72    rx_packets: u64,
73    tx_packets: u64,
74}
75
76impl NetStats {
77    fn record_rx(&self, bytes: usize) {
78        self.rx_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
79        self.rx_packets.fetch_add(1, Ordering::Relaxed);
80    }
81
82    fn record_tx(&self, bytes: usize) {
83        self.tx_bytes.fetch_add(bytes as u64, Ordering::Relaxed);
84        self.tx_packets.fetch_add(1, Ordering::Relaxed);
85    }
86
87    fn snapshot(&self) -> NetStatsSnapshot {
88        NetStatsSnapshot {
89            rx_bytes: self.rx_bytes.load(Ordering::Relaxed),
90            tx_bytes: self.tx_bytes.load(Ordering::Relaxed),
91            rx_packets: self.rx_packets.load(Ordering::Relaxed),
92            tx_packets: self.tx_packets.load(Ordering::Relaxed),
93        }
94    }
95}
96
97// ── smoltcp phy::Device ───────────────────────────────────────────────────────
98
99/// smoltcp physical-layer device backed by a connected Unix datagram socket.
100///
101/// Frames from the VM arrive via `recv()` and are queued in `rx_queue`.
102/// smoltcp reads them through `receive()`. Frames smoltcp wants to transmit
103/// are sent directly to the peer via `transmit()` / `TxToken::consume()`.
104///
105/// The socket MUST be connected to the peer (via `UnixDatagram::connect`) before
106/// use so that `send()` works without a destination address. On macOS, using
107/// `send_to()` on a socket whose peer has called `connect()` to us causes
108/// ECONNRESET / EDESTADDRREQ in the peer's receive path.
109struct UnixgramDevice {
110    socket: UnixDatagram,
111    rx_queue: VecDeque<Vec<u8>>,
112    stats: Arc<NetStats>,
113}
114
115impl UnixgramDevice {
116    /// Drain the socket into `rx_queue` (non-blocking, batch up to 64 frames).
117    fn drain(&mut self) {
118        let mut buf = vec![0u8; MAX_FRAME];
119        for _ in 0..64 {
120            match self.socket.recv(&mut buf) {
121                Ok(n) => {
122                    tracing::trace!(
123                        bytes = n,
124                        "NetProxy received ethernet frame from guest/libkrun"
125                    );
126                    self.stats.record_tx(n);
127                    self.rx_queue.push_back(buf[..n].to_vec())
128                }
129                Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
130                Err(e) => {
131                    tracing::warn!(error = %e, "NetProxy: recv from libkrun failed");
132                    break;
133                }
134            }
135        }
136    }
137}
138
139/// Owned received frame — consumed by smoltcp's interface layer.
140struct OwnedRxToken(Vec<u8>);
141
142impl smoltcp::phy::RxToken for OwnedRxToken {
143    fn consume<R, F>(mut self, f: F) -> R
144    where
145        F: FnOnce(&mut [u8]) -> R,
146    {
147        f(&mut self.0)
148    }
149}
150
151/// Transmit token — smoltcp writes a frame into `buf`, which we then send.
152///
153/// The socket must already be connected to the peer so `send()` works without
154/// an explicit destination address.
155struct TxToken {
156    socket: UnixDatagram,
157    stats: Arc<NetStats>,
158}
159
160impl smoltcp::phy::TxToken for TxToken {
161    fn consume<R, F>(self, len: usize, f: F) -> R
162    where
163        F: FnOnce(&mut [u8]) -> R,
164    {
165        let mut buf = vec![0u8; len];
166        let result = f(&mut buf);
167        tracing::trace!(
168            bytes = len,
169            "NetProxy sending ethernet frame to guest/libkrun"
170        );
171        if let Err(e) = self.socket.send(&buf) {
172            tracing::warn!(error = %e, len, "NetProxy: send to libkrun failed");
173        } else {
174            self.stats.record_rx(len);
175        }
176        result
177    }
178}
179
180impl smoltcp::phy::Device for UnixgramDevice {
181    type RxToken<'a>
182        = OwnedRxToken
183    where
184        Self: 'a;
185    type TxToken<'a>
186        = TxToken
187    where
188        Self: 'a;
189
190    fn receive(&mut self, _ts: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
191        let frame = self.rx_queue.pop_front()?;
192        let tx = TxToken {
193            socket: self.socket.try_clone().ok()?,
194            stats: Arc::clone(&self.stats),
195        };
196        Some((OwnedRxToken(frame), tx))
197    }
198
199    fn transmit(&mut self, _ts: Instant) -> Option<Self::TxToken<'_>> {
200        Some(TxToken {
201            socket: self.socket.try_clone().ok()?,
202            stats: Arc::clone(&self.stats),
203        })
204    }
205
206    fn capabilities(&self) -> smoltcp::phy::DeviceCapabilities {
207        let mut caps = smoltcp::phy::DeviceCapabilities::default();
208        caps.medium = smoltcp::phy::Medium::Ethernet;
209        caps.max_transmission_unit = MAX_FRAME;
210        caps
211    }
212}
213
214// ── Port-forward state ────────────────────────────────────────────────────────
215
216/// Parsed port-forward rule: `host_port → guest_ip:guest_port`.
217struct PortForward {
218    listener: TcpListener,
219    guest_ip: Ipv4Addr,
220    guest_port: u16,
221    /// TCP handshake in progress: (smoltcp handle, host TcpStream).
222    pending: Vec<(smoltcp::iface::SocketHandle, TcpStream)>,
223    /// Fully established connections ready for data proxying.
224    active: Vec<(smoltcp::iface::SocketHandle, TcpStream)>,
225}
226
227// ── Proxy engine ──────────────────────────────────────────────────────────────
228
229struct ProxyEngine {
230    device: UnixgramDevice,
231    iface: Interface,
232    sockets: SocketSet<'static>,
233    dns_handle: smoltcp::iface::SocketHandle,
234    dns_servers: Vec<Ipv4Addr>,
235    port_forwards: Vec<PortForward>,
236    next_ephemeral: u16,
237    shutdown: Arc<AtomicBool>,
238    stats: Arc<NetStats>,
239    stats_path: Option<PathBuf>,
240    last_stats_write: std::time::Instant,
241}
242
243impl ProxyEngine {
244    fn new(
245        socket: UnixDatagram,
246        gateway_ip: Ipv4Addr,
247        prefix_len: u8,
248        dns_servers: Vec<Ipv4Addr>,
249        port_forwards: Vec<PortForward>,
250        shutdown: Arc<AtomicBool>,
251        stats: Arc<NetStats>,
252        stats_path: Option<PathBuf>,
253    ) -> Self {
254        let mut device = UnixgramDevice {
255            socket,
256            rx_queue: VecDeque::new(),
257            stats: Arc::clone(&stats),
258        };
259
260        // Configure smoltcp interface as the gateway.
261        let config = Config::new(GATEWAY_MAC.into());
262        let mut iface = Interface::new(config, &mut device, smoltcp_now());
263        iface.update_ip_addrs(|addrs| {
264            let cidr = IpCidr::new(IpAddress::Ipv4(to_smoltcp_ipv4(gateway_ip)), prefix_len);
265            addrs.push(cidr).ok();
266        });
267
268        let mut sockets = SocketSet::new(vec![]);
269
270        // DNS socket: listens on UDP/53 on the gateway IP.
271        let dns_rx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]);
272        let dns_tx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]);
273        let mut dns_socket = udp::Socket::new(dns_rx, dns_tx);
274        dns_socket.bind(53).ok();
275        let dns_handle = sockets.add(dns_socket);
276
277        Self {
278            device,
279            iface,
280            sockets,
281            dns_handle,
282            dns_servers,
283            port_forwards,
284            next_ephemeral: EPHEMERAL_BASE,
285            shutdown,
286            stats,
287            stats_path,
288            last_stats_write: std::time::Instant::now(),
289        }
290    }
291
292    fn run(&mut self) {
293        self.write_stats_snapshot();
294        loop {
295            if self.shutdown.load(Ordering::Relaxed) {
296                break;
297            }
298
299            let now = smoltcp_now();
300
301            // 1. Drain UnixGram socket into rx_queue.
302            self.device.drain();
303
304            // 2. Accept new host connections on port-forward listeners.
305            self.accept_connections(now);
306
307            // 3. Poll smoltcp (processes ARP, TCP, UDP frames).
308            self.iface.poll(now, &mut self.device, &mut self.sockets);
309
310            // 4. Promote pending TCP connections to active once established.
311            self.promote_established();
312
313            // 5. Proxy data for active TCP connections.
314            self.proxy_data();
315
316            // 6. Forward DNS queries to real DNS servers.
317            self.forward_dns();
318
319            // 7. Remove closed connections and release their smoltcp sockets.
320            self.cleanup();
321
322            // 8. Publish resource counters for `a3s-box stats`.
323            self.maybe_write_stats_snapshot();
324
325            // 9. Sleep until the next smoltcp event or at most 5 ms.
326            let delay = self
327                .iface
328                .poll_delay(now, &self.sockets)
329                .unwrap_or(smoltcp::time::Duration::from_millis(1));
330            std::thread::sleep(Duration::from_micros(delay.micros().min(5_000)));
331        }
332        self.write_stats_snapshot();
333    }
334
335    fn maybe_write_stats_snapshot(&mut self) {
336        if self.last_stats_write.elapsed() < STATS_WRITE_INTERVAL {
337            return;
338        }
339        self.last_stats_write = std::time::Instant::now();
340        self.write_stats_snapshot();
341    }
342
343    fn write_stats_snapshot(&self) {
344        let Some(path) = self.stats_path.as_deref() else {
345            return;
346        };
347        if let Err(e) = write_stats_file(path, self.stats.snapshot()) {
348            tracing::debug!(error = %e, path = %path.display(), "NetProxy: failed to write stats file");
349        }
350    }
351
352    // ── Accept new host connections ───────────────────────────────────────────
353
354    fn accept_connections(&mut self, now: Instant) {
355        // First pass: accept connections, collect (forward_index, stream, guest_ip, guest_port).
356        // We can't call open_guest_tcp while mutably borrowing port_forwards.
357        let mut new_conns: Vec<(usize, TcpStream, Ipv4Addr, u16)> = Vec::new();
358        for (i, pf) in self.port_forwards.iter_mut().enumerate() {
359            loop {
360                match pf.listener.accept() {
361                    Ok((stream, _)) => {
362                        stream.set_nonblocking(true).ok();
363                        stream.set_nodelay(true).ok();
364                        new_conns.push((i, stream, pf.guest_ip, pf.guest_port));
365                    }
366                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
367                    Err(e) => {
368                        tracing::warn!(error = %e, "Port-forward listener error");
369                        break;
370                    }
371                }
372            }
373        }
374
375        // Second pass: open smoltcp TCP sockets and push to pending.
376        for (i, stream, guest_ip, guest_port) in new_conns {
377            let handle = self.open_guest_tcp(guest_ip, guest_port, now);
378            self.port_forwards[i].pending.push((handle, stream));
379            tracing::debug!(
380                guest = %guest_ip,
381                port = guest_port,
382                handle = ?handle,
383                "NetProxy accepted host connection and initiated guest TCP connect"
384            );
385        }
386    }
387
388    /// Create a smoltcp TCP socket and initiate a connection to the guest.
389    fn open_guest_tcp(
390        &mut self,
391        guest_ip: Ipv4Addr,
392        guest_port: u16,
393        _now: Instant,
394    ) -> smoltcp::iface::SocketHandle {
395        let rx = tcp::SocketBuffer::new(vec![0u8; 65536]);
396        let tx = tcp::SocketBuffer::new(vec![0u8; 65536]);
397        let mut socket = tcp::Socket::new(rx, tx);
398
399        let local_port = self.next_ephemeral;
400        self.next_ephemeral = self.next_ephemeral.wrapping_add(1);
401        if self.next_ephemeral < EPHEMERAL_BASE {
402            self.next_ephemeral = EPHEMERAL_BASE;
403        }
404
405        let remote = IpEndpoint::new(IpAddress::Ipv4(to_smoltcp_ipv4(guest_ip)), guest_port);
406        socket
407            .connect(self.iface.context(), remote, local_port)
408            .ok();
409        socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(30)));
410
411        self.sockets.add(socket)
412    }
413
414    // ── Promote pending → active ──────────────────────────────────────────────
415
416    fn promote_established(&mut self) {
417        for pf in &mut self.port_forwards {
418            let mut still_pending = Vec::new();
419            for (handle, stream) in pf.pending.drain(..) {
420                let socket = self.sockets.get::<tcp::Socket>(handle);
421                use smoltcp::socket::tcp::State;
422                match socket.state() {
423                    State::Established => {
424                        tracing::debug!(handle = ?handle, "NetProxy guest TCP connection established");
425                        pf.active.push((handle, stream));
426                    }
427                    State::Closed | State::TimeWait | State::CloseWait => {
428                        tracing::debug!(handle = ?handle, state = ?socket.state(), "NetProxy guest TCP connection closed before establishment");
429                        // Connection failed; close host side
430                        drop(stream);
431                        self.sockets.remove(handle);
432                    }
433                    _ => {
434                        still_pending.push((handle, stream));
435                    }
436                }
437            }
438            pf.pending = still_pending;
439        }
440    }
441
442    // ── Bidirectional data proxy ──────────────────────────────────────────────
443
444    fn proxy_data(&mut self) {
445        for pf in &mut self.port_forwards {
446            for (handle, host_stream) in &mut pf.active {
447                let socket = self.sockets.get_mut::<tcp::Socket>(*handle);
448
449                // smoltcp → host (data received from guest)
450                if socket.can_recv() {
451                    socket
452                        .recv(|data| {
453                            tracing::trace!(handle = ?*handle, bytes = data.len(), "NetProxy forwarding guest -> host bytes");
454                            let _ = host_stream.write_all(data);
455                            (data.len(), ())
456                        })
457                        .ok();
458                }
459
460                // host → smoltcp (data from host curl/client)
461                if socket.can_send() {
462                    let mut buf = [0u8; 8192];
463                    match host_stream.read(&mut buf) {
464                        Ok(0) => {
465                            tracing::debug!(handle = ?*handle, "NetProxy host side closed connection");
466                            socket.close();
467                        }
468                        Ok(n) => {
469                            tracing::trace!(handle = ?*handle, bytes = n, "NetProxy forwarding host -> guest bytes");
470                            socket.send_slice(&buf[..n]).ok();
471                        }
472                        Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
473                        Err(_) => {
474                            socket.close();
475                        }
476                    }
477                }
478            }
479        }
480    }
481
482    // ── DNS forwarding ────────────────────────────────────────────────────────
483
484    fn forward_dns(&mut self) {
485        let dns_server = match self.dns_servers.first() {
486            Some(s) => *s,
487            None => return,
488        };
489
490        let socket = self.sockets.get_mut::<udp::Socket>(self.dns_handle);
491        if !socket.can_recv() {
492            return;
493        }
494        let (query, src_endpoint) = match socket.recv() {
495            Ok(r) => r,
496            Err(_) => return,
497        };
498        let query = query.to_vec();
499        let src = src_endpoint;
500
501        // Forward query to the real DNS server via a host UDP socket.
502        match UdpSocket::bind("0.0.0.0:0") {
503            Ok(udp) => {
504                udp.set_read_timeout(Some(Duration::from_secs(2))).ok();
505                let dest = SocketAddrV4::new(dns_server, 53);
506                if udp.send_to(&query, dest).is_ok() {
507                    let mut resp = vec![0u8; 4096];
508                    if let Ok((n, _)) = udp.recv_from(&mut resp) {
509                        let socket = self.sockets.get_mut::<udp::Socket>(self.dns_handle);
510                        socket.send_slice(&resp[..n], src).ok();
511                    }
512                }
513            }
514            Err(e) => {
515                tracing::warn!(error = %e, "DNS forward: failed to bind host UDP socket");
516            }
517        }
518    }
519
520    // ── Cleanup ───────────────────────────────────────────────────────────────
521
522    fn cleanup(&mut self) {
523        use smoltcp::socket::tcp::State;
524        for pf in &mut self.port_forwards {
525            // Collect handles that need to be removed first, then remove outside retain.
526            let mut to_remove = Vec::new();
527            pf.active.retain(|(handle, _stream)| {
528                let state = self.sockets.get::<tcp::Socket>(*handle).state();
529                match state {
530                    State::Closed | State::TimeWait | State::CloseWait => {
531                        to_remove.push(*handle);
532                        false
533                    }
534                    _ => true,
535                }
536            });
537            for h in to_remove {
538                self.sockets.remove(h);
539            }
540        }
541    }
542}
543
544// ── NetProxyManager lifecycle ─────────────────────────────────────────────────
545
546/// Manages the lifecycle of the pure-Rust vfkit network proxy thread.
547///
548/// Drop calls `stop()` automatically.
549pub struct NetProxyManager {
550    socket_path: PathBuf,
551    stats_path: PathBuf,
552    net_socket_fd: Option<RawFd>,
553    net_proxy_fd: Option<RawFd>,
554}
555
556impl NetProxyManager {
557    /// Create a new manager. Socket will be placed at
558    /// `~/.a3s/boxes/<box_id>/sockets/net.sock`.
559    pub fn new(box_dir: &Path) -> Self {
560        let socket_dir = box_dir.join("sockets");
561        Self {
562            socket_path: socket_dir.join("net.sock"),
563            stats_path: socket_dir.join("net.stats.json"),
564            net_socket_fd: None,
565            net_proxy_fd: None,
566        }
567    }
568
569    pub fn socket_path(&self) -> &Path {
570        &self.socket_path
571    }
572
573    pub fn stats_path(&self) -> &Path {
574        &self.stats_path
575    }
576
577    pub fn net_socket_fd(&self) -> Option<RawFd> {
578        self.net_socket_fd
579    }
580
581    pub fn net_proxy_fd(&self) -> Option<RawFd> {
582        self.net_proxy_fd
583    }
584
585    /// Create socketpair for NetProxy.
586    ///
587    /// Unlike the name suggests, this does NOT spawn a thread. Thread spawning
588    /// happens in `spawn_inherited_netproxy()` called from the shim.
589    pub fn spawn(
590        &mut self,
591        _ip: Ipv4Addr,
592        _gateway: Ipv4Addr,
593        _prefix_len: u8,
594        _dns_servers: &[Ipv4Addr],
595        _port_map: &[String],
596    ) -> Result<()> {
597        let (proxy_socket, krun_fd) = socketpair_unixgram()?;
598        self.net_socket_fd = Some(krun_fd);
599        self.net_proxy_fd = Some(proxy_socket.into_raw_fd());
600        Ok(())
601    }
602
603    pub fn stop(&mut self) {
604        if let Some(fd) = self.net_socket_fd.take() {
605            unsafe {
606                libc::close(fd);
607            }
608        }
609        if let Some(fd) = self.net_proxy_fd.take() {
610            unsafe {
611                libc::close(fd);
612            }
613        }
614        std::fs::remove_file(&self.socket_path).ok();
615        std::fs::remove_file(&self.stats_path).ok();
616    }
617
618    pub fn is_running(&mut self) -> bool {
619        self.net_socket_fd.is_some() || self.net_proxy_fd.is_some()
620    }
621}
622
623impl Drop for NetProxyManager {
624    fn drop(&mut self) {
625        self.stop();
626    }
627}
628
629// ── Helpers ───────────────────────────────────────────────────────────────────
630
631pub fn spawn_inherited_netproxy(
632    fd: RawFd,
633    guest_ip: Ipv4Addr,
634    gateway: Ipv4Addr,
635    prefix_len: u8,
636    dns_servers: &[Ipv4Addr],
637    port_map: &[String],
638    stats_path: Option<PathBuf>,
639) -> Result<()> {
640    let socket = unsafe { UnixDatagram::from_raw_fd(fd) };
641    let port_forwards = parse_port_forwards(port_map, guest_ip)
642        .map_err(|e| BoxError::NetworkError(format!("invalid port_map: {}", e)))?;
643    let dns_servers = dns_servers.to_vec();
644    let shutdown = Arc::new(AtomicBool::new(false));
645    let stats = Arc::new(NetStats::default());
646
647    std::thread::Builder::new()
648        .name("a3s-netproxy".to_string())
649        .spawn(move || {
650            tracing::info!(fd, gateway = %gateway, guest_ip = %guest_ip, stats = ?stats_path, "NetProxy thread started");
651            if let Err(e) = socket.set_nonblocking(true) {
652                tracing::error!(error = %e, "NetProxy: set_nonblocking failed");
653                return;
654            }
655
656            let mut engine = ProxyEngine::new(
657                socket,
658                gateway,
659                prefix_len,
660                dns_servers,
661                port_forwards,
662                shutdown,
663                stats,
664                stats_path,
665            );
666            engine.run();
667            tracing::info!("NetProxy thread exiting");
668        })
669        .map_err(|e| BoxError::NetworkError(format!("failed to spawn netproxy thread: {}", e)))?;
670
671    Ok(())
672}
673
674fn socketpair_unixgram() -> Result<(UnixDatagram, RawFd)> {
675    let mut fds = [-1; 2];
676    let ret = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) };
677    if ret != 0 {
678        return Err(BoxError::NetworkError(format!(
679            "failed to create unix datagram socketpair: {}",
680            io::Error::last_os_error()
681        )));
682    }
683
684    let proxy_socket = unsafe { UnixDatagram::from_raw_fd(fds[0]) };
685    Ok((proxy_socket, fds[1]))
686}
687
688fn write_stats_file(path: &Path, stats: NetStatsSnapshot) -> io::Result<()> {
689    if let Some(parent) = path.parent() {
690        std::fs::create_dir_all(parent)?;
691    }
692    let updated_at_ms = std::time::SystemTime::now()
693        .duration_since(std::time::UNIX_EPOCH)
694        .unwrap_or_default()
695        .as_millis();
696    let body = format!(
697        "{{\"schema\":\"a3s-box.netproxy.stats.v1\",\"rx_bytes\":{},\"tx_bytes\":{},\"rx_packets\":{},\"tx_packets\":{},\"updated_at_ms\":{}}}\n",
698        stats.rx_bytes, stats.tx_bytes, stats.rx_packets, stats.tx_packets, updated_at_ms
699    );
700    let tmp = path.with_extension("json.tmp");
701    std::fs::write(&tmp, body)?;
702    std::fs::rename(tmp, path)
703}
704
705/// Parse `["8088:80", "443:443"]` into `Vec<PortForward>`.
706///
707/// Each rule maps `host_port → guest_ip:guest_port`. Guest IP is always the
708/// IPAM-assigned `guest_ip`.
709fn parse_port_forwards(
710    port_map: &[String],
711    guest_ip: Ipv4Addr,
712) -> std::result::Result<Vec<PortForward>, String> {
713    let mut forwards = Vec::new();
714    for entry in port_map {
715        let mapping = a3s_box_core::parse_port_mapping(entry)?;
716        let host_port = mapping.host_port;
717        let guest_port = mapping.guest_port;
718
719        let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, host_port))
720            .map_err(|e| format!("cannot bind 0.0.0.0:{}: {}", host_port, e))?;
721        listener
722            .set_nonblocking(true)
723            .map_err(|e| format!("set_nonblocking on listener: {}", e))?;
724
725        tracing::info!(
726            host_port,
727            guest_port,
728            guest_ip = %guest_ip,
729            "Port-forward listener ready"
730        );
731        forwards.push(PortForward {
732            listener,
733            guest_ip,
734            guest_port,
735            pending: Vec::new(),
736            active: Vec::new(),
737        });
738    }
739    Ok(forwards)
740}
741
742// ── Tests ─────────────────────────────────────────────────────────────────────
743
744#[cfg(test)]
745mod tests {
746    use super::*;
747
748    fn port_is_bindable(port: u16) -> bool {
749        TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_ok()
750    }
751
752    fn ports_are_bindable(ports: &[u16]) -> bool {
753        ports.iter().copied().all(port_is_bindable)
754    }
755
756    #[test]
757    fn test_smoltcp_now_returns_reasonable_value() {
758        let now = smoltcp_now();
759        // Should return microseconds since epoch
760        assert!(now.micros() > 0);
761    }
762
763    #[test]
764    fn test_to_smoltcp_ipv4_conversion() {
765        let ip = Ipv4Addr::new(10, 88, 0, 1);
766        let smol_ip = to_smoltcp_ipv4(ip);
767        assert_eq!(smol_ip.as_bytes(), &[10, 88, 0, 1]);
768    }
769
770    #[test]
771    fn test_to_smoltcp_ipv4_loopback() {
772        let ip = Ipv4Addr::new(127, 0, 0, 1);
773        let smol_ip = to_smoltcp_ipv4(ip);
774        assert_eq!(smol_ip.as_bytes(), &[127, 0, 0, 1]);
775    }
776
777    #[test]
778    fn test_parse_port_forwards_empty_rules() {
779        let guest = Ipv4Addr::new(10, 89, 0, 2);
780        let fwds = parse_port_forwards(&[], guest).unwrap();
781        assert!(fwds.is_empty());
782    }
783
784    #[test]
785    fn test_parse_port_forwards_rejects_udp_suffix() {
786        let guest = Ipv4Addr::new(10, 89, 0, 2);
787        let rules = vec!["19990:80/udp".to_string()];
788        let error = match parse_port_forwards(&rules, guest) {
789            Ok(_) => panic!("UDP port mapping unexpectedly succeeded"),
790            Err(error) => error,
791        };
792
793        assert!(error.contains("only TCP is supported"));
794    }
795
796    #[test]
797    fn test_parse_port_forwards_multiple_rules() {
798        let guest = Ipv4Addr::new(10, 89, 0, 2);
799        if !ports_are_bindable(&[19991, 19992, 19993]) {
800            eprintln!("skipping test: one or more host ports are not bindable");
801            return;
802        }
803        let rules = vec![
804            "19991:80".to_string(),
805            "19992:443".to_string(),
806            "19993:8080".to_string(),
807        ];
808        let fwds = parse_port_forwards(&rules, guest).unwrap();
809        assert_eq!(fwds.len(), 3);
810        assert_eq!(fwds[0].guest_port, 80);
811        assert_eq!(fwds[1].guest_port, 443);
812        assert_eq!(fwds[2].guest_port, 8080);
813    }
814
815    #[test]
816    fn test_parse_port_forwards_empty_string() {
817        let guest = Ipv4Addr::new(10, 89, 0, 2);
818        // Empty entry should fail parsing
819        let rules = vec!["".to_string()];
820        let result = parse_port_forwards(&rules, guest);
821        assert!(result.is_err());
822    }
823
824    #[test]
825    fn test_netproxy_manager_new() {
826        let dir = tempfile::tempdir().unwrap();
827        let mgr = NetProxyManager::new(dir.path());
828        assert_eq!(
829            mgr.socket_path(),
830            dir.path().join("sockets").join("net.sock")
831        );
832        assert_eq!(
833            mgr.stats_path(),
834            dir.path().join("sockets").join("net.stats.json")
835        );
836        assert_eq!(mgr.net_socket_fd(), None);
837    }
838
839    #[test]
840    fn test_netproxy_manager_not_running_initially() {
841        let dir = tempfile::tempdir().unwrap();
842        let mut mgr = NetProxyManager::new(dir.path());
843        assert!(!mgr.is_running());
844    }
845
846    #[test]
847    fn test_netproxy_manager_stop_when_not_started() {
848        let dir = tempfile::tempdir().unwrap();
849        let mut mgr = NetProxyManager::new(dir.path());
850        mgr.stop(); // must not panic
851        assert!(!mgr.is_running());
852    }
853
854    #[test]
855    fn test_netproxy_manager_drop_cleans_up() {
856        let dir = tempfile::tempdir().unwrap();
857        let socket_path = dir.path().join("sockets").join("net.sock");
858        std::fs::create_dir_all(dir.path().join("sockets")).unwrap();
859        std::fs::write(&socket_path, "fake").unwrap();
860        {
861            let _mgr = NetProxyManager::new(dir.path());
862            // Drop triggers cleanup
863        }
864        assert!(!socket_path.exists());
865    }
866
867    #[test]
868    fn test_write_stats_file_writes_json_snapshot() {
869        let dir = tempfile::tempdir().unwrap();
870        let path = dir.path().join("sockets").join("net.stats.json");
871
872        write_stats_file(
873            &path,
874            NetStatsSnapshot {
875                rx_bytes: 1024,
876                tx_bytes: 2048,
877                rx_packets: 3,
878                tx_packets: 4,
879            },
880        )
881        .unwrap();
882
883        let json: serde_json::Value =
884            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
885        assert_eq!(json["schema"], "a3s-box.netproxy.stats.v1");
886        assert_eq!(json["rx_bytes"], 1024);
887        assert_eq!(json["tx_bytes"], 2048);
888        assert_eq!(json["rx_packets"], 3);
889        assert_eq!(json["tx_packets"], 4);
890    }
891
892    #[test]
893    fn test_parse_port_forwards_valid() {
894        let guest = Ipv4Addr::new(10, 89, 0, 2);
895        if !ports_are_bindable(&[19988, 19443]) {
896            eprintln!("skipping test: one or more host ports are not bindable");
897            return;
898        }
899        // Use a random high port to avoid conflicts
900        let rules = vec!["19988:80".to_string(), "19443:443".to_string()];
901        let fwds = parse_port_forwards(&rules, guest).unwrap();
902        assert_eq!(fwds.len(), 2);
903        assert_eq!(fwds[0].guest_port, 80);
904        assert_eq!(fwds[1].guest_port, 443);
905    }
906
907    #[test]
908    fn test_parse_port_forwards_with_protocol_suffix() {
909        let guest = Ipv4Addr::new(10, 89, 0, 2);
910        if !port_is_bindable(19989) {
911            eprintln!("skipping test: host port 19989 is not bindable");
912            return;
913        }
914        let rules = vec!["19989:80/tcp".to_string()];
915        let fwds = parse_port_forwards(&rules, guest).unwrap();
916        assert_eq!(fwds[0].guest_port, 80);
917    }
918
919    #[test]
920    fn test_parse_port_forwards_invalid_format() {
921        let guest = Ipv4Addr::new(10, 89, 0, 2);
922        assert!(parse_port_forwards(&["notaport".to_string()], guest).is_err());
923        assert!(parse_port_forwards(&["abc:80".to_string()], guest).is_err());
924        assert!(parse_port_forwards(&["80:xyz".to_string()], guest).is_err());
925    }
926
927    // Note: test_netproxy_manager_spawn_binds_and_releases_host_ports was removed
928    // because spawn() no longer spawns a thread or binds ports. Port binding
929    // now happens in spawn_inherited_netproxy() called from the shim.
930}