Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/wg-appliance-core/src/tun.rs

//! The tun device: `/dev/net/tun` + `TUNSETIFF`, and the MTU.
//!
//! This is the entire kernel dependency of the userspace lane. `CONFIG_TUN` is
//! built in (`=y`) on every ordinary distribution kernel — measured on the
//! kernel gunnar's ISO packs, 2026-08-12 — so unlike `CONFIG_WIREGUARD` it is
//! not a thing an image can arrive without.
//!
//! # The fd is the device
//!
//! A tun interface created this way lives exactly as long as the fd. Closing it
//! deletes the interface, which is why [`TunDevice`] is held for the process
//! lifetime and never `dup`'d into something that might drop first. There is no
//! `IFF_PERSIST` here on purpose: a persistent interface would outlive a crashed
//! appliance and the next boot would find an interface with an address and no
//! packet pump behind it — a tunnel that pings and carries nothing.
//!
//! # The ioctl request type is not portable, and that is a real bug elsewhere
//!
//! `libc::Ioctl` is `c_ulong` on glibc and `c_int` on **musl**. Constants typed
//! as `c_ulong` compile on a developer's glibc box and fail on the musl target
//! the appliance actually ships as. The constants below are untyped and cast at
//! the call, which is why this file builds for both.

use std::io;
use std::os::fd::{AsRawFd, RawFd};

/// `TUNSETIFF` (`<linux/if_tun.h>`).
const TUNSETIFF: u64 = 0x400454ca;
/// `IFF_TUN | IFF_NO_PI` — a layer-3 device with no 4-byte packet-information
/// prefix, so what is read off the fd is an IP packet and nothing else.
const IFF_TUN: i16 = 0x0001;
const IFF_NO_PI: i16 = 0x1000;
/// `SIOCSIFMTU` (`<linux/sockios.h>`).
const SIOCSIFMTU: u64 = 0x8922;

/// The clone device every tun interface is created through.
pub const TUN_PATH: &str = "/dev/net/tun";
/// `tun`'s fixed misc-device numbers (`Documentation/admin-guide/devices.txt`).
const TUN_MAJOR: u32 = 10;
const TUN_MINOR: u32 = 200;

/// The MTU a WireGuard interface gets when nothing says otherwise.
///
/// 1420 = 1500 − 20 (outer IPv4) − 8 (UDP) − 32 (WireGuard's data header + the
/// Poly1305 tag + the 16-byte alignment WireGuard's transport format costs).
/// It is the number `wg-quick(8)` picks for an IPv4 endpoint over a 1500-byte
/// path, and it matters more here than under kernel WireGuard: an inner packet
/// that does not fit becomes an IP-fragmented outer datagram, and a middlebox
/// that drops fragments turns a working tunnel into one where `ssh` connects
/// and a `git push` hangs at 100%.
pub const DEFAULT_MTU: u32 = 1420;

/// A tun interface, owned by the fd that created it.
#[derive(Debug)]
pub struct TunDevice {
    fd: RawFd,
    name: String,
}

/// Minimal `struct ifreq`: a 16-byte name and a 24-byte union.
#[repr(C)]
struct IfReq {
    name: [libc::c_char; 16],
    union: [u8; 24],
}

/// The ABI assumption every ioctl below rests on, checked by the COMPILER
/// rather than asserted in a comment: `struct ifreq` is `IFNAMSIZ` (16) plus a
/// 24-byte union, so 40 bytes on every LP64 Linux target. The kernel copies
/// exactly that many bytes in and out, so a smaller [`IfReq`] would be a buffer
/// overflow and this line would stop the build.
const _: () = assert!(std::mem::size_of::<IfReq>() == 40);

/// The `ifreq`-shaped ioctls this module issues.
///
/// An enum rather than a raw request number, because [`ifreq_ioctl`] is a SAFE
/// function whose soundness rests on the request being one that reads and
/// writes exactly `sizeof(struct ifreq)` bytes. With a bare `c_ulong` parameter
/// a later caller could hand it any request at all — `SIOCGIFCONF`, say, which
/// treats the union as a pointer and a length — and the safety comment below
/// would quietly stop being true. The compiler now enforces what the comment
/// claims.
#[derive(Clone, Copy)]
enum IfReqIoctl {
    /// `TUNSETIFF`.
    SetIff,
    /// `SIOCSIFMTU`.
    SetMtu,
}

