Tundra (tundra v0.5.0)

Copy Markdown View Source

TUN device support for Elixir.

Tundra provides a simple API for creating and using TUN devices on Linux and Darwin.

TUN device creation is a privileged operation requiring root or CAP_NET_ADMIN on Linux. Tundra supports two modes of operation:

  1. Direct creation (Linux only): When the BEAM VM runs with sufficient privileges, Tundra creates TUN devices directly via the NIF without requiring a server process.

  2. Server-based creation: When the BEAM VM lacks privileges, Tundra delegates device creation to a separate privileged server daemon (tundra_server).

Tundra automatically attempts direct creation first and falls back to the server if privileges are insufficient. Once created, the device is represented within the runtime as a socket on Darwin and a NIF resource on Linux, with process-ownership semantics:

  • Only the owning process can read from or write to the device and receive i/o notifications from it.
  • The TUN device is removed when the owning process exits.

Server Process

For unprivileged operation, Tundra requires a separate privileged server daemon (tundra_server) to be running. The server is a standalone C program located in the c_src/server/ directory and must be built and started independently with root privileges before using the Tundra library.

The server listens on a Unix domain socket at /var/run/tundra.sock and accepts requests from the NIF to create and configure TUN devices. The resulting file descriptor is sent back to the NIF via SCM_RIGHTS, which then creates a socket from it (on Darwin) or wraps it in a NIF resource (on Linux).

Users connecting to the server must be members of the tundra group. On Linux, use sudo usermod -aG tundra $USER; on macOS, use sudo dseditgroup -o edit -a $USER -t user tundra. Log out and back in after adding yourself to the group.

See the c_src/server/README.md file for instructions on building and running the server.

Non-blocking I/O

Tundra only supports non-blocking I/O on TUN devices. The recv/3 and send/3 functions currently require that :nowait is pssed as the last argument. If the operation would block, the function will return {:select, select_info} and a notification of the following form will be sent to the owning process when the device is ready:

{:"$socket", dev, :select, select_handle}

Note that, although on Linux the underlying TUN device is not technically a socket, the same notification is used to reduce platform specific code. Only the contents of dev differ.

IPv6

Tundra is designed to work with IPv6 and has only been tested with IPv6.

Example

The following GenServer creates a TUN device and then relays packets by

  1. Reading a frame from the device
  2. Swapping the source and destination addresses
  3. Writing the modified frame back to the device
defmodule Reflector do
  use GenServer

  @mtu 1500

  def start_link(_), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  @impl true
  def init(_args) do
    # Create a TUN device with the given address and options
    {:ok, state} =
      Tundra.create("fd11:b7b7:4360::2",
        dstaddr: "fd11:b7b7:4360::1",
        netmask: "ffff:ffff:ffff:ffff::",
        mtu: @mtu)
    {:ok, state, {:continue, :read}}
  end

  @impl true
  def handle_continue(:read, {dev, _} = state) do
    # Read a frame from the device
    case Tundra.recv(dev, @mtu, :nowait) do
      {:ok, data} ->
        {:noreply, state, {:continue, {:reflect, data}}}
      {:select, _} ->
        {:noreply, state}
    end
  end

  def handle_continue({:reflect, data}, {dev, _} = state) do
    # Swap the source and destination addresses of the IPv6 packet
    <<pre::binary-size(8),
      src::binary-size(16),
      dst::binary-size(16),
      rest::binary>> = data
    reflected = [pre, dst, src, rest]

    # Write the frame back to the device, assume it won't block
    :ok = Tundra.send(dev, reflected, :nowait)
    {:noreply, state, {:continue, :read}}
  end

  @impl true
  def handle_info({:"$socket", dev, :select, _}, {dev, _} = state) do
    # Handle 'input ready' notifications
    {:noreply, state, {:continue, :read}}
  end

end

Summary

Types

A TUN device address. May be represented either as tuple or a string containing a dotted IP address.

A TUN device.

A TUN device creation option.

Functions

Adopt an already-created TUN device from an open file descriptor.

Cancel a pending operation on a TUN device.

Close a TUN device.

Transfer control of a TUN device to another process.

Create a TUN device.

Receive data from a TUN device.

Send data to a TUN device.

Types

tun_address()

