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.