impl IfReqIoctl {
    fn code(self) -> libc::c_ulong {
        match self {
            IfReqIoctl::SetIff => TUNSETIFF,
            IfReqIoctl::SetMtu => SIOCSIFMTU,
        }
    }
}

/// Run an `ifreq`-shaped ioctl (`TUNSETIFF`, `SIOCSIFMTU`) and return its raw
/// result.
fn ifreq_ioctl(fd: RawFd, code: IfReqIoctl, req: &mut IfReq) -> libc::c_int {
    // SAFETY: both ioctls this is called with are the `ifreq` family, which read
    // and write EXACTLY `sizeof(struct ifreq)` bytes at the pointer. The
    // `const _` assertion above proves `IfReq` is that size, `&mut IfReq` gives
    // a unique, non-null, live pointer for the duration of the call, and
    // `#[repr(C)]` fixes the field order to the kernel's. `IfReq` has alignment
    // 1 where the C type has 8; that is sound because the kernel moves the
    // struct with `copy_{from,to}_user` (byte-wise, no aligned loads) and
    // nothing here ever dereferences the pointer as a `struct ifreq` — the name
    // is written byte by byte and the union is read with `from_ne_bytes`.
    unsafe { libc::ioctl(fd, code.code() as libc::Ioctl, req as *mut IfReq) }
}

/// `close(2)` a descriptor this module opened.
fn close_fd(fd: RawFd) {
    // SAFETY: every `fd` reaching here came from `open(2)` or `socket(2)` in
    // this module and was checked for a negative result first, and each is
    // closed exactly once — the callers return immediately afterwards and keep
    // no other copy. `close(2)` touches no memory of ours; its result is unused
    // because there is nothing a caller could do about a failed close of a
    // descriptor it is finished with.
    unsafe { libc::close(fd) };
}

impl IfReq {
    fn new(name: &str) -> IfReq {
        let mut r = IfReq { name: [0; 16], union: [0; 24] };
        for (i, b) in name.bytes().take(15).enumerate() {
            r.name[i] = b as libc::c_char;
        }
        r
    }
}