@type tun_address() :: String.t() | tuple()

A TUN device address. May be represented either as tuple or a string containing a dotted IP address.

tun_device()

@type tun_device() :: :socket.socket() | {:"$tundra", reference()}

A TUN device.

On Darwin, this is a socket. On Linux, this is a reference to a NIF resource.

tun_option()

@type tun_option() ::
  {:dstaddr, tun_address()}
  | {:netmask, tun_address()}
  | {:mtu, non_neg_integer()}

A TUN device creation option.

Functions

adopt(fd)

@spec adopt(non_neg_integer()) :: {:ok, {tun_device(), String.t()}} | {:error, any()}

Adopt an already-created TUN device from an open file descriptor.

Use this when a TUN device has been created and configured outside of Tundra (for example by another program, an init system, or inherited at startup) and you want to drive it with Tundra's API. The device must already be configured; adopt/1 only takes ownership of the descriptor, it does not configure the interface.

fd is the integer file descriptor of the device:

  • On Linux, a descriptor opened on /dev/net/tun and attached to a TUN device via TUNSETIFF.
  • On Darwin, a utun control socket descriptor (PF_SYSTEM/SYSPROTO_CONTROL).

On success returns a tuple containing a device tuple and the name of the device, exactly as create/2 does. As with a created device, the adopted device is owned by the calling process and is closed when that process exits.

The original descriptor is consumed

On success, Tundra duplicates fd (via the socket library on Darwin, or the NIF on Linux) and closes the original. The caller must not use fd after a successful call — reading from, writing to or closing it leads to undefined behaviour. On error, fd is left untouched and remains owned by the caller.

Examples

iex> Tundra.adopt(23)
{:ok, {{:"$socket", #Reference<0.2990923237.3512074243.109526>}, "utun6"}} # Darwin
{:ok, {{:"$tundra", #Reference<0.2990923237.3512074243.109526>}, "tun0"}}  # Linux

cancel(sock, select_info)

@spec cancel(tun_device(), :socket.select_info()) :: :ok | {:error, any()}

Cancel a pending operation on a TUN device.

close(sock)

@spec close(tun_device()) :: :ok | {:error, atom()}

Close a TUN device.

controlling_process(sock, pid)

@spec controlling_process(tun_device(), pid()) :: :ok | {:error, any()}

Transfer control of a TUN device to another process.

Must be called by the current owner of the device.

create(address, opts \\ [])

@spec create(tun_address(), [tun_option()]) ::
  {:ok, {tun_device(), String.t()}} | {:error, any()}

Create a TUN device.

Creates a new TUN device with the given address and options. The options currently supported are:

  • :netmask - The netmask of the device.
  • :dstaddr - The destination address of the device.
  • :mtu - The maximum transmission unit of the device.

On success returns a tuple containing a device tuple and the name of the device.

Examples

iex> Tundra.create("fd11:b7b7:4360::2",
        dstaddr: "fd11:b7b7:4360::1",
        netmask: "ffff:ffff:ffff:ffff::",
        mtu: 16000)
{:ok, {{:"$socket", #Reference<0.2990923237.3512074243.109526>}, "utun6"}} # Darwin
{:ok, {{:"$tundra", #Reference<0.2990923237.3512074243.109526>}, "tun0"}}  # Linux

recv(sock, length, atom)

@spec recv(tun_device(), non_neg_integer(), :nowait) ::
  {:ok, binary()} | {:select, :socket.select_info()} | {:error, any()}

Receive data from a TUN device.

The length argument specifies the maximum number of bytes to read. The caller is responsible for ensuring this length is at least as large as the MTU of the device. The returned data is the raw IP packet without any TUN framing headers.

The :nowait option specifies that the operation should not block if no data is available. If data is available, it will be returned immediately. If no data is available, the function will return {:select, select_info}.

send(sock, data, atom)

@spec send(tun_device(), iodata(), :nowait) :: :ok | {:select, :socket.select_info()}

Send data to a TUN device.

The data argument is an iodata containing a raw IP packet (IPv4 or IPv6) that will be written to the device. The TUN framing header is added automatically based on the IP version detected in the packet.

The :nowait option specifies that the operation should not block if the device's output buffer is full. If the buffer is full, the function will return {:select, select_info}.