published on in linux
tags: networks

Using the TUN/TAP driver to create a serial network connection

How to interface the TUN/TAP driver on Linux to connect two computers using a serial connection

TUN/TAP

The TUN/TAP driver is a way for userspace applications to do networking. It creates a virtual network interface that behaves like a real one but every packet it receives gets forwarded to a userspace application. TUN adapters operate with layer 3 packets (such as IP packets) and TAP adapter works with layer 2 packets (like Ethernet frames). TUN is therefore useful for creating a point-to-point network tunnel while TAP can bridge two networks like a switch (but at a slightly higher overhead).

For our project we want to connect two computers using a serial connection. To avoid writing a real driver for our USB adapters, which is a complicated matter, we will instead develop a small application to interface with the TUN driver and forward the packets to a serial port.

Problems with serial connections

Using a serial connection for networking is full of issues, specifically:

  • lack of session management
  • lack of flow control
  • lack of framing

The problem that affects our project the most is the lack of framing. We will investigate each problem separately.

Lack of session management

When using serial connections over long distances (like modem connections) it’s often necessary to setup the hardware (think: dialing) and authenticate yourself with the remote site. For our simple application, which is to connect two nearby computers, this is not important.

Lack of flow control

Due to the nature of serial connections (fixed speed) it’s often desirable to enforce flow control over the serial line. IP doesn’t do this so it must be handled in some other way.

Lack of framing

Each network layer implements some services and has certain demands from the layer below it. IP expects that the link layer below it will handle framing of the packets, meaning that the link layer is responsible for separating the IP packets in the data stream of the serial connection.

If we send every packet down the serial line it can be difficult to separate them on the other side. In some cases, it could be possible to tell each packet apart from each other using the length information included in the IP header. However, connections are not perfect and during transmission the packets can get damaged, altering their content or length.

A lot of standards exist to solve these issues, one of them being the famous PPP (Point-to-Point Protocol). PPP is complex with a lot of mandatory and optional extensions, making it capable of handling a lot of tasks (framing, session control and hardware/link configuration). As a result of this, it’s difficult to implement and well out of the scope of this project. We will handle framing using a different protocol, SLIP.

SLIP is very simple, it assumes the physical connection is already established and takes care of framing by defining four special 8-bit sequences (4 control bytes) as follows:

Byte Name Description
0xC0 END Frame end
0xDB ESC Frame escape
0xDC ESC_END Escaped frame end
0xDD ESC_ESC Escaped frame escape

To mark each packet’s end, SLIP mandates sending an END byte. It is possible however for END bytes to appear in the packet’s content so SLIP defines an escape sequence (ESC) to handle this. END is replaced by ESC + ESC_END and ESC is replaced with ESC + ESC_ESC.

Visualization of the serial line when transmitting IP packets followed by END sequences

It’s a common tactic to send END before packets to distinguish them from line noise that may exist. The IP layer will discard any line noise because it won’t include valid IP headers.

Discarding line noise by using an additional END sequence

Interfacing with the TUN/TAP driver on Linux

The TUN/TAP driver should be available on every modern Linux system and the module should auto-load when we try to create a virtual interface. If not, you can load it manually:

$ sudo modprobe tun

The TUN/TAP driver provides a device at /dev/net/tun called ‘clone device’. This is used as a starting point for any virtual interface. To create or attach to an existing virtual interface we must first open() the clone device and then use an ioctl() system call with the following parameters:

  • The file descriptor returned by open()
  • The TUNSETIFF constant
  • A pointer to a data structure containing the interface’s name and the operation mode (TUN or TAP)

In detail, the TUNSETIFF call does the following:

  • If a non-existent or empty interface name is specified, the kernel will create a new interface if the user has the CAP_NET_ADMIN permission (or is root).
  • If the specified interface already exists it means the user wants to attach to an existing adapter. This can be done by normal users and will succeed if the user has rights to access the clone device, is the owner/is in the group of the virtual interface and the specified operation mode (TUN or TAP) matches the one set at creation.

iproute2 can do this task for us so let’s create a new virtual interface:

 $ sudo ip tuntap add mode tun user $USER
 $ ip tuntap
 tun0: tun UNKNOWN_FLAGS:800 user 1000 group 1000
 $ sudo ip addr add "10.10.10.1/24" dev tun0