impl TunDevice {
    /// Create (or attach to) the tun interface `name` and set its MTU.
    ///
    /// `EPERM` here is the ordinary "not `CAP_NET_ADMIN`" and `ENOENT` is
    /// "`/dev/net/tun` does not exist" — a kernel without `CONFIG_TUN`, or a
    /// container without the device node. Both are named by the returned error
    /// rather than folded into one "could not start the tunnel".
    pub fn create(name: &str, mtu: u32) -> io::Result<TunDevice> {
        if name.is_empty() || name.len() > 15 {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!("{name:?} is not a legal interface name (1..=15 bytes)"),
            ));
        }
        let path = std::ffi::CString::new(TUN_PATH).expect("a literal with no NUL");
        // SAFETY: `path` is a NUL-terminated `CString` built from a literal,
        // owned by this frame and alive across both `open` calls; `open(2)`
        // only READS through the pointer and keeps no reference. The flags are
        // `libc`'s own constants. A negative result is checked before the
        // descriptor is used.
        let mut fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
        if fd < 0 && io::Error::last_os_error().kind() == io::ErrorKind::NotFound {
            // An OS-less appliance has no udev and no populated `/dev`: PID 1
            // mounted `/proc` and made the one node it needed, and nothing ever
            // made this one. The DRIVER is in the kernel (`CONFIG_TUN=y`) — it
            // is only the node that is missing, so making it is not a fallback,
            // it is the missing step. `(10, 200)` is the fixed, documented
            // misc-device number for `tun` (`Documentation/admin-guide/devices.txt`),
            // not something discovered at runtime.
            make_tun_node();
            // SAFETY: as above — same live `CString`, same read-only use.
            fd = unsafe { libc::open(path.as_ptr(), libc::O_RDWR | libc::O_CLOEXEC) };
        }
        if fd < 0 {
            return Err(annotate(
                io::Error::last_os_error(),
                "open /dev/net/tun (the userspace lane's ONE kernel requirement: CONFIG_TUN; \
                 the node was not there and could not be created either)",
            ));
        }
        let mut req = IfReq::new(name);
        let flags = IFF_TUN | IFF_NO_PI;
        req.union[..2].copy_from_slice(&flags.to_ne_bytes());
        let rc = ifreq_ioctl(fd, IfReqIoctl::SetIff, &mut req);
        if rc < 0 {
            let err = io::Error::last_os_error();
            close_fd(fd);
            return Err(annotate(err, &format!("TUNSETIFF {name}")));
        }
        let dev = TunDevice { fd, name: name.to_string() };
        dev.set_mtu(mtu)?;
        Ok(dev)
    }

    /// Set the interface MTU (`SIOCSIFMTU` on a throwaway datagram socket).
    pub fn set_mtu(&self, mtu: u32) -> io::Result<()> {
        // SAFETY: `socket(2)` takes three integers and no pointers, so there is
        // no memory for it to touch; the arguments are `libc`'s own constants
        // for a UDP socket, the conventional carrier for the `SIOC*` interface
        // ioctls. A negative return is checked below.
        let s = unsafe { libc::socket(libc::AF_INET, libc::SOCK_DGRAM, 0) };
        if s < 0 {
            return Err(annotate(io::Error::last_os_error(), "socket for SIOCSIFMTU"));
        }
        let mut req = IfReq::new(&self.name);
        req.union[..4].copy_from_slice(&(mtu as i32).to_ne_bytes());
        let rc = ifreq_ioctl(s, IfReqIoctl::SetMtu, &mut req);
        let err = io::Error::last_os_error();
        close_fd(s);
        if rc < 0 {
            return Err(annotate(err, &format!("SIOCSIFMTU {} = {mtu}", self.name)));
        }
        Ok(())
    }

    /// The interface name the kernel gave this device.
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Read one IP packet. Blocks unless the fd has been made non-blocking.
    pub fn recv(&self, buf: &mut [u8]) -> io::Result<usize> {
        // SAFETY: `buf` is a live, uniquely borrowed slice and `buf.len()` is
        // exactly its length, so the kernel cannot write past it; `read(2)`
        // writes at most that many bytes and returns how many. `self.fd` is
        // owned by this `TunDevice` and is only closed in `Drop`, so it is open
        // for the whole call.
        let n = unsafe { libc::read(self.fd, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) };
        if n < 0 {
            return Err(io::Error::last_os_error());
        }
        Ok(n as usize)
    }

    /// Write one IP packet into the interface.
    pub fn send(&self, buf: &[u8]) -> io::Result<usize> {
        // SAFETY: `buf` is a live shared slice and `buf.len()` is exactly its
        // length; `write(2)` only READS that many bytes through the pointer and
        // keeps no reference. `self.fd` is owned by this `TunDevice` and only
        // closed in `Drop`, so it is open for the whole call.
        let n = unsafe { libc::write(self.fd, buf.as_ptr() as *const libc::c_void, buf.len()) };
        if n < 0 {
            return Err(io::Error::last_os_error());
        }
        Ok(n as usize)
    }
}

impl AsRawFd for TunDevice {
    fn as_raw_fd(&self) -> RawFd {
        self.fd
    }
}

impl Drop for TunDevice {
    fn drop(&mut self) {
        // Closing the fd is what deletes the interface. Nothing else to undo.
        // SAFETY: `self.fd` came from a checked `open(2)` in `create`, is owned
        // by this value alone (`TunDevice` is not `Clone` and hands out only a
        // borrowed `RawFd` through `AsRawFd`), and `Drop::drop` runs exactly
        // once — so this is the one and only close of that descriptor.
        close_fd(self.fd);
    }
}

