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}"
);
}
}
}
}