We have created a virtual network interface (tun0 in my case), accessible by the current user and set it’s address to 10.10.10.1. If we run ip link we observe that the link is always DOWN:

$ ip link | grep tun0
tun0: <NO-CARRIER,UP,...> state DOWN ...

Since there is no program attached to this virtual interface the kernel treats it like an Ethernet adapter without a cable connected (NO-CARRIER). This means that even if we attached Wireshark to the interface and tried to ping, for example, 10.10.10.2 we would see no traffic.

Let’s write the app that will handle sending the packets from the TUN interface to a serial port. We must first open() the clone device and use the ioctl() as mentioned above.

int tun_alloc(char dev[IFNAMSIZ], short flags) {
  // Interface request structure
  struct ifreq ifr;

  // File descriptor
  int fileDescriptor;

  // Open the tun device, if it doesn't exist return the error
  char *cloneDevice = "/dev/net/tun";
  if ((fileDescriptor = open(cloneDevice, O_RDWR)) < 0) {
    perror("open /dev/net/tun");
    return fileDescriptor;
  }

  // Initialize the ifreq structure with 0s and the set flags
  memset(&ifr, 0, sizeof(ifr));
  ifr.ifr_flags = flags;

  // If a device name is passed we should add it to the ifreq struct
  // Otherwise the kernel will try to allocate the next available
  // device of the given type
  if (*dev) {
    strncpy(ifr.ifr_name, dev, IFNAMSIZ);
  }

  // Ask the kernel to create the new device
  int err = ioctl(fileDescriptor, TUNSETIFF, (void *) &ifr);
  if (err < 0) {
    // If something went wrong close the file and return
    perror("ioctl TUNSETIFF");
    close(fileDescriptor);
    return err;
  }

  // Write the device name back to the dev variable so the caller
  // can access it
  strcpy(dev, ifr.ifr_name);

  // Return the file descriptor
  return fileDescriptor;
}

The tun_alloc() function takes two parameters, dev is the name of the virtual device (empty if we want the kernel to decide for us) and flags contains some options about the interface (including whether it’s a TUN or a TAP interface).

Three additional ioctl() calls are available, two for settings the owner user and group of a virtual interface (TUNSETOWNER and TUNSETGROUP respectively) and one for setting the persist option (TUNSETPERSIST), which controls whether the virtual interface should persist after the application exits. Since we are using ip tuntap to create our interfaces we don’t have to implement these calls.

Reading and writing to the virtual interface is simple, we can use read() and write() like any file.

// Blocking read from interface
read(tunFd, inBuffer, maxLength);

// Write to interface
write(tunFd, outBuffer, length);

It’s worth noting that using read() on the interface will always return one frame and will block execution of the program until a packet arrives.

Using the serial ports

To interface with the serial ports I used the libserialport, it’s a lightweight library that takes care of all the pesky details about serial ports.

// Get a port by it's name
struct sp_port *serialPort;
sp_get_port_by_name(serialPortName, &serialPort);

// Open the port
sp_open(serialPort, SP_MODE_READ_WRITE);

// Set 8 bits, no parity, 115200bps and no flow control
sp_set_bits(serialPort, 8);
sp_set_parity(serialPort, SP_PARITY_NONE);
sp_set_baudrate(serialPort, 115200);
sp_set_xon_xoff(serialPort, SP_XONXOFF_DISABLED);
sp_set_flowcontrol(serialPort, SP_FLOWCONTROL_NONE);

// Read some data
sp_blocking_read(serialPort, inBuffer, maxLength, timeout);

Putting the application together

I’ve decided to use two different threads, one for reading data from the virtual interface and one for writing. This is important since using read() on the TUN device blocks execution until a packet is ready for sending.

void *tunToSerial(void *ptr) {
  // Grab thread parameters
  struct CommDevices *args = ptr;

  int tunFd = args->tunFileDescriptor;
  struct sp_port *serialPort = args->serialPort;

  // Create TUN buffer
  unsigned char inBuffer[2048];
  unsigned char outBuffer[4096];

  // Incoming byte count
  ssize_t count;

  // Encoded data size
  unsigned long encodedLength = 0;

  // Serial error messages
  enum sp_return serialResult;

  while (1) {
    // Read from the TUN interface
    count = read(tunFd, inBuffer, sizeof(inBuffer));
    if (count < 0) {
      fprintf(stderr, "Could not read from interface\n");
    }

    // Encode data
    slip_encode(inBuffer, (unsigned long) count, outBuffer, 4096, &encodedLength);

    // Write to serial port
    serialResult = sp_nonblocking_write(serialPort, outBuffer, encodedLength);
    if (serialResult < 0) {
      fprintf(stderr, "Could not send data to serial port: %d\n", serialResult);
    }
  }
}