/// Create `/dev/net/tun` (and `/dev/net`) when nothing else has.
///
/// **This is what an OS-less appliance actually needs and no tutorial mentions.**
/// The tun DRIVER is builtin; what is missing on a distroless PID-1 is the
/// device NODE, because there is no udev, no `/dev` package and no
/// `devtmpfs` mount — PID 1 made `/dev/tty0` by hand and stopped there.
/// gunnar's first live boot on the userspace lane failed exactly here, with
/// `open /dev/net/tun: No such file or directory`, on a kernel that had tun
/// compiled in.
///
/// Best-effort and silent: every error here re-surfaces as the `open` failing
/// again, with the honest message, one line below the call site. On a machine
/// that is not PID 1 (a container without `--device`, a developer's box) this
/// fails on `EPERM`/`EROFS` and nothing is harmed.
fn make_tun_node() {
    let dir = std::ffi::CString::new("/dev/net").expect("a literal with no NUL");
    // SAFETY: `dir` is a NUL-terminated `CString` from a literal, alive for the
    // call, and `mkdir(2)` only READS through the pointer. The result is
    // deliberately unused: `EEXIST` is the ordinary case and every other
    // failure re-surfaces as the `open` below failing again with the honest
    // message — see this function's doc comment.
    unsafe { libc::mkdir(dir.as_ptr(), 0o755) };
    let node = std::ffi::CString::new(TUN_PATH).expect("a literal with no NUL");
    let dev = libc::makedev(TUN_MAJOR, TUN_MINOR);
    // SAFETY: `node` is a NUL-terminated `CString` from a literal, alive for the
    // call, and `mknod(2)` only READS through the pointer. `dev` is built by
    // `libc::makedev` from the fixed, documented misc-device numbers for `tun`
    // (10, 200), so the mode and device number are well-formed by construction.
    // The result is unused for the same reason as the `mkdir` above.
    unsafe { libc::mknod(node.as_ptr(), libc::S_IFCHR | 0o600, dev) };
}

/// Keep the errno's `ErrorKind` (callers match on `PermissionDenied` /
/// `NotFound`) and put the syscall in the message.
fn annotate(err: io::Error, what: &str) -> io::Error {
    io::Error::new(err.kind(), format!("{what}: {err}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The MTU arithmetic, written down as a check rather than as a comment:
    /// 1420 + 20 + 8 + 32 is exactly 1480, and a full inner packet plus every
    /// byte of WireGuard and outer overhead must fit a 1500-byte path.
    #[test]
    fn the_default_mtu_leaves_room_for_the_whole_outer_packet() {
        const OUTER_IPV4: u32 = 20;
        const UDP: u32 = 8;
        const WIREGUARD_TRANSPORT: u32 = 32;
        assert!(
            DEFAULT_MTU + OUTER_IPV4 + UDP + WIREGUARD_TRANSPORT <= 1500,
            "a full {DEFAULT_MTU}-byte inner packet would need IP fragmentation on a 1500 path"
        );
    }

    /// LAW 2 — a name the kernel cannot hold must be refused HERE, with the name
    /// in the message. `TUNSETIFF` copies 16 bytes out of `ifr_name` and a
    /// 16-character name would arrive un-terminated, naming some other device.
    #[test]
    fn an_impossible_interface_name_is_refused_before_the_syscall() {
        for bad in ["", "wg0123456789abcdef"] {
            let err = TunDevice::create(bad, DEFAULT_MTU)
                .expect_err("a name the ifreq cannot hold must not reach the kernel");
            assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput, "{err}");
        }
    }

    /// The device is real or the error is real — never a stub. Unprivileged CI
    /// gets `EPERM`/`EACCES` (or `ENOENT` where there is no `/dev/net/tun`);
    /// under root it genuinely creates the interface and the fd is > 2.
    #[test]
    fn create_either_makes_a_device_or_fails_honestly() {
        match TunDevice::create("wgcoretest0", DEFAULT_MTU) {
            Ok(dev) => {
                assert_eq!(dev.name(), "wgcoretest0");
                assert!(dev.as_raw_fd() > 2, "a real fd, not a placeholder");
            }
            Err(e) => {
                let kind = e.kind();
                assert!(
                    matches!(
                        kind,
                        std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::NotFound
                    ),
                    "the failure must name a real cause, not a stub: {e}"
                );
                assert!(
                    e.to_string().contains("/dev/net/tun") || e.to_string().contains("TUNSETIFF"),
                    "the message must say which syscall said no: {e}"
                );
            }
        }
    }
}