Skip to main content

smolvm_network/
netns_tap.rs

1//! CNI netns ↔ virtio-net L2 bridge for the Kubernetes runtime.
2//!
3//! Kubernetes semantics require the pod to live *in* its CNI-allocated network
4//! namespace with the CNI-assigned IP, reachable at L2 from other pods — which
5//! the smoltcp NAT gateway ([`crate::frame_stream`]) deliberately does not
6//! provide. This module gives the alternative "netns-tap" datapath the
7//! containerd shim uses (see docs/kubernetes-runtime.md):
8//!
9//! ```text
10//!  CNI netns                         host (shim)                 guest
11//!  ┌─────────┐   ethernet frames   ┌──────────────┐  unixstream ┌────────┐
12//!  │  tapX    │◀───────────────────▶│ netns_tap    │◀───────────▶│ libkrun │
13//!  │ (vethed  │   raw, no framing   │ frame pump   │  4B-len +   │virtio-  │
14//!  │  by CNI) │                     │              │   frame     │  net    │
15//!  └─────────┘                     └──────────────┘             └────────┘
16//! ```
17//!
18//! The shim opens a tap **inside the pod netns**, plugs it into the CNI bridge
19//! the same way a container veth would be, and pumps raw Ethernet frames
20//! between the tap fd and libkrun's unixstream (which uses the
21//! `[4-byte BE length][frame]` protocol from [`crate::frame_stream`]). No IP
22//! logic lives here — the guest configures the CNI address/routes/MTU
23//! statically from boot config; this is a pure L2 wire.
24
25use std::io;
26use std::os::fd::{AsRawFd, OwnedFd, RawFd};
27use std::os::unix::net::UnixStream;
28use std::sync::atomic::{AtomicBool, Ordering};
29use std::sync::Arc;
30use std::thread::JoinHandle;
31
32use crate::frame_stream::{read_frame, write_frame};
33
34/// Largest Ethernet frame we relay (jumbo-safe MTU + header + VLAN slack).
35const TAP_READ_BUF: usize = 65_536;
36
37/// A running netns-tap bridge. Dropping it signals both pump threads to stop
38/// and joins them; the tap fd closes with the bridge.
39pub struct NetnsTapBridge {
40    stop: Arc<AtomicBool>,
41    threads: Vec<JoinHandle<()>>,
42}
43
44impl NetnsTapBridge {
45    /// Stop the pumps and join. Idempotent; also called on drop.
46    pub fn shutdown(&mut self) {
47        self.stop.store(true, Ordering::SeqCst);
48        for t in self.threads.drain(..) {
49            let _ = t.join();
50        }
51    }
52}
53
54impl Drop for NetnsTapBridge {
55    fn drop(&mut self) {
56        self.shutdown();
57    }
58}
59
60/// Pump raw Ethernet frames between a tap fd and libkrun's unixstream.
61///
62/// `tap` is a TAP device fd opened with `IFF_NO_PI` (no 4-byte packet-info
63/// prefix — payloads are bare Ethernet frames). `stream` is the AF_UNIX stream
64/// libkrun is given for its virtio-net backend. Two threads run until either
65/// side closes or [`NetnsTapBridge::shutdown`] is called:
66/// - **tap → stream**: `read()` a frame from the tap, `write_frame` it (adds the
67///   4-byte length prefix libkrun expects).
68/// - **stream → tap**: `read_frame` (strips the prefix), `write()` the raw frame
69///   to the tap.
70pub fn start_netns_tap_bridge(stream: UnixStream, tap: OwnedFd) -> io::Result<NetnsTapBridge> {
71    // Independent fds for each direction so concurrent read+write never block
72    // each other (the same lesson as frame_stream's split sockets).
73    let stream_rx = stream.try_clone()?;
74    let stream_tx = stream;
75    let tap_rx = tap.try_clone()?;
76    let tap_tx = tap;
77
78    let stop = Arc::new(AtomicBool::new(false));
79
80    let stop_a = stop.clone();
81    let t_up = std::thread::Builder::new()
82        .name("netns-tap-tx".into())
83        .spawn(move || pump_tap_to_stream(tap_rx, stream_tx, &stop_a))?;
84
85    let stop_b = stop.clone();
86    let t_down = std::thread::Builder::new()
87        .name("netns-tap-rx".into())
88        .spawn(move || pump_stream_to_tap(stream_rx, tap_tx, &stop_b))?;
89
90    Ok(NetnsTapBridge {
91        stop,
92        threads: vec![t_up, t_down],
93    })
94}
95
96fn pump_tap_to_stream(tap: OwnedFd, mut stream: UnixStream, stop: &AtomicBool) {
97    let mut buf = vec![0u8; TAP_READ_BUF];
98    let fd = tap.as_raw_fd();
99    while !stop.load(Ordering::SeqCst) {
100        match read_fd(fd, &mut buf) {
101            Ok(0) => break, // tap closed
102            Ok(n) => {
103                if let Err(e) = write_frame(&mut stream, &buf[..n]) {
104                    if !stop.load(Ordering::SeqCst) {
105                        tracing::debug!("netns-tap: stream write ended: {e}");
106                    }
107                    break;
108                }
109            }
110            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
111            Err(e) => {
112                if !stop.load(Ordering::SeqCst) {
113                    tracing::debug!("netns-tap: tap read ended: {e}");
114                }
115                break;
116            }
117        }
118    }
119}
120
121fn pump_stream_to_tap(mut stream: UnixStream, tap: OwnedFd, stop: &AtomicBool) {
122    let fd = tap.as_raw_fd();
123    while !stop.load(Ordering::SeqCst) {
124        match read_frame(&mut stream) {
125            Ok(frame) => {
126                // A short write to a tap would corrupt the frame; tap writes are
127                // atomic per frame, so a partial write is a hard error.
128                if let Err(e) = write_fd_all(fd, &frame) {
129                    if !stop.load(Ordering::SeqCst) {
130                        tracing::debug!("netns-tap: tap write ended: {e}");
131                    }
132                    break;
133                }
134            }
135            Err(e) => {
136                if !stop.load(Ordering::SeqCst) {
137                    tracing::debug!("netns-tap: stream read ended: {e}");
138                }
139                break;
140            }
141        }
142    }
143}
144
145// Raw fd read/write: the tap fd is a character device, not a std type. Using
146// libc directly avoids wrapping it in a File (whose Drop would double-close a
147// try_clone'd fd we already own).
148
149fn read_fd(fd: RawFd, buf: &mut [u8]) -> io::Result<usize> {
150    let n = unsafe { libc::read(fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
151    if n < 0 {
152        Err(io::Error::last_os_error())
153    } else {
154        Ok(n as usize)
155    }
156}
157
158fn write_fd_all(fd: RawFd, buf: &[u8]) -> io::Result<()> {
159    // One write() call per Ethernet frame (tap semantics are datagram-like).
160    let n = unsafe { libc::write(fd, buf.as_ptr() as *const libc::c_void, buf.len()) };
161    if n < 0 {
162        return Err(io::Error::last_os_error());
163    }
164    if (n as usize) != buf.len() {
165        return Err(io::Error::new(
166            io::ErrorKind::WriteZero,
167            "short tap write truncated an ethernet frame",
168        ));
169    }
170    Ok(())
171}
172
173/// Open a TAP device inside the network namespace at `netns_path` and return
174/// its fd. The tap is created with `IFF_TAP | IFF_NO_PI`, named `ifname`, and
175/// brought up; the CNI plugin (already run by containerd) owns bridging it into
176/// the pod network. The fd remains valid in the caller's process after we
177/// switch back to the original netns — only the *device* lives in the pod's
178/// netns.
179///
180/// Requires `CAP_SYS_ADMIN`. Isolated so the frame pump can be unit-tested
181/// without root ([`start_netns_tap_bridge`] takes any `OwnedFd`).
182#[cfg(target_os = "linux")]
183pub fn open_tap_in_netns(netns_path: &str, ifname: &str) -> io::Result<OwnedFd> {
184    use std::fs::File;
185    use std::os::fd::AsFd;
186
187    if ifname.len() >= libc::IFNAMSIZ {
188        return Err(io::Error::new(
189            io::ErrorKind::InvalidInput,
190            "interface name too long",
191        ));
192    }
193
194    // Remember our current netns so we can return to it no matter what.
195    let self_ns = File::open("/proc/self/ns/net")?;
196    let target = File::open(netns_path)?;
197
198    // setns(CLONE_NEWNET) affects only the calling thread, so do the whole
199    // enter → create tap → restore on a dedicated scoped thread. That keeps the
200    // caller's thread (and any tokio worker it belongs to) in the host netns.
201    let ifname = ifname.to_string();
202    std::thread::scope(|s| {
203        s.spawn(|| -> io::Result<OwnedFd> {
204            enter_netns(target.as_fd().as_raw_fd())?;
205            let res = create_tap(&ifname);
206            // Always attempt to restore, even on failure — a scoped worker left
207            // in the pod netns would be a latent bug if the platform ever
208            // pooled it (it does not today, but don't rely on that).
209            let _ = enter_netns(self_ns.as_fd().as_raw_fd());
210            res
211        })
212        .join()
213        .map_err(|_| io::Error::other("tap-open thread panicked"))?
214    })
215}
216
217/// Wire a tap (created by [`open_tap_in_netns`]) into the pod's CNI datapath via
218/// `tc` mirred redirect: every frame arriving on the CNI interface (`cni_if` —
219/// the veth the CNI plugin placed in the netns) is redirected to the tap's
220/// egress (into the VM), and every frame the VM emits on the tap is redirected
221/// back onto the CNI interface. The guest NIC, configured with the pod's CNI
222/// IP+MAC, thus appears on the pod network at L2. This is Kata's "tcfilter" mode
223/// and works with any CNI plugin (no assumptions about bridges/veth naming).
224///
225/// Runs `tc` inside `netns_path` via `nsenter`. Requires CAP_NET_ADMIN; call in
226/// the boot subprocess's privileged window, before any uid drop. Both `tc` and
227/// `nsenter` (iproute2 + util-linux) must be on PATH — they are on every k8s node.
228#[cfg(target_os = "linux")]
229pub fn setup_tc_redirect(netns_path: &str, cni_if: &str, tap_if: &str) -> io::Result<()> {
230    // A clsact/ingress qdisc on each device gives us the ingress hook the mirred
231    // redirect attaches to. `matchall` (kernel 4.9+) classifies every frame.
232    tc(netns_path, &["qdisc", "add", "dev", cni_if, "ingress"])?;
233    tc(netns_path, &["qdisc", "add", "dev", tap_if, "ingress"])?;
234    tc(
235        netns_path,
236        &[
237            "filter", "add", "dev", cni_if, "ingress", "protocol", "all", "matchall", "action",
238            "mirred", "egress", "redirect", "dev", tap_if,
239        ],
240    )?;
241    tc(
242        netns_path,
243        &[
244            "filter", "add", "dev", tap_if, "ingress", "protocol", "all", "matchall", "action",
245            "mirred", "egress", "redirect", "dev", cni_if,
246        ],
247    )?;
248    Ok(())
249}
250
251#[cfg(target_os = "linux")]
252fn tc(netns_path: &str, args: &[&str]) -> io::Result<()> {
253    let out = std::process::Command::new("nsenter")
254        .arg(format!("--net={netns_path}"))
255        .arg("tc")
256        .args(args)
257        .output()?;
258    if !out.status.success() {
259        return Err(io::Error::other(format!(
260            "nsenter --net={netns_path} tc {}: {}",
261            args.join(" "),
262            String::from_utf8_lossy(&out.stderr).trim()
263        )));
264    }
265    Ok(())
266}
267
268#[cfg(target_os = "linux")]
269fn enter_netns(ns_fd: RawFd) -> io::Result<()> {
270    let rc = unsafe { libc::setns(ns_fd, libc::CLONE_NEWNET) };
271    if rc != 0 {
272        return Err(io::Error::last_os_error());
273    }
274    Ok(())
275}
276
277#[cfg(target_os = "linux")]
278fn create_tap(ifname: &str) -> io::Result<OwnedFd> {
279    use std::os::fd::FromRawFd;
280
281    let tun = unsafe { libc::open(c"/dev/net/tun".as_ptr(), libc::O_RDWR) };
282    if tun < 0 {
283        return Err(io::Error::last_os_error());
284    }
285    // SAFETY: tun >= 0 is a fresh owned fd.
286    let owned = unsafe { OwnedFd::from_raw_fd(tun) };
287
288    #[repr(C)]
289    struct Ifreq {
290        name: [libc::c_char; libc::IFNAMSIZ],
291        flags: libc::c_short,
292        _pad: [u8; 22],
293    }
294    let mut req = Ifreq {
295        name: [0; libc::IFNAMSIZ],
296        flags: (libc::IFF_TAP | libc::IFF_NO_PI) as libc::c_short,
297        _pad: [0; 22],
298    };
299    for (i, b) in ifname.bytes().enumerate() {
300        req.name[i] = b as libc::c_char;
301    }
302    // TUNSETIFF = _IOW('T', 202, int)
303    const TUNSETIFF: libc::c_ulong = 0x4004_54ca;
304    let rc = unsafe { libc::ioctl(owned.as_raw_fd(), TUNSETIFF as _, &mut req) };
305    if rc < 0 {
306        return Err(io::Error::last_os_error());
307    }
308
309    bring_up(ifname)?;
310    Ok(owned)
311}
312
313/// `ip link set <ifname> up` via a netlink-free SIOCSIFFLAGS ioctl (we are in
314/// the target netns on this thread).
315#[cfg(target_os = "linux")]
316fn bring_up(ifname: &str) -> io::Result<()> {
317    let sock = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) };
318    if sock < 0 {
319        return Err(io::Error::last_os_error());
320    }
321    let _guard = scopeguard_close(sock);
322
323    #[repr(C)]
324    struct IfreqFlags {
325        name: [libc::c_char; libc::IFNAMSIZ],
326        flags: libc::c_short,
327        _pad: [u8; 22],
328    }
329    let mut req = IfreqFlags {
330        name: [0; libc::IFNAMSIZ],
331        flags: 0,
332        _pad: [0; 22],
333    };
334    for (i, b) in ifname.bytes().enumerate() {
335        req.name[i] = b as libc::c_char;
336    }
337    const SIOCGIFFLAGS: libc::c_ulong = 0x8913;
338    const SIOCSIFFLAGS: libc::c_ulong = 0x8914;
339    if unsafe { libc::ioctl(sock, SIOCGIFFLAGS as _, &mut req) } < 0 {
340        return Err(io::Error::last_os_error());
341    }
342    req.flags |= (libc::IFF_UP | libc::IFF_RUNNING) as libc::c_short;
343    if unsafe { libc::ioctl(sock, SIOCSIFFLAGS as _, &mut req) } < 0 {
344        return Err(io::Error::last_os_error());
345    }
346    Ok(())
347}
348
349#[cfg(target_os = "linux")]
350fn scopeguard_close(fd: RawFd) -> impl Drop {
351    struct Closer(RawFd);
352    impl Drop for Closer {
353        fn drop(&mut self) {
354            unsafe { libc::close(self.0) };
355        }
356    }
357    Closer(fd)
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use std::io::{Read, Write};
364    use std::os::fd::OwnedFd;
365
366    /// A socketpair stands in for both the tap fd and the libkrun stream so the
367    /// pump logic runs without root or a real tap.
368    fn socketpair() -> (OwnedFd, OwnedFd) {
369        let mut fds = [0i32; 2];
370        let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, fds.as_mut_ptr()) };
371        assert_eq!(rc, 0);
372        use std::os::fd::FromRawFd;
373        unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }
374    }
375
376    #[test]
377    fn frames_relay_both_directions() {
378        // "tap" side = a socketpair; the pump reads raw bytes from tap_far and
379        // length-frames them onto the stream, and vice versa.
380        let (tap_near, tap_far) = socketpair();
381        let (stream_near_raw, stream_far_raw) = socketpair();
382        let stream_near = UnixStream::from(stream_near_raw);
383        let mut stream_far = UnixStream::from(stream_far_raw);
384
385        let _bridge = start_netns_tap_bridge(stream_near, tap_near).unwrap();
386
387        // tap → stream: write a raw frame into the tap side; expect it framed on
388        // the stream side.
389        let frame = b"\xde\xad\xbe\xef hello ethernet";
390        let mut tap_far_w = UnixStream::from(tap_far);
391        tap_far_w.write_all(frame).unwrap();
392        let got = read_frame(&mut stream_far).unwrap();
393        assert_eq!(&got, frame);
394
395        // stream → tap: write a framed packet on the stream; expect raw bytes on
396        // the tap side.
397        let frame2 = b"reply frame \x00\x01\x02";
398        write_frame(&mut stream_far, frame2).unwrap();
399        let mut buf = vec![0u8; frame2.len()];
400        tap_far_w.read_exact(&mut buf).unwrap();
401        assert_eq!(&buf, frame2);
402    }
403}