Reading from the serial port is slightly more complicated since the data is available in small chunks (3-4 bytes). I opted to store everything in a buffer until an END sequence arrives which marks the end of a packet, which is then decoded and sent to the virtual interface.

void *serialToTun(void *ptr) {
  // Grab thread parameters
  struct CommDevices *args = ptr;

  int tunFd = args->tunFileDescriptor;
  struct sp_port *serialPort = args->serialPort;

  // Create two buffers, one to store raw data from the serial port and
  // one to store SLIP frames
  unsigned char inBuffer[4096];
  unsigned char outBuffer[4096] = {0};
  unsigned long outSize = 0;
  int inIndex = 0;

  // Incoming byte count
  size_t count;

  // Serial result
  enum sp_return serialResult;

  // Add 'RX ready' event to serial port
  struct sp_event_set *eventSet;
  sp_new_event_set(&eventSet);
  sp_add_port_events(eventSet, serialPort, SP_EVENT_RX_READY);

  while (1) {
    // Wait for the event (RX Ready)
    sp_wait(eventSet, 0);
    count = sp_input_waiting(serialPort); // Bytes ready for reading

    // Read bytes from serial
    serialResult = sp_blocking_read(serialPort, &inBuffer[inIndex], count, 0);

    if (serialResult < 0) {
      fprintf(stderr, "Serial error! %d\n", serialResult);
    } else {
      // We need to check if there is an SLIP_END sequence in the new bytes
      for (unsigned long i = 0; i < serialResult; i++) {
        if (inBuffer[inIndex] == SLIP_END) {
          // Decode the packet that is marked by SLIP_END
          slip_decode(inBuffer, inIndex, outBuffer, 4096, &outSize);

          // Write the packet to the virtual interface
          write(tunFd, outBuffer, outSize);

          // Copy the remaining data (belonging to the next packet)
          // to the start of the buffer
          memcpy(inBuffer, &inBuffer[inIndex + 1], serialResult - i - 1);
          inIndex = serialResult - i - 1;
          break;
        } else {
          inIndex++;
        }
      }
    }
  }
}

Hardware and results

My computers don’t have serial ports and I didn’t have any USB-to-Serial adapters handy so I used two Arduino Unos as my serial ports. The AVR microcontroller is disabled by connecting the RESET pin to ground so that it doesn’t interfere with the serial lines.

Hardware block diagram

Once the hardware is ready we can create the virtual interfaces and run the app.

# Create a virtual interface on both computers
$ sudo ip tuntap add mode tun user $USER dev tun0
# Add an IP address to the first computer ...
$ sudo ip addr add "10.10.10.1/24" dev tun0
# ... and the other
$ sudo ip addr add "10.10.10.2/24" dev tun0
# Finally, run the app on both
$ ./serial-tun -i tun0 -b 921600 -p /dev/ttyUSB0

The network created is actually usable, I was able to create a SOCKS proxy and browse the web (albeit a bit slowly) through the serial tunnel. We can test the bandwidth using iperf:

$ iperf -s
...
[  5] local 10.10.10.1 port 53116 connected with 10.10.10.2 port 5001
[ ID] Interval       Transfer     Bandwidth
[  5]  0.0-10.5 sec   640 KBytes   501 Kbits/sec

The data transfer rate is almost the half of the baud rate used (501 KBits/s out of 922 Kbit/s) which seems pretty slow, SLIP should have minimal overhead. It’s possible that higher data speeds can be achieved with some optimization of the TUN app.

Source code

The source code is available on Github, it is compiled by CMake so to get started:

$ git clone https://github.com/sakisds/Serial-TUN.git
$ cd Serial-TUN
$ cmake
$ make
$ ./serial-tun -i <interface name> -p <serial port> -b <baud rate>

For further reading I recommend this tutorial on TUN/TAP, it helped me understand how the driver works to begin this project.