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 ProxyEngineConfig {
230    socket: UnixDatagram,
231    gateway_ip: Ipv4Addr,
232    prefix_len: u8,
233    dns_servers: Vec<Ipv4Addr>,
234    port_forwards: Vec<PortForward>,
235    shutdown: Arc<AtomicBool>,
236    stats: Arc<NetStats>,
237    stats_path: Option<PathBuf>,
238}
239
240struct ProxyEngine {
241    device: UnixgramDevice,
242    iface: Interface,
243    sockets: SocketSet<'static>,
244    dns_handle: smoltcp::iface::SocketHandle,
245    dns_servers: Vec<Ipv4Addr>,
246    port_forwards: Vec<PortForward>,
247    next_ephemeral: u16,
248    shutdown: Arc<AtomicBool>,
249    stats: Arc<NetStats>,
250    stats_path: Option<PathBuf>,
251    last_stats_write: std::time::Instant,
252}
253
254impl ProxyEngine {
255    fn new(config: ProxyEngineConfig) -> Self {
256        let ProxyEngineConfig {
257            socket,
258            gateway_ip,
259            prefix_len,
260            dns_servers,
261            port_forwards,
262            shutdown,
263            stats,
264            stats_path,
265        } = config;
266
267        let mut device = UnixgramDevice {
268            socket,
269            rx_queue: VecDeque::new(),
270            stats: Arc::clone(&stats),
271        };
272
273        // Configure smoltcp interface as the gateway.
274        let config = Config::new(GATEWAY_MAC.into());
275        let mut iface = Interface::new(config, &mut device, smoltcp_now());
276        iface.update_ip_addrs(|addrs| {
277            let cidr = IpCidr::new(IpAddress::Ipv4(to_smoltcp_ipv4(gateway_ip)), prefix_len);
278            addrs.push(cidr).ok();
279        });
280
281        let mut sockets = SocketSet::new(vec![]);
282
283        // DNS socket: listens on UDP/53 on the gateway IP.
284        let dns_rx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]);
285        let dns_tx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 8192]);
286        let mut dns_socket = udp::Socket::new(dns_rx, dns_tx);
287        dns_socket.bind(53).ok();
288        let dns_handle = sockets.add(dns_socket);
289
290        Self {
291            device,
292            iface,
293            sockets,
294            dns_handle,
295            dns_servers,
296            port_forwards,
297            next_ephemeral: EPHEMERAL_BASE,
298            shutdown,
299            stats,
300            stats_path,
301            last_stats_write: std::time::Instant::now(),
302        }
303    }
304
305    fn run(&mut self) {
306        self.write_stats_snapshot();
307        loop {
308            if self.shutdown.load(Ordering::Relaxed) {
309                break;
310            }
311
312            let now = smoltcp_now();
313
314            // 1. Drain UnixGram socket into rx_queue.
315            self.device.drain();
316
317            // 2. Accept new host connections on port-forward listeners.
318            self.accept_connections(now);
319
320            // 3. Poll smoltcp (processes ARP, TCP, UDP frames).
321            self.iface.poll(now, &mut self.device, &mut self.sockets);
322
323            // 4. Promote pending TCP connections to active once established.
324            self.promote_established();
325
326            // 5. Proxy data for active TCP connections.
327            self.proxy_data();
328
329            // 6. Forward DNS queries to real DNS servers.
330            self.forward_dns();
331
332            // 7. Remove closed connections and release their smoltcp sockets.
333            self.cleanup();
334
335            // 8. Publish resource counters for `a3s-box stats`.
336            self.maybe_write_stats_snapshot();
337
338            // 9. Sleep until the next smoltcp event or at most 5 ms.
339            let delay = self
340                .iface
341                .poll_delay(now, &self.sockets)
342                .unwrap_or(smoltcp::time::Duration::from_millis(1));
343            std::thread::sleep(Duration::from_micros(delay.micros().min(5_000)));
344        }
345        self.write_stats_snapshot();
346    }
347
348    fn maybe_write_stats_snapshot(&mut self) {
349        if self.last_stats_write.elapsed() < STATS_WRITE_INTERVAL {
350            return;
351        }
352        self.last_stats_write = std::time::Instant::now();
353        self.write_stats_snapshot();
354    }
355
356    fn write_stats_snapshot(&self) {
357        let Some(path) = self.stats_path.as_deref() else {
358            return;
359        };
360        if let Err(e) = write_stats_file(path, self.stats.snapshot()) {
361            tracing::debug!(error = %e, path = %path.display(), "NetProxy: failed to write stats file");
362        }
363    }
364
365    // ── Accept new host connections ───────────────────────────────────────────
366
367    fn accept_connections(&mut self, now: Instant) {
368        // First pass: accept connections, collect (forward_index, stream, guest_ip, guest_port).
369        // We can't call open_guest_tcp while mutably borrowing port_forwards.
370        let mut new_conns: Vec<(usize, TcpStream, Ipv4Addr, u16)> = Vec::new();
371        for (i, pf) in self.port_forwards.iter_mut().enumerate() {
372            loop {
373                match pf.listener.accept() {
374                    Ok((stream, _)) => {
375                        stream.set_nonblocking(true).ok();
376                        stream.set_nodelay(true).ok();
377                        new_conns.push((i, stream, pf.guest_ip, pf.guest_port));
378                    }
379                    Err(e) if e.kind() == io::ErrorKind::WouldBlock => break,
380                    Err(e) => {
381                        tracing::warn!(error = %e, "Port-forward listener error");
382                        break;
383                    }
384                }
385            }
386        }
387
388        // Second pass: open smoltcp TCP sockets and push to pending.
389        for (i, stream, guest_ip, guest_port) in new_conns {
390            let handle = self.open_guest_tcp(guest_ip, guest_port, now);
391            self.port_forwards[i].pending.push((handle, stream));
392            tracing::debug!(
393                guest = %guest_ip,
394                port = guest_port,
395                handle = ?handle,
396                "NetProxy accepted host connection and initiated guest TCP connect"
397            );
398        }
399    }
400
401    /// Create a smoltcp TCP socket and initiate a connection to the guest.
402    fn open_guest_tcp(
403        &mut self,
404        guest_ip: Ipv4Addr,
405        guest_port: u16,
406        _now: Instant,
407    ) -> smoltcp::iface::SocketHandle {
408        let rx = tcp::SocketBuffer::new(vec![0u8; 65536]);
409        let tx = tcp::SocketBuffer::new(vec![0u8; 65536]);
410        let mut socket = tcp::Socket::new(rx, tx);
411
412        let local_port = self.next_ephemeral;
413        self.next_ephemeral = self.next_ephemeral.wrapping_add(1);
414        if self.next_ephemeral < EPHEMERAL_BASE {
415            self.next_ephemeral = EPHEMERAL_BASE;
416        }
417
418        let remote = IpEndpoint::new(IpAddress::Ipv4(to_smoltcp_ipv4(guest_ip)), guest_port);
419        socket
420            .connect(self.iface.context(), remote, local_port)
421            .ok();
422        socket.set_keep_alive(Some(smoltcp::time::Duration::from_secs(30)));
423
424        self.sockets.add(socket)
425    }
426
427    // ── Promote pending → active ──────────────────────────────────────────────
428
429    fn promote_established(&mut self) {
430        for pf in &mut self.port_forwards {
431            let mut still_pending = Vec::new();
432            for (handle, stream) in pf.pending.drain(..) {
433                let socket = self.sockets.get::<tcp::Socket>(handle);
434                use smoltcp::socket::tcp::State;
435                match socket.state() {
436                    State::Established => {
437                        tracing::debug!(handle = ?handle, "NetProxy guest TCP connection established");
438                        pf.active.push((handle, stream));
439                    }
440                    State::Closed | State::TimeWait | State::CloseWait => {
441                        tracing::debug!(handle = ?handle, state = ?socket.state(), "NetProxy guest TCP connection closed before establishment");
442                        // Connection failed; close host side
443                        drop(stream);
444                        self.sockets.remove(handle);
445                    }
446                    _ => {
447                        still_pending.push((handle, stream));
448                    }
449                }
450            }
451            pf.pending = still_pending;
452        }
453    }
454
455    // ── Bidirectional data proxy ──────────────────────────────────────────────
456
457    fn proxy_data(&mut self) {
458        for pf in &mut self.port_forwards {
459            for (handle, host_stream) in &mut pf.active {
460                let socket = self.sockets.get_mut::<tcp::Socket>(*handle);
461
462                // smoltcp → host (data received from guest)
463                if socket.can_recv() {
464                    socket
465                        .recv(|data| {
466                            tracing::trace!(handle = ?*handle, bytes = data.len(), "NetProxy forwarding guest -> host bytes");
467                            let _ = host_stream.write_all(data);
468                            (data.len(), ())
469                        })
470                        .ok();
471                }
472
473                // host → smoltcp (data from host curl/client)
474                if socket.can_send() {
475                    let mut buf = [0u8; 8192];
476                    match host_stream.read(&mut buf) {
477                        Ok(0) => {
478                            tracing::debug!(handle = ?*handle, "NetProxy host side closed connection");
479                            socket.close();
480                        }
481                        Ok(n) => {
482                            tracing::trace!(handle = ?*handle, bytes = n, "NetProxy forwarding host -> guest bytes");
483                            socket.send_slice(&buf[..n]).ok();
484                        }
485                        Err(e) if e.kind() == io::ErrorKind::WouldBlock => {}
486                        Err(_) => {
487                            socket.close();
488                        }
489                    }
490                }
491            }
492        }
493    }
494
495    // ── DNS forwarding ────────────────────────────────────────────────────────
496
497    fn forward_dns(&mut self) {
498        let dns_server = match self.dns_servers.first() {
499            Some(s) => *s,
500            None => return,
501        };
502
503        let socket = self.sockets.get_mut::<udp::Socket>(self.dns_handle);
504        if !socket.can_recv() {
505            return;
506        }
507        let (query, src_endpoint) = match socket.recv() {
508            Ok(r) => r,
509            Err(_) => return,
510        };
511        let query = query.to_vec();
512        let src = src_endpoint;
513
514        // Forward query to the real DNS server via a host UDP socket.
515        match UdpSocket::bind("0.0.0.0:0") {
516            Ok(udp) => {
517                udp.set_read_timeout(Some(Duration::from_secs(2))).ok();
518                let dest = SocketAddrV4::new(dns_server, 53);
519                if udp.send_to(&query, dest).is_ok() {
520                    let mut resp = vec![0u8; 4096];
521                    if let Ok((n, _)) = udp.recv_from(&mut resp) {
522                        let socket = self.sockets.get_mut::<udp::Socket>(self.dns_handle);
523                        socket.send_slice(&resp[..n], src).ok();
524                    }
525                }
526            }
527            Err(e) => {
528                tracing::warn!(error = %e, "DNS forward: failed to bind host UDP socket");
529            }
530        }
531    }
532
533    // ── Cleanup ───────────────────────────────────────────────────────────────
534
535    fn cleanup(&mut self) {
536        use smoltcp::socket::tcp::State;
537        for pf in &mut self.port_forwards {
538            // Collect handles that need to be removed first, then remove outside retain.
539            let mut to_remove = Vec::new();
540            pf.active.retain(|(handle, _stream)| {
541                let state = self.sockets.get::<tcp::Socket>(*handle).state();
542                match state {
543                    State::Closed | State::TimeWait | State::CloseWait => {
544                        to_remove.push(*handle);
545                        false
546                    }
547                    _ => true,
548                }
549            });
550            for h in to_remove {
551                self.sockets.remove(h);
552            }
553        }
554    }
555}
556
557// ── NetProxyManager lifecycle ─────────────────────────────────────────────────
558
559/// Manages the lifecycle of the pure-Rust vfkit network proxy thread.
560///
561/// Drop calls `stop()` automatically.
562pub struct NetProxyManager {
563    socket_path: PathBuf,
564    stats_path: PathBuf,
565    net_socket_fd: Option<RawFd>,
566    net_proxy_fd: Option<RawFd>,
567}
568
569impl NetProxyManager {
570    /// Create a new manager. Socket will be placed at
571    /// `~/.a3s/boxes/<box_id>/sockets/net.sock`.
572    pub fn new(box_dir: &Path) -> Self {
573        let socket_dir = box_dir.join("sockets");
574        Self {
575            socket_path: socket_dir.join("net.sock"),
576            stats_path: socket_dir.join("net.stats.json"),
577            net_socket_fd: None,
578            net_proxy_fd: None,
579        }
580    }
581
582    pub fn socket_path(&self) -> &Path {
583        &self.socket_path
584    }
585
586    pub fn stats_path(&self) -> &Path {
587        &self.stats_path
588    }
589
590    pub fn net_socket_fd(&self) -> Option<RawFd> {
591        self.net_socket_fd
592    }
593
594    pub fn net_proxy_fd(&self) -> Option<RawFd> {
595        self.net_proxy_fd
596    }
597
598    /// Create socketpair for NetProxy.
599    ///
600    /// Unlike the name suggests, this does NOT spawn a thread. Thread spawning
601    /// happens in `spawn_inherited_netproxy()` called from the shim.
602    pub fn spawn(
603        &mut self,
604        _ip: Ipv4Addr,
605        _gateway: Ipv4Addr,
606        _prefix_len: u8,
607        _dns_servers: &[Ipv4Addr],
608        _port_map: &[String],
609    ) -> Result<()> {
610        let (proxy_socket, krun_fd) = socketpair_unixgram()?;
611        self.net_socket_fd = Some(krun_fd);
612        self.net_proxy_fd = Some(proxy_socket.into_raw_fd());
613        Ok(())
614    }
615
616    pub fn stop(&mut self) {
617        if let Some(fd) = self.net_socket_fd.take() {
618            unsafe {
619                libc::close(fd);
620            }
621        }
622        if let Some(fd) = self.net_proxy_fd.take() {
623            unsafe {
624                libc::close(fd);
625            }
626        }
627        std::fs::remove_file(&self.socket_path).ok();
628        std::fs::remove_file(&self.stats_path).ok();
629    }
630
631    pub fn is_running(&mut self) -> bool {
632        self.net_socket_fd.is_some() || self.net_proxy_fd.is_some()
633    }
634}
635
636impl Drop for NetProxyManager {
637    fn drop(&mut self) {
638        self.stop();
639    }
640}
641
642// ── Helpers ───────────────────────────────────────────────────────────────────
643
644pub fn spawn_inherited_netproxy(
645    fd: RawFd,
646    guest_ip: Ipv4Addr,
647    gateway: Ipv4Addr,
648    prefix_len: u8,
649    dns_servers: &[Ipv4Addr],
650    port_map: &[String],
651    stats_path: Option<PathBuf>,
652) -> Result<()> {
653    let socket = unsafe { UnixDatagram::from_raw_fd(fd) };
654    let port_forwards = parse_port_forwards(port_map, guest_ip)
655        .map_err(|e| BoxError::NetworkError(format!("invalid port_map: {}", e)))?;
656    let dns_servers = dns_servers.to_vec();
657    let shutdown = Arc::new(AtomicBool::new(false));
658    let stats = Arc::new(NetStats::default());
659
660    std::thread::Builder::new()
661        .name("a3s-netproxy".to_string())
662        .spawn(move || {
663            tracing::info!(fd, gateway = %gateway, guest_ip = %guest_ip, stats = ?stats_path, "NetProxy thread started");
664            if let Err(e) = socket.set_nonblocking(true) {
665                tracing::error!(error = %e, "NetProxy: set_nonblocking failed");
666                return;
667            }
668
669            let mut engine = ProxyEngine::new(ProxyEngineConfig {
670                socket,
671                gateway_ip: gateway,
672                prefix_len,
673                dns_servers,
674                port_forwards,
675                shutdown,
676                stats,
677                stats_path,
678            });
679            engine.run();
680            tracing::info!("NetProxy thread exiting");
681        })
682        .map_err(|e| BoxError::NetworkError(format!("failed to spawn netproxy thread: {}", e)))?;
683
684    Ok(())
685}
686
687fn socketpair_unixgram() -> Result<(UnixDatagram, RawFd)> {
688    let mut fds = [-1; 2];
689    let ret = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) };
690    if ret != 0 {
691        return Err(BoxError::NetworkError(format!(
692            "failed to create unix datagram socketpair: {}",
693            io::Error::last_os_error()
694        )));
695    }
696
697    let proxy_socket = unsafe { UnixDatagram::from_raw_fd(fds[0]) };
698    Ok((proxy_socket, fds[1]))
699}
700
701fn write_stats_file(path: &Path, stats: NetStatsSnapshot) -> io::Result<()> {
702    if let Some(parent) = path.parent() {
703        std::fs::create_dir_all(parent)?;
704    }
705    let updated_at_ms = std::time::SystemTime::now()
706        .duration_since(std::time::UNIX_EPOCH)
707        .unwrap_or_default()
708        .as_millis();
709    let body = format!(
710        "{{\"schema\":\"a3s-box.netproxy.stats.v1\",\"rx_bytes\":{},\"tx_bytes\":{},\"rx_packets\":{},\"tx_packets\":{},\"updated_at_ms\":{}}}\n",
711        stats.rx_bytes, stats.tx_bytes, stats.rx_packets, stats.tx_packets, updated_at_ms
712    );
713    let tmp = path.with_extension("json.tmp");
714    std::fs::write(&tmp, body)?;
715    std::fs::rename(tmp, path)
716}
717
718/// Parse `["8088:80", "443:443"]` into `Vec<PortForward>`.
719///
720/// Each rule maps `host_port → guest_ip:guest_port`. Guest IP is always the
721/// IPAM-assigned `guest_ip`.
722fn parse_port_forwards(
723    port_map: &[String],
724    guest_ip: Ipv4Addr,
725) -> std::result::Result<Vec<PortForward>, String> {
726    let mut forwards = Vec::new();
727    for entry in port_map {
728        let mapping = a3s_box_core::parse_port_mapping(entry)?;
729        let host_port = mapping.host_port;
730        let guest_port = mapping.guest_port;
731
732        let listener = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, host_port))
733            .map_err(|e| format!("cannot bind 0.0.0.0:{}: {}", host_port, e))?;
734        listener
735            .set_nonblocking(true)
736            .map_err(|e| format!("set_nonblocking on listener: {}", e))?;
737
738        tracing::info!(
739            host_port,
740            guest_port,
741            guest_ip = %guest_ip,
742            "Port-forward listener ready"
743        );
744        forwards.push(PortForward {
745            listener,
746            guest_ip,
747            guest_port,
748            pending: Vec::new(),
749            active: Vec::new(),
750        });
751    }
752    Ok(forwards)
753}
754
755// ── Tests ─────────────────────────────────────────────────────────────────────
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    fn port_is_bindable(port: u16) -> bool {
762        TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, port)).is_ok()
763    }
764
765    fn ports_are_bindable(ports: &[u16]) -> bool {
766        ports.iter().copied().all(port_is_bindable)
767    }
768
769    #[test]
770    fn test_smoltcp_now_returns_reasonable_value() {
771        let now = smoltcp_now();
772        // Should return microseconds since epoch
773        assert!(now.micros() > 0);
774    }
775
776    #[test]
777    fn test_to_smoltcp_ipv4_conversion() {
778        let ip = Ipv4Addr::new(10, 88, 0, 1);
779        let smol_ip = to_smoltcp_ipv4(ip);
780        assert_eq!(smol_ip.as_bytes(), &[10, 88, 0, 1]);
781    }
782
783    #[test]
784    fn test_to_smoltcp_ipv4_loopback() {
785        let ip = Ipv4Addr::new(127, 0, 0, 1);
786        let smol_ip = to_smoltcp_ipv4(ip);
787        assert_eq!(smol_ip.as_bytes(), &[127, 0, 0, 1]);
788    }
789
790    #[test]
791    fn test_net_stats_records_bytes_and_packets() {
792        let stats = NetStats::default();
793
794        stats.record_rx(64);
795        stats.record_rx(128);
796        stats.record_tx(512);
797
798        let snapshot = stats.snapshot();
799        assert_eq!(snapshot.rx_bytes, 192);
800        assert_eq!(snapshot.rx_packets, 2);
801        assert_eq!(snapshot.tx_bytes, 512);
802        assert_eq!(snapshot.tx_packets, 1);
803    }
804
805    #[test]
806    fn test_parse_port_forwards_empty_rules() {
807        let guest = Ipv4Addr::new(10, 89, 0, 2);
808        let fwds = parse_port_forwards(&[], guest).unwrap();
809        assert!(fwds.is_empty());
810    }
811
812    #[test]
813    fn test_parse_port_forwards_rejects_udp_suffix() {
814        let guest = Ipv4Addr::new(10, 89, 0, 2);
815        let rules = vec!["19990:80/udp".to_string()];
816        let error = match parse_port_forwards(&rules, guest) {
817            Ok(_) => panic!("UDP port mapping unexpectedly succeeded"),
818            Err(error) => error,
819        };
820
821        assert!(error.contains("only TCP is supported"));
822    }
823
824    #[test]
825    fn test_parse_port_forwards_multiple_rules() {
826        let guest = Ipv4Addr::new(10, 89, 0, 2);
827        if !ports_are_bindable(&[19991, 19992, 19993]) {
828            eprintln!("skipping test: one or more host ports are not bindable");
829            return;
830        }
831        let rules = vec![
832            "19991:80".to_string(),
833            "19992:443".to_string(),
834            "19993:8080".to_string(),
835        ];
836        let fwds = parse_port_forwards(&rules, guest).unwrap();
837        assert_eq!(fwds.len(), 3);
838        assert_eq!(fwds[0].guest_port, 80);
839        assert_eq!(fwds[1].guest_port, 443);
840        assert_eq!(fwds[2].guest_port, 8080);
841    }
842
843    #[test]
844    fn test_parse_port_forwards_empty_string() {
845        let guest = Ipv4Addr::new(10, 89, 0, 2);
846        // Empty entry should fail parsing
847        let rules = vec!["".to_string()];
848        let result = parse_port_forwards(&rules, guest);
849        assert!(result.is_err());
850    }
851
852    #[test]
853    fn test_netproxy_manager_new() {
854        let dir = tempfile::tempdir().unwrap();
855        let mgr = NetProxyManager::new(dir.path());
856        assert_eq!(
857            mgr.socket_path(),
858            dir.path().join("sockets").join("net.sock")
859        );
860        assert_eq!(
861            mgr.stats_path(),
862            dir.path().join("sockets").join("net.stats.json")
863        );
864        assert_eq!(mgr.net_socket_fd(), None);
865    }
866
867    #[test]
868    fn test_netproxy_manager_not_running_initially() {
869        let dir = tempfile::tempdir().unwrap();
870        let mut mgr = NetProxyManager::new(dir.path());
871        assert!(!mgr.is_running());
872    }
873
874    #[test]
875    fn test_netproxy_manager_stop_when_not_started() {
876        let dir = tempfile::tempdir().unwrap();
877        let mut mgr = NetProxyManager::new(dir.path());
878        mgr.stop(); // must not panic
879        assert!(!mgr.is_running());
880    }
881
882    #[test]
883    fn test_netproxy_manager_spawn_creates_socketpair_fds_and_stop_closes_them() {
884        let dir = tempfile::tempdir().unwrap();
885        let mut mgr = NetProxyManager::new(dir.path());
886
887        mgr.spawn(
888            Ipv4Addr::new(10, 89, 0, 2),
889            Ipv4Addr::new(10, 89, 0, 1),
890            24,
891            &[Ipv4Addr::new(8, 8, 8, 8)],
892            &[],
893        )
894        .unwrap();
895
896        assert!(mgr.is_running());
897        assert!(mgr.net_socket_fd().is_some());
898        assert!(mgr.net_proxy_fd().is_some());
899
900        mgr.stop();
901        assert!(!mgr.is_running());
902        assert!(mgr.net_socket_fd().is_none());
903        assert!(mgr.net_proxy_fd().is_none());
904    }
905
906    #[test]
907    fn test_netproxy_manager_drop_cleans_up() {
908        let dir = tempfile::tempdir().unwrap();
909        let socket_path = dir.path().join("sockets").join("net.sock");
910        std::fs::create_dir_all(dir.path().join("sockets")).unwrap();
911        std::fs::write(&socket_path, "fake").unwrap();
912        {
913            let _mgr = NetProxyManager::new(dir.path());
914            // Drop triggers cleanup
915        }
916        assert!(!socket_path.exists());
917    }
918
919    #[test]
920    fn test_write_stats_file_writes_json_snapshot() {
921        let dir = tempfile::tempdir().unwrap();
922        let path = dir.path().join("sockets").join("net.stats.json");
923
924        write_stats_file(
925            &path,
926            NetStatsSnapshot {
927                rx_bytes: 1024,
928                tx_bytes: 2048,
929                rx_packets: 3,
930                tx_packets: 4,
931            },
932        )
933        .unwrap();
934
935        let json: serde_json::Value =
936            serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
937        assert_eq!(json["schema"], "a3s-box.netproxy.stats.v1");
938        assert_eq!(json["rx_bytes"], 1024);
939        assert_eq!(json["tx_bytes"], 2048);
940        assert_eq!(json["rx_packets"], 3);
941        assert_eq!(json["tx_packets"], 4);
942    }
943
944    #[test]
945    fn test_write_stats_file_overwrites_existing_file_and_removes_temp() {
946        let dir = tempfile::tempdir().unwrap();
947        let path = dir.path().join("net.stats.json");
948        let tmp = path.with_extension("json.tmp");
949        std::fs::write(&path, "old").unwrap();
950        std::fs::write(&tmp, "stale temp").unwrap();
951
952        write_stats_file(
953            &path,
954            NetStatsSnapshot {
955                rx_bytes: 1,
956                tx_bytes: 2,
957                rx_packets: 3,
958                tx_packets: 4,
959            },
960        )
961        .unwrap();
962
963        let json: serde_json::Value =
964            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
965        assert_eq!(json["rx_bytes"], 1);
966        assert_eq!(json["tx_bytes"], 2);
967        assert!(!tmp.exists());
968    }
969
970    #[test]
971    fn test_parse_port_forwards_valid() {
972        let guest = Ipv4Addr::new(10, 89, 0, 2);
973        if !ports_are_bindable(&[19988, 19443]) {
974            eprintln!("skipping test: one or more host ports are not bindable");
975            return;
976        }
977        // Use a random high port to avoid conflicts
978        let rules = vec!["19988:80".to_string(), "19443:443".to_string()];
979        let fwds = parse_port_forwards(&rules, guest).unwrap();
980        assert_eq!(fwds.len(), 2);
981        assert_eq!(fwds[0].guest_port, 80);
982        assert_eq!(fwds[1].guest_port, 443);
983    }
984
985    #[test]
986    fn test_parse_port_forwards_with_protocol_suffix() {
987        let guest = Ipv4Addr::new(10, 89, 0, 2);
988        if !port_is_bindable(19989) {
989            eprintln!("skipping test: host port 19989 is not bindable");
990            return;
991        }
992        let rules = vec!["19989:80/tcp".to_string()];
993        let fwds = parse_port_forwards(&rules, guest).unwrap();
994        assert_eq!(fwds[0].guest_port, 80);
995    }
996
997    #[test]
998    fn test_parse_port_forwards_invalid_format() {
999        let guest = Ipv4Addr::new(10, 89, 0, 2);
1000        assert!(parse_port_forwards(&["notaport".to_string()], guest).is_err());
1001        assert!(parse_port_forwards(&["abc:80".to_string()], guest).is_err());
1002        assert!(parse_port_forwards(&["80:xyz".to_string()], guest).is_err());
1003    }
1004
1005    #[test]
1006    fn test_parse_port_forwards_reports_bind_conflict() {
1007        let guest = Ipv4Addr::new(10, 89, 0, 2);
1008        let held = TcpListener::bind(SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 0)).unwrap();
1009        let port = held.local_addr().unwrap().port();
1010
1011        let error = match parse_port_forwards(&[format!("{port}:80")], guest) {
1012            Ok(_) => panic!("port-forward bind conflict should return an error"),
1013            Err(error) => error,
1014        };
1015
1016        assert!(error.contains(&format!("cannot bind 0.0.0.0:{port}")));
1017    }
1018
1019    // Note: test_netproxy_manager_spawn_binds_and_releases_host_ports was removed
1020    // because spawn() no longer spawns a thread or binds ports. Port binding
1021    // now happens in spawn_inherited_netproxy() called from the shim.
